Skip to content
Open
45 changes: 37 additions & 8 deletions apps/chrome-extension/public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{

Check failure on line 1 in apps/chrome-extension/public/manifest.json

View workflow job for this annotation

GitHub Actions / Lint (Biome)

format

File content differs from formatting output
"manifest_version": 3,
"name": "Cap - Screen Recorder & Screen Capture",
"short_name": "Cap",
Expand Down Expand Up @@ -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"
}
}
136 changes: 133 additions & 3 deletions apps/chrome-extension/src/background/service-worker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {

Check failure on line 1 in apps/chrome-extension/src/background/service-worker.ts

View workflow job for this annotation

GitHub Actions / Lint (Biome)

format

File content differs from formatting output
ApiRequestError,
createAuthStart,
fetchBootstrap,
Expand Down Expand Up @@ -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;
},
);
Comment on lines +93 to +103

Copy link
Copy Markdown

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, standalonePanelOpen can 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.

Suggested change
const closeStandalonePanel = () => {
if (!standalonePanelOpen) return;
standalonePanelOpen = false;
chrome.runtime.sendMessage(
{ target: "standalone-panel", type: "close" },
() => {
void chrome.runtime.lastError;
},
);
const closeStandalonePanel = () => {
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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -684,6 +748,7 @@
{ createIfMissing: false },
).catch(() => undefined);
}
closeStandalonePanel();
await Promise.all([
broadcastOverlayHide(),
updateSharedUiState((current) => ({
Expand Down Expand Up @@ -732,15 +797,25 @@
await closeAllExtensionUi();
return;
}
if (standalonePanelOpen) {
closeStandalonePanel();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One edge case: on a cold-started MV3 worker, refreshStandalonePanelFlag() runs via callback, so this click can hit the standalonePanelOpen check before the flag is rebuilt. That makes the first click after SW restart re-open/focus the already-open panel instead of toggling it closed.

Suggested change
}
if (!standalonePanelOpen) {
try {
standalonePanelOpen = await new Promise<boolean>((resolve) => {
chrome.runtime.getContexts(
{
contextTypes: [
"SIDE_PANEL",
"TAB",
] as chrome.runtime.ContextType[],
documentUrls: [chrome.runtime.getURL(POPUP_URL)],
},
(contexts) => {
if (chrome.runtime.lastError) return resolve(false);
resolve((contexts ?? []).length > 0);
},
);
});
} catch {
// best-effort
}
}
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,
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 standalone-panel-opened (race/suspension), standalonePanelOpen stays false and toggle/teardown can miss it. Consider setting the flag on successful open() as well.

Suggested change
if (windowId !== undefined && chrome.sidePanel) {
await chrome.sidePanel.open({ windowId });
return;
}
if (windowId !== undefined && chrome.sidePanel) {
await chrome.sidePanel.open({ windowId });
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",
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Prompt To Fix With AI
This 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
chrome.sidePanel.open({ windowId: tab.windowId }).then(
() => undefined,
(error) => {
console.warn("sidePanel.open failed, using popup window", error);
return openRecorderPanel(tab);
},
);
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) => {
Expand Down
Loading
Loading