From c54ff9c1671c07783471ac5881d4ede149fc2f84 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Wed, 22 Jul 2026 12:36:21 +0530 Subject: [PATCH 1/7] feat(chrome-extension): open recorder in the side panel when the page is not injectable --- apps/chrome-extension/public/manifest.json | 45 +++++++++++++++---- .../src/background/service-worker.ts | 17 ++++++- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/chrome-extension/public/manifest.json b/apps/chrome-extension/public/manifest.json index 10592ea068..563e29699f 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 937631bd2d..949dbe3530 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -750,7 +750,22 @@ 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. + // recorder — docked 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 }); + return; + } + } catch { + // Fall through to the popup window. + } chrome.windows.create({ url: chrome.runtime.getURL(POPUP_URL), type: "popup", From bbe20400715bd23384b9396d9d193b45a507d874 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Wed, 22 Jul 2026 13:22:10 +0530 Subject: [PATCH 2/7] fix(chrome-extension): track standalone panel lifecycle for toggle, teardown, and single-recorder rule --- .../src/background/service-worker.ts | 57 ++++++++++++++++++- apps/chrome-extension/src/popup/main.tsx | 26 +++++++++ apps/chrome-extension/src/shared/types.ts | 8 +++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index 949dbe3530..0575cb5967 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -85,6 +85,21 @@ 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 = () => { + if (!standalonePanelOpen) return; + standalonePanelOpen = false; + chrome.runtime.sendMessage( + { target: "standalone-panel", type: "close" }, + () => { + void chrome.runtime.lastError; + }, + ); +}; // Content scripts read the webcam "dismissed" flag and the cached preview // frame from chrome.storage.session, which is only exposed to trusted @@ -684,6 +699,7 @@ const closeAllExtensionUi = async () => { { createIfMissing: false }, ).catch(() => undefined); } + closeStandalonePanel(); await Promise.all([ broadcastOverlayHide(), updateSharedUiState((current) => ({ @@ -741,6 +757,9 @@ const openRecorderPanel = async (actionTab?: chrome.tabs.Tab) => { 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, @@ -763,8 +782,8 @@ const openRecorderPanel = async (actionTab?: chrome.tabs.Tab) => { await chrome.sidePanel.open({ windowId }); return; } - } catch { - // Fall through to the popup window. + } catch (error) { + console.warn("sidePanel.open failed, using popup window", error); } chrome.windows.create({ url: chrome.runtime.getURL(POPUP_URL), @@ -1651,6 +1670,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)) { @@ -1829,6 +1858,30 @@ 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( + () => undefined, + (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/popup/main.tsx b/apps/chrome-extension/src/popup/main.tsx index be8fc9c046..cd30190352 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 a92eec470f..bb6d712294 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"; From 5124ff3ca4e4ca1d53b7ae24697af4d402a6d8e3 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Wed, 22 Jul 2026 13:40:14 +0530 Subject: [PATCH 3/7] fix(chrome-extension): keep click gesture alive so overlay-delivery failures fall back to the side panel --- .../src/background/service-worker.ts | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index 0575cb5967..9adf21ed2e 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -371,6 +371,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; @@ -748,12 +771,19 @@ 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) { @@ -768,13 +798,14 @@ const openRecorderPanel = async (actionTab?: chrome.tabs.Tab) => { } } - // Pages we cannot inject into (chrome://, the Web Store, etc.) still get a - // recorder — docked 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. + // 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; From b9b11625c4df84fcc296d5ca1896f9ff4d194021 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Wed, 22 Jul 2026 21:16:33 +0530 Subject: [PATCH 4/7] fix(chrome-extension): let re-injected bootstrap take over orphaned tabs instead of deferring to a dead flag --- .../chrome-extension/src/content/bootstrap.ts | 74 ++++++++++++++----- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/apps/chrome-extension/src/content/bootstrap.ts b/apps/chrome-extension/src/content/bootstrap.ts index 60cf019e17..5a1507e0d0 100644 --- a/apps/chrome-extension/src/content/bootstrap.ts +++ b/apps/chrome-extension/src/content/bootstrap.ts @@ -62,8 +62,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 +80,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 +113,10 @@ const bootstrap = () => { changes: Record, areaName: string, ) => { + if (!isCurrent()) { + detachListeners(); + return; + } if (areaName !== "session") return; if ( isUiPhase(changes[RECORDING_STATE_KEY]?.newValue) || @@ -109,6 +131,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)) { @@ -148,12 +175,21 @@ const bootstrap = () => { 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 +200,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 +217,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); From f663203793d23ef83f2e80013a3e24c18a012f3e Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Wed, 22 Jul 2026 21:35:02 +0530 Subject: [PATCH 5/7] fix(chrome-extension): clear zombie capture status so stop works and the recorder doc can idle-close --- .../src/offscreen/recorder.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/chrome-extension/src/offscreen/recorder.ts b/apps/chrome-extension/src/offscreen/recorder.ts index 6f7177c229..92c235236c 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(); } From 849ddf7217784db9628d2d3b1a394e6a5063c883 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Wed, 22 Jul 2026 21:53:42 +0530 Subject: [PATCH 6/7] fix(chrome-extension): sweep stale overlay UI when a new bootstrap takes over a tab --- apps/chrome-extension/src/content/bootstrap.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/chrome-extension/src/content/bootstrap.ts b/apps/chrome-extension/src/content/bootstrap.ts index 5a1507e0d0..d33837cb19 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; @@ -171,6 +176,18 @@ const bootstrap = (isCurrent: () => boolean) => { 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)); From 49b60e399e691a12fb676d3d345acb92b1d3e0e0 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 23 Jul 2026 22:56:04 +0530 Subject: [PATCH 7/7] fix(chrome-extension): survive service worker restarts in standalone panel tracking --- .../src/background/service-worker.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index 9adf21ed2e..23b61801ff 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -91,7 +91,9 @@ let recordingStartInFlight: Promise | null = null; let standalonePanelOpen = false; const closeStandalonePanel = () => { - if (!standalonePanelOpen) return; + // 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" }, @@ -101,6 +103,30 @@ const closeStandalonePanel = () => { ); }; +// 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 // contexts unless the access level is widened. Without this every session @@ -811,6 +837,9 @@ const openRecorderPanel = async (actionTab?: chrome.tabs.Tab) => { 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) { @@ -1905,7 +1934,9 @@ chrome.action.onClicked.addListener((tab) => { return; } chrome.sidePanel.open({ windowId: tab.windowId }).then( - () => undefined, + () => { + standalonePanelOpen = true; + }, (error) => { console.warn("sidePanel.open failed, using popup window", error); return openRecorderPanel(tab);