-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(chrome-extension): open the recorder in Chrome's side panel on non-injectable pages #2027
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c54ff9c
bbe2040
5124ff3
b9b1162
f663203
849ddf7
49b60e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,4 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ApiRequestError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| createAuthStart, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fetchBootstrap, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -85,6 +85,47 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| let browserWindowFocused = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let externalCaptureAutoPipPending = false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let recordingStartInFlight: Promise<OffscreenResponse> | 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 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<boolean>((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 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| { createIfMissing: false }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ).catch(() => undefined); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| closeStandalonePanel(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await Promise.all([ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| broadcastOverlayHide(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| updateSharedUiState((current) => ({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -732,15 +797,25 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| await closeAllExtensionUi(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (standalonePanelOpen) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| closeStandalonePanel(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One edge case: on a cold-started MV3 worker,
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+838
to
+844
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor robustness: if the side panel opens successfully but the standalone page never manages to ping
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } 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 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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.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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1932
to
+1935
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When Chrome restarts the MV3 service worker while the side panel remains open, Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/chrome-extension/src/background/service-worker.ts
Line: 1903-1906
Comment:
**Side-panel state is lost**
When Chrome restarts the MV3 service worker while the side panel remains open, `standalonePanelOpen` resets to `false` and the surviving panel does not report itself again, so the next icon click calls `sidePanel.open()` instead of closing the visible recorder.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| chrome.sidePanel.open({ windowId: tab.windowId }).then( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| standalonePanelOpen = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (error) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.warn("sidePanel.open failed, using popup window", error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return openRecorderPanel(tab); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1936
to
+1944
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same idea in the click-path: setting the flag on success makes the toggle behavior less dependent on the standalone page sending its lifecycle message.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| void syncRecordingStatus() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .catch(() => recordingStatus) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .then((currentStatus) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the service worker gets restarted while the side panel is open,
standalonePanelOpencan desync and this early return prevents teardown/toggle from reaching the panel. Might be safer to always send the close message and just keep the flag best-effort.