diff --git a/scripts/cdp.mjs b/scripts/cdp.mjs new file mode 100644 index 0000000..960b4ef --- /dev/null +++ b/scripts/cdp.mjs @@ -0,0 +1,160 @@ +// Driving a headless Chrome with the extension loaded, over the DevTools +// protocol. Shared by scripts/rc-load.mjs and scripts/smoke-test.mjs. +// +// Two things about this are not obvious and are the reason it is a module: +// +// - Branded Chrome >= 137 ignores --load-extension, so the extension has to +// be loaded over CDP with Extensions.loadUnpacked, which needs +// --enable-unsafe-extension-debugging. +// - The MV3 service worker target appears *before* Chrome finishes binding +// the extension APIs into it: for a few hundred milliseconds `chrome` is +// an object while `chrome.runtime` and `chrome.offscreen` are undefined. +// Evaluating in that window fails in a way that reads as a broken build +// rather than a race, so withChrome waits for the bindings. +// +// scripts/capture-status-docs.mjs still carries its own copy of this; it is +// the oldest and most intricate of the three, and folding it in is a separate +// change from adding a new consumer. + +import { spawn } from 'node:child_process' +import { findChrome } from './render-icons.mjs' + +/** Resolve when `fn()` returns something truthy, or throw after `timeoutMs`. */ +export async function poll(fn, what, timeoutMs = 15_000) { + const start = Date.now() + for (;;) { + const value = await fn() + if (value) return value + if (Date.now() - start > timeoutMs) throw new Error(`timed out waiting for ${what}`) + await new Promise((r) => setTimeout(r, 150)) + } +} + +function connect(wsUrl) { + const ws = new WebSocket(wsUrl) + const pending = new Map() + let id = 0 + const ready = new Promise((resolve) => ws.addEventListener('open', resolve)) + ws.addEventListener('message', (e) => { + const msg = JSON.parse(e.data) + if (msg.id && pending.has(msg.id)) { + pending.get(msg.id)(msg) + pending.delete(msg.id) + } + }) + return { + ready, + close: () => ws.close(), + send(method, params = {}, sessionId) { + return new Promise((resolve) => { + const i = ++id + pending.set(i, resolve) + ws.send(JSON.stringify({ id: i, method, params, sessionId })) + }) + }, + } +} + +/** + * Launch Chrome with `extensionDir` installed, hand `fn` a way to talk to it, + * and tear everything down afterwards. + * + * `fn` receives: + * - `extensionId` — the id Chrome assigned (the store id, if the manifest + * carries the store `key`) + * - `inWorker(expr)` — evaluate an expression inside the extension's service + * worker and return its value; awaits promises + * - `openTab(url)` / `send` — for anything else + */ +export async function withChrome({ extensionDir, profile, headless = true }, fn) { + const args = [ + '--remote-debugging-port=0', + '--no-sandbox', + '--enable-unsafe-extension-debugging', + `--user-data-dir=${profile}`, + '--no-first-run', + '--no-default-browser-check', + 'about:blank', + ] + if (headless) args.unshift('--headless=new') + + const chrome = spawn(findChrome(), args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let cdp + try { + const wsUrl = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Chrome did not report a debugging endpoint')), + 30_000, + ) + chrome.stderr.on('data', (d) => { + const m = String(d).match(/ws:\/\/[^\s]+/) + if (m) { + clearTimeout(timer) + resolve(m[0]) + } + }) + chrome.on('exit', (code) => reject(new Error(`Chrome exited early (${code})`))) + }) + + cdp = connect(wsUrl) + await cdp.ready + + const loaded = await cdp.send('Extensions.loadUnpacked', { path: extensionDir }) + if (loaded.error) throw new Error(`loadUnpacked failed: ${JSON.stringify(loaded.error)}`) + const extensionId = loaded.result.id + + const worker = await poll( + async () => { + const { result } = await cdp.send('Target.getTargets') + return result.targetInfos.find( + (t) => t.type === 'service_worker' && t.url.includes(extensionId), + ) + }, + `a service worker for ${extensionId}`, + ) + const { result: attached } = await cdp.send('Target.attachToTarget', { + targetId: worker.targetId, + flatten: true, + }) + const sessionId = attached.sessionId + await cdp.send('Runtime.enable', {}, sessionId) + + const inWorker = async (expression) => { + const { result } = await cdp.send( + 'Runtime.evaluate', + { expression, awaitPromise: true, returnByValue: true }, + sessionId, + ) + if (result.exceptionDetails) { + throw new Error( + `evaluate failed: ${result.exceptionDetails.text} ` + + `${result.exceptionDetails.exception?.description ?? ''}`, + ) + } + return result.result.value + } + + // See the header note: the target exists before the APIs are bound. + await poll( + () => inWorker('Boolean(globalThis.chrome?.runtime?.id)'), + 'the extension APIs to appear in the service worker', + ) + + return await fn({ cdp, inWorker, extensionId }) + } finally { + cdp?.close() + chrome.kill() + // Chrome writes to its profile directory on the way out; a caller that + // deletes the profile the instant kill() returns races that and fails + // with ENOTEMPTY. Wait for the process to actually be gone. + if (chrome.exitCode === null && chrome.signalCode === null) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, 5_000) + chrome.on('exit', () => { + clearTimeout(timer) + resolve() + }) + }) + } + } +} diff --git a/scripts/rc-load.mjs b/scripts/rc-load.mjs index 93b57c8..308a6f2 100644 --- a/scripts/rc-load.mjs +++ b/scripts/rc-load.mjs @@ -24,12 +24,11 @@ // uses a throwaway profile. import { execFileSync } from 'node:child_process' -import { spawn } from 'node:child_process' import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { withChrome } from './cdp.mjs' import { extensionIdFromKey, redirectUriFor } from './check-oauth-metadata.mjs' -import { findChrome } from './render-icons.mjs' /** Newest release zip by version, not by mtime — rebuilds must not reorder. */ export function latestReleaseZip(names) { @@ -66,97 +65,6 @@ function extract(zip, dest) { execFileSync('python3', ['-c', 'import sys,zipfile;zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])', zip, dest]) } -// --- CDP --------------------------------------------------------------------- - -async function connect(chromePath, extDir, interactive, profile) { - const args = [ - '--remote-debugging-port=0', - '--no-sandbox', - '--enable-unsafe-extension-debugging', - `--user-data-dir=${profile}`, - '--no-first-run', - '--no-default-browser-check', - 'about:blank', - ] - if (!interactive) args.unshift('--headless=new') - const chrome = spawn(chromePath, args, { stdio: ['ignore', 'pipe', 'pipe'] }) - - const wsUrl = await new Promise((res, rej) => { - const t = setTimeout(() => rej(new Error('Chrome did not report a debugging endpoint')), 30_000) - chrome.stderr.on('data', (d) => { - const m = String(d).match(/ws:\/\/[^\s]+/) - if (m) { - clearTimeout(t) - res(m[0]) - } - }) - chrome.on('exit', (code) => rej(new Error(`Chrome exited early (${code})`))) - }) - - const ws = new WebSocket(wsUrl) - await new Promise((r) => ws.addEventListener('open', r)) - let id = 0 - const pending = new Map() - ws.addEventListener('message', (e) => { - const m = JSON.parse(e.data) - if (m.id && pending.has(m.id)) { - pending.get(m.id)(m) - pending.delete(m.id) - } - }) - const send = (method, params = {}, sessionId) => - new Promise((r) => { - const i = ++id - pending.set(i, r) - ws.send(JSON.stringify({ id: i, method, params, sessionId })) - }) - - const loaded = await send('Extensions.loadUnpacked', { path: extDir }) - if (loaded.error) throw new Error(`loadUnpacked failed: ${JSON.stringify(loaded.error)}`) - return { chrome, send, extId: loaded.result.id } -} - -/** The worker is lazy; it appears once Chrome starts it for onInstalled. */ -async function attachToWorker(send, extId) { - for (let i = 0; i < 80; i++) { - const { result } = await send('Target.getTargets') - const sw = result.targetInfos.find( - (t) => t.type === 'service_worker' && t.url.includes(extId), - ) - if (sw) { - const { result: att } = await send('Target.attachToTarget', { - targetId: sw.targetId, - flatten: true, - }) - await send('Runtime.enable', {}, att.sessionId) - await waitForExtensionApis(send, att.sessionId) - return att.sessionId - } - await new Promise((r) => setTimeout(r, 250)) - } - throw new Error(`no service worker target appeared for ${extId}`) -} - -/** - * The worker target exists before Chrome has finished binding the extension - * APIs into it: for a few hundred milliseconds `chrome` is an object while - * `chrome.runtime` and `chrome.offscreen` are still undefined. Evaluating the - * probe in that window fails in a way that looks like a broken build rather - * than a race, so wait for the bindings to land. - */ -async function waitForExtensionApis(send, sessionId) { - for (let i = 0; i < 80; i++) { - const { result } = await send( - 'Runtime.evaluate', - { expression: 'Boolean(globalThis.chrome?.runtime?.id && chrome.offscreen)', returnByValue: true }, - sessionId, - ) - if (result.result?.value === true) return - await new Promise((r) => setTimeout(r, 250)) - } - throw new Error('extension APIs never appeared in the service worker') -} - // Runs inside the extension's own service worker. Creating the offscreen // document and messaging it is exactly what startSignIn does; the bogus handle // stops before any consent window. @@ -224,26 +132,27 @@ async function main(argv) { console.log(`rc-load: ${zip}`) console.log(` version ${manifest.version}, stamped with the store identity ${ids.store}`) - const chromePath = findChrome() - const { chrome, send, extId } = await connect(chromePath, extDir, interactive, profile) - const problems = [] - if (extId !== ids.store) { - problems.push(`Chrome loaded this build as ${extId}, not the store id ${ids.store}`) - } - - const sessionId = await attachToWorker(send, extId) - const { result: ev } = await send( - 'Runtime.evaluate', - { expression: PROBE, awaitPromise: true, returnByValue: true }, - sessionId, + const probe = await withChrome( + { extensionDir: extDir, profile, headless: !interactive }, + async ({ inWorker, extensionId }) => { + if (extensionId !== ids.store) { + problems.push(`Chrome loaded this build as ${extensionId}, not the store id ${ids.store}`) + } + const raw = await inWorker(PROBE) + const parsed = typeof raw === 'string' ? JSON.parse(raw) : undefined + if (!parsed) problems.push(`the service worker did not answer the probe: ${raw}`) + if (interactive) { + console.log('\nrc-load: Chrome is open with the release candidate installed.') + console.log(' Pin the toolbar icon and sign in for real; Ctrl-C here when done.') + await new Promise(() => {}) + } + return parsed + }, ) - if (process.env.RC_DEBUG) console.error('DEBUG evaluate:', JSON.stringify(ev, null, 2).slice(0, 2000)) - const raw = ev.result?.value - const probe = typeof raw === 'string' ? JSON.parse(raw) : undefined - if (!probe) problems.push(`the service worker did not answer the probe: ${JSON.stringify(ev)}`) - else if (probe.offscreenError) problems.push(`offscreen document failed: ${probe.offscreenError}`) - else { + + if (probe?.offscreenError) problems.push(`offscreen document failed: ${probe.offscreenError}`) + else if (probe) { const verdict = classifyAuthProbe(probe.authProbe) if (verdict === 'invalid-redirect') { problems.push( @@ -258,21 +167,14 @@ async function main(argv) { } } - if (interactive) { - console.log('\nrc-load: Chrome is open with the release candidate installed.') - console.log(' Pin the toolbar icon and sign in for real; Ctrl-C here when done.') - await new Promise(() => {}) - } - - chrome.kill() - rmSync(work, { recursive: true, force: true }) + rmSync(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) if (problems.length > 0) { console.error(`\nrc-load: FAILED (${problems.length} problem(s)):`) for (const p of problems) console.error(` - ${p}`) process.exit(1) } - console.log(`\nrc-load: OK — ran as ${extId} and authorization got past its own metadata`) + console.log(`\nrc-load: OK — ran as ${ids.store} and authorization got past its own metadata`) } if (process.argv[1] === import.meta.filename) await main(process.argv.slice(2))