From a4064657049baf356713d187eb1ea1a4ebc65ebf Mon Sep 17 00:00:00 2001 From: sardorml Date: Thu, 16 Jul 2026 20:21:20 +0500 Subject: [PATCH 01/10] feat(engine): AAC mic track and gapless pause in the web recorders stream-recorder gains an optional micStream muxed as AAC-LC into the screen fMP4 (cross-track-offset keeps A/V aligned; drops to video-only where AAC encoding is unavailable) and pause()/resume() that stop encoding while shifting later chunk timestamps back by the wall-clock pause, so the muxed timeline stays gapless. Timestamps are clamped per track to stay monotonic. The webcam recorder pauses via MediaRecorder.pause()/resume(). --- packages/engine/src/web/stream-recorder.ts | 189 +++++++++++++++++++-- packages/engine/src/web/webcam.ts | 9 + 2 files changed, 183 insertions(+), 15 deletions(-) diff --git a/packages/engine/src/web/stream-recorder.ts b/packages/engine/src/web/stream-recorder.ts index 18b0ba3..e0b42d2 100644 --- a/packages/engine/src/web/stream-recorder.ts +++ b/packages/engine/src/web/stream-recorder.ts @@ -12,6 +12,9 @@ const KEYFRAME_INTERVAL_US = 2_000_000; // Encoder back-pressure bound: drop frames rather than queue unboundedly. const MAX_ENCODE_QUEUE = 2; +const AAC_CODEC = "mp4a.40.2"; +const AUDIO_BITRATE = 128_000; + export type StreamRecorderLog = { info(message: string): void; warn(message: string): void; @@ -20,6 +23,8 @@ export type StreamRecorderLog = { export type StreamRecorderOptions = { /** Display capture; constrain it to the contract's max dims at getDisplayMedia time. */ stream: MediaStream; + /** Mic capture muxed into the fMP4 as AAC-LC; dropped (warn) where AAC encoding is unavailable. */ + micStream?: MediaStream | null; /** Receives muxed fMP4 bytes as they are written (the upload feed). */ output(bytes: Uint8Array, position: number): void; /** Fires when capture ends without stop() — track ended or fatal encode error. */ @@ -36,7 +41,10 @@ export type StreamRecorderResult = { }; export type StreamRecorder = { - /** Idempotent; flushes the encoder and finalizes the muxer. */ + /** Stops encoding new media; the output timeline stays continuous across the gap. */ + pause(): Promise; + resume(): void; + /** Idempotent; flushes the encoders and finalizes the muxer. */ stop(): Promise; }; @@ -66,12 +74,26 @@ async function pickSupportedH264(probe: { return candidates[candidates.length - 1]!; } +async function isAacSupported(config: AudioEncoderConfig): Promise { + try { + const res = await AudioEncoder.isConfigSupported(config); + return res.supported === true; + } catch { + return false; + } +} + /* * The MediaStream frame source of the output contract: getDisplayMedia → - * MediaStreamTrackProcessor → VideoEncoder → the shared fMP4 muxer. Stream- - * driven (no rAF/canvas), so it keeps encoding at full rate in background - * tabs/offscreen documents. This is also the capture path for platforms - * without a native sidecar. + * MediaStreamTrackProcessor → VideoEncoder → the shared fMP4 muxer (+ an + * optional mic → AudioEncoder AAC track). Stream-driven (no rAF/canvas), so it + * keeps encoding at full rate in background tabs/offscreen documents. This is + * also the capture path for platforms without a native sidecar. + * + * Pause drops frames instead of stopping capture, then shifts every later + * chunk's timestamp back by the wall-clock pause total — the muxed timeline is + * gapless. Timestamps are clamped per track so the muxer always sees them + * monotonic even if capture and wall clocks disagree by a few ms. */ export async function startStreamRecorder( opts: StreamRecorderOptions, @@ -88,11 +110,36 @@ export async function startStreamRecorder( const codec = await pickSupportedH264({ width, height, framerate, bitrate }); + const micTrack = opts.micStream?.getAudioTracks()[0] ?? null; + const micSettings = micTrack?.getSettings(); + const audioConfig: AudioEncoderConfig | null = micTrack + ? { + codec: AAC_CODEC, + sampleRate: micSettings?.sampleRate ?? 48_000, + numberOfChannels: micSettings?.channelCount ?? 1, + bitrate: AUDIO_BITRATE, + } + : null; + const withAudio = audioConfig !== null && (await isAacSupported(audioConfig)); + if (audioConfig && !withAudio) { + log.warn( + "stream-recorder: AAC encoding unavailable, recording without mic", + ); + } + const muxer = createFmp4Muxer({ video: { width, height, frameRate: framerate }, + audio: + withAudio && audioConfig + ? { + numberOfChannels: audioConfig.numberOfChannels, + sampleRate: audioConfig.sampleRate, + } + : null, output: opts.output, - // Capture-clock timestamps don't start at zero. - firstTimestampBehavior: "offset", + // Capture-clock timestamps don't start at zero; with a second track both + // must shift by the same amount to keep A/V alignment. + firstTimestampBehavior: withAudio ? "cross-track-offset" : "offset", }); let encoderError: Error | null = null; @@ -101,17 +148,34 @@ export async function startStreamRecorder( let firstTsUs: number | null = null; let lastEndUs = 0; + let paused = false; + let pauseStartedAtMs: number | null = null; + let pauseOffsetUs = 0; + let forceKeyframe = false; + let lastVideoTsUs = -Infinity; + let lastAudioTsUs = -Infinity; + const encoder = new VideoEncoder({ output: (chunk, meta) => { + const data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + const tsUs = Math.max(chunk.timestamp - pauseOffsetUs, lastVideoTsUs + 1); + lastVideoTsUs = tsUs; try { - muxer.addVideoChunk(chunk, meta); + muxer.addVideoChunkRaw( + data, + chunk.type, + tsUs, + chunk.duration ?? 0, + meta, + ); } catch (err) { encoderError = err instanceof Error ? err : new Error(String(err)); return; } encodedFrames++; - if (firstTsUs === null) firstTsUs = chunk.timestamp; - lastEndUs = chunk.timestamp + (chunk.duration ?? 0); + if (firstTsUs === null) firstTsUs = tsUs; + lastEndUs = tsUs + (chunk.duration ?? 0); }, error: (err) => { encoderError = err; @@ -126,8 +190,38 @@ export async function startStreamRecorder( hardwareAcceleration: "prefer-hardware", avc: { format: "avc" }, }); + + // Mic encode errors are contained: the screen capture is load-bearing, the + // mic track is best-effort. + let audioFailed = false; + const audioEncoder = + withAudio && audioConfig + ? new AudioEncoder({ + output: (chunk, meta) => { + const data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + const tsUs = Math.max( + chunk.timestamp - pauseOffsetUs, + lastAudioTsUs + 1, + ); + lastAudioTsUs = tsUs; + try { + muxer.addAudioChunkRaw(data, tsUs, chunk.duration ?? 0, meta); + } catch (err) { + audioFailed = true; + log.warn(`stream-recorder: audio mux failed (${String(err)})`); + } + }, + error: (err) => { + audioFailed = true; + log.warn(`stream-recorder: audio encode failed (${String(err)})`); + }, + }) + : null; + if (audioEncoder && audioConfig) audioEncoder.configure(audioConfig); + log.info( - `stream-recorder: started (${width}x${height}@${Math.round(framerate)}fps, ${codec})`, + `stream-recorder: started (${width}x${height}@${Math.round(framerate)}fps, ${codec}${audioEncoder ? " + aac mic" : ""})`, ); const reader = new MediaStreamTrackProcessor({ @@ -145,18 +239,44 @@ export async function startStreamRecorder( frame.close(); break; } - if (encoder.encodeQueueSize > MAX_ENCODE_QUEUE) { - droppedFrames++; + if (paused || encoder.encodeQueueSize > MAX_ENCODE_QUEUE) { + if (!paused) droppedFrames++; frame.close(); continue; } - const keyFrame = frame.timestamp - lastKeyUs >= KEYFRAME_INTERVAL_US; - if (keyFrame) lastKeyUs = frame.timestamp; + const keyFrame = + forceKeyframe || frame.timestamp - lastKeyUs >= KEYFRAME_INTERVAL_US; + if (keyFrame) { + lastKeyUs = frame.timestamp; + forceKeyframe = false; + } encoder.encode(frame, { keyFrame }); frame.close(); } })(); + const audioReader = audioEncoder + ? new MediaStreamTrackProcessor({ + track: micTrack!, + }).readable.getReader() + : null; + + const audioReadLoop = audioReader + ? (async () => { + for (;;) { + const { done, value: data } = await audioReader.read(); + if (done || !data) break; + if (stopping || audioFailed || paused) { + data.close(); + if (stopping || audioFailed) break; + continue; + } + audioEncoder!.encode(data); + data.close(); + } + })() + : null; + async function finish(): Promise { stopping = true; try { @@ -164,7 +284,13 @@ export async function startStreamRecorder( } catch { /* already done */ } + try { + await audioReader?.cancel(); + } catch { + /* already done */ + } await readLoop.catch(() => {}); + await audioReadLoop?.catch(() => {}); if (encoder.state === "configured") { await encoder.flush().catch(() => {}); } @@ -173,6 +299,16 @@ export async function startStreamRecorder( } catch { /* already closed */ } + if (audioEncoder) { + if (audioEncoder.state === "configured") { + await audioEncoder.flush().catch(() => {}); + } + try { + audioEncoder.close(); + } catch { + /* already closed */ + } + } if (encoderError) throw encoderError; muxer.finalize(); const durationMs = @@ -197,6 +333,29 @@ export async function startStreamRecorder( let finishPromise: Promise | null = null; return { + async pause() { + if (paused || stopping) return; + paused = true; + pauseStartedAtMs = performance.now(); + // Drain in-flight chunks now so none are emitted after the offset grows. + if (encoder.state === "configured") { + await encoder.flush().catch(() => {}); + } + if (audioEncoder?.state === "configured") { + await audioEncoder.flush().catch(() => {}); + } + }, + resume() { + if (!paused || stopping) return; + if (pauseStartedAtMs !== null) { + pauseOffsetUs += Math.round( + (performance.now() - pauseStartedAtMs) * 1_000, + ); + pauseStartedAtMs = null; + } + forceKeyframe = true; + paused = false; + }, stop() { finishPromise ??= finish(); return finishPromise; diff --git a/packages/engine/src/web/webcam.ts b/packages/engine/src/web/webcam.ts index 5b48448..81ed930 100644 --- a/packages/engine/src/web/webcam.ts +++ b/packages/engine/src/web/webcam.ts @@ -100,6 +100,8 @@ export type WebcamRecorderOptions = { }; export type WebcamRecorder = { + pause(): void; + resume(): void; stop(): Promise<{ totalBytes: number }>; abort(): void; }; @@ -148,6 +150,13 @@ export function startWebcamRecorder( log.info(`webcam-recorder: started (mime=${mimeType})`); return { + // MediaRecorder keeps the WebM timeline continuous across pause/resume. + pause() { + if (recorder.state === "recording") recorder.pause(); + }, + resume() { + if (recorder.state === "paused") recorder.resume(); + }, async stop() { await new Promise((resolve) => { if (recorder.state === "inactive") { From 9771191c786e085f252c935f11248f7ff13c45d4 Mon Sep 17 00:00:00 2001 From: sardorml Date: Thu, 16 Jul 2026 20:21:20 +0500 Subject: [PATCH 02/10] feat(extension): harden the upload client - abort() now releases the server-side multipart via POST /api/r/abort (idempotent, best-effort) instead of only clearing local buffers - a finalize lost to the network falls back to the no-auth GET /api/r/state probe: state === ready means the idempotent finalize already applied - queued parts wait out offline windows and resume on the online signal; a request that dies mid-flight stays fatal (desktop parity) - 429/413/401 map to actionable copy via friendlyUploadError; checkAuth probes GET /api/r/auth/check so a revoked token is caught early --- apps/extension/lib/api/client.ts | 34 +++++ apps/extension/lib/api/errors.ts | 27 ++++ apps/extension/lib/api/types.ts | 13 ++ apps/extension/lib/api/upload-streamer.ts | 82 +++++++++++- apps/extension/tests/errors.test.ts | 44 ++++++ apps/extension/tests/upload-streamer.test.ts | 134 ++++++++++++++++++- 6 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 apps/extension/lib/api/errors.ts create mode 100644 apps/extension/tests/errors.test.ts diff --git a/apps/extension/lib/api/client.ts b/apps/extension/lib/api/client.ts index 637b5a4..b245eaf 100644 --- a/apps/extension/lib/api/client.ts +++ b/apps/extension/lib/api/client.ts @@ -1,16 +1,21 @@ import { WEB_BASE } from "../config"; import type { + AbortRequest, FinalizeRequest, FinalizeResponse, InitRequest, InitResponse, PartResponse, RecordingApiError, + StateResponse, UploadTransport, } from "./types"; export const RECORDING_API_BASE = `${WEB_BASE}/api/r`; +export const recordingViewUrl = (slug: string): string => + `${WEB_BASE}/r/${slug}`; + export class RecordingApiHttpError extends Error { readonly status: number; readonly code: string | undefined; @@ -108,6 +113,25 @@ async function postBytes( const partPath = (route: string, slug: string, partNumber: number): string => `/${route}?slug=${encodeURIComponent(slug)}&part=${partNumber}`; +export type AuthCheckResult = "ok" | "invalid" | "unreachable"; + +// 401 → sign-in revoked (clear the local session); network/5xx → inconclusive, +// keep the session and let the next recording surface the real error. +export async function checkAuth( + deviceId: string, + token: string, +): Promise { + try { + const res = await fetch(`${RECORDING_API_BASE}/auth/check`, { + headers: recordingHeaders(deviceId, token), + }); + if (res.ok) return "ok"; + return res.status === 401 ? "invalid" : "unreachable"; + } catch { + return "unreachable"; + } +} + export function createRecordingTransport( deviceId: string, token: string | null, @@ -142,5 +166,15 @@ export function createRecordingTransport( "image/jpeg", ); }, + abort: async (req: AbortRequest) => { + await postJson<{ ok: true }>("/abort", deviceId, token, req); + }, + state: async (slug: string) => { + const res = await fetch( + `${RECORDING_API_BASE}/state?slug=${encodeURIComponent(slug)}`, + ); + return parseResponse(res, "/state"); + }, + viewUrl: recordingViewUrl, }; } diff --git a/apps/extension/lib/api/errors.ts b/apps/extension/lib/api/errors.ts new file mode 100644 index 0000000..2ebfe36 --- /dev/null +++ b/apps/extension/lib/api/errors.ts @@ -0,0 +1,27 @@ +import { RecordingApiHttpError } from "./client"; + +const FRIENDLY_BY_CODE: Record = { + active_limit: + "Recording limit reached — delete old recordings or upgrade your plan.", + storage_limit: + "Storage is full — delete old recordings or upgrade your plan.", + duration_exceeded: "This recording exceeds your plan's duration limit.", + invalid_token: "Your sign-in expired. Sign in again to keep recording.", + missing_token: "Sign in to record.", +}; + +export function isAuthFailure(err: unknown): boolean { + return err instanceof RecordingApiHttpError && err.status === 401; +} + +export function friendlyUploadError(err: unknown): string { + if (err instanceof RecordingApiHttpError) { + const friendly = err.code ? FRIENDLY_BY_CODE[err.code] : undefined; + if (friendly) return friendly; + return err.message; + } + if (err instanceof TypeError) { + return "Upload failed — check your connection and try again."; + } + return err instanceof Error ? err.message : String(err); +} diff --git a/apps/extension/lib/api/types.ts b/apps/extension/lib/api/types.ts index 97a69bd..9eba8e9 100644 --- a/apps/extension/lib/api/types.ts +++ b/apps/extension/lib/api/types.ts @@ -44,11 +44,21 @@ export type FinalizeResponse = { url: string; }; +export type AbortRequest = { + slug: string; +}; + export type RecordingApiError = { error: string; code?: string; }; +// The no-auth probe: /api/r/state answers "ready" once a finalize has been +// applied, letting the client treat a finalize lost to the network as success. +export type StateResponse = { + state: string; +}; + // finalizeWebcam returns void: the webcam is best-effort, so its `{ ok }` body // is unused. export type UploadTransport = { @@ -66,4 +76,7 @@ export type UploadTransport = { finalizeScreen(req: FinalizeRequest): Promise; finalizeWebcam(req: FinalizeRequest): Promise; uploadPoster(slug: string, bytes: Uint8Array): Promise; + abort(req: AbortRequest): Promise; + state(slug: string): Promise; + viewUrl(slug: string): string; }; diff --git a/apps/extension/lib/api/upload-streamer.ts b/apps/extension/lib/api/upload-streamer.ts index 4db4077..863d03e 100644 --- a/apps/extension/lib/api/upload-streamer.ts +++ b/apps/extension/lib/api/upload-streamer.ts @@ -22,11 +22,33 @@ type PartStream = { abort(): void; }; -// One multipart stream, one request in flight. Fail-fast: a failed part rejects -// drain(). +export type OnlineSignal = { + isOnline(): boolean; + onOnline(cb: () => void): () => void; +}; + +// Offscreen documents get the standard connectivity events. +function defaultOnlineSignal(): OnlineSignal { + return { + isOnline: () => + typeof navigator === "undefined" || navigator.onLine !== false, + onOnline: (cb) => { + if (typeof window === "undefined") return () => {}; + window.addEventListener("online", cb); + return () => window.removeEventListener("online", cb); + }, + }; +} + +/* + * One multipart stream, one request in flight. Fail-fast: a failed part rejects + * drain(). Queued parts wait out an offline window instead of failing — only a + * request that dies mid-flight is fatal (desktop parity). + */ function createPartStream( uploadPart: PartUploader, chunkBytes: number, + online: OnlineSignal, ): PartStream { let partNumber = 1; const etags: PartRef[] = []; @@ -38,6 +60,26 @@ function createPartStream( // Guards part-number claiming: once draining, a late push can't race the pump. let draining = false; let failure: unknown = null; + let unsubOnline: (() => void) | null = null; + + function pumpWhenOnline(): void { + if (unsubOnline) return; + unsubOnline = online.onOnline(() => { + unsubOnline?.(); + unsubOnline = null; + pump(); + }); + } + + function waitOnline(): Promise { + if (online.isOnline()) return Promise.resolve(); + return new Promise((resolve) => { + const unsub = online.onOnline(() => { + unsub(); + resolve(); + }); + }); + } function takePart(maxBytes: number): Uint8Array { const target = Math.min(bufBytes, maxBytes); @@ -66,6 +108,10 @@ function createPartStream( if (aborted || draining || failure) return; if (inFlight) return; if (bufBytes < chunkBytes) return; + if (!online.isOnline()) { + pumpWhenOnline(); + return; + } const bytes = takePart(chunkBytes); const n = partNumber++; inFlight = (async () => { @@ -106,10 +152,12 @@ function createPartStream( // R2 rejects a non-trailing part that isn't CHUNK_BYTES, so split a tail // larger than one chunk into full parts plus one smaller trailing part. while (bufBytes > chunkBytes) { + await waitOnline(); const res = await uploadPart(partNumber++, takePart(chunkBytes)); etags.push({ partNumber: res.partNumber, etag: res.etag }); } if (bufBytes > 0) { + await waitOnline(); const res = await uploadPart(partNumber++, takePart(bufBytes)); etags.push({ partNumber: res.partNumber, etag: res.etag }); } @@ -119,6 +167,8 @@ function createPartStream( aborted = true; buf = []; bufBytes = 0; + unsubOnline?.(); + unsubOnline = null; }, }; } @@ -126,6 +176,7 @@ function createPartStream( export type RecordingUploadOptions = { transport: UploadTransport; chunkBytes?: number; + online?: OnlineSignal; }; export type RecordingUpload = { @@ -149,19 +200,23 @@ export async function startRecordingUpload( options: RecordingUploadOptions, ): Promise { const { transport, chunkBytes = CHUNK_BYTES } = options; + const online = options.online ?? defaultOnlineSignal(); const res = await transport.init(init); const slug = res.slug; const screen = createPartStream( (n, bytes) => transport.uploadScreenPart(slug, n, bytes), chunkBytes, + online, ); const webcam = res.webcamUploadId ? createPartStream( (n, bytes) => transport.uploadWebcamPart(slug, n, bytes), chunkBytes, + online, ) : null; + let aborted = false; return { slug, @@ -178,11 +233,20 @@ export async function startRecordingUpload( async finish(): Promise { const screenParts = await screen.drain(); if (screenParts.length === 0) throw new Error("No screen parts uploaded"); - const result = await transport.finalizeScreen({ - slug, - parts: screenParts, - sizeBytes: screen.totalBytes, - }); + let result: FinalizeResponse; + try { + result = await transport.finalizeScreen({ + slug, + parts: screenParts, + sizeBytes: screen.totalBytes, + }); + } catch (err) { + // A finalize lost to the network may still have been applied — the + // no-auth state probe disambiguates (finalize is idempotent). + const probe = await transport.state(slug).catch(() => null); + if (probe?.state !== "ready") throw err; + result = { url: transport.viewUrl(slug) }; + } if (webcam) { try { const webcamParts = await webcam.drain(); @@ -202,6 +266,10 @@ export async function startRecordingUpload( abort(): void { screen.abort(); webcam?.abort(); + if (aborted) return; + aborted = true; + // Release the server-side multipart + pending row; best-effort. + void transport.abort({ slug }).catch(() => {}); }, }; } diff --git a/apps/extension/tests/errors.test.ts b/apps/extension/tests/errors.test.ts new file mode 100644 index 0000000..e3701df --- /dev/null +++ b/apps/extension/tests/errors.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { RecordingApiHttpError } from "../lib/api/client"; +import { friendlyUploadError, isAuthFailure } from "../lib/api/errors"; + +describe("friendlyUploadError", () => { + it("maps quota codes to actionable copy", () => { + const err = new RecordingApiHttpError( + "/init: HTTP 429", + 429, + "active_limit", + ); + expect(friendlyUploadError(err)).toMatch(/Recording limit reached/); + }); + + it("maps a revoked token to a re-sign-in prompt", () => { + const err = new RecordingApiHttpError( + "/init: HTTP 401", + 401, + "invalid_token", + ); + expect(friendlyUploadError(err)).toMatch(/Sign in again/); + }); + + it("keeps the server message for unmapped codes", () => { + const err = new RecordingApiHttpError("/init: teapot", 418, "teapot"); + expect(friendlyUploadError(err)).toBe("/init: teapot"); + }); + + it("turns fetch-level failures into a connectivity hint", () => { + expect(friendlyUploadError(new TypeError("Failed to fetch"))).toMatch( + /connection/, + ); + }); +}); + +describe("isAuthFailure", () => { + it("is true only for HTTP 401", () => { + expect( + isAuthFailure(new RecordingApiHttpError("x", 401, "invalid_token")), + ).toBe(true); + expect(isAuthFailure(new RecordingApiHttpError("x", 429))).toBe(false); + expect(isAuthFailure(new Error("x"))).toBe(false); + }); +}); diff --git a/apps/extension/tests/upload-streamer.test.ts b/apps/extension/tests/upload-streamer.test.ts index 05a4018..e3ae07e 100644 --- a/apps/extension/tests/upload-streamer.test.ts +++ b/apps/extension/tests/upload-streamer.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { startRecordingUpload } from "../lib/api/upload-streamer"; +import { + startRecordingUpload, + type OnlineSignal, +} from "../lib/api/upload-streamer"; import type { FinalizeRequest, InitRequest, @@ -16,6 +19,7 @@ type FakeTransport = UploadTransport & { finalizedScreen: FinalizeRequest | null; finalizedWebcam: FinalizeRequest | null; posterBytes: number | null; + abortedSlugs: string[]; }; function fakeTransport( @@ -26,6 +30,7 @@ function fakeTransport( let finalizedScreen: FinalizeRequest | null = null; let finalizedWebcam: FinalizeRequest | null = null; let posterBytes: number | null = null; + const abortedSlugs: string[] = []; const base: UploadTransport = { init: async () => ({ @@ -57,6 +62,11 @@ function fakeTransport( uploadPoster: async (_slug, bytes) => { posterBytes = bytes.byteLength; }, + abort: async (req) => { + abortedSlugs.push(req.slug); + }, + state: async () => ({ state: "pending" }), + viewUrl: (slug) => `https://captureflow.xyz/r/${slug}`, }; const transport = { ...base, ...opts.overrides }; @@ -77,11 +87,39 @@ function fakeTransport( get posterBytes() { return posterBytes; }, + get abortedSlugs() { + return abortedSlugs; + }, + }; +} + +// Manually driven connectivity: flip `online` and fire the queued listeners. +function fakeOnline(initial = true) { + let online = initial; + const listeners = new Set<() => void>(); + const signal: OnlineSignal = { + isOnline: () => online, + onOnline: (cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + }; + return { + signal, + set(next: boolean) { + online = next; + if (online) { + for (const cb of [...listeners]) cb(); + } + }, }; } const bytes = (n: number): Uint8Array => new Uint8Array(n); +const tick = (): Promise => + new Promise((resolve) => setTimeout(resolve, 0)); + describe("startRecordingUpload — screen stream", () => { it("buffers below the chunk size and ships one trailing part on finish", async () => { const transport = fakeTransport(); @@ -232,3 +270,97 @@ describe("startRecordingUpload — poster", () => { expect(transport.posterBytes).toBe(512); }); }); + +describe("startRecordingUpload — abort", () => { + it("releases the server-side upload exactly once", async () => { + const transport = fakeTransport(); + const upload = await startRecordingUpload(INIT, { + transport, + chunkBytes: 100, + }); + upload.pushScreen(bytes(40)); + upload.abort(); + upload.abort(); + await tick(); + expect(transport.abortedSlugs).toEqual(["abc12345"]); + }); +}); + +describe("startRecordingUpload — offline handling", () => { + it("holds queued parts while offline and resumes on the online signal", async () => { + const transport = fakeTransport(); + const net = fakeOnline(false); + const upload = await startRecordingUpload(INIT, { + transport, + chunkBytes: 100, + online: net.signal, + }); + + upload.pushScreen(bytes(250)); + await tick(); + expect(transport.screenParts).toHaveLength(0); + + net.set(true); + await tick(); + expect(transport.screenParts.map((p) => p.size)).toEqual([100, 100]); + + await upload.finish(); + expect(transport.screenParts.map((p) => p.size)).toEqual([100, 100, 50]); + }); + + it("waits for connectivity before draining the tail", async () => { + const transport = fakeTransport(); + const net = fakeOnline(true); + const upload = await startRecordingUpload(INIT, { + transport, + chunkBytes: 100, + online: net.signal, + }); + + upload.pushScreen(bytes(40)); + net.set(false); + const finishing = upload.finish(); + await tick(); + expect(transport.screenParts).toHaveLength(0); + + net.set(true); + await finishing; + expect(transport.screenParts).toEqual([{ partNumber: 1, size: 40 }]); + }); +}); + +describe("startRecordingUpload — finalize fallback", () => { + it("treats a network-lost finalize as success when the state probe says ready", async () => { + const transport = fakeTransport({ + overrides: { + finalizeScreen: async () => { + throw new TypeError("fetch failed"); + }, + state: async () => ({ state: "ready" }), + }, + }); + const upload = await startRecordingUpload(INIT, { + transport, + chunkBytes: 100, + }); + upload.pushScreen(bytes(40)); + const res = await upload.finish(); + expect(res.url).toBe("https://captureflow.xyz/r/abc12345"); + }); + + it("rethrows the finalize failure when the recording is not ready", async () => { + const transport = fakeTransport({ + overrides: { + finalizeScreen: async () => { + throw new TypeError("fetch failed"); + }, + }, + }); + const upload = await startRecordingUpload(INIT, { + transport, + chunkBytes: 100, + }); + upload.pushScreen(bytes(40)); + await expect(upload.finish()).rejects.toThrow("fetch failed"); + }); +}); From c8d8faa5780cb90db1cb7504104ec122d6674a31 Mon Sep 17 00:00:00 2001 From: sardorml Date: Thu, 16 Jul 2026 20:21:20 +0500 Subject: [PATCH 03/10] test(extension): pin the forked wire types against apps/web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Textual drift guard (PLAN.md Decision 5): extracts the shared type declarations from both files, normalises comments/whitespace, and fails on any mismatch — without a cross-app import that would violate the boundary rule. --- apps/extension/tests/wire-types.test.ts | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 apps/extension/tests/wire-types.test.ts diff --git a/apps/extension/tests/wire-types.test.ts b/apps/extension/tests/wire-types.test.ts new file mode 100644 index 0000000..a689bfa --- /dev/null +++ b/apps/extension/tests/wire-types.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/* + * Drift guard for the deliberately forked wire types (PLAN.md Decision 5): the + * extension may not import from apps/web, so this compares the shared type + * declarations textually. A mismatch means one side of the protocol changed — + * update both files. + */ + +const SHARED_TYPES = [ + "RecordingVisibility", + "InitRequest", + "InitResponse", + "PartResponse", + "FinalizeRequest", + "FinalizeResponse", + "AbortRequest", + "RecordingApiError", +] as const; + +const read = (relative: string): string => + readFileSync(fileURLToPath(new URL(relative, import.meta.url)), "utf8"); + +function extractTypes(source: string): Map { + const stripped = source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, ""); + const types = new Map(); + const re = /export type (\w+) =/g; + for (let match = re.exec(stripped); match; match = re.exec(stripped)) { + let index = re.lastIndex; + let depth = 0; + while (index < stripped.length) { + const ch = stripped[index]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + else if (ch === ";" && depth === 0) break; + index++; + } + types.set( + match[1]!, + stripped.slice(re.lastIndex, index).replace(/\s+/g, " ").trim(), + ); + } + return types; +} + +describe("wire types stay in sync with apps/web", () => { + const extension = extractTypes(read("../lib/api/types.ts")); + const web = extractTypes(read("../../web/lib/recording/types.ts")); + + it.each([...SHARED_TYPES])("%s matches", (name) => { + expect(extension.get(name), `${name} missing in extension`).toBeDefined(); + expect(web.get(name), `${name} missing in web`).toBeDefined(); + expect(extension.get(name)).toBe(web.get(name)); + }); +}); From 4fa0556b4898c72b3a120da19a6640c03fb55653 Mon Sep 17 00:00:00 2001 From: sardorml Date: Thu, 16 Jul 2026 20:21:47 +0500 Subject: [PATCH 04/10] feat(extension): pause/resume, restart, delete, and mic-only recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recorder runs as restartable sessions over one set of acquired streams: restart discards the upload (server abort included) and re-inits without re-showing the picker; delete aborts and stops the capture. Pause drives the engine's gapless pause on both tracks and the 30-min cap only counts active time. Without a camera the mic no longer drops — it rides the screen fMP4 as the engine's AAC track. RecordingStatus carries startedAt/paused timing so any surface can derive the live clock, and the active-upload crash marker is relayed to the SW (offscreen documents cannot touch chrome.storage). --- apps/extension/entrypoints/offscreen/main.ts | 14 +- apps/extension/lib/capture/limits.ts | 4 + apps/extension/lib/capture/recorder.ts | 248 +++++++++++++++---- apps/extension/lib/format.ts | 7 + apps/extension/lib/messaging.ts | 28 ++- apps/extension/lib/storage.ts | 48 +++- apps/extension/tests/format.test.ts | 15 +- 7 files changed, 299 insertions(+), 65 deletions(-) create mode 100644 apps/extension/lib/capture/limits.ts diff --git a/apps/extension/entrypoints/offscreen/main.ts b/apps/extension/entrypoints/offscreen/main.ts index de707db..7db5d6d 100644 --- a/apps/extension/entrypoints/offscreen/main.ts +++ b/apps/extension/entrypoints/offscreen/main.ts @@ -1,5 +1,12 @@ import { onMessage, sendMessage } from "@/lib/messaging"; -import { recordAndUpload, stopActiveRecording } from "@/lib/capture/recorder"; +import { + deleteActiveRecording, + pauseActiveRecording, + recordAndUpload, + restartActiveRecording, + resumeActiveRecording, + stopActiveRecording, +} from "@/lib/capture/recorder"; /* * getDisplayMedia runs here in an offscreen doc created with the DISPLAY_MEDIA @@ -10,7 +17,12 @@ onMessage("beginCapture", ({ data }) => recordAndUpload(data, { onStatus: (status) => void sendMessage("recordingStatus", status), onResult: (result) => void sendMessage("recordingResult", result), + onActiveUpload: (upload) => void sendMessage("activeUploadChanged", upload), }), ); onMessage("stopCapture", () => stopActiveRecording()); +onMessage("pauseCapture", () => pauseActiveRecording()); +onMessage("resumeCapture", () => resumeActiveRecording()); +onMessage("restartCapture", () => restartActiveRecording()); +onMessage("deleteCapture", () => deleteActiveRecording()); diff --git a/apps/extension/lib/capture/limits.ts b/apps/extension/lib/capture/limits.ts new file mode 100644 index 0000000..82994aa --- /dev/null +++ b/apps/extension/lib/capture/limits.ts @@ -0,0 +1,4 @@ +// The server can't cap a live stream's length (no duration known at init), so +// the client enforces a hard ceiling. Standalone module: the control bar +// content script needs the number without pulling in the recorder bundle. +export const MAX_DURATION_MS = 30 * 60 * 1000; diff --git a/apps/extension/lib/capture/recorder.ts b/apps/extension/lib/capture/recorder.ts index 7eb0f1b..8aaef93 100644 --- a/apps/extension/lib/capture/recorder.ts +++ b/apps/extension/lib/capture/recorder.ts @@ -5,29 +5,60 @@ import { type StreamRecorder, type WebcamRecorder, } from "@captureflow/engine/web"; -import { createRecordingTransport } from "../api/client"; +import { createRecordingTransport, RecordingApiHttpError } from "../api/client"; +import { friendlyUploadError } from "../api/errors"; import { startRecordingUpload, type RecordingUpload, } from "../api/upload-streamer"; import type { CaptureContext } from "../messaging"; -import type { RecordingResultPayload, RecordingStatus } from "../storage"; - -// The server can't cap a live stream's length (no duration known at init), so -// enforce a hard client-side ceiling. -const MAX_DURATION_MS = 30 * 60 * 1000; +import type { + ActiveUpload, + RecordingResultPayload, + RecordingStatus, +} from "../storage"; +import { MAX_DURATION_MS } from "./limits"; type Callbacks = { onStatus: (status: RecordingStatus) => void; onResult: (result: RecordingResultPayload) => void; + // Relayed to the SW: chrome.storage is unavailable in offscreen documents. + onActiveUpload: (upload: ActiveUpload | null) => void; +}; + +type SessionCommands = { + stop(): void; + pause(): void; + resume(): void; + restart(): void; + discard(): void; }; +type SessionEnd = "done" | "restart"; + let sessionActive = false; -let activeStop: (() => void) | null = null; +let commands: SessionCommands | null = null; export function stopActiveRecording(): void { - activeStop?.(); + commands?.stop(); +} +export function pauseActiveRecording(): void { + commands?.pause(); +} +export function resumeActiveRecording(): void { + commands?.resume(); +} +export function restartActiveRecording(): void { + commands?.restart(); } +export function deleteActiveRecording(): void { + commands?.discard(); +} + +const deviceConstraint = ( + deviceId: string | undefined, +): MediaTrackConstraints | boolean => + deviceId ? { deviceId: { ideal: deviceId } } : true; export async function recordAndUpload( ctx: CaptureContext, @@ -55,7 +86,7 @@ export async function recordAndUpload( if (err instanceof DOMException && err.name === "NotAllowedError") { cb.onStatus({ kind: "cancelled" }); } else { - cb.onResult({ ok: false, error: errorMessage(err) }); + cb.onResult(failure(err)); } return; } @@ -66,13 +97,65 @@ export async function recordAndUpload( if (ctx.camera) { try { webcamStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: ctx.mic, + video: deviceConstraint(ctx.cameraId), + audio: ctx.mic ? deviceConstraint(ctx.micId) : false, }); } catch { webcamStream = null; } } + // Without a camera the mic can't ride the webcam track, so it is muxed into + // the screen fMP4 as AAC instead. + let micStream: MediaStream | null = null; + if (!webcamStream && ctx.mic) { + try { + micStream = await navigator.mediaDevices.getUserMedia({ + video: false, + audio: deviceConstraint(ctx.micId), + }); + } catch { + micStream = null; + } + } + + const stopAllTracks = (): void => { + stopTracks(screenStream); + if (webcamStream) stopTracks(webcamStream); + if (micStream) stopTracks(micStream); + }; + + // Restart discards the session's upload but keeps the acquired streams, so + // no picker or permission prompt re-appears. + try { + for (;;) { + const end = await runSession( + ctx, + cb, + { screenStream, webcamStream, micStream }, + stopAllTracks, + ); + if (end !== "restart") break; + } + } finally { + stopAllTracks(); + commands = null; + sessionActive = false; + } +} + +type SessionStreams = { + screenStream: MediaStream; + webcamStream: MediaStream | null; + micStream: MediaStream | null; +}; + +async function runSession( + ctx: CaptureContext, + cb: Callbacks, + streams: SessionStreams, + stopAllTracks: () => void, +): Promise { + const { screenStream, webcamStream, micStream } = streams; let upload: RecordingUpload; try { @@ -86,34 +169,37 @@ export async function recordAndUpload( { transport }, ); } catch (err) { - sessionActive = false; - stopTracks(screenStream); - if (webcamStream) stopTracks(webcamStream); - cb.onResult({ ok: false, error: errorMessage(err) }); - return; + cb.onResult(failure(err)); + return "done"; } + cb.onActiveUpload({ slug: upload.slug, deviceId: ctx.deviceId }); - const startedAt = Date.now(); - let capTimer: ReturnType | undefined; + let endSession!: (end: SessionEnd) => void; + const sessionEnd = new Promise((resolve) => { + endSession = resolve; + }); + let ended = false; + // `session` is created after the recorders; the ref keeps a track that ends + // in that window from hitting the uninitialized binding. + let sessionRef: SessionCommands | null = null; let screenRecorder: StreamRecorder; try { screenRecorder = await startStreamRecorder({ stream: screenStream, + micStream, // fMP4 fragments are strictly appended, so position is ignorable; copy // because the muxer reuses its output buffer. output: (bytes) => upload.pushScreen(bytes.slice()), // Covers the browser's native "Stop sharing" control and fatal encode // errors — both end the whole recording. - onEnded: () => stopActiveRecording(), + onEnded: () => sessionRef?.stop(), }); } catch (err) { - sessionActive = false; upload.abort(); - stopTracks(screenStream); - if (webcamStream) stopTracks(webcamStream); - cb.onResult({ ok: false, error: errorMessage(err) }); - return; + cb.onActiveUpload(null); + cb.onResult(failure(err)); + return "done"; } // A webcam recorder failure is contained to its own stream so the @@ -128,53 +214,125 @@ export async function recordAndUpload( onChunk: (buf) => upload.pushWebcam(new Uint8Array(buf)), }); } catch { - stopTracks(webcamStream); - webcamStream = null; + webcamRecorder = null; } } + const startedAt = Date.now(); + let pausedMs = 0; + let pausedAt: number | null = null; + const activeElapsed = (): number => + (pausedAt ?? Date.now()) - startedAt - pausedMs; + + let capTimer = setTimeout(() => session.stop(), MAX_DURATION_MS); + + const stopRecorders = async (): Promise => { + await Promise.all([ + screenRecorder.stop().catch(() => null), + webcamRecorder?.stop().catch(() => null) ?? Promise.resolve(null), + ]); + }; + const finalize = async (): Promise => { - if (capTimer) clearTimeout(capTimer); - activeStop = null; cb.onStatus({ kind: "uploading" }); try { await Promise.all([ screenRecorder.stop(), webcamRecorder?.stop() ?? Promise.resolve(null), ]); - stopTracks(screenStream); - if (webcamStream) stopTracks(webcamStream); + // Release the capture (sharing indicator) before the tail upload. + stopAllTracks(); const { url } = await upload.finish(); + cb.onActiveUpload(null); cb.onResult({ ok: true, url, bytes: upload.screenBytes + upload.webcamBytes, - durationMs: Date.now() - startedAt, + durationMs: activeElapsed(), }); } catch (err) { - stopTracks(screenStream); - if (webcamStream) stopTracks(webcamStream); + stopAllTracks(); upload.abort(); - cb.onResult({ ok: false, error: errorMessage(err) }); - } finally { - sessionActive = false; + cb.onActiveUpload(null); + cb.onResult(failure(err)); } + endSession("done"); }; - let stopRequested = false; - activeStop = () => { - if (stopRequested) return; - stopRequested = true; - void finalize(); + // Restart/delete path: the muxer still flushes into the (aborted) upload, + // where pushes are no-ops. + const discardSession = async (): Promise => { + upload.abort(); + await stopRecorders(); + cb.onActiveUpload(null); + }; + + const session: SessionCommands = { + stop() { + if (ended) return; + ended = true; + clearTimeout(capTimer); + void finalize(); + }, + pause() { + if (ended || pausedAt !== null) return; + pausedAt = Date.now(); + clearTimeout(capTimer); + void screenRecorder.pause(); + webcamRecorder?.pause(); + cb.onStatus({ kind: "paused", startedAt, pausedMs, pausedAt }); + }, + resume() { + if (ended || pausedAt === null) return; + pausedMs += Date.now() - pausedAt; + pausedAt = null; + screenRecorder.resume(); + webcamRecorder?.resume(); + capTimer = setTimeout( + () => session.stop(), + Math.max(1_000, MAX_DURATION_MS - activeElapsed()), + ); + cb.onStatus({ kind: "recording", startedAt, pausedMs }); + }, + restart() { + if (ended) return; + ended = true; + clearTimeout(capTimer); + cb.onStatus({ kind: "preparing" }); + void discardSession().then(() => endSession("restart")); + }, + discard() { + if (ended) return; + ended = true; + clearTimeout(capTimer); + void discardSession().then(() => { + stopAllTracks(); + cb.onStatus({ kind: "cancelled" }); + endSession("done"); + }); + }, }; + sessionRef = session; + commands = session; - capTimer = setTimeout(stopActiveRecording, MAX_DURATION_MS); - cb.onStatus({ kind: "recording" }); + cb.onStatus({ kind: "recording", startedAt, pausedMs: 0 }); // Poster frame from the screen — best-effort, never gates the recording. void capturePoster(screenStream) .then((bytes) => (bytes ? upload.uploadPoster(bytes) : undefined)) .catch(() => undefined); + + return sessionEnd; +} + +function failure(err: unknown): RecordingResultPayload { + return { + ok: false, + error: friendlyUploadError(err), + ...(err instanceof RecordingApiHttpError && err.code + ? { code: err.code } + : {}), + }; } async function capturePoster(stream: MediaStream): Promise { @@ -208,7 +366,3 @@ async function capturePoster(stream: MediaStream): Promise { function stopTracks(stream: MediaStream): void { for (const track of stream.getTracks()) track.stop(); } - -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} diff --git a/apps/extension/lib/format.ts b/apps/extension/lib/format.ts index 41036a1..6cad15a 100644 --- a/apps/extension/lib/format.ts +++ b/apps/extension/lib/format.ts @@ -1,3 +1,10 @@ +export function formatClock(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + const BYTE_UNITS = ["KB", "MB", "GB", "TB"] as const; export function formatBytes(bytes: number): string { diff --git a/apps/extension/lib/messaging.ts b/apps/extension/lib/messaging.ts index 2061e97..63afa0c 100644 --- a/apps/extension/lib/messaging.ts +++ b/apps/extension/lib/messaging.ts @@ -1,5 +1,9 @@ import { defineExtensionMessaging } from "@webext-core/messaging"; -import type { RecordingResultPayload, RecordingStatus } from "./storage"; +import type { + ActiveUpload, + RecordingResultPayload, + RecordingStatus, +} from "./storage"; export type StartResult = { ok: true } | { ok: false; error: string }; @@ -8,23 +12,41 @@ export type CaptureContext = { token: string; camera: boolean; mic: boolean; + cameraId?: string; + micId?: string; }; /* - * Direction by message: popup→SW for sign-in and recording control; - * SW→offscreen for capture control; offscreen→SW for status/result. + * Direction by message: popup/control-bar→SW for sign-in and recording + * control; SW→offscreen for capture control; offscreen→SW for status/result. */ type ProtocolMap = { openSignIn(): void; signOut(): void; setCameraBubble(input: { on: boolean; mic: boolean }): void; cameraStatus(input: { blocked: boolean }): void; + ensureMediaGrant(): void; + mediaGrantResult(input: { granted: boolean; denied: boolean }): void; + closeRecorderOverlay(): void; + setOverlayVisible(input: { visible: boolean }): void; + setOverlayHeight(input: { height: number }): void; startRecording(): StartResult; stopRecording(): void; + pauseRecording(): void; + resumeRecording(): void; + restartRecording(): void; + deleteRecording(): void; beginCapture(ctx: CaptureContext): void; stopCapture(): void; + pauseCapture(): void; + resumeCapture(): void; + restartCapture(): void; + deleteCapture(): void; recordingStatus(status: RecordingStatus): void; recordingResult(result: RecordingResultPayload): void; + // Offscreen docs can't touch chrome.storage, so the crash marker is relayed + // to the SW. + activeUploadChanged(upload: ActiveUpload | null): void; }; export const { sendMessage, onMessage } = diff --git a/apps/extension/lib/storage.ts b/apps/extension/lib/storage.ts index e136b9a..c148b99 100644 --- a/apps/extension/lib/storage.ts +++ b/apps/extension/lib/storage.ts @@ -1,28 +1,39 @@ export type CapturePrefs = { camera: boolean; mic: boolean; + cameraId?: string; + micId?: string; }; -export type RecordingStatusKind = - | "idle" - | "preparing" - | "recording" - | "uploading" - | "done" - | "cancelled" - | "error"; +/* + * Timing fields let any surface (popup, control bar) derive the live elapsed + * time locally: elapsed = (pausedAt ?? now) - startedAt - pausedMs. + */ +export type RecordingStatus = + | { kind: "idle" } + | { kind: "preparing" } + | { kind: "recording"; startedAt: number; pausedMs: number } + | { kind: "paused"; startedAt: number; pausedMs: number; pausedAt: number } + | { kind: "uploading" } + | { kind: "done" } + | { kind: "cancelled" } + | { kind: "error"; detail?: string }; -export type RecordingStatus = { - kind: RecordingStatusKind; - detail?: string; -}; +export type RecordingStatusKind = RecordingStatus["kind"]; export type RecordingResultPayload = | { ok: true; url: string; bytes: number; durationMs: number } - | { ok: false; error: string }; + | { ok: false; error: string; code?: string }; export type RecordingResult = RecordingResultPayload & { at: number }; +// Marker for the recording being uploaded right now; a marker that outlives +// its offscreen document is a crashed upload the SW aborts server-side. +export type ActiveUpload = { + slug: string; + deviceId: string; +}; + const recordingStatusItem = storage.defineItem( "session:recordingStatus", { fallback: { kind: "idle" } }, @@ -42,6 +53,12 @@ const cameraBlockedItem = storage.defineItem("local:cameraBlocked", { fallback: false, }); +// local: (not session:) so a browser crash still leaves the marker for the sweep. +const activeUploadItem = storage.defineItem( + "local:activeUpload", + { fallback: null }, +); + export const getRecordingStatus = (): Promise => recordingStatusItem.getValue(); export const setRecordingStatus = (status: RecordingStatus): Promise => @@ -74,3 +91,8 @@ export const setCameraBlocked = (blocked: boolean): Promise => export const watchCameraBlocked = ( cb: (blocked: boolean) => void, ): (() => void) => cameraBlockedItem.watch(cb); + +export const getActiveUpload = (): Promise => + activeUploadItem.getValue(); +export const setActiveUpload = (upload: ActiveUpload | null): Promise => + activeUploadItem.setValue(upload); diff --git a/apps/extension/tests/format.test.ts b/apps/extension/tests/format.test.ts index ff2cb01..0421edb 100644 --- a/apps/extension/tests/format.test.ts +++ b/apps/extension/tests/format.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from "vitest"; -import { formatBytes } from "../lib/format"; +import { formatBytes, formatClock } from "../lib/format"; + +describe("formatClock", () => { + it("renders mm:ss with zero-padded seconds", () => { + expect(formatClock(0)).toBe("0:00"); + expect(formatClock(9_000)).toBe("0:09"); + expect(formatClock(65_000)).toBe("1:05"); + expect(formatClock(30 * 60 * 1000)).toBe("30:00"); + }); + + it("clamps negatives to zero", () => { + expect(formatClock(-5_000)).toBe("0:00"); + }); +}); describe("formatBytes", () => { it("renders bytes under 1 KiB without a decimal", () => { From fe022bef8052b1096eca05b5caab45fcad6acae2 Mon Sep 17 00:00:00 2001 From: sardorml Date: Thu, 16 Jul 2026 20:21:47 +0500 Subject: [PATCH 05/10] feat(extension): in-page recording control bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declared content script (http/https) rendering a draggable shadow-DOM bar — stop, countdown timer, pause/resume, restart, delete — from the session- storage recording status, so it re-mounts after navigation with a continuous timer. Buttons disable while the tail upload saves. --- .../entrypoints/control-bar.content/index.ts | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 apps/extension/entrypoints/control-bar.content/index.ts diff --git a/apps/extension/entrypoints/control-bar.content/index.ts b/apps/extension/entrypoints/control-bar.content/index.ts new file mode 100644 index 0000000..0d40754 --- /dev/null +++ b/apps/extension/entrypoints/control-bar.content/index.ts @@ -0,0 +1,198 @@ +import { sendMessage } from "@/lib/messaging"; +import { + getRecordingStatus, + watchRecordingStatus, + type RecordingStatus, +} from "@/lib/storage"; +import { formatClock } from "@/lib/format"; +import { MAX_DURATION_MS } from "@/lib/capture/limits"; + +const HOST_ID = "captureflow-control-bar"; + +const STOP_ICON = ``; +const PAUSE_ICON = ``; +const RESUME_ICON = ``; +const RESTART_ICON = ``; +const DELETE_ICON = ``; + +const BAR_CSS = ` + .bar { + position: fixed; + left: 16px; + top: 50%; + transform: translateY(-50%); + z-index: 2147483647; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 8px; + border-radius: 16px; + background: #16181d; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; + color: #e8eaed; + cursor: grab; + user-select: none; + } + .bar.dragging { cursor: grabbing; } + button { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border: 0; + border-radius: 50%; + background: transparent; + color: #e8eaed; + cursor: pointer; + padding: 0; + } + button:hover { background: #2a2e36; } + button:disabled { opacity: 0.4; cursor: default; } + button:disabled:hover { background: transparent; } + .stop { background: #2a2e36; } + .stop:hover { background: #363b45; } + .timer { + font-size: 12px; + font-variant-numeric: tabular-nums; + color: #e8eaed; + padding: 2px 0 4px; + } + .timer.saving { font-size: 10px; color: #9aa0aa; } +`; + +type Bar = { + update(status: RecordingStatus): void; + destroy(): void; +}; + +function iconButton(icon: string, title: string, onClick: () => void) { + const button = document.createElement("button"); + button.type = "button"; + button.title = title; + button.innerHTML = icon; + button.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + // Keep button presses from starting a bar drag. + button.addEventListener("pointerdown", (event) => event.stopPropagation()); + return button; +} + +function makeDraggable(host: HTMLElement, bar: HTMLElement): void { + bar.addEventListener("pointerdown", (event) => { + const rect = bar.getBoundingClientRect(); + const dx = event.clientX - rect.left; + const dy = event.clientY - rect.top; + bar.classList.add("dragging"); + const move = (ev: PointerEvent) => { + bar.style.left = `${Math.max(0, ev.clientX - dx)}px`; + bar.style.top = `${Math.max(0, ev.clientY - dy)}px`; + bar.style.transform = "none"; + }; + const up = () => { + bar.classList.remove("dragging"); + host.ownerDocument.removeEventListener("pointermove", move); + host.ownerDocument.removeEventListener("pointerup", up); + }; + host.ownerDocument.addEventListener("pointermove", move); + host.ownerDocument.addEventListener("pointerup", up); + }); +} + +function createBar(): Bar { + const host = document.createElement("div"); + host.id = HOST_ID; + const shadow = host.attachShadow({ mode: "closed" }); + + const style = document.createElement("style"); + style.textContent = BAR_CSS; + shadow.appendChild(style); + + const bar = document.createElement("div"); + bar.className = "bar"; + shadow.appendChild(bar); + + const stop = iconButton(STOP_ICON, "Stop and save", () => + sendMessage("stopRecording", undefined), + ); + stop.classList.add("stop"); + + const timer = document.createElement("div"); + timer.className = "timer"; + + let paused = false; + const pause = iconButton(PAUSE_ICON, "Pause", () => + sendMessage(paused ? "resumeRecording" : "pauseRecording", undefined), + ); + const restart = iconButton(RESTART_ICON, "Restart recording", () => + sendMessage("restartRecording", undefined), + ); + const del = iconButton(DELETE_ICON, "Delete recording", () => + sendMessage("deleteRecording", undefined), + ); + + bar.append(stop, timer, pause, restart, del); + makeDraggable(host, bar); + document.documentElement.appendChild(host); + + let current: RecordingStatus = { kind: "idle" }; + const renderTimer = (): void => { + if (current.kind === "recording" || current.kind === "paused") { + const elapsed = + (current.kind === "paused" ? current.pausedAt : Date.now()) - + current.startedAt - + current.pausedMs; + timer.textContent = formatClock(MAX_DURATION_MS - elapsed); + } + }; + const tick = setInterval(renderTimer, 500); + + return { + update(status: RecordingStatus) { + current = status; + const saving = status.kind === "uploading"; + paused = status.kind === "paused"; + pause.innerHTML = paused ? RESUME_ICON : PAUSE_ICON; + pause.title = paused ? "Resume" : "Pause"; + for (const button of [stop, pause, restart, del]) { + button.disabled = saving; + } + timer.classList.toggle("saving", saving); + if (saving) { + timer.textContent = "Saving…"; + } else { + renderTimer(); + } + }, + destroy() { + clearInterval(tick); + host.remove(); + }, + }; +} + +export default defineContentScript({ + matches: ["http://*/*", "https://*/*"], + main() { + let bar: Bar | null = null; + const apply = (status: RecordingStatus): void => { + const visible = + status.kind === "recording" || + status.kind === "paused" || + status.kind === "uploading"; + if (!visible) { + bar?.destroy(); + bar = null; + return; + } + bar ??= createBar(); + bar.update(status); + }; + void getRecordingStatus().then(apply); + watchRecordingStatus(apply); + }, +}); From 5c36ff3b01fde6f4eb8666a54526a031c2e51da8 Mon Sep 17 00:00:00 2001 From: sardorml Date: Thu, 16 Jul 2026 20:21:47 +0500 Subject: [PATCH 06/10] feat(extension): in-page recorder overlay, combined grant, screenshot mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recorder UI now opens as an extension iframe floating top-right over a blurred backdrop instead of an anchored action popup (default_popup is stripped via a build:manifestGenerated hook; restricted pages fall back to a standalone popup window). The iframe stays opaque and is sized to the panel through SW-relayed height reports — iframe transparency renders as an opaque white canvas when the host page's color-scheme differs, and window.postMessage proved unreliable on some sites. Panel redesign: home/video/screenshot tabs, device rows with on/off pills and device selects, live mic level meter, recording-limit caption, Effects/Blur stubs and a More menu, brand icon. Opening it without a camera+mic grant injects an invisible grant frame that asks for both devices in one native prompt; on allow both toggle on and the bubble appears (a dismissed prompt is not treated as a block). Screenshot mode captures the visible tab (overlay hidden for the frame) and posts the PNG to the forked /api/s/upload domain. host_permissions now cover the API origin (localhost too in dev builds) so extension-context fetches skip CORS/Private-Network-Access — Brave otherwise blocks extension frames from reaching a localhost dev server. The SW also sweeps crashed uploads on startup and opens the recording URL on success. --- apps/extension/entrypoints/background.ts | 202 +++++++++++++- apps/extension/entrypoints/bubble/main.ts | 47 +++- apps/extension/entrypoints/popup/App.tsx | 198 ++++++++++++- .../entrypoints/popup/DevicePickers.tsx | 201 ++++++++++---- .../entrypoints/popup/FooterActions.tsx | 94 +++++++ apps/extension/entrypoints/popup/MicMeter.tsx | 64 +++++ .../entrypoints/popup/RecorderPanel.tsx | 86 +++--- .../entrypoints/popup/ScreenshotPanel.tsx | 134 +++++++++ .../entrypoints/popup/SignInGate.tsx | 2 +- apps/extension/entrypoints/popup/main.tsx | 14 + apps/extension/entrypoints/popup/popup.css | 262 +++++++++++++----- apps/extension/hooks/use-media-devices.ts | 73 +++++ apps/extension/lib/api/screenshot.ts | 38 +++ apps/extension/lib/overlay/camera-bubble.ts | 21 +- .../extension/lib/overlay/recorder-overlay.ts | 90 ++++++ apps/extension/lib/surface.ts | 18 ++ apps/extension/wxt.config.ts | 20 +- 17 files changed, 1379 insertions(+), 185 deletions(-) create mode 100644 apps/extension/entrypoints/popup/FooterActions.tsx create mode 100644 apps/extension/entrypoints/popup/MicMeter.tsx create mode 100644 apps/extension/entrypoints/popup/ScreenshotPanel.tsx create mode 100644 apps/extension/hooks/use-media-devices.ts create mode 100644 apps/extension/lib/api/screenshot.ts create mode 100644 apps/extension/lib/overlay/recorder-overlay.ts create mode 100644 apps/extension/lib/surface.ts diff --git a/apps/extension/entrypoints/background.ts b/apps/extension/entrypoints/background.ts index 9ede5f2..7f8318d 100644 --- a/apps/extension/entrypoints/background.ts +++ b/apps/extension/entrypoints/background.ts @@ -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"; @@ -87,6 +96,40 @@ async function releaseCameraBubble(): Promise { bubbleTabId = undefined; } +// Tab hosting the invisible camera+mic grant frame (one combined prompt). +let grantTabId: number | undefined; + +async function requestMediaGrant(): Promise { + 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 { + 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 { @@ -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 { +// 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 { + 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 { + 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 { 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 { + 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 @@ -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(); } }); @@ -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 @@ -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) { @@ -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); + } }); }); diff --git a/apps/extension/entrypoints/bubble/main.ts b/apps/extension/entrypoints/bubble/main.ts index b0d7cf8..0db0f81 100644 --- a/apps/extension/entrypoints/bubble/main.ts +++ b/apps/extension/entrypoints/bubble/main.ts @@ -1,10 +1,47 @@ import { sendMessage } from "@/lib/messaging"; -// getUserMedia here seeds the grant the offscreen recorder reuses; the mic -// track is released since only the camera is previewed. -const audio = new URLSearchParams(location.search).get("audio") === "1"; +/* + * Two modes, both seeding the extension-origin grant the offscreen recorder + * reuses: + * preview (default) — live circular camera; the mic track is released since + * only the camera is previewed. + * grant=1 — invisible frame that asks for camera+mic in ONE native prompt + * and releases everything; the SW tears the frame down on the result + * message. + */ +const params = new URLSearchParams(location.search); +const audio = params.get("audio") === "1"; +const grantOnly = params.get("grant") === "1"; -async function run(): Promise { +async function isCameraDenied(): Promise { + try { + const perm = await navigator.permissions.query({ + name: "camera" as PermissionName, + }); + return perm.state === "denied"; + } catch { + return false; + } +} + +async function runGrant(): Promise { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + for (const track of stream.getTracks()) track.stop(); + void sendMessage("mediaGrantResult", { granted: true, denied: false }); + } catch { + // A dismissed prompt is not a Block — only a real denial flags the camera. + void sendMessage("mediaGrantResult", { + granted: false, + denied: await isCameraDenied(), + }); + } +} + +async function runPreview(): Promise { const video = document.getElementById("cam"); if (!(video instanceof HTMLVideoElement)) return; try { @@ -20,4 +57,4 @@ async function run(): Promise { } } -void run(); +void (grantOnly ? runGrant() : runPreview()); diff --git a/apps/extension/entrypoints/popup/App.tsx b/apps/extension/entrypoints/popup/App.tsx index 7725c89..0da8fe3 100644 --- a/apps/extension/entrypoints/popup/App.tsx +++ b/apps/extension/entrypoints/popup/App.tsx @@ -2,10 +2,15 @@ import { useEffect, useState } from "react"; import { sendMessage } from "@/lib/messaging"; import { getAuthSession, + setAuthSession, watchAuthSession, type AuthSession, } from "@/lib/auth/session"; +import { getDeviceId } from "@/lib/auth/device-id"; +import { checkAuth } from "@/lib/api/client"; +import { WEB_BASE } from "@/lib/config"; import { + getCameraBlocked, getRecordingResult, getRecordingStatus, watchRecordingResult, @@ -13,16 +18,101 @@ import { type RecordingResult, type RecordingStatus, } from "@/lib/storage"; +import { closeSurface, isOverlaySurface } from "@/lib/surface"; import { RecorderPanel } from "./RecorderPanel"; +import { ScreenshotPanel } from "./ScreenshotPanel"; +import { FooterActions } from "./FooterActions"; import { SignInGate } from "./SignInGate"; // "loading" until storage resolves, to avoid flashing the sign-in gate. type AuthState = AuthSession | null | "loading"; +type Mode = "video" | "screenshot"; + +const LIVE_KINDS = new Set(["preparing", "recording", "paused", "uploading"]); + +async function hasMediaGrant(): Promise { + try { + const [cam, mic] = await Promise.all([ + navigator.permissions.query({ name: "camera" as PermissionName }), + navigator.permissions.query({ name: "microphone" as PermissionName }), + ]); + return cam.state === "granted" && mic.state === "granted"; + } catch { + return false; + } +} + +const HOME_ICON = ( + + + +); + +const VIDEO_ICON = ( + + + + +); + +const PHOTO_ICON = ( + + + + +); + +const CLOSE_ICON = ( + + + +); + export function App() { const [auth, setAuth] = useState("loading"); const [status, setStatus] = useState({ kind: "idle" }); const [result, setResult] = useState(null); + const [mode, setMode] = useState("video"); useEffect(() => { void getAuthSession().then(setAuth); @@ -38,22 +128,114 @@ export function App() { }; }, []); + // Probe the token once per popup open; a revoked token flips the UI (and the + // action gating) back to signed-out instead of failing at record time. + useEffect(() => { + void (async () => { + const session = await getAuthSession(); + if (!session) return; + const deviceId = await getDeviceId(); + if ((await checkAuth(deviceId, session.token)) === "invalid") { + await setAuthSession(null); + } + })(); + }, []); + + /* + * Missing camera/mic grant → one combined native prompt on the page (the SW + * injects an invisible extension frame), instead of separate prompts behind + * each toggle. Skipped while recording and after an explicit Block. + */ + useEffect(() => { + void (async () => { + const session = await getAuthSession(); + if (!session) return; + const status = await getRecordingStatus(); + if (LIVE_KINDS.has(status.kind)) return; + if (await getCameraBlocked()) return; + if (await hasMediaGrant()) return; + void sendMessage("ensureMediaGrant", undefined); + })(); + }, []); + + // Overlay-only: Escape closes, like clicking the blurred backdrop. + useEffect(() => { + if (!isOverlaySurface) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") closeSurface(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); + if (auth === "loading") return null; if (!auth) return ; + const openHome = () => { + void chrome.tabs.create({ url: `${WEB_BASE}/recordings` }); + closeSurface(); + }; + // The popup closes as soon as the OS picker takes focus, so recording state // is read back from storage when the popup is reopened. const onStart = () => sendMessage("startRecording", undefined); const onStop = () => sendMessage("stopRecording", undefined); - const onSignOut = () => sendMessage("signOut", undefined); return ( - +
+
+ +
+ + +
+ +
+ + {mode === "video" ? ( + + ) : ( + + )} + + +
); } diff --git a/apps/extension/entrypoints/popup/DevicePickers.tsx b/apps/extension/entrypoints/popup/DevicePickers.tsx index 33a5645..2db7726 100644 --- a/apps/extension/entrypoints/popup/DevicePickers.tsx +++ b/apps/extension/entrypoints/popup/DevicePickers.tsx @@ -8,6 +8,117 @@ import { watchCapturePrefs, type CapturePrefs, } from "@/lib/storage"; +import { + useMediaDevices, + type MediaDeviceOption, +} from "@/hooks/use-media-devices"; +import { MicMeter } from "./MicMeter"; + +const CAMERA_ICON = ( + + + + +); + +const MIC_ICON = ( + + + + +); + +async function isMicGranted(): Promise { + try { + const perm = await navigator.permissions.query({ + name: "microphone" as PermissionName, + }); + return perm.state === "granted"; + } catch { + return false; + } +} + +type DeviceRowProps = { + icon: React.ReactNode; + fallbackLabel: string; + devices: MediaDeviceOption[]; + selectedId: string | undefined; + on: boolean; + onToggle: () => void; + onSelect: (deviceId: string) => void; +}; + +function DeviceRow({ + icon, + fallbackLabel, + devices, + selectedId, + on, + onToggle, + onSelect, +}: DeviceRowProps) { + const hasLabels = devices.some((d) => d.label && d.deviceId); + return ( +
+ + {icon} + + {hasLabels ? ( + + ) : ( + {fallbackLabel} + )} + +
+ ); +} export function DevicePickers() { const [prefs, setPrefs] = useState({ @@ -15,6 +126,7 @@ export function DevicePickers() { mic: false, }); const [blocked, setBlocked] = useState(false); + const devices = useMediaDevices(); useEffect(() => { void getCapturePrefs().then(setPrefs); @@ -27,27 +139,40 @@ export function DevicePickers() { }; }, []); - // Mic rides the camera stream (Decision 4), so camera-off clears it. The bubble - // both previews and seeds the grant, so reflect the new state on the active tab. + /* + * Camera preview + grant run through the in-page bubble (getUserMedia can't + * prompt from a popup). A camera-less mic needs its own grant, seeded by the + * permissions tab. + */ const update = async (partial: Partial) => { const next = { ...prefs, ...partial }; - if (!next.camera) next.mic = false; setPrefs(next); await setCapturePrefs(next); - void sendMessage("setCameraBubble", { on: next.camera, mic: next.mic }); + if ( + partial.camera !== undefined || + (next.camera && partial.mic !== undefined) + ) { + void sendMessage("setCameraBubble", { on: next.camera, mic: next.mic }); + } + if (partial.mic === true && !next.camera && !(await isMicGranted())) { + await chrome.tabs.create({ + url: chrome.runtime.getURL("permissions.html?audio=1"), + }); + } }; - if (blocked) { - return ( -
-
- - ◎ - - -
+ return ( +
+ void update({ camera: !prefs.camera })} + onSelect={(cameraId) => void update({ cameraId })} + /> + {blocked && (

Camera blocked

@@ -62,40 +187,18 @@ export function DevicePickers() { Try again

-
- ); - } - - return ( -
-
- - ◎ - - -
-
- - ⏺ - - -
- {prefs.camera && ( + )} + void update({ mic: !prefs.mic })} + onSelect={(micId) => void update({ micId })} + /> + + {prefs.camera && !blocked && (

Your camera bubble appears on normal web pages — not on internal browser pages like this one. diff --git a/apps/extension/entrypoints/popup/FooterActions.tsx b/apps/extension/entrypoints/popup/FooterActions.tsx new file mode 100644 index 0000000..d6e06f5 --- /dev/null +++ b/apps/extension/entrypoints/popup/FooterActions.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { sendMessage } from "@/lib/messaging"; +import { WEB_BASE } from "@/lib/config"; +import { closeSurface } from "@/lib/surface"; + +const EFFECTS_ICON = ( + + + + + + +); + +const BLUR_ICON = ( + + + +); + +const MORE_ICON = ( + + + + + +); + +export function FooterActions() { + const [menuOpen, setMenuOpen] = useState(false); + + const openDashboard = () => { + void chrome.tabs.create({ url: `${WEB_BASE}/recordings` }); + closeSurface(); + }; + const onSignOut = () => { + void sendMessage("signOut", undefined); + closeSurface(); + }; + + return ( +

+ + +
+ + {menuOpen && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/apps/extension/entrypoints/popup/MicMeter.tsx b/apps/extension/entrypoints/popup/MicMeter.tsx new file mode 100644 index 0000000..169850c --- /dev/null +++ b/apps/extension/entrypoints/popup/MicMeter.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; + +/* + * Live input level under the mic row. Only attaches when the extension origin + * already holds a mic grant — getUserMedia can't prompt from a popup, so an + * ungranted mic renders nothing rather than a dead meter. + */ +export function MicMeter({ enabled }: { enabled: boolean }) { + const [level, setLevel] = useState(0); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let raf = 0; + let stream: MediaStream | null = null; + let audioContext: AudioContext | null = null; + + void (async () => { + try { + const perm = await navigator.permissions.query({ + name: "microphone" as PermissionName, + }); + if (perm.state !== "granted") return; + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (cancelled) return; + audioContext = new AudioContext(); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 256; + audioContext.createMediaStreamSource(stream).connect(analyser); + const samples = new Uint8Array(analyser.frequencyBinCount); + const loop = () => { + analyser.getByteTimeDomainData(samples); + let peak = 0; + for (const sample of samples) { + peak = Math.max(peak, Math.abs(sample - 128)); + } + setLevel(Math.min(1, peak / 56)); + raf = requestAnimationFrame(loop); + }; + loop(); + } catch { + /* no meter — the mic still records */ + } + })(); + + return () => { + cancelled = true; + cancelAnimationFrame(raf); + for (const track of stream?.getTracks() ?? []) track.stop(); + void audioContext?.close().catch(() => {}); + setLevel(0); + }; + }, [enabled]); + + if (!enabled) return null; + return ( +
+
+
+ ); +} diff --git a/apps/extension/entrypoints/popup/RecorderPanel.tsx b/apps/extension/entrypoints/popup/RecorderPanel.tsx index b6e4cfe..039c716 100644 --- a/apps/extension/entrypoints/popup/RecorderPanel.tsx +++ b/apps/extension/entrypoints/popup/RecorderPanel.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { RecordingResult, RecordingStatus } from "@/lib/storage"; +import { MAX_DURATION_MS } from "@/lib/capture/limits"; import { DevicePickers } from "./DevicePickers"; type RecorderPanelProps = { @@ -7,9 +8,29 @@ type RecorderPanelProps = { result: RecordingResult | null; onStart: () => void; onStop: () => void; - onSignOut: () => void; }; +const SCREEN_ICON = ( + + + + +); + const BUSY_LABEL: Partial> = { preparing: "Starting…", uploading: "Uploading…", @@ -52,7 +73,13 @@ function StatusLine({ case "preparing": return

Choose a source in the picker…

; case "recording": - return

Recording… stop when you’re done.

; + case "paused": + return ( +

+ {status.kind === "paused" ? "Paused" : "Recording"} — control it from + the bar on the page. +

+ ); case "uploading": return

Uploading your recording…

; case "cancelled": @@ -65,11 +92,7 @@ function StatusLine({ ); default: if (result?.ok) return ; - return ( -

- Record your screen and get an instant recording link. -

- ); + return null; } } @@ -78,43 +101,25 @@ export function RecorderPanel({ result, onStart, onStop, - onSignOut, }: RecorderPanelProps) { - const isRecording = status.kind === "recording"; + const isLive = status.kind === "recording" || status.kind === "paused"; const isBusy = status.kind === "preparing" || status.kind === "uploading"; return ( -
-
-
- - CaptureFlow -
-
- - -
-
- + <>
- Source -

- Pick a screen, window, or browser tab when recording starts. -

+
+ + {SCREEN_ICON} + + Screen, window, or tab + Pick at start +
- {isRecording ? ( + {isLive ? ( @@ -128,14 +133,11 @@ export function RecorderPanel({ {BUSY_LABEL[status.kind] ?? "Start Recording"} )} +

+ {Math.round(MAX_DURATION_MS / 60_000)} min recording limit +

- -
- -
-
+ ); } diff --git a/apps/extension/entrypoints/popup/ScreenshotPanel.tsx b/apps/extension/entrypoints/popup/ScreenshotPanel.tsx new file mode 100644 index 0000000..e491123 --- /dev/null +++ b/apps/extension/entrypoints/popup/ScreenshotPanel.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import { getAuthSession, setAuthSession } from "@/lib/auth/session"; +import { getDeviceId } from "@/lib/auth/device-id"; +import { uploadScreenshot } from "@/lib/api/screenshot"; +import { friendlyUploadError, isAuthFailure } from "@/lib/api/errors"; +import { sendMessage } from "@/lib/messaging"; +import { isOverlaySurface } from "@/lib/surface"; + +type ShotState = + | { kind: "idle" } + | { kind: "busy" } + | { kind: "done"; viewUrl: string } + | { kind: "error"; detail: string }; + +const TAB_ICON = ( + + + + +); + +export function ScreenshotPanel() { + const [state, setState] = useState({ kind: "idle" }); + const [copied, setCopied] = useState(false); + + const capture = async () => { + setState({ kind: "busy" }); + try { + const session = await getAuthSession(); + if (!session) throw new Error("Sign in to capture."); + const [tab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + // The overlay (blur + panel) must not end up inside the capture. + let dataUrl: string; + if (isOverlaySurface) { + await sendMessage("setOverlayVisible", { visible: false }); + await new Promise((resolve) => setTimeout(resolve, 150)); + try { + dataUrl = await chrome.tabs.captureVisibleTab({ format: "png" }); + } finally { + void sendMessage("setOverlayVisible", { visible: true }); + } + } else { + dataUrl = await chrome.tabs.captureVisibleTab({ format: "png" }); + } + const png = await (await fetch(dataUrl)).blob(); + const bitmap = await createImageBitmap(png); + const deviceId = await getDeviceId(); + const res = await uploadScreenshot(deviceId, session.token, { + png, + width: bitmap.width, + height: bitmap.height, + title: tab?.title ?? undefined, + }); + bitmap.close(); + setState({ kind: "done", viewUrl: res.viewUrl }); + await chrome.tabs.create({ url: res.viewUrl }); + } catch (err) { + if (isAuthFailure(err)) await setAuthSession(null); + setState({ kind: "error", detail: friendlyUploadError(err) }); + } + }; + + const copy = async (url: string) => { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard blocked — the link is still tappable */ + } + }; + + return ( + <> +
+
+ + {TAB_ICON} + + Current tab +
+
+ + + + {state.kind === "done" && ( +
+

+ Your screenshot link is ready ✓ +

+
+ + {state.viewUrl} + + +
+
+ )} + {state.kind === "error" && ( +

{state.detail}

+ )} + + ); +} diff --git a/apps/extension/entrypoints/popup/SignInGate.tsx b/apps/extension/entrypoints/popup/SignInGate.tsx index 148838e..51365dc 100644 --- a/apps/extension/entrypoints/popup/SignInGate.tsx +++ b/apps/extension/entrypoints/popup/SignInGate.tsx @@ -12,7 +12,7 @@ export function SignInGate() {
- + CaptureFlow
diff --git a/apps/extension/entrypoints/popup/main.tsx b/apps/extension/entrypoints/popup/main.tsx index 34b8b32..2bc94fe 100644 --- a/apps/extension/entrypoints/popup/main.tsx +++ b/apps/extension/entrypoints/popup/main.tsx @@ -1,8 +1,22 @@ import React from "react"; import { createRoot } from "react-dom/client"; +import { isOverlaySurface } from "@/lib/surface"; +import { sendMessage } from "@/lib/messaging"; import { App } from "./App"; import "./popup.css"; +// The SW sizes the overlay iframe to the panel's content height. +if (isOverlaySurface) { + document.body.classList.add("cf-overlay"); + const reportHeight = () => { + const height = document.body.scrollHeight; + if (height > 0) { + void sendMessage("setOverlayHeight", { height }).catch(() => {}); + } + }; + new ResizeObserver(reportHeight).observe(document.body); +} + const container = document.getElementById("root"); if (container) { createRoot(container).render( diff --git a/apps/extension/entrypoints/popup/popup.css b/apps/extension/entrypoints/popup/popup.css index 0b3c271..f73714d 100644 --- a/apps/extension/entrypoints/popup/popup.css +++ b/apps/extension/entrypoints/popup/popup.css @@ -1,6 +1,7 @@ :root { --cf-bg: #16181d; - --cf-surface: #1f2229; + --cf-surface: #22252c; + --cf-surface-hover: #2a2e36; --cf-line: #2c3038; --cf-fg: #e8eaed; --cf-muted: #9aa0aa; @@ -16,7 +17,7 @@ body { margin: 0; - width: 320px; + width: 340px; font-family: ui-sans-serif, system-ui, @@ -30,10 +31,21 @@ body { .cf-panel { display: flex; flex-direction: column; - gap: 14px; - padding: 16px; + gap: 12px; + padding: 14px; } +/* + * Overlay surface: the iframe is sized exactly to the panel and carries the + * rounding/shadow page-side, so the document stays opaque (any transparency + * turns into a white canvas when the host page's color-scheme differs). + */ +body.cf-overlay { + width: 348px; +} + +/* ---- header (sign-in gate) ---- */ + .cf-header { display: flex; align-items: center; @@ -49,96 +61,165 @@ body { } .cf-logo { - width: 16px; - height: 16px; + width: 18px; + height: 18px; border-radius: 5px; - background: var(--cf-accent); } -.cf-mode { - display: inline-flex; - background: var(--cf-surface); - border: 1px solid var(--cf-line); - border-radius: 8px; - padding: 2px; +/* ---- top bar: home · mode tabs · close ---- */ + +.cf-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } -.cf-mode-btn { +.cf-iconbtn { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; border: 0; + border-radius: 10px; background: transparent; color: var(--cf-muted); - font-size: 12px; - padding: 4px 10px; - border-radius: 6px; cursor: pointer; } -.cf-mode-btn.is-active { - background: var(--cf-accent); - color: #fff; +.cf-iconbtn:hover { + background: var(--cf-surface-hover); + color: var(--cf-fg); } -.cf-mode-btn:disabled { - cursor: not-allowed; - opacity: 0.5; +.cf-tabs { + display: inline-flex; + gap: 2px; + background: var(--cf-surface); + border-radius: 12px; + padding: 3px; } -.cf-section { +.cf-tab { display: flex; - flex-direction: column; - gap: 6px; + align-items: center; + justify-content: center; + width: 44px; + height: 30px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--cf-muted); + cursor: pointer; } -.cf-label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--cf-muted); +.cf-tab:hover { + color: var(--cf-fg); } -.cf-source { - margin: 0; - font-size: 13px; +.cf-tab.is-active { + background: var(--cf-surface-hover); color: var(--cf-fg); - background: var(--cf-surface); - border: 1px solid var(--cf-line); - border-radius: 8px; - padding: 10px 12px; +} + +/* ---- sections & rows ---- */ + +.cf-section { + display: flex; + flex-direction: column; + gap: 8px; } .cf-pickers { gap: 8px; } -.cf-picker { +.cf-row { display: flex; align-items: center; - gap: 8px; + gap: 10px; background: var(--cf-surface); - border: 1px solid var(--cf-line); - border-radius: 8px; - padding: 8px 10px; + border-radius: 12px; + padding: 11px 12px; } -.cf-picker-icon { - color: var(--cf-muted); - font-size: 14px; - width: 16px; - text-align: center; +.cf-row.is-on { + outline: 1px solid var(--cf-line); +} + +.cf-row-icon { + display: flex; + color: var(--cf-fg); } -.cf-select { +.cf-row-label { flex: 1; + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cf-row-select { + flex: 1; + min-width: 0; background: transparent; border: 0; color: var(--cf-fg); font-size: 13px; + font-weight: 500; +} + +.cf-row-select:focus { + outline: none; } -.cf-select:disabled { +.cf-row-note { + font-size: 11px; color: var(--cf-muted); } +.cf-pill { + border: 0; + border-radius: 999px; + padding: 4px 12px; + font-size: 12px; + font-weight: 600; + background: var(--cf-surface-hover); + color: var(--cf-muted); + cursor: pointer; +} + +.cf-pill.is-on { + background: var(--cf-ok); + color: #0b2b1e; +} + +.cf-meter { + height: 4px; + border-radius: 999px; + background: var(--cf-surface); + overflow: hidden; +} + +.cf-meter-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--cf-accent), #7ea7ff); + transition: width 80ms linear; +} + +.cf-source { + margin: 0; + font-size: 13px; + color: var(--cf-fg); + background: var(--cf-surface); + border-radius: 12px; + padding: 11px 12px; +} + .cf-hint { margin: 2px 0 0; font-size: 11px; @@ -152,8 +233,7 @@ body { align-items: flex-start; gap: 8px; background: var(--cf-surface); - border: 1px solid var(--cf-line); - border-radius: 8px; + border-radius: 12px; padding: 10px 12px; } @@ -166,7 +246,7 @@ body { .cf-try { border: 0; - border-radius: 6px; + border-radius: 8px; background: var(--cf-accent); color: #fff; font-size: 12px; @@ -179,14 +259,16 @@ body { background: var(--cf-accent-hover); } +/* ---- primary action ---- */ + .cf-start { border: 0; - border-radius: 10px; + border-radius: 12px; background: var(--cf-accent); color: #fff; font-size: 14px; font-weight: 600; - padding: 12px; + padding: 13px; cursor: pointer; transition: background 0.15s ease; } @@ -208,6 +290,15 @@ body { background: #f37e7e; } +.cf-limit { + margin: -4px 0 0; + text-align: center; + font-size: 11px; + color: var(--cf-muted); +} + +/* ---- result & status ---- */ + .cf-result { display: flex; flex-direction: column; @@ -219,8 +310,7 @@ body { align-items: center; gap: 8px; background: var(--cf-surface); - border: 1px solid var(--cf-line); - border-radius: 8px; + border-radius: 10px; padding: 8px 10px; } @@ -270,22 +360,68 @@ body { color: var(--cf-error); } -.cf-footer { +/* ---- footer tools: Effects · Blur · More ---- */ + +.cf-tools { display: flex; - justify-content: flex-end; + justify-content: space-around; border-top: 1px solid var(--cf-line); padding-top: 10px; } -.cf-signout { +.cf-tool { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; border: 0; + border-radius: 10px; background: transparent; color: var(--cf-muted); - font-size: 12px; - padding: 2px 4px; + font-size: 11px; + padding: 6px 14px; cursor: pointer; } -.cf-signout:hover { +.cf-tool:hover:not(:disabled) { + background: var(--cf-surface-hover); + color: var(--cf-fg); +} + +.cf-tool:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.cf-tool-menu-anchor { + position: relative; +} + +.cf-menu { + position: absolute; + right: 0; + bottom: calc(100% + 6px); + display: flex; + flex-direction: column; + min-width: 150px; + background: var(--cf-surface); + border: 1px solid var(--cf-line); + border-radius: 10px; + padding: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.cf-menu-item { + border: 0; + border-radius: 7px; + background: transparent; color: var(--cf-fg); + font-size: 12px; + text-align: left; + padding: 8px 10px; + cursor: pointer; +} + +.cf-menu-item:hover { + background: var(--cf-surface-hover); } diff --git a/apps/extension/hooks/use-media-devices.ts b/apps/extension/hooks/use-media-devices.ts new file mode 100644 index 0000000..0ea4ea3 --- /dev/null +++ b/apps/extension/hooks/use-media-devices.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; + +export type MediaDeviceOption = { + deviceId: string; + label: string; +}; + +export type MediaDevices = { + cameras: MediaDeviceOption[]; + mics: MediaDeviceOption[]; +}; + +const EMPTY: MediaDevices = { cameras: [], mics: [] }; + +function toOption( + device: MediaDeviceInfo, + fallback: string, +): MediaDeviceOption { + return { + deviceId: device.deviceId, + // Labels are empty until the extension origin holds a camera/mic grant. + label: device.label || fallback, + }; +} + +export function useMediaDevices(): MediaDevices { + const [devices, setDevices] = useState(EMPTY); + + useEffect(() => { + let cancelled = false; + const refresh = async (): Promise => { + try { + const all = await navigator.mediaDevices.enumerateDevices(); + if (cancelled) return; + setDevices({ + cameras: all + .filter((d) => d.kind === "videoinput") + .map((d) => toOption(d, "Camera")), + mics: all + .filter((d) => d.kind === "audioinput") + .map((d) => toOption(d, "Microphone")), + }); + } catch { + if (!cancelled) setDevices(EMPTY); + } + }; + void refresh(); + navigator.mediaDevices.addEventListener("devicechange", refresh); + // Labels appear the moment the combined grant lands, without a re-open. + const watched: PermissionStatus[] = []; + void (async () => { + for (const name of ["camera", "microphone"]) { + try { + const perm = await navigator.permissions.query({ + name: name as PermissionName, + }); + if (cancelled) return; + perm.addEventListener("change", refresh); + watched.push(perm); + } catch { + /* permission name unsupported */ + } + } + })(); + return () => { + cancelled = true; + navigator.mediaDevices.removeEventListener("devicechange", refresh); + for (const perm of watched) perm.removeEventListener("change", refresh); + }; + }, []); + + return devices; +} diff --git a/apps/extension/lib/api/screenshot.ts b/apps/extension/lib/api/screenshot.ts new file mode 100644 index 0000000..739ad43 --- /dev/null +++ b/apps/extension/lib/api/screenshot.ts @@ -0,0 +1,38 @@ +import { WEB_BASE } from "../config"; +import { parseResponse, recordingHeaders } from "./client"; + +// The screenshot domain (`/api/s/*`) is deliberately forked from recording on +// the server; the extension only needs its single-shot upload. +export type ScreenshotUploadResponse = { + id: string; + viewUrl: string; + editUrl: string; +}; + +export type ScreenshotUploadInput = { + png: Blob; + width: number; + height: number; + title?: string; +}; + +export async function uploadScreenshot( + deviceId: string, + token: string, + input: ScreenshotUploadInput, +): Promise { + const headers = recordingHeaders(deviceId, token, { + "content-type": "image/png", + "x-captureflow-screenshot-width": String(input.width), + "x-captureflow-screenshot-height": String(input.height), + }); + if (input.title) { + headers["x-captureflow-screenshot-title"] = input.title; + } + const res = await fetch(`${WEB_BASE}/api/s/upload`, { + method: "POST", + headers, + body: input.png, + }); + return parseResponse(res, "/s/upload"); +} diff --git a/apps/extension/lib/overlay/camera-bubble.ts b/apps/extension/lib/overlay/camera-bubble.ts index 5c794b1..53a9112 100644 --- a/apps/extension/lib/overlay/camera-bubble.ts +++ b/apps/extension/lib/overlay/camera-bubble.ts @@ -1,4 +1,5 @@ export const BUBBLE_FRAME_ID = "captureflow-camera-bubble"; +export const GRANT_FRAME_ID = "captureflow-media-grant"; /* * Injected via chrome.scripting (serialized): mounts/re-points a circular @@ -16,12 +17,30 @@ export function mountCameraBubble(frameUrl: string, frameId: string): void { iframe.src = frameUrl; iframe.allow = "camera; microphone"; iframe.style.cssText = - "position:fixed;bottom:24px;left:24px;width:160px;height:160px;border:0;" + + "position:fixed;bottom:24px;left:24px;width:220px;height:220px;border:0;" + "border-radius:50%;z-index:2147483647;background:transparent;" + "box-shadow:0 8px 28px rgba(0,0,0,.35);"; document.documentElement.appendChild(iframe); } +/* + * Near-invisible variant for the combined camera+mic grant: the native prompt + * is attributed to the extension frame, so the frame must be in the page, but + * nothing should render. 1×1 (not display:none — Chrome may not service + * getUserMedia from an undisplayed frame). + */ +export function mountGrantFrame(frameUrl: string, frameId: string): void { + if (document.getElementById(frameId)) return; + const iframe = document.createElement("iframe"); + iframe.id = frameId; + iframe.src = frameUrl; + iframe.allow = "camera; microphone"; + iframe.style.cssText = + "position:fixed;bottom:0;left:0;width:1px;height:1px;border:0;" + + "opacity:0;pointer-events:none;z-index:2147483647;"; + document.documentElement.appendChild(iframe); +} + export function unmountCameraBubble(frameId: string): void { document.getElementById(frameId)?.remove(); } diff --git a/apps/extension/lib/overlay/recorder-overlay.ts b/apps/extension/lib/overlay/recorder-overlay.ts new file mode 100644 index 0000000..f47dfb5 --- /dev/null +++ b/apps/extension/lib/overlay/recorder-overlay.ts @@ -0,0 +1,90 @@ +export const RECORDER_FRAME_ID = "captureflow-recorder-frame"; +export const RECORDER_BACKDROP_ID = "captureflow-recorder-backdrop"; + +/* + * Injected via chrome.scripting (serialized): toggles the in-page recorder — + * a blurred/dimmed backdrop with the extension panel floating top-right. Clicking the backdrop closes it page-side (no SW round-trip). + * Args only — the serialized body can't close over module scope. + * + * The iframe is sized exactly to the panel (the app reports its content height + * through the SW), with the shadow/rounding painted page-side on the iframe + * element. No iframe transparency: a color-scheme mismatch with the host page + * would repaint any transparent area as an opaque white canvas. + */ +export function toggleRecorderOverlay( + frameUrl: string, + frameId: string, + backdropId: string, +): void { + const existing = document.getElementById(frameId); + if (existing) { + existing.remove(); + document.getElementById(backdropId)?.remove(); + return; + } + + const backdrop = document.createElement("div"); + backdrop.id = backdropId; + backdrop.style.cssText = + "position:fixed;inset:0;z-index:2147483646;background:rgba(10,11,14,.45);" + + "backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"; + backdrop.addEventListener("click", () => { + backdrop.remove(); + document.getElementById(frameId)?.remove(); + }); + + const iframe = document.createElement("iframe"); + iframe.id = frameId; + iframe.src = frameUrl; + iframe.allow = "camera; microphone"; + iframe.style.cssText = + "position:fixed;top:14px;right:14px;width:348px;height:0;border:0;" + + "z-index:2147483647;background:#16181d;border-radius:16px;" + + "box-shadow:0 24px 64px rgba(0,0,0,.5);transition:height .15s ease;"; + + // If no height report ever lands, show the panel at a sane size instead of + // leaving a 0px (invisible) frame. Literals: serialized body, no outer scope. + setTimeout(() => { + const frame = document.getElementById(frameId); + if (frame instanceof HTMLIFrameElement && frame.style.height === "0px") { + frame.style.height = "440px"; + } + }, 600); + + document.documentElement.appendChild(backdrop); + document.documentElement.appendChild(iframe); +} + +// Height reports flow app → SW → this (chrome.scripting), not postMessage: +// runtime messaging works on every page, unlike page-window message events. +export function setRecorderOverlayHeight( + height: number, + frameId: string, +): void { + const frame = document.getElementById(frameId); + if (!(frame instanceof HTMLIFrameElement)) return; + const max = Math.round(window.innerHeight * 0.92); + frame.style.height = `${Math.min(Math.max(Math.round(height), 0), max)}px`; +} + +export function removeRecorderOverlay( + frameId: string, + backdropId: string, +): void { + document.getElementById(frameId)?.remove(); + document.getElementById(backdropId)?.remove(); +} + +// Screenshot capture hides the overlay for a frame so the blur and panel +// never end up baked into the captured image. +export function setRecorderOverlayVisible( + visible: boolean, + frameId: string, + backdropId: string, +): void { + const value = visible ? "visible" : "hidden"; + const frame = document.getElementById(frameId); + const backdrop = document.getElementById(backdropId); + if (frame) frame.style.visibility = value; + if (backdrop) backdrop.style.visibility = value; +} diff --git a/apps/extension/lib/surface.ts b/apps/extension/lib/surface.ts new file mode 100644 index 0000000..ba1f3c8 --- /dev/null +++ b/apps/extension/lib/surface.ts @@ -0,0 +1,18 @@ +import { sendMessage } from "./messaging"; + +/* + * The recorder app renders on three surfaces: the in-page overlay iframe + * (?overlay=1, the default UX), a standalone popup window (restricted-page + * fallback), and the legacy anchored action popup. window.close() is a no-op + * inside an iframe, so closing the overlay goes through the service worker. + */ +export const isOverlaySurface = + new URLSearchParams(location.search).get("overlay") === "1"; + +export function closeSurface(): void { + if (isOverlaySurface) { + void sendMessage("closeRecorderOverlay", undefined); + } else { + window.close(); + } +} diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 2857578..1c651ff 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -26,20 +26,34 @@ export default defineConfig({ permissions: isFirefox ? ["storage"] : ["storage", "offscreen", "scripting", "activeTab"], + /* + * Extension-context fetches to the API need host permission to skip + * CORS/Private-Network-Access checks — Brave otherwise blocks extension + * frames from reaching localhost in dev. Mirrors the + * externally_connectable dev/prod split. + */ + host_permissions: matches, // Firefox MV3 lacks web→extension messaging, so omit it there. ...(isFirefox ? {} : { externally_connectable: { matches } }), - // The injected camera bubble (and the permission-grant fallback) load - // these extension pages inside a web origin. + // The injected camera bubble, the permission-grant fallback, and the + // in-page recorder overlay load these extension pages inside a web origin. ...(isFirefox ? {} : { web_accessible_resources: [ { - resources: ["bubble.html", "permissions.html"], + resources: ["bubble.html", "permissions.html", "popup.html"], matches: [""], }, ], }), }; }, + hooks: { + // The recorder opens as an in-page overlay from action.onClicked; + // an anchored default_popup would swallow the click. + "build:manifestGenerated": (_wxt, manifest) => { + if (manifest.action) delete manifest.action.default_popup; + }, + }, }); From 4f21ca6adee00e10827a109d9b86700b9034aa63 Mon Sep 17 00:00:00 2001 From: sardorml Date: Thu, 16 Jul 2026 20:22:05 +0500 Subject: [PATCH 07/10] chore(brand): ship the real logo in the extension icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension still had placeholder icon PNGs — gen-brand-icons.py never emitted them. Regenerated 16/32/48/128 from the canonical master and taught the script the extension output so future regens stay in sync. --- apps/extension/public/icon/128.png | Bin 2469 -> 13894 bytes apps/extension/public/icon/16.png | Bin 295 -> 1125 bytes apps/extension/public/icon/32.png | Bin 536 -> 2396 bytes apps/extension/public/icon/48.png | Bin 830 -> 3830 bytes scripts/gen-brand-icons.py | 6 ++++++ 5 files changed, 6 insertions(+) diff --git a/apps/extension/public/icon/128.png b/apps/extension/public/icon/128.png index fc3b4dead6b10e1674d361ae2bb52a42b946bb2f..de69f5ee4a37c027eacb60135d96a59ce877aee7 100644 GIT binary patch literal 13894 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 UBUmYm1poj507*qoM6N<$g78HFlK=n! delta 2460 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 Eg6JcgdjJ3c delta 511 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 delta 808 zcmV+@1K0fa9li#TBYyx1a7bBm000XU000XU0RWnu7ytkQ@JU2LRA_e*vN*VF%?U78N_7VoA~kt=JJ7&vk0t zTy)(eB+WRDoycQLJ<^dZ%VVE2zH?{nJC+pyzGP>)RF9a1&wot77L&^35-<)jsl2^s z(zDgWtgyK?DcEBBI1}(PF0p)4Qvz19#rCoB;-#Hu5}KyP@*oPeomo@`eaHy=Vw_W^u0d}0&OJRzN<|9f9T)wC!co-cZFpG$xk3{TbZvlFFC^I{;XiTf7i zTJu668U?IbTz|kX<3eOKG6|bj8d4_@G@v{y2}1Z3JT$<^^v4Y@S;fX1;*#o449F<*FOM0ZNOyp4W;WFyS>eM%sO;@5XB&TZExJ z-vfn8p!hCfxjfRY?V^9r*bLC@ssqLQK?Yh^u4~)QN1U-E8JM5Oz|}>)fm<;epw~^W zeU>4_6Mr#?>y2~_VlKf?eL7I83{0b|@3d`7CekPk4e;q<%5SyUS@>8ixfLpEJFj9()rNPC;X05OfWNO=3Q1_`Ve?WhY9D zgYyI((>W48>u+RhmN(PQ*in}VJP8Kkpp|+9EPw647MXyp)EW@*GyT8UnN$yq3z1Q& z?sW^=sTAKiS!}VrI+=hK^MW80nS^KkDkW9G6J6kz`@{?Qow?y08LAa$i}peqQYxc; z6w=NI?31w1lRMKQc@Tx#W>Q@myI+DT;8aAWL1J96>``^NfafD&GY(Wl_EW5YHL3>2 mz9HR4!X`bNYQg<%qrpGa6Q#-Oj9v%;0000.png into the manifest) + for size in (16, 32, 48, 128): + square(master, size).save(EXT / f"{size}.png") + log("extension public/icon 16/32/48/128") + def swap_og(master, og_path): og = Image.open(og_path).convert("RGBA") From 1af9ee6fe6a0c728ef981004bbfba6b9e158fcd5 Mon Sep 17 00:00:00 2001 From: sardorml Date: Thu, 16 Jul 2026 20:22:05 +0500 Subject: [PATCH 08/10] fix(web): resolve the screenshot image URL through the binding env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer built imageUrl from lib/site's process.env-only R2_PUBLIC_BASE_URL, which never sees the .dev.vars media-proxy override — every screenshot uploaded against next dev rendered a broken image pointing at the prod CDN. publicScreenshotUrlFor now resolves binding env → process.env → CDN fallback, mirroring the recording domain's publicUrlFor. Prod output is unchanged. --- apps/web/app/s/[id]/page.tsx | 10 +++------- apps/web/lib/screenshot/r2.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/web/app/s/[id]/page.tsx b/apps/web/app/s/[id]/page.tsx index 9c0c99d..4df34ab 100644 --- a/apps/web/app/s/[id]/page.tsx +++ b/apps/web/app/s/[id]/page.tsx @@ -7,13 +7,12 @@ import { getScreenshot, getScreenshotWithOwner, } from "@/lib/screenshot/db"; -import { publicScreenshotUrl } from "@/lib/screenshot/r2"; +import { publicScreenshotUrlFor } from "@/lib/screenshot/r2"; import { verifySession } from "@/lib/screenshot/verify-session"; import { canViewResource } from "@/lib/visibility"; import { APP_SITE_URL, APP_WEB_SITE_URL, - R2_PUBLIC_BASE_URL, screenshotViewUrlFor, PRODUCT_NAME, } from "@/lib/site"; @@ -44,7 +43,7 @@ export async function generateMetadata({ params }: Props): Promise { return { title: PRODUCT_NAME, robots: { index: false, follow: false } }; } } - const imageUrl = publicScreenshotUrl(screenshot.id, R2_PUBLIC_BASE_URL); + const imageUrl = await publicScreenshotUrlFor(screenshot.id); const title = screenshot.title ?? `${PRODUCT_NAME} screenshot`; return { title, @@ -103,10 +102,7 @@ export default async function ScreenshotPage({ params }: Props) { // Cache-bust so a re-saved screenshot isn't served from the browser disk cache. const cacheKey = screenshot.editedAt ?? screenshot.updatedAt ?? screenshot.createdAt; - const imageUrl = `${publicScreenshotUrl( - screenshot.id, - R2_PUBLIC_BASE_URL, - )}?v=${cacheKey}`; + const imageUrl = `${await publicScreenshotUrlFor(screenshot.id)}?v=${cacheKey}`; // +1 so the viewer sees their own (post-read) load reflected. const displayViews = screenshot.viewCount + 1; const isOwner = !!( diff --git a/apps/web/lib/screenshot/r2.ts b/apps/web/lib/screenshot/r2.ts index fb744a6..74e4bd6 100644 --- a/apps/web/lib/screenshot/r2.ts +++ b/apps/web/lib/screenshot/r2.ts @@ -77,3 +77,14 @@ export async function putScreenshotState( export function publicScreenshotUrl(id: string, r2BaseUrl: string): string { return `${r2BaseUrl}/${screenshotStorageKey(id)}`; } + +// Resolve the base per request: in dev the binding env carries the local +// media-proxy override (.dev.vars), which process.env never sees. +export async function publicScreenshotUrlFor(id: string): Promise { + const env = await getCloudflareEnv(); + const base = + env?.R2_PUBLIC_BASE_URL ?? + process.env.R2_PUBLIC_BASE_URL ?? + "https://cdn.captureflow.xyz"; + return publicScreenshotUrl(id, base); +} From e9bcc7868f91f0cdf24a20d95b47574ae1c0acca Mon Sep 17 00:00:00 2001 From: sardorml Date: Thu, 16 Jul 2026 20:22:05 +0500 Subject: [PATCH 09/10] docs(extension): refresh PLAN.md to the as-built state Status header, superseded auth decision (externally_connectable tab sign-in), phase 2/3 + screenshot-mode results, the in-page recorder surface, and the hardening notes now match the implementation. --- apps/extension/PLAN.md | 64 ++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/apps/extension/PLAN.md b/apps/extension/PLAN.md index 9b20c36..15dcffa 100644 --- a/apps/extension/PLAN.md +++ b/apps/extension/PLAN.md @@ -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. @@ -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=`; 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: @@ -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 @@ -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 `