From c5b11bcdc68060d6b331c978566e22e24a8f0718 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:18:20 +0000 Subject: [PATCH 1/5] feat(chrome-extension): open the recorder on Cap's own pages and in place of browser pages it cannot run on --- apps/chrome-extension/how-it-works.html | 4 + .../src/background/service-worker.ts | 91 +++++++++++++++++-- .../chrome-extension/src/how-it-works/main.ts | 8 ++ .../src/how-it-works/styles.css | 8 ++ apps/chrome-extension/src/shared/page-nav.ts | 2 + .../src/shared/recorder-host.ts | 16 ++++ .../src/shared/recorder-pages.ts | 27 ++++++ apps/chrome-extension/src/shared/types.ts | 4 + 8 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 apps/chrome-extension/src/shared/recorder-host.ts create mode 100644 apps/chrome-extension/src/shared/recorder-pages.ts 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

+

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.ts b/apps/chrome-extension/src/background/service-worker.ts index 1215d083140..a47dc947cf2 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,61 @@ 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; +}; + +// Opens (never toggles) the panel in a Cap page that asked for it. +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 +1757,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 +1827,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 = `

Pin Cap to your toolbar

Step 3 -

Click the Cap icon whenever you're ready to record.

+

Click the Cap icon to open the recorder. Try it right here.

From 097ed75faf4ccdd21491b343960c2c91c7348131 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:18:20 +0000 Subject: [PATCH 3/5] test(chrome-extension): cover the recorder on Cap pages and the browser page fallback --- apps/chrome-extension/e2e/overlay-ui.spec.ts | 99 ++++++++++++++++++++ 1 file changed, 99 insertions(+) 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(); + } +}); From ae300292ff05fb39cf28878fdbe42d59ffe02bf0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:26:59 +0000 Subject: [PATCH 4/5] chore(chrome-extension): drop comments that restate the recorder page code --- apps/chrome-extension/src/background/service-worker.ts | 1 - apps/chrome-extension/src/shared/recorder-pages.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index a47dc947cf2..19085678ad0 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -821,7 +821,6 @@ const openRecorderOnCapPage = async (fromTab: chrome.tabs.Tab | null) => { return true; }; -// Opens (never toggles) the panel in a Cap page that asked for it. const showRecorderPanelInTab = async (tab: chrome.tabs.Tab) => { const currentStatus = await syncRecordingStatus().catch( () => recordingStatus, diff --git a/apps/chrome-extension/src/shared/recorder-pages.ts b/apps/chrome-extension/src/shared/recorder-pages.ts index e48bbb05b23..8f0d0302fd4 100644 --- a/apps/chrome-extension/src/shared/recorder-pages.ts +++ b/apps/chrome-extension/src/shared/recorder-pages.ts @@ -10,7 +10,6 @@ const RECORDER_HOST_PATHS = new Set([ "/options.html", ]); -// Opens a Cap page with the recorder panel already showing. export const RECORDER_OPEN_PARAM = "recorder"; export const isRecorderHostPage = (url: string | undefined) => { From 5b98e6a214279517c8db0b623ff07e67f5a0abee Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:26:59 +0000 Subject: [PATCH 5/5] test(chrome-extension): cover replacing a New Tab with the recorder page --- .../src/background/service-worker.test.ts | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) 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(); + }); +});