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
2 changes: 1 addition & 1 deletion apps/desktop/src/main/upng.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// upng-js has no published types. Declare the slim surface the
// screenshot-compress module uses on the Electron main side: decode a
// PNG buffer, project to RGBA, re-encode with `cnum: 256` to get
// Loom-class palette PNG output.
// compact indexed-palette PNG output.

declare module "upng-js" {
type EncodeBuffer = ArrayBuffer | Uint8Array;
Expand Down
64 changes: 46 additions & 18 deletions apps/extension/PLAN.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# CaptureFlow Browser Extension — Implementation Plan

> Status: **Phases 0–1 built (Phase 1 pending manual verification); Phase 2+ planned.**
> Status: **Phases 0–3 + screenshot mode built (in-page UI); pending manual end-to-end verification.**
> Decisions below are locked unless revised in a follow-up.
> Built with [WXT](https://wxt.dev) as a Manifest V3 extension under `apps/extension`.
> A Loom-style screen recorder that uploads through the **existing** `/api/r/*` (recordings /
> An instant-share screen recorder that uploads through the **existing** `/api/r/*` (recordings /
> `recording` domain) protocol — it is recording **client #3**, alongside the web dashboard and the
> Electron desktop app. No new backend domain.

Expand Down Expand Up @@ -69,6 +69,13 @@ is kept in reserve for a later one-click "record this tab" express mode.

### Decision 2 — Auth: how the extension gets a credential for `/api/r/*`

**SUPERSEDED (as built):** tab sign-in via `externally_connectable`. A signed-out
toolbar click opens `${WEB_BASE}/auth/callback?ext=<runtime.id>`; the callback page posts the
device token back with `chrome.runtime.sendMessage` and the SW verifies the sender
(`isTrustedAuthSender`: exact origin + `/auth/callback` path). Logout on the web propagates the
same way. `launchWebAuthFlow`, the `identity` permission, and the `chromiumapp.org` return are
gone — the original design below is kept for the decision record.

**`chrome.identity.launchWebAuthFlow`**, device-token (Bearer) model, mapped 1:1 onto the
desktop flow:

Expand Down Expand Up @@ -328,8 +335,8 @@ Quota attribution already targets the workspace owner; `isDevDevice` already exe
— the architecture is validated (this is what ruled out `chrome.desktopCapture`, see Decision 1).
Ship gate met: loads unpacked, popup opens, recording lands a byte count, `typecheck/build/format` green.
- **Phase 1 — Auth + screen-only MVP (BUILT, pending manual verification).**
`launchWebAuthFlow` + token/device-id storage shipped; Backend Changes 1, 2 & 3 landed as
separate commits. The offscreen doc records the screen and streams `init → part×N → finalize`,
Tab sign-in via `externally_connectable` (see Decision 2) + token/device-id storage shipped;
Backend Changes 1, 2 & 3 landed as separate commits. The offscreen doc records the screen and streams `init → part×N → finalize`,
and the popup shows the returned `{url}` with a copy button. Stops on the popup Stop button,
the browser's native "Stop sharing" control, or a 30-min client cap.
_Auth is merged into Phase 1 because `init/route.ts:75` requires a bearer token
Expand All @@ -339,21 +346,42 @@ Quota attribution already targets the workspace owner; `isDevDevice` already exe
`device-id.test.ts` pins persistence, `return-target.test.ts` pins the callback allow-list.
**Remaining: manual end-to-end check** (load unpacked → sign in → record → open the link) —
the auth window + native picker can't be automated.
- **Phase 2 — Camera + mic (dual stream) (BUILT except live preview).** Dual-stream upload client
- **Phase 2 — Camera + mic (dual stream) (BUILT, incl. live preview).** Dual-stream upload client
(screen required + best-effort webcam), offscreen webcam recorder (camera + mic, WebM), `hasWebcam`
init, `webcam-part → webcam-finalize`, poster frame, camera/mic pickers + a permissions.html grant
page (getUserMedia only prompts from a tab). Mic rides the webcam stream (Decision 4), so it's
coupled to the camera. **Remaining:** the live cam-bubble preview (a content-script `<video>`) is
deferred to land with Phase 3's content-script work; **screen + mic without a camera** isn't
recorded yet (route mic to the screen track later). Ship gate (dual-track recording, `webcamState ===
ready`) is met pending the same manual end-to-end check as Phase 1.
- **Phase 3 — Control-bar UX & effects.** Shadow-DOM control bar (timer/stop/pause/restart/delete/
cam toggle), re-injection on navigation restoring state from storage, tab-audio loopback via
`AudioContext`, blur/effects, optional one-click `tabCapture` mode. Ship gate: recording survives
a full page navigation with a continuous timer.
- **Later — Screenshot mode.** The popup's screenshot toggle captures a still and posts to
the **screenshot** domain (`/api/s/upload`, `apps/web/lib/screenshot/*`) — a single-shot PUT with its own
CORS (already allows `Authorization`). Kept forked from `recording` (no shared `lib/media/`).
page (getUserMedia only prompts from a tab). The live cam bubble is an extension-origin iframe
(`bubble.html`) injected via `chrome.scripting` (activeTab); it doubles as the grant surface and is
released before recording (one camera can't be opened twice). Opening the popup without a
camera+mic grant injects an invisible `bubble.html?grant=1` frame that asks for **both in one
native prompt**; on Allow both devices flip on and the bubble appears
(`permissions.html?video=1&audio=1` tab on restricted pages). **Screen + mic without a camera**
records via the engine's AAC mic track muxed into the screen fMP4.
- **Recorder surface (BUILT).** There is no anchored action popup (a WXT
`build:manifestGenerated` hook strips `default_popup`): the toolbar click injects
`popup.html?overlay=1` as an extension iframe floating top-right over a blurred/dimmed backdrop.
The iframe is sized exactly to the panel — the app reports its content height through the SW
(`setOverlayHeight` → `chrome.scripting`; a 600ms page-side fallback shows 440px if no report
lands) and the page-side script carries the rounding/shadow — because iframe transparency turns
into an opaque white canvas when the host page's color-scheme differs. Backdrop click / Escape close it,
and it dismisses itself when the native picker opens. Restricted pages fall back to a standalone
popup window (`popup.html?window=1`). Screenshot capture hides the overlay for a frame so the
blur never bakes into the image.
- **Phase 3 — Control-bar UX (BUILT except effects).** Declared content script (`http/https`) renders
a Shadow-DOM control bar — stop, countdown timer, pause/resume, restart, delete, draggable — from
`session:` recording status, so it re-mounts after navigation with a continuous timer. Pause is
gapless: the engine drops frames and shifts later timestamps (webcam pauses via
`MediaRecorder.pause()`). Remaining from the original scope: tab-audio loopback, blur/effects
(popup stubs), optional one-click `tabCapture` mode.
- **Screenshot mode (BUILT).** The popup's screenshot tab captures the visible tab
(`chrome.tabs.captureVisibleTab`, activeTab) and posts the PNG to the **screenshot** domain
(`/api/s/upload`, `apps/web/lib/screenshot/*`) with width/height/title headers. Kept forked from
`recording` (no shared `lib/media/`).
- **Hardening (BUILT).** `POST /api/r/abort` on failure/delete/restart + an SW startup sweep that
aborts a crashed upload (`local:activeUpload` marker with no offscreen doc); finalize falls back
to the `/api/r/state` probe when the response is lost; part pumps wait out offline windows
(mid-flight failures stay fatal); 429/413/401 map to friendly copy and a 401 clears the session;
`GET /api/r/auth/check` probes the token on popup open; a Vitest wire-drift guard
(`tests/wire-types.test.ts`) pins the forked types against `apps/web/lib/recording/types.ts`.

---

Expand Down Expand Up @@ -388,7 +416,7 @@ ready`) is met pending the same manual end-to-end check as Phase 1.
- `format.ts` — `mm:ss` edge cases.
- Web side: a CORS test asserting `Authorization` in allow-headers (Change 1); a `/auth/callback`
return allow-list test (Change 2).
- **Wire-compat guard:** a standalone CI script that reads `apps/extension/lib/api/types.ts` and
- **Wire-compat guard:** `tests/wire-types.test.ts` reads `apps/extension/lib/api/types.ts` and
`apps/web/lib/recording/types.ts` textually and flags drift — **not** a cross-app type import.

**Not unit-tested (integration only):** `desktopCapture`/`getUserMedia`/`MediaRecorder`, offscreen
Expand Down
202 changes: 189 additions & 13 deletions apps/extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import { onMessage, sendMessage, type StartResult } from "@/lib/messaging";
import {
getActiveUpload,
getCapturePrefs,
saveRecordingResult,
setActiveUpload,
setCameraBlocked,
setCapturePrefs,
setRecordingStatus,
} from "@/lib/storage";
import { createRecordingTransport } from "@/lib/api/client";
import {
isTrustedAuthSender,
isTrustedWebOrigin,
openSignInTab,
parseExternalMessage,
} from "@/lib/auth/handoff";
import {
getAuthSession,
setAuthSession,
watchAuthSession,
} from "@/lib/auth/session";
import { getAuthSession, setAuthSession } from "@/lib/auth/session";
import { getDeviceId } from "@/lib/auth/device-id";
import {
BUBBLE_FRAME_ID,
GRANT_FRAME_ID,
mountCameraBubble,
mountGrantFrame,
unmountCameraBubble,
} from "@/lib/overlay/camera-bubble";
import {
RECORDER_BACKDROP_ID,
RECORDER_FRAME_ID,
removeRecorderOverlay,
setRecorderOverlayHeight,
setRecorderOverlayVisible,
toggleRecorderOverlay,
} from "@/lib/overlay/recorder-overlay";

const OFFSCREEN_URL = "offscreen.html";

Expand Down Expand Up @@ -87,6 +96,40 @@ async function releaseCameraBubble(): Promise<void> {
bubbleTabId = undefined;
}

// Tab hosting the invisible camera+mic grant frame (one combined prompt).
let grantTabId: number | undefined;

async function requestMediaGrant(): Promise<void> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.id === undefined) return;
if (isInjectable(tab.url)) {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: mountGrantFrame,
args: [chrome.runtime.getURL("bubble.html?grant=1"), GRANT_FRAME_ID],
});
grantTabId = tab.id;
} else {
await chrome.tabs.create({
url: chrome.runtime.getURL("permissions.html?video=1&audio=1"),
});
}
}

async function removeGrantFrame(): Promise<void> {
if (grantTabId === undefined) return;
try {
await chrome.scripting.executeScript({
target: { tabId: grantTabId },
func: unmountCameraBubble,
args: [GRANT_FRAME_ID],
});
} catch {
/* tab closed or no longer injectable */
}
grantTabId = undefined;
}

// One long-lived offscreen document runs getDisplayMedia + MediaRecorder;
// creating a second silently kills the first, so always guard on hasDocument().
async function ensureOffscreenDocument(): Promise<void> {
Expand All @@ -104,18 +147,86 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

// Signed in → the icon opens the recorder popup; signed out → it has no popup so
// the click fires onClicked, which opens the web sign-in tab (Loom-style).
async function syncActionPopup(): Promise<void> {
// Tab hosting the in-page recorder overlay.
let overlayTabId: number | undefined;

/*
* The recorder opens as an extension iframe floating over a blurred page,
* injected on toolbar click (there is no anchored action popup).
* Restricted pages can't host it, so they get a standalone popup window.
*/
async function toggleOverlayOnActiveTab(): Promise<void> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.id === undefined) return;
if (!isInjectable(tab.url)) {
await chrome.windows.create({
url: chrome.runtime.getURL("popup.html?window=1"),
type: "popup",
width: 372,
height: 660,
});
return;
}
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: toggleRecorderOverlay,
args: [
chrome.runtime.getURL("popup.html?overlay=1"),
RECORDER_FRAME_ID,
RECORDER_BACKDROP_ID,
],
});
overlayTabId = tab.id;
}

async function closeRecorderOverlay(tabId?: number): Promise<void> {
const target = tabId ?? overlayTabId;
if (target === undefined) return;
try {
await chrome.scripting.executeScript({
target: { tabId: target },
func: removeRecorderOverlay,
args: [RECORDER_FRAME_ID, RECORDER_BACKDROP_ID],
});
} catch {
/* tab closed or no longer injectable */
}
if (target === overlayTabId) overlayTabId = undefined;
}

async function onActionClicked(): Promise<void> {
const session = await getAuthSession();
await chrome.action.setPopup({ popup: session ? "popup.html" : "" });
if (!session) {
await openSignInTab();
return;
}
await toggleOverlayOnActiveTab();
}

/*
* An active-upload marker with no offscreen document means the browser (or the
* offscreen doc) died mid-recording: release the server-side multipart so it
* doesn't sit against the user's quota. /api/r/abort authorizes by device id.
*/
async function sweepStaleUpload(): Promise<void> {
const stale = await getActiveUpload();
if (!stale) return;
if (await chrome.offscreen.hasDocument()) return;
await setActiveUpload(null);
const transport = createRecordingTransport(stale.deviceId, null);
await transport.abort({ slug: stale.slug }).catch(() => {});
await setRecordingStatus({ kind: "idle" });
}

export default defineBackground(() => {
void syncActionPopup();
watchAuthSession(() => void syncActionPopup());
void sweepStaleUpload();
// The control bar (an untrusted content-script context) renders from
// session-storage recording state; nothing sensitive lives in session:.
void chrome.storage.session.setAccessLevel({
accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS",
});

chrome.action.onClicked.addListener(() => void openSignInTab());
chrome.action.onClicked.addListener(() => void onActionClicked());

/*
* The web app posts auth (from the callback page) and logout (from any of its
Expand Down Expand Up @@ -165,12 +276,61 @@ export default defineBackground(() => {
setCameraBubble(data.on, data.mic),
);

onMessage("ensureMediaGrant", () => requestMediaGrant());

onMessage("closeRecorderOverlay", ({ sender }) =>
closeRecorderOverlay(sender?.tab?.id),
);

onMessage("setOverlayVisible", async ({ data, sender }) => {
const target = sender?.tab?.id ?? overlayTabId;
if (target === undefined) return;
try {
await chrome.scripting.executeScript({
target: { tabId: target },
func: setRecorderOverlayVisible,
args: [data.visible, RECORDER_FRAME_ID, RECORDER_BACKDROP_ID],
});
} catch {
/* tab closed or no longer injectable */
}
});

onMessage("setOverlayHeight", async ({ data, sender }) => {
const target = sender?.tab?.id ?? overlayTabId;
if (target === undefined) return;
try {
await chrome.scripting.executeScript({
target: { tabId: target },
func: setRecorderOverlayHeight,
args: [data.height, RECORDER_FRAME_ID],
});
} catch {
/* tab closed or no longer injectable */
}
});

// One combined prompt granted → both devices flip on and the live bubble
// appears. A dismissed prompt changes nothing.
onMessage("mediaGrantResult", async ({ data }) => {
await removeGrantFrame();
if (data.granted) {
await setCameraBlocked(false);
const prefs = await getCapturePrefs();
await setCapturePrefs({ ...prefs, camera: true, mic: true });
await setCameraBubble(true, true);
} else if (data.denied) {
await setCameraBlocked(true);
}
});

// The bubble's getUserMedia result is the source of truth for camera access.
onMessage("cameraStatus", async ({ data }) => {
await setCameraBlocked(data.blocked);
if (data.blocked) {
const prefs = await getCapturePrefs();
if (prefs.camera) await setCapturePrefs({ camera: false, mic: false });
// A blocked camera doesn't take the mic down — it records standalone now.
if (prefs.camera) await setCapturePrefs({ ...prefs, camera: false });
await releaseCameraBubble();
}
});
Expand All @@ -182,6 +342,8 @@ export default defineBackground(() => {
const deviceId = await getDeviceId();
const prefs = await getCapturePrefs();
await setRecordingStatus({ kind: "preparing" });
// The panel gets out of the way before the native picker appears.
await closeRecorderOverlay();
if (prefs.camera) await releaseCameraBubble();
await ensureOffscreenDocument();
// Fire-and-forget: the offscreen doc reports back via
Expand All @@ -191,6 +353,8 @@ export default defineBackground(() => {
token: session.token,
camera: prefs.camera,
mic: prefs.mic,
cameraId: prefs.cameraId,
micId: prefs.micId,
}).catch((error) => void reportFailure(errorMessage(error)));
return { ok: true };
} catch (error) {
Expand All @@ -201,14 +365,26 @@ export default defineBackground(() => {
});

onMessage("stopRecording", () => sendMessage("stopCapture", undefined));
onMessage("pauseRecording", () => sendMessage("pauseCapture", undefined));
onMessage("resumeRecording", () => sendMessage("resumeCapture", undefined));
onMessage("restartRecording", () => sendMessage("restartCapture", undefined));
onMessage("deleteRecording", () => sendMessage("deleteCapture", undefined));

onMessage("recordingStatus", ({ data }) => setRecordingStatus(data));

onMessage("activeUploadChanged", ({ data }) => setActiveUpload(data));

onMessage("recordingResult", async ({ data }) => {
await saveRecordingResult(data);
await setRecordingStatus(
data.ok ? { kind: "done" } : { kind: "error", detail: data.error },
);
if (data.ok) {
// Land the user on the fresh recording page.
await chrome.tabs.create({ url: data.url });
} else if (data.code === "invalid_token" || data.code === "missing_token") {
await setAuthSession(null);
}
});
});

Expand Down
Loading
Loading