diff --git a/apps/chrome-extension/e2e/overlay-ui.spec.ts b/apps/chrome-extension/e2e/overlay-ui.spec.ts
index 24dd119ff78..30ff8a75985 100644
--- a/apps/chrome-extension/e2e/overlay-ui.spec.ts
+++ b/apps/chrome-extension/e2e/overlay-ui.spec.ts
@@ -1418,3 +1418,102 @@ test("the recorder panel and camera preview load on a page with a strict CSP", a
await mockServer.close();
}
});
+
+test("the recorder opens and records on Cap's welcome page", async () => {
+ const mockServer = await createMockCapServer();
+ const extension = await launchExtensionContext();
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin);
+ const extensionOrigin = `chrome-extension://${new URL(worker.url()).host}`;
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(`${extensionOrigin}/popup.html`);
+ const welcomePage = await extension.context.newPage();
+ await welcomePage.goto(`${extensionOrigin}/welcome.html`);
+ await welcomePage.bringToFront();
+
+ await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "open-recorder-panel",
+ });
+ await expect
+ .poll(() => frameWithUrl(welcomePage, "popup.html") !== null, {
+ timeout: 15_000,
+ })
+ .toBe(true);
+ await expect
+ .poll(() => frameWithUrl(welcomePage, "camera-preview.html") !== null, {
+ timeout: 15_000,
+ })
+ .toBe(true);
+ const panel = frameWithUrl(welcomePage, "popup.html");
+ if (!panel) throw new Error("recorder panel missing");
+ await panel.getByRole("button", { name: "Start recording" }).click();
+
+ await expect
+ .poll(
+ async () =>
+ (
+ (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "get-recording-status",
+ })) as { status?: { phase?: string } }
+ ).status?.phase,
+ { timeout: 15_000 },
+ )
+ .toBe("recording");
+ expect(frameWithUrl(welcomePage, "camera-preview.html")).not.toBeNull();
+
+ await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "stop-recording",
+ });
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
+
+test("opening Cap on a page it cannot run on opens the recorder on a Cap page", async () => {
+ const mockServer = await createMockCapServer();
+ const extension = await launchExtensionContext();
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin, {
+ webcamEnabled: false,
+ });
+ const extensionOrigin = `chrome-extension://${new URL(worker.url()).host}`;
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(`${extensionOrigin}/popup.html`);
+ await messengerPage.bringToFront();
+
+ await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "open-recorder-panel",
+ });
+ const findCapPage = () =>
+ extension?.context
+ .pages()
+ .find((candidate) => candidate.url().includes("how-it-works.html"));
+ await expect.poll(() => Boolean(findCapPage())).toBe(true);
+ const page = findCapPage();
+ if (!page) throw new Error("Cap page missing");
+ await expect
+ .poll(() => frameWithUrl(page, "popup.html") !== null, {
+ timeout: 15_000,
+ })
+ .toBe(true);
+ await expect(page.locator("#recorder-note")).toBeVisible();
+ await expect.poll(() => page.url()).not.toContain("recorder=open");
+ const panel = frameWithUrl(page, "popup.html");
+ await expect(
+ panel?.getByRole("button", { name: "Start recording" }) ??
+ page.locator("missing"),
+ ).toBeVisible();
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
diff --git a/apps/chrome-extension/how-it-works.html b/apps/chrome-extension/how-it-works.html
index 3734907d4d1..ae0f2e49b38 100644
--- a/apps/chrome-extension/how-it-works.html
+++ b/apps/chrome-extension/how-it-works.html
@@ -82,6 +82,10 @@
How Cap works
+
+ Cap can't open on browser pages like New Tab or Settings, so your
+ recorder opened here. It opens on any website too.
+
Instant Mode uploads while you record, so your share link is ready the
moment you stop.
diff --git a/apps/chrome-extension/src/background/service-worker.test.ts b/apps/chrome-extension/src/background/service-worker.test.ts
index b29f474272b..56d9121f7b5 100644
--- a/apps/chrome-extension/src/background/service-worker.test.ts
+++ b/apps/chrome-extension/src/background/service-worker.test.ts
@@ -15,6 +15,8 @@ let onMessage: MessageListener;
let stored: Record;
let pollReplies: Array<(response: OffscreenResponse) => void>;
let contextReplies: Array<(contexts: object[]) => void>;
+let onActionClicked: (tab: chrome.tabs.Tab) => void;
+let tabsApi: Record>;
const liveStatus: RecordingStatus = {
phase: "recording",
@@ -37,6 +39,14 @@ beforeEach(async () => {
},
setAccessLevel: vi.fn(),
};
+ tabsApi = {
+ update: vi.fn((tabId, properties, callback) =>
+ callback({ id: tabId, windowId: 1, ...properties }),
+ ),
+ create: vi.fn((properties, callback) =>
+ callback({ id: 99, ...properties }),
+ ),
+ };
vi.stubGlobal("chrome", {
storage: { local: storage, session: storage },
runtime: {
@@ -59,16 +69,26 @@ beforeEach(async () => {
setPopup: vi.fn((_options, callback) => callback()),
setBadgeText: vi.fn((_options, callback) => callback()),
setTitle: vi.fn((_options, callback) => callback()),
- onClicked: { addListener: vi.fn() },
+ onClicked: {
+ addListener: (listener: (tab: chrome.tabs.Tab) => void) => {
+ onActionClicked = listener;
+ },
+ },
},
tabs: {
+ ...tabsApi,
query: (_query: object, callback: (tabs: object[]) => void) =>
callback([]),
onActivated: { addListener: vi.fn() },
onUpdated: { addListener: vi.fn() },
onRemoved: { addListener: vi.fn() },
},
- windows: { onFocusChanged: { addListener: vi.fn() } },
+ windows: {
+ onFocusChanged: { addListener: vi.fn() },
+ getLastFocused: vi.fn((callback) => callback({ id: 1 })),
+ update: vi.fn((_windowId, _properties, callback) => callback()),
+ create: vi.fn(),
+ },
});
await import("./service-worker");
});
@@ -203,3 +223,49 @@ describe("offscreen messaging", () => {
expect(await response).toMatchObject({ ok: true, status: pausedStatus });
});
});
+
+describe("recorder on browser pages", () => {
+ const clickOn = async (url: string) => {
+ onActionClicked({ id: 7, index: 2, windowId: 1, url } as chrome.tabs.Tab);
+ // No recorder document exists, however many times the worker asks.
+ await vi.waitFor(() => {
+ while (contextReplies.length > 0) answerContexts(false);
+ expect(
+ tabsApi.update.mock.calls.length + tabsApi.create.mock.calls.length,
+ ).toBe(1);
+ });
+ };
+ const capPage = expect.stringMatching(
+ /^chrome-extension:\/\/test\/how-it-works\.html\?recorder=open$/,
+ );
+
+ it.each(["chrome://newtab/", "edge://newtab/"])(
+ "replaces an empty %s with the recorder page",
+ async (url) => {
+ await clickOn(url);
+
+ expect(tabsApi.update).toHaveBeenCalledWith(
+ 7,
+ { url: capPage, active: true },
+ expect.any(Function),
+ );
+ expect(tabsApi.create).not.toHaveBeenCalled();
+ },
+ );
+
+ it("opens the recorder page beside any other browser page", async () => {
+ await clickOn("chrome://settings/");
+
+ expect(tabsApi.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: capPage,
+ active: true,
+ windowId: 1,
+ index: 3,
+ openerTabId: 7,
+ }),
+ expect.any(Function),
+ );
+ expect(tabsApi.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts
index 1215d083140..19085678ad0 100644
--- a/apps/chrome-extension/src/background/service-worker.ts
+++ b/apps/chrome-extension/src/background/service-worker.ts
@@ -10,6 +10,10 @@ import {
isServiceWorkerRequest,
} from "../shared/messages";
import { rememberRecordingMode } from "../shared/preferences";
+import {
+ isRecorderHostPage,
+ RECORDER_OPEN_PARAM,
+} from "../shared/recorder-pages";
import {
clearAuth,
clearAuthError,
@@ -364,6 +368,7 @@ const sendOverlay = async (
const canInjectIntoTab = (tab: chrome.tabs.Tab) => {
if (tab.id === undefined) return false;
if (!tab.url) return true;
+ if (isRecorderHostPage(tab.url)) return true;
try {
const protocol = new URL(tab.url).protocol;
return (
@@ -380,6 +385,12 @@ const isWebPageSender = (sender: chrome.runtime.MessageSender) => {
return !senderUrl.startsWith("chrome-extension:");
};
+// Cap's own pages run the overlay as the top-level document of a tab. When
+// embedded in a frame they could be a hostile site's iframe, so only the top
+// frame counts.
+const isRecorderHostSender = (sender: chrome.runtime.MessageSender) =>
+ Boolean(sender.tab) && sender.frameId === 0 && isRecorderHostPage(sender.url);
+
// camera-preview.html is web accessible, so any site can load it in an
// iframe. Only honour camera requests carrying a token that one of our
// content scripts registered: web pages cannot send runtime messages, so a
@@ -754,8 +765,13 @@ 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.
+ // Browser pages (New Tab, settings, the Web Store) cannot show the
+ // in-page recorder, so it opens on a Cap page instead.
+ const opened = await openRecorderOnCapPage(
+ actionTab ?? (await getActiveTab()),
+ ).catch(() => false);
+ if (opened) return;
+
chrome.windows.create({
url: chrome.runtime.getURL(POPUP_URL),
type: "popup",
@@ -764,6 +780,60 @@ const openRecorderPanel = async (actionTab?: chrome.tabs.Tab) => {
});
};
+// The action click grants activeTab, which exposes the clicked tab's URL
+// even for browser pages.
+const NEW_TAB_URL =
+ /^(chrome|edge|brave|vivaldi|opera):\/\/(newtab|new-tab-page)\/?$|^about:blank$/;
+
+// An empty New Tab is replaced by the Cap page; any other page keeps its tab
+// and gets the Cap page beside it. The page asks for the panel once loaded.
+const openRecorderOnCapPage = async (fromTab: chrome.tabs.Tab | null) => {
+ const url = new URL(chrome.runtime.getURL("how-it-works.html"));
+ url.searchParams.set(RECORDER_OPEN_PARAM, "open");
+ const fromUrl = fromTab?.url ?? fromTab?.pendingUrl ?? "";
+ if (fromTab?.id !== undefined && NEW_TAB_URL.test(fromUrl)) {
+ await updateTab(fromTab.id, url.toString());
+ return true;
+ }
+ const tab = await new Promise((resolve, reject) => {
+ chrome.tabs.create(
+ {
+ url: url.toString(),
+ active: true,
+ ...(fromTab
+ ? {
+ windowId: fromTab.windowId,
+ index: fromTab.index + 1,
+ openerTabId: fromTab.id,
+ }
+ : {}),
+ },
+ (created) => {
+ if (chrome.runtime.lastError || !created) {
+ reject(new Error(chrome.runtime.lastError?.message));
+ return;
+ }
+ resolve(created);
+ },
+ );
+ });
+ if (tab.windowId !== undefined) await focusWindow(tab.windowId);
+ return true;
+};
+
+const showRecorderPanelInTab = async (tab: chrome.tabs.Tab) => {
+ const currentStatus = await syncRecordingStatus().catch(
+ () => recordingStatus,
+ );
+ await updateSharedUiState((current) => ({
+ ...current,
+ panelOpen: true,
+ readyBarDismissed: false,
+ updatedAt: Date.now(),
+ }));
+ await showPreviewForRecorderOpen(tab, currentStatus).catch(() => undefined);
+};
+
const getPreviewTabIdForPip = async () => {
const settings = await loadSettings();
if (
@@ -1686,6 +1756,14 @@ const handleRequest = async (
return { ok: true };
}
+ if (message.type === "show-recorder-panel") {
+ if (!sender.tab || !isRecorderHostSender(sender)) {
+ return { ok: false, error: "Unauthorized" };
+ }
+ await showRecorderPanelInTab(sender.tab);
+ return { ok: true };
+ }
+
if (
message.type === "pause-recording" ||
message.type === "resume-recording"
@@ -1748,10 +1826,10 @@ const handleRequest = async (
}
if (message.type === "register-overlay-token") {
- // Tokens may only come from content scripts. Extension pages are
- // excluded so a web-accessible page embedded by a hostile site cannot
- // authorise itself.
- if (!isWebPageSender(sender)) {
+ // Tokens may only come from content scripts or Cap's own pages hosting
+ // the overlay. Other extension pages are excluded so a web-accessible
+ // page embedded by a hostile site cannot authorise itself.
+ if (!isWebPageSender(sender) && !isRecorderHostSender(sender)) {
return { ok: false, error: "Unauthorized" };
}
await registerOverlayToken(message.token);
diff --git a/apps/chrome-extension/src/how-it-works/main.ts b/apps/chrome-extension/src/how-it-works/main.ts
index 55f31874bb9..f56e34db6f3 100644
--- a/apps/chrome-extension/src/how-it-works/main.ts
+++ b/apps/chrome-extension/src/how-it-works/main.ts
@@ -1,4 +1,12 @@
import { mountPageNav } from "../shared/page-nav";
+import { RECORDER_OPEN_PARAM } from "../shared/recorder-pages";
import "./styles.css";
+// Read before the page nav drops the flag from the URL.
+const openedInPlaceOfBrowserPage =
+ new URLSearchParams(window.location.search).get(RECORDER_OPEN_PARAM) ===
+ "open";
+const recorderNote = document.getElementById("recorder-note");
+if (recorderNote && openedInPlaceOfBrowserPage) recorderNote.hidden = false;
+
mountPageNav("how-it-works");
diff --git a/apps/chrome-extension/src/how-it-works/styles.css b/apps/chrome-extension/src/how-it-works/styles.css
index 6c2209e186e..904b0a6d648 100644
--- a/apps/chrome-extension/src/how-it-works/styles.css
+++ b/apps/chrome-extension/src/how-it-works/styles.css
@@ -183,3 +183,11 @@
animation: none;
}
}
+
+.recorder-note {
+ max-width: 460px;
+ margin-bottom: 18px;
+ border-radius: 16px;
+ font-size: 13px;
+ text-align: left;
+}
diff --git a/apps/chrome-extension/src/shared/page-nav.ts b/apps/chrome-extension/src/shared/page-nav.ts
index 261bf5840a0..7586e0fc4d0 100644
--- a/apps/chrome-extension/src/shared/page-nav.ts
+++ b/apps/chrome-extension/src/shared/page-nav.ts
@@ -1,3 +1,4 @@
+import { openRequestedRecorder } from "./recorder-host";
import { loadSettings } from "./storage";
export const PAGE_NAV_LINKS = [
@@ -18,6 +19,7 @@ const CAP_LOGO_SVG = `
Step 3
- Click the Cap icon whenever you're ready to record.
+ Click the Cap icon to open the recorder. Try it right here.