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 4ff776bb..1eca0258 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,9 +1,4 @@ -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; +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,"; @@ -124,84 +119,159 @@ function registerListeners() { }); } -let ws = null; -let reconnectTimer = null; -let reconnectAttempts = 0; -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; +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 msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); + const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); + return res.ok; } catch { + return false; } } -console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); -}; -console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); -}; -console.error = (...args) => { - _origError(...args); - forwardLog("error", args); -}; -async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; +async function ensureOffscreen() { + if (!chrome.offscreen) return false; try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); - if (!res.ok) return; - } catch { + 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() { + const ready = await ensureOffscreen(); + if (!ready) { + await legacyConnect(); return; } try { - ws = new WebSocket(DAEMON_WS_URL); + await chrome.runtime.sendMessage({ type: "ws-connect" }); } catch { - scheduleReconnect(); + } +} +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; } - ws.onopen = () => { + legacyWs.onopen = () => { console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; + legacyReconnectAttempts = 0; + if (legacyReconnectTimer) { + clearTimeout(legacyReconnectTimer); + legacyReconnectTimer = null; } - ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); + legacyWs?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); }; - ws.onmessage = async (event) => { + legacyWs.onmessage = async (event) => { try { const command = JSON.parse(event.data); const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); + await wsSend(JSON.stringify(result)); } catch (err) { console.error("[opencli] Message handling error:", err); } }; - ws.onclose = () => { + legacyWs.onclose = () => { console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); + legacyWs = null; + scheduleLegacyReconnect(); }; - ws.onerror = () => { - ws?.close(); + legacyWs.onerror = () => { + legacyWs?.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(); +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) { + void offscreenConnect(); + } + } catch { + void offscreenConnect(); + } +} +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 }; + } 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) { + 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); + forwardLog("info", args); +}; +console.warn = (...args) => { + _origWarn(...args); + forwardLog("warn", args); +}; +console.error = (...args) => { + _origError(...args); + forwardLog("error", args); +}; const automationSessions = /* @__PURE__ */ new Map(); const WINDOW_IDLE_TIMEOUT = 3e4; function getWorkspaceKey(workspace) { @@ -264,9 +334,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 connectTransport(); console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { @@ -276,14 +346,35 @@ chrome.runtime.onStartup.addListener(() => { initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") void connect(); + if (alarm.name === "keepalive") { + void connectTransport(); + } }); 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-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); + const result = await handleCommand(command); + await wsSend(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + })(); + return false; + } + if (msg?.type === "log") { + void wsSend(JSON.stringify(msg)); + return false; } return false; }); @@ -435,17 +526,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..8914daa2 --- /dev/null +++ b/extension/dist/offscreen.js @@ -0,0 +1,144 @@ +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(() => { + }); +} +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 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 { + 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: chrome.runtime.getManifest().version })); + void flushPendingFrames(); + }; + ws.onmessage = (event) => { + pendingFrames.push(event.data); + void flushPendingFrames(); + }; + ws.onclose = () => { + console.log("[opencli/offscreen] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + ws.onerror = () => { + 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++; + 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/scripts/package-release.mjs b/extension/scripts/package-release.mjs index 1a57dca9..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]; @@ -94,9 +98,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); @@ -104,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); @@ -114,6 +145,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 c6452262..43a1e2f8 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -3,115 +3,194 @@ * * 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 { DAEMON_PING_URL, DAEMON_WS_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'); +let forceLegacyTransport = false; +let legacyWs: WebSocket | null = null; +let legacyReconnectTimer: ReturnType | null = null; +let legacyReconnectAttempts = 0; +const MAX_EAGER_ATTEMPTS = 6; -const _origLog = console.log.bind(console); -const _origWarn = console.warn.bind(console); -const _origError = console.error.bind(console); +function prefersOffscreenTransport(): boolean { + return !forceLegacyTransport && !!(chrome as any).offscreen; +} -function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void { - if (!ws || ws.readyState !== WebSocket.OPEN) return; +async function probeDaemon(): Promise { 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 */ } + const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + return res.ok; + } catch { + return false; + } } -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; +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 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 { + const ready = await ensureOffscreen(); + if (!ready) { + await legacyConnect(); + return; + } try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); - if (!res.ok) return; // unexpected response — not our daemon + await chrome.runtime.sendMessage({ type: 'ws-connect' }); } catch { - return; // daemon not running — skip WebSocket to avoid console noise + // offscreen not ready yet — it will auto-connect on boot anyway } +} + +async function legacyConnect(): Promise { + if (legacyWs?.readyState === WebSocket.OPEN || legacyWs?.readyState === WebSocket.CONNECTING) return; + if (!(await probeDaemon())) return; try { - ws = new WebSocket(DAEMON_WS_URL); + legacyWs = new WebSocket(DAEMON_WS_URL); } catch { - scheduleReconnect(); + scheduleLegacyReconnect(); return; } - ws.onopen = () => { + legacyWs.onopen = () => { console.log('[opencli] Connected to daemon'); - reconnectAttempts = 0; // Reset on successful connection - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; + legacyReconnectAttempts = 0; + if (legacyReconnectTimer) { + clearTimeout(legacyReconnectTimer); + legacyReconnectTimer = null; } - // Send version so the daemon can report mismatches to the CLI - ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + legacyWs?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); }; - ws.onmessage = async (event) => { + legacyWs.onmessage = async (event) => { try { const command = JSON.parse(event.data as string) as Command; const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); + await wsSend(JSON.stringify(result)); } catch (err) { console.error('[opencli] Message handling error:', err); } }; - ws.onclose = () => { + legacyWs.onclose = () => { console.log('[opencli] Disconnected from daemon'); - ws = null; - scheduleReconnect(); + legacyWs = null; + scheduleLegacyReconnect(); }; - ws.onerror = () => { - ws?.close(); + legacyWs.onerror = () => { + legacyWs?.close(); }; } -/** - * 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(); +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) { + // Offscreen WS is down — trigger reconnect + void offscreenConnect(); + } + } catch { + void offscreenConnect(); + } +} + +/** 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 }; + } catch { + return { connected: false, reconnecting: false }; + } +} + +// ─── Console log forwarding ────────────────────────────────────────── +// Logs from offscreen arrive as { type:'log', level, msg, ts } messages. +// SW-side logs are forwarded directly via wsSend. + +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); + +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() })); +} + +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 +199,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 +216,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 +247,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 +268,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 connectTransport(); console.log('[opencli] OpenCLI extension initialized'); } @@ -215,26 +283,55 @@ 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 connectTransport(); + } }); -// ─── 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 + } + + // ── 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; + const result = await handleCommand(command); + await wsSend(JSON.stringify(result)); + } catch (err) { + console.error('[opencli] Message handling error:', err); + } + })(); + 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 +363,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 +392,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 +401,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 +421,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 +467,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 +475,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 +499,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 +529,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 +625,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..b6983ba4 --- /dev/null +++ b/extension/src/offscreen.ts @@ -0,0 +1,194 @@ +/** + * 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, 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 ───────────────────────────────────────────────────────── + +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 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; + + // 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 { + 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: chrome.runtime.getManifest().version })); + void flushPendingFrames(); + }; + + ws.onmessage = (event) => { + pendingFrames.push(event.data as string); + void flushPendingFrames(); + }; + + ws.onclose = () => { + console.log('[opencli/offscreen] Disconnected from daemon'); + ws = null; + scheduleReconnect(); + }; + + ws.onerror = () => { + ws?.close(); + }; +} + +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++; + 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/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"] } 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', }, },