Skip to content
Merged
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
99 changes: 99 additions & 0 deletions apps/chrome-extension/e2e/overlay-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Welcome recording test never creates the auto-selected capture source

Medium Severity

launchExtensionContext() hard-codes both capture auto-selection flags to Cap Close UI Target, but this test opens only the popup and Welcome page, whose title is Welcome - Cap. Unlike the existing recording tests, it never loads /capture.html or creates another matching source. With the configured fullscreen mode, Start reaches getDisplayMedia() without a selectable matching source, so the recording-status assertion times out. Create a matching capture target before starting, or configure this test's auto-selection to match the Welcome page.

Open in Capy


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();
Comment thread
richiemcilroy marked this conversation as resolved.

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();
}
});
4 changes: 4 additions & 0 deletions apps/chrome-extension/how-it-works.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@
</g>
</svg>
<h1>How Cap works</h1>
<p id="recorder-note" class="paper-pill recorder-note" hidden>
Cap can't open on browser pages like New Tab or Settings, so your
recorder opened here. It opens on any website too.
</p>
<p class="lede">
Instant Mode uploads while you record, so your share link is ready the
moment you stop.
Expand Down
70 changes: 68 additions & 2 deletions apps/chrome-extension/src/background/service-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ let onMessage: MessageListener;
let stored: Record<string, unknown>;
let pollReplies: Array<(response: OffscreenResponse) => void>;
let contextReplies: Array<(contexts: object[]) => void>;
let onActionClicked: (tab: chrome.tabs.Tab) => void;
let tabsApi: Record<string, ReturnType<typeof vi.fn>>;

const liveStatus: RecordingStatus = {
phase: "recording",
Expand All @@ -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: {
Expand All @@ -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");
});
Expand Down Expand Up @@ -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();
});
});
90 changes: 84 additions & 6 deletions apps/chrome-extension/src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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<chrome.tabs.Tab>((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 (
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions apps/chrome-extension/src/how-it-works/main.ts
Original file line number Diff line number Diff line change
@@ -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");
8 changes: 8 additions & 0 deletions apps/chrome-extension/src/how-it-works/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,11 @@
animation: none;
}
}

.recorder-note {
max-width: 460px;
margin-bottom: 18px;
border-radius: 16px;
font-size: 13px;
text-align: left;
}
2 changes: 2 additions & 0 deletions apps/chrome-extension/src/shared/page-nav.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { openRequestedRecorder } from "./recorder-host";
import { loadSettings } from "./storage";

export const PAGE_NAV_LINKS = [
Expand All @@ -18,6 +19,7 @@ const CAP_LOGO_SVG = `<svg class="page-nav-logo" xmlns="http://www.w3.org/2000/s

export const mountPageNav = (active: PageNavId) => {
if (document.querySelector(".page-nav")) return;
openRequestedRecorder();

const nav = document.createElement("nav");
nav.className = "page-nav";
Expand Down
Loading
Loading