Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
}
}
146 changes: 143 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,55 @@
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 = (): Promise<void> =>
new Promise<void>((resolve) => {
try {
chrome.runtime.getContexts(
{
contextTypes: [
"SIDE_PANEL",
"TAB",
] as chrome.runtime.ContextType[],
documentUrls: [chrome.runtime.getURL(POPUP_URL)],
},
(contexts) => {
if (!chrome.runtime.lastError) {
standalonePanelOpen = (contexts ?? []).length > 0;
}
resolve();
},
);
} catch {
// getContexts is unavailable in older Chrome — fall back to the
// message-driven flag alone.
resolve();
}
});
let isStandaloneFlagReady = false;
const standaloneFlagReady = refreshStandalonePanelFlag().then(() => {

Check warning on line 134 in apps/chrome-extension/src/background/service-worker.ts

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/correctness/noUnusedVariables

This variable standaloneFlagReady is unused.
isStandaloneFlagReady = true;
});

// 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 +405,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 +756,7 @@
{ createIfMissing: false },
).catch(() => undefined);
}
closeStandalonePanel();
await Promise.all([
broadcastOverlayHide(),
updateSharedUiState((current) => ({
Expand Down Expand Up @@ -732,15 +805,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 +832,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 +846 to +852

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 +1738,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 +1926,34 @@
});

chrome.action.onClicked.addListener((tab) => {
// If the startup flag-refresh hasn't settled yet, the standalonePanelOpen
// value may be stale (worker restarted while panel was visible). Fall back
// to openRecorderPanel so Chrome's click gesture is never voided by an
// async wait — the cost is one extra popup open on a cold-restart race,
// which is far better than a dead icon click.
if (
isStandaloneFlagReady &&
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 +1942 to +1945

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 +1946 to +1954

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