diff --git a/apps/chrome-extension/public/manifest.json b/apps/chrome-extension/public/manifest.json index 10592ea0688..563e29699fd 100644 --- a/apps/chrome-extension/public/manifest.json +++ b/apps/chrome-extension/public/manifest.json @@ -31,26 +31,55 @@ "identity", "offscreen", "scripting", + "sidePanel", "storage", "tabCapture" ], - "host_permissions": ["http://*/*", "https://*/*", "file:///*"], + "host_permissions": [ + "http://*/*", + "https://*/*", + "file:///*" + ], "content_scripts": [ { - "matches": ["http://*/*", "https://*/*", "file:///*"], - "js": ["assets/content-bootstrap.js"], + "matches": [ + "http://*/*", + "https://*/*", + "file:///*" + ], + "js": [ + "assets/content-bootstrap.js" + ], "run_at": "document_idle" } ], "web_accessible_resources": [ { - "resources": ["icons/*", "content/overlay.js", "welcome.html"], - "matches": ["http://*/*", "https://*/*", "file:///*"] + "resources": [ + "icons/*", + "content/overlay.js", + "welcome.html" + ], + "matches": [ + "http://*/*", + "https://*/*", + "file:///*" + ] }, { - "resources": ["camera-preview.html", "popup.html"], - "matches": ["http://*/*", "https://*/*", "file:///*"], + "resources": [ + "camera-preview.html", + "popup.html" + ], + "matches": [ + "http://*/*", + "https://*/*", + "file:///*" + ], "use_dynamic_url": true } - ] + ], + "side_panel": { + "default_path": "popup-window.html" + } } diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index 937631bd2d7..23b61801ff2 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -85,6 +85,47 @@ let offscreenDocumentCreation: Promise | null = null; let browserWindowFocused = true; let externalCaptureAutoPipPending = false; let recordingStartInFlight: Promise | null = null; +// The standalone recorder (side panel or fallback popup window) reports its +// lifecycle so the action click can toggle it and UI teardown can reach it — +// there is no chrome.sidePanel API to query or close a panel directly. +let standalonePanelOpen = false; + +const closeStandalonePanel = () => { + // Unconditional: the flag is best-effort (a restarted worker boots with + // false while the panel survives), and a close broadcast with no panel + // listening is harmless. + standalonePanelOpen = false; + chrome.runtime.sendMessage( + { target: "standalone-panel", type: "close" }, + () => { + void chrome.runtime.lastError; + }, + ); +}; + +// Rebuild the flag after a worker restart: the panel outlives this worker's +// memory, and without the flag the next icon click would re-open instead of +// toggling the visible recorder closed. +const refreshStandalonePanelFlag = () => { + try { + chrome.runtime.getContexts( + { + contextTypes: [ + "SIDE_PANEL", + "TAB", + ] as chrome.runtime.ContextType[], + documentUrls: [chrome.runtime.getURL(POPUP_URL)], + }, + (contexts) => { + if (chrome.runtime.lastError) return; + standalonePanelOpen = (contexts ?? []).length > 0; + }, + ); + } catch { + // Fall back to the message-driven flag alone. + } +}; +refreshStandalonePanelFlag(); // Content scripts read the webcam "dismissed" flag and the cached preview // frame from chrome.storage.session, which is only exposed to trusted @@ -356,6 +397,29 @@ const sendOverlay = async ( return sendOverlayMessageWithRetries(tabId, message); }; +// Delivery for paths that may still need sidePanel.open afterwards: awaits +// on chrome.* callbacks carry the user's click gesture through, but a single +// setTimeout voids it — so this variant skips the timed retry loop. The +// bootstrap content script acknowledges synchronously once injected, making +// one direct send, one inject, and one post-inject send sufficient. +const sendOverlayGestureSafe = async ( + tabId: number, + message: OverlayMessage, +) => { + if (await sendOverlayMessage(tabId, message)) return true; + + const injected = await new Promise((resolve) => { + chrome.scripting.executeScript( + { target: { tabId }, files: ["assets/content-bootstrap.js"] }, + () => resolve(!chrome.runtime.lastError), + ); + }); + + if (!injected) return false; + + return sendOverlayMessage(tabId, message); +}; + const canInjectIntoTab = (tab: chrome.tabs.Tab) => { if (tab.id === undefined) return false; if (!tab.url) return true; @@ -684,6 +748,7 @@ const closeAllExtensionUi = async () => { { createIfMissing: false }, ).catch(() => undefined); } + closeStandalonePanel(); await Promise.all([ broadcastOverlayHide(), updateSharedUiState((current) => ({ @@ -732,15 +797,25 @@ const openRecorderPanel = async (actionTab?: chrome.tabs.Tab) => { await closeAllExtensionUi(); return; } + if (standalonePanelOpen) { + closeStandalonePanel(); + return; + } const currentStatus = await syncRecordingStatus().catch( () => recordingStatus, ); for (const tab of await getRecorderPanelTabs(actionTab)) { - const delivered = await sendOverlay(tab.id, { + // Gesture-safe delivery: if this tab cannot take the panel the side + // panel below still needs the click gesture, which a timed retry + // would void. + const delivered = await sendOverlayGestureSafe(tab.id, { type: "overlay-panel-toggle", }); if (delivered) { + // One recorder at a time: the in-page panel supersedes a side panel + // left open from an earlier non-injectable page. + closeStandalonePanel(); await focusTab(tab.id); void showPreviewForRecorderOpen(tab, currentStatus).catch( () => undefined, @@ -749,8 +824,27 @@ const openRecorderPanel = async (actionTab?: chrome.tabs.Tab) => { } } - // Pages we cannot inject into (chrome://, the Web Store, etc.) still get a - // recorder via a standalone popup window. + // No tab could take the panel — chrome:// pages, the Web Store, or an + // injectable page whose content script is not answering. Dock the + // recorder in the browser's side panel so it stays attached to the window + // the user is looking at instead of floating as a separate popup. + // sidePanel.open consumes the user gesture that reached this handler; + // every await above it is a chrome.* call, which preserves that gesture. + // If Chrome still rejects (gesture expired, API missing), fall back to + // the standalone window. + try { + const windowId = + actionTab?.windowId ?? (await getActiveTab())?.windowId; + if (windowId !== undefined && chrome.sidePanel) { + await chrome.sidePanel.open({ windowId }); + // Set eagerly: the panel page pings standalone-panel-opened on load, + // but the toggle must work even if that message loses a race. + standalonePanelOpen = true; + return; + } + } catch (error) { + console.warn("sidePanel.open failed, using popup window", error); + } chrome.windows.create({ url: chrome.runtime.getURL(POPUP_URL), type: "popup", @@ -1636,6 +1730,16 @@ const handleRequest = async ( return { ok: true }; } + if (message.type === "standalone-panel-opened") { + standalonePanelOpen = true; + return { ok: true }; + } + + if (message.type === "standalone-panel-closed") { + standalonePanelOpen = false; + return { ok: true }; + } + if (message.type === "settings-updated") { await saveSettings(message.settings); if (isWebcamPreviewEnabled(message.settings)) { @@ -1814,6 +1918,32 @@ chrome.runtime.onInstalled.addListener((details) => { }); chrome.action.onClicked.addListener((tab) => { + // sidePanel.open must ride the click gesture, which Chrome can void + // across async hops — so decide synchronously. The tab is right in the + // event, injectability is a pure URL check, and the in-memory status + // mirror covers the "clicking stops a live recording" case. + if ( + chrome.sidePanel && + !canInjectIntoTab(tab) && + !isCapturingRecordingStatus(recordingStatus) && + tab.windowId !== undefined + ) { + // Second click toggles the panel closed, mirroring the overlay panel. + if (standalonePanelOpen) { + closeStandalonePanel(); + return; + } + chrome.sidePanel.open({ windowId: tab.windowId }).then( + () => { + standalonePanelOpen = true; + }, + (error) => { + console.warn("sidePanel.open failed, using popup window", error); + return openRecorderPanel(tab); + }, + ); + return; + } void syncRecordingStatus() .catch(() => recordingStatus) .then((currentStatus) => { diff --git a/apps/chrome-extension/src/content/bootstrap.ts b/apps/chrome-extension/src/content/bootstrap.ts index 60cf019e170..d33837cb199 100644 --- a/apps/chrome-extension/src/content/bootstrap.ts +++ b/apps/chrome-extension/src/content/bootstrap.ts @@ -30,6 +30,11 @@ const BOOTSTRAP_FLAG = "__capExtensionContentBootstrap"; const INSTALLED_ATTRIBUTE = "data-cap-chrome-extension-installed"; const READY_EVENT = "cap-chrome-extension-ready"; const OPEN_EVENT = "cap-chrome-extension-open"; +// Mirrors of the overlay module's own constants (the bootstrap must stay a +// few KB, so it cannot import them): the root the overlay mounts under and +// the DOM event that makes a previous generation unmount cleanly. +const OVERLAY_ROOT_ID = "cap-extension-recorder-overlay"; +const OVERLAY_TEARDOWN_EVENT = "cap-extension-overlay-teardown"; const isCapWebOrigin = () => { const { hostname, protocol } = window.location; @@ -62,8 +67,16 @@ const readPanelOpen = (value: unknown): boolean => typeof value === "object" && (value as { panelOpen?: unknown }).panelOpen === true; -const bootstrap = () => { - const overlayModuleUrl = chrome.runtime.getURL("content/overlay.js"); +const bootstrap = (isCurrent: () => boolean) => { + // The query string forces a fresh module per injection: after a takeover, + // importing the bare URL would return the cached orphan copy, whose + // `initialized` guard makes init() a silent no-op — the tab would keep + // acknowledging messages while never mounting UI again. A fresh copy's + // mountOverlay already tears down any previous tree via the DOM-level + // teardown event, so generations hand over cleanly. + const overlayModuleUrl = `${chrome.runtime.getURL( + "content/overlay.js", + )}?instance=${Date.now().toString(36)}`; // Messages acknowledged while the overlay module is still being fetched. // init() hands them to the module, whose components replay them on mount, // so the panel toggle or webcam settings push that triggered the lazy @@ -72,16 +85,26 @@ const bootstrap = () => { let modulePromise: Promise | null = null; let moduleStarted = false; + const detachListeners = () => { + try { + chrome.runtime.onMessage.removeListener(handleRuntimeMessage); + chrome.storage.onChanged.removeListener(handleStorageChange); + } catch { + // An orphaned instance has no extension context left to detach from. + } + }; + const startOverlayModule = () => { + if (!isCurrent()) return Promise.resolve(); modulePromise ??= import(/* @vite-ignore */ overlayModuleUrl) .then((module: OverlayModule) => { + if (!isCurrent()) return; moduleStarted = true; // The module registers its own runtime and storage listeners; // from here the bootstrap goes dormant. Messages arriving in the // brief window before the module's listeners mount are covered by // the service worker's send retries and the storage mirror. - chrome.runtime.onMessage.removeListener(handleRuntimeMessage); - chrome.storage.onChanged.removeListener(handleStorageChange); + detachListeners(); module.init(pendingMessages); }) .catch(() => { @@ -95,6 +118,10 @@ const bootstrap = () => { changes: Record, areaName: string, ) => { + if (!isCurrent()) { + detachListeners(); + return; + } if (areaName !== "session") return; if ( isUiPhase(changes[RECORDING_STATE_KEY]?.newValue) || @@ -109,6 +136,11 @@ const bootstrap = () => { _sender: chrome.runtime.MessageSender, sendResponse: (response?: unknown) => void, ) => { + if (!isCurrent()) { + // A newer injection owns this tab; go silent so only it acknowledges. + detachListeners(); + return false; + } if (moduleStarted) return false; if (isOverlayMessage(message)) { @@ -144,16 +176,37 @@ const bootstrap = () => { chrome.runtime.onMessage.addListener(handleRuntimeMessage); chrome.storage.onChanged.addListener(handleStorageChange); + // A previous generation's UI may still be in the DOM: its embedded + // extension iframes died with the old instance (a black camera bubble, a + // dead panel), the extension reload wiped the session state that would + // have triggered a fresh mount, and the old watcher sees a revived + // chrome object so it never self-destructs. Sweep it here — the wake + // checks below remount fresh UI whenever current state warrants it. + const staleRoot = document.getElementById(OVERLAY_ROOT_ID); + if (staleRoot) { + staleRoot.dispatchEvent(new Event(OVERLAY_TEARDOWN_EVENT)); + staleRoot.remove(); + } + if (isCapWebOrigin()) { document.documentElement.setAttribute(INSTALLED_ATTRIBUTE, "true"); window.dispatchEvent(new CustomEvent(READY_EVENT)); window.addEventListener(OPEN_EVENT, () => { - chrome.runtime.sendMessage( - { target: "service-worker", type: "open-recorder-panel" }, - () => { - void chrome.runtime.lastError; - }, - ); + // DOM listeners outlive the extension context that registered them: + // after a takeover the current copy answers this event, and an + // orphan with no successor must fail silently rather than throw + // "Extension context invalidated" into the page console. + if (!isCurrent()) return; + try { + chrome.runtime.sendMessage( + { target: "service-worker", type: "open-recorder-panel" }, + () => { + void chrome.runtime.lastError; + }, + ); + } catch { + // Orphaned context; the event is lost until re-injection. + } }); } @@ -164,6 +217,7 @@ const bootstrap = () => { chrome.storage.session.get( [RECORDING_STATE_KEY, SHARED_UI_STATE_KEY], (items) => { + if (!isCurrent()) return; if (chrome.runtime.lastError || !items) return; if ( isUiPhase(items[RECORDING_STATE_KEY]) || @@ -180,11 +234,14 @@ const bootstrap = () => { }; // chrome.scripting.executeScript re-runs this file in the same isolated -// world (the service worker injects it before messaging tabs that predate -// the extension), so a second execution must not stack duplicate listeners -// or reload the overlay module. +// world: before messaging tabs that predate the extension, and again after +// an extension reload or update orphans the previous copy (its chrome.* +// listeners die, but its flag and DOM listeners survive). Deferring to a +// boolean flag therefore left such tabs permanently unable to answer the +// service worker. Instead every run claims the tab with a fresh token and +// earlier instances detect the takeover and go silent — the token check +// needs no extension context, which is exactly what orphans have lost. const globalScope = globalThis as Record; -if (globalScope[BOOTSTRAP_FLAG] !== true) { - globalScope[BOOTSTRAP_FLAG] = true; - bootstrap(); -} +const instanceToken: object = {}; +globalScope[BOOTSTRAP_FLAG] = instanceToken; +bootstrap(() => globalScope[BOOTSTRAP_FLAG] === instanceToken); diff --git a/apps/chrome-extension/src/offscreen/recorder.ts b/apps/chrome-extension/src/offscreen/recorder.ts index 6f7177c229f..92c235236c8 100644 --- a/apps/chrome-extension/src/offscreen/recorder.ts +++ b/apps/chrome-extension/src/offscreen/recorder.ts @@ -1301,6 +1301,20 @@ const getCurrentUploadSnapshot = () => uploadStatus: undefined, }; +// A capture phase with nothing behind it: the session that owned it is gone +// (crashed, or the document was force-closed mid-recording and restored) but +// no terminal status was ever written. The stale phase pins every tab's +// recording bar, makes stop a no-op, and blocks the idle self-close that +// would let the service worker heal — so it must be detected and cleared. +const isZombieCaptureStatus = () => + !activeRecording && + !startInProgress && + !retryInProgress && + !countdownInProgress && + (status.phase === "creating" || + status.phase === "recording" || + status.phase === "paused"); + async function stopRecording() { // A stop during the pre-roll countdown cancels the start before any frame is // captured. Resolving the countdown wait lets startRecording's @@ -1319,6 +1333,9 @@ async function stopRecording() { // itself resolves as a cancellation. if (startInProgress) { startCancelRequested = true; + } else if (isZombieCaptureStatus()) { + status = { phase: "idle" }; + broadcastStatus(); } return status; } @@ -1719,6 +1736,13 @@ const canCloseIdleDocument = () => (status.phase === "idle" || status.phase === "completed"); window.setInterval(() => { + // Clear zombie capture phases before the idle check: a stale + // creating/recording/paused status would otherwise hold this document + // open forever and keep every tab's recording bar frozen. + if (isZombieCaptureStatus()) { + status = { phase: "idle" }; + broadcastStatus(); + } if (canCloseIdleDocument()) { window.close(); } diff --git a/apps/chrome-extension/src/popup/main.tsx b/apps/chrome-extension/src/popup/main.tsx index be8fc9c0469..cd301903521 100644 --- a/apps/chrome-extension/src/popup/main.tsx +++ b/apps/chrome-extension/src/popup/main.tsx @@ -74,6 +74,32 @@ const postPanelMessage = ( ); }; +// The standalone recorder (side panel or fallback popup window) has no +// embedding overlay to report through, so it tells the service worker its +// lifecycle directly: the action click uses the flag to toggle, and UI +// teardown answers with "close" since no extension API can close a side +// panel from the outside. +if (!IS_EMBEDDED) { + void sendServiceWorkerMessage({ + target: "service-worker", + type: "standalone-panel-opened", + }).catch(() => undefined); + window.addEventListener("pagehide", () => { + chrome.runtime.sendMessage( + { target: "service-worker", type: "standalone-panel-closed" }, + () => { + void chrome.runtime.lastError; + }, + ); + }); + chrome.runtime.onMessage.addListener((message: unknown) => { + const candidate = message as { target?: string; type?: string } | null; + if (candidate?.target === "standalone-panel" && candidate.type === "close") { + window.close(); + } + }); +} + // Maps the offscreen document's authoritative permission query to a stored // access flag. "unknown" (no Permissions API) leaves the flag untouched so a // browser that cannot report state never wipes a working grant. diff --git a/apps/chrome-extension/src/shared/types.ts b/apps/chrome-extension/src/shared/types.ts index a92eec470f0..bb6d712294d 100644 --- a/apps/chrome-extension/src/shared/types.ts +++ b/apps/chrome-extension/src/shared/types.ts @@ -385,6 +385,14 @@ export type ServiceWorkerRequest = target: "service-worker"; type: "close-extension-ui"; } + | { + target: "service-worker"; + type: "standalone-panel-opened"; + } + | { + target: "service-worker"; + type: "standalone-panel-closed"; + } | { target: "service-worker"; type: "settings-updated";