From 26c43709d59c37a82dcc40094a5288f245a3c5bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 16:53:21 +0000 Subject: [PATCH] Stage 1: messaging, state, permissions, offscreen scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lay down the cross-context plumbing for the rewrite: typed messages between popup/service worker/offscreen, session storage for recording state, helpers for the per-origin permission request, badge timer via chrome.alarms, and an offscreen document scaffold that the service worker creates on start and closes on stop. The popup now has the real two-state UI (idle / recording) plus an "unsupported page" state for chrome:// and similar URLs. On the first Start click it requests debugger + tabCapture for the current tab's origin only (e.g. *://example.com/*) via chrome.permissions.request, so the user sees a single dialog scoped to the site rather than "all websites" at install time. No actual recording yet — that's Stages 2-5. End to end the START / STOP flow now works: badge ticks, offscreen document spawns and closes, state survives popup close, and tab closure mid-record is detected via chrome.tabs.onRemoved and triggers cleanup. --- src/background/index.ts | 92 ++++++++++++++++++++++++++++++++++++- src/lib/messaging.ts | 19 ++++++++ src/lib/permissions.ts | 30 +++++++++++++ src/lib/state.ts | 20 +++++++++ src/offscreen/index.html | 10 +++++ src/offscreen/index.ts | 1 + src/popup/App.tsx | 97 +++++++++++++++++++++++++++++++++++++++- src/popup/index.css | 31 ++++++++++++- src/types/messages.ts | 19 ++++++++ vite.config.ts | 11 +++-- 10 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 src/lib/messaging.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/lib/state.ts create mode 100644 src/offscreen/index.html create mode 100644 src/offscreen/index.ts create mode 100644 src/types/messages.ts diff --git a/src/background/index.ts b/src/background/index.ts index a603b50..328bad0 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,3 +1,91 @@ -chrome.runtime.onInstalled.addListener(() => { - console.log('Bug Recorder service worker installed') +import { onMessage } from '@/lib/messaging' +import { clearState, getState, setState } from '@/lib/state' +import type { Status } from '@/types/messages' + +const BADGE_ALARM = 'badge-tick' +const OFFSCREEN_URL = 'src/offscreen/index.html' + +async function ensureOffscreen(): Promise { + if (await chrome.offscreen.hasDocument()) return + await chrome.offscreen.createDocument({ + url: OFFSCREEN_URL, + reasons: [chrome.offscreen.Reason.USER_MEDIA], + justification: 'Hosts MediaRecorder for tab screencast.', + }) +} + +async function closeOffscreen(): Promise { + if (await chrome.offscreen.hasDocument()) { + await chrome.offscreen.closeDocument() + } +} + +async function updateBadge(tabId: number): Promise { + const state = await getState() + if (!state) { + await chrome.action.setBadgeText({ text: '', tabId }) + return + } + const elapsed = Math.floor((Date.now() - state.startedAt) / 1000) + const minutes = Math.floor(elapsed / 60) + const seconds = String(elapsed % 60).padStart(2, '0') + await chrome.action.setBadgeText({ text: `${minutes}:${seconds}`, tabId }) + await chrome.action.setBadgeBackgroundColor({ color: '#ec235a', tabId }) +} + +async function start(tabId: number, originPattern: string): Promise { + await setState({ tabId, startedAt: Date.now(), originPattern }) + await ensureOffscreen() + await chrome.alarms.create(BADGE_ALARM, { periodInMinutes: 1 / 60 }) + await updateBadge(tabId) + console.log('[bug-recorder] start', { tabId, originPattern }) +} + +async function stop(): Promise { + const state = await getState() + await clearState() + await chrome.alarms.clear(BADGE_ALARM) + if (state) { + await chrome.action.setBadgeText({ text: '', tabId: state.tabId }) + } + await closeOffscreen() + console.log('[bug-recorder] stop') +} + +onMessage(async msg => { + switch (msg.type) { + case 'START': + await start(msg.tabId, msg.originPattern) + return + case 'STOP': + await stop() + return + case 'GET_STATUS': { + const state = await getState() + const status: Status = state + ? { recording: true, ...state } + : { recording: false } + return status + } + } }) + +chrome.alarms.onAlarm.addListener(async alarm => { + if (alarm.name !== BADGE_ALARM) return + const state = await getState() + if (!state) { + await chrome.alarms.clear(BADGE_ALARM) + return + } + await updateBadge(state.tabId) +}) + +chrome.tabs.onRemoved.addListener(async tabId => { + const state = await getState() + if (state && state.tabId === tabId) { + console.log('[bug-recorder] tab closed during recording — cancelling') + await stop() + } +}) + +console.log('[bug-recorder] service worker loaded') diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts new file mode 100644 index 0000000..55d6cec --- /dev/null +++ b/src/lib/messaging.ts @@ -0,0 +1,19 @@ +import type { Request } from '@/types/messages' + +export function sendMessage(message: Request): Promise { + return chrome.runtime.sendMessage(message) +} + +type Handler = (msg: Request) => Promise + +export function onMessage(handler: Handler): void { + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + handler(msg as Request) + .then(sendResponse) + .catch(err => { + console.error('[bug-recorder] message handler error', err) + sendResponse(undefined) + }) + return true + }) +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..bb08f34 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,30 @@ +const RECORDER_PERMISSIONS = ['debugger', 'tabCapture'] as const + +export function originPattern(url: string): string { + const u = new URL(url) + return `${u.protocol}//${u.hostname}/*` +} + +export function isRecordableUrl(url: string | undefined): boolean { + if (!url) return false + try { + const u = new URL(url) + return u.protocol === 'http:' || u.protocol === 'https:' + } catch { + return false + } +} + +export function requestRecorderPermissions(origin: string): Promise { + return chrome.permissions.request({ + permissions: [...RECORDER_PERMISSIONS], + origins: [origin], + }) +} + +export function hasRecorderPermissions(origin: string): Promise { + return chrome.permissions.contains({ + permissions: [...RECORDER_PERMISSIONS], + origins: [origin], + }) +} diff --git a/src/lib/state.ts b/src/lib/state.ts new file mode 100644 index 0000000..94e0b2b --- /dev/null +++ b/src/lib/state.ts @@ -0,0 +1,20 @@ +export type RecordingState = { + tabId: number + startedAt: number + originPattern: string +} + +const KEY = 'recordingState' + +export async function getState(): Promise { + const result = await chrome.storage.session.get(KEY) + return (result[KEY] as RecordingState | undefined) ?? null +} + +export async function setState(state: RecordingState): Promise { + await chrome.storage.session.set({ [KEY]: state }) +} + +export async function clearState(): Promise { + await chrome.storage.session.remove(KEY) +} diff --git a/src/offscreen/index.html b/src/offscreen/index.html new file mode 100644 index 0000000..f8bde49 --- /dev/null +++ b/src/offscreen/index.html @@ -0,0 +1,10 @@ + + + + + Bug Recorder offscreen + + + + + diff --git a/src/offscreen/index.ts b/src/offscreen/index.ts new file mode 100644 index 0000000..05d4dde --- /dev/null +++ b/src/offscreen/index.ts @@ -0,0 +1 @@ +console.log('[bug-recorder] offscreen document loaded') diff --git a/src/popup/App.tsx b/src/popup/App.tsx index 61a5254..b59ab14 100644 --- a/src/popup/App.tsx +++ b/src/popup/App.tsx @@ -1,8 +1,101 @@ +import { useEffect, useState } from 'react' +import { sendMessage } from '@/lib/messaging' +import { + isRecordableUrl, + originPattern, + requestRecorderPermissions, +} from '@/lib/permissions' +import type { Status } from '@/types/messages' + +type UiState = + | { kind: 'loading' } + | { kind: 'unsupported'; reason: string } + | { kind: 'idle'; tab: chrome.tabs.Tab; deniedOnce: boolean } + | { kind: 'recording' } + export default function App() { + const [ui, setUi] = useState({ kind: 'loading' }) + + useEffect(() => { + void load() + }, []) + + async function load() { + const status = await sendMessage({ type: 'GET_STATUS' }) + if (status?.recording) { + setUi({ kind: 'recording' }) + return + } + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + if (!tab || !isRecordableUrl(tab.url)) { + setUi({ + kind: 'unsupported', + reason: 'Open a regular http(s) page to record.', + }) + return + } + setUi({ kind: 'idle', tab, deniedOnce: false }) + } + + async function start(tab: chrome.tabs.Tab) { + if (!tab.id || !tab.url) return + const pattern = originPattern(tab.url) + const granted = await requestRecorderPermissions(pattern) + if (!granted) { + setUi({ kind: 'idle', tab, deniedOnce: true }) + return + } + await sendMessage({ + type: 'START', + tabId: tab.id, + originPattern: pattern, + }) + window.close() + } + + async function stop() { + await sendMessage({ type: 'STOP' }) + window.close() + } + + if (ui.kind === 'loading') { + return ( +
+

Loading…

+
+ ) + } + + if (ui.kind === 'unsupported') { + return ( +
+

Bug Recorder

+

{ui.reason}

+
+ ) + } + + if (ui.kind === 'recording') { + return ( +
+ +

Click when you have reproduced the bug.

+
+ ) + } + return (
-

Bug Recorder

-

Scaffold ready — UI coming in stage 1.

+ +

+ {ui.deniedOnce + ? 'Permissions are required to record. Click Start again.' + : 'This will reload the page and start recording.'} +

) } diff --git a/src/popup/index.css b/src/popup/index.css index 8eac486..076e1d5 100644 --- a/src/popup/index.css +++ b/src/popup/index.css @@ -22,7 +22,36 @@ body { } .popup .caption { - margin: 0; + margin: 12px 0 0; color: #666; font-size: 12px; } + +.btn { + width: 180px; + height: 36px; + border: none; + border-radius: 18px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 120ms ease; +} + +.btn-start { + background: #f5f5f5; + color: #ec235a; +} + +.btn-start:hover { + background: #ebebeb; +} + +.btn-stop { + background: #ec235a; + color: #fff; +} + +.btn-stop:hover { + background: #d61e51; +} diff --git a/src/types/messages.ts b/src/types/messages.ts new file mode 100644 index 0000000..40b88f8 --- /dev/null +++ b/src/types/messages.ts @@ -0,0 +1,19 @@ +export type StartRequest = { + type: 'START' + tabId: number + originPattern: string +} + +export type StopRequest = { + type: 'STOP' +} + +export type GetStatusRequest = { + type: 'GET_STATUS' +} + +export type Request = StartRequest | StopRequest | GetStatusRequest + +export type Status = + | { recording: false } + | { recording: true; tabId: number; startedAt: number; originPattern: string } diff --git a/vite.config.ts b/vite.config.ts index aca02fc..52c7842 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,11 +17,16 @@ export default defineConfig({ crx({ manifest }), zip({ outDir: 'release', outFileName: `crx-${name}-${version}.zip` }), ], + build: { + rollupOptions: { + input: { + offscreen: 'src/offscreen/index.html', + }, + }, + }, server: { cors: { - origin: [ - /chrome-extension:\/\//, - ], + origin: [/chrome-extension:\/\//], }, }, })