diff --git a/AGENTS.md b/AGENTS.md index 1e89a8e..dc1af64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,10 @@ the finished AI readable link as JSON, with no clicks, prompts, or clipboard use `chrome.exe` and still honor the flag), leaves the flag out, and asks for Load unpacked. On Chrome, `-Status` checks only the occlusion flag; `--ping` is the real check. `test/native-host.test.mjs` covers this with stand-in exes, through `-ShowLaunchArgs`. +- **Load a rebuilt `dist/` with `--reload`, not a browser restart.** After + `npm run build:extension`, `node scripts/sourcecapsule-capture.mjs --reload` asks the worker to + call `chrome.runtime.reload()`, then pings until the fresh worker's host answers. An extension + built before this action answers `unknown_action`; that build needs one reload by hand. - **The host must not outlive its browser.** Node inherits the browser's stdio through the `.cmd`, and the host exits on stdin end/close/error or a failed stdout write (`test/native-host.test.mjs` covers the stdin case). Without that, a killed browser left an diff --git a/extension-src/background.js b/extension-src/background.js index d871fa0..98cb463 100644 --- a/extension-src/background.js +++ b/extension-src/background.js @@ -266,6 +266,13 @@ async function handleAutomationRequest(request) { extensionId: chrome.runtime.id, }; } + if (request.action === 'reload') { + // Reply first: chrome.runtime.reload() tears down this worker and its native port, and + // the fresh worker reconnects to a new host on start. Loads a rebuilt dist/ without a + // browser restart. + setTimeout(() => chrome.runtime.reload(), 500); + return { ok: true, reloading: true, extensionVersion: chrome.runtime.getManifest().version }; + } if (request.action === 'capture-share') return captureShare(request); return { ok: false, error: 'unknown_action', message: `Unsupported action: ${request.action}` }; } diff --git a/scripts/sourcecapsule-capture.mjs b/scripts/sourcecapsule-capture.mjs index 4ecdbd7..e6eb020 100644 --- a/scripts/sourcecapsule-capture.mjs +++ b/scripts/sourcecapsule-capture.mjs @@ -2,6 +2,7 @@ // Unattended SourceCapsule capture. // // node scripts/sourcecapsule-capture.mjs --url "https://x.com/handle/status/123" --json +// node scripts/sourcecapsule-capture.mjs --reload (load a rebuilt dist/ without a restart) // // Structured JSON goes to stdout, progress and diagnostics to stderr, nonzero exit on // failure. The CLI never touches X itself: it hands the URL to the extension through the @@ -20,6 +21,8 @@ const PIPE = process.env.SOURCECAPSULE_PIPE || DEFAULT_PIPE; const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; const CONNECT_RETRIES = 3; const CONNECT_RETRY_DELAY_MS = 750; +// A reloaded worker reconnects at start; the 30s reconnect alarm is the backstop. +const RELOAD_WAIT_MS = 45000; // Only a canonical post permalink is accepted. Anything else (search pages, profiles, // redirect shorteners) would either fail downstream or capture the wrong thing. @@ -46,6 +49,7 @@ function parseArgs(argv) { if (arg === '--json') args.json = true; else if (arg === '--url') args.url = argv[++i]; else if (arg === '--ping') args.ping = true; + else if (arg === '--reload') args.reload = true; else if (arg === '--timeout') { const seconds = numericValue('--timeout', argv[++i]); if (seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) { @@ -182,6 +186,42 @@ async function main() { return reply.ok ? 0 : 1; } + if (args.reload) { + progress('asking the extension to reload itself'); + const ask = await request({ id, action: 'reload', timeoutMs: 15000 }, 15000); + if (!ask.ok) { + if (ask.error === 'unknown_action') { + ask.message = + 'The running extension predates --reload. Reload it once from brave://extensions (or restart with the launcher); later builds can reload themselves.'; + } + process.stdout.write(`${JSON.stringify(ask, null, 2)}\n`); + return 1; + } + // The old worker and its host go away; the new worker starts its own host. Wait for it. + const deadline = Date.now() + RELOAD_WAIT_MS; + let lastError; + await new Promise((resolve) => setTimeout(resolve, 1500)); + while (Date.now() < deadline) { + try { + const pong = await request({ id: `${id}-p`, action: 'ping', timeoutMs: 5000 }, 5000); + if (pong.ok) { + progress('extension reloaded and answering'); + process.stdout.write(`${JSON.stringify({ ...pong, reloaded: true }, null, 2)}\n`); + return 0; + } + lastError = new Error(pong.message || pong.error); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error( + `The extension did not answer within ${RELOAD_WAIT_MS / 1000}s of reloading: ${ + lastError ? lastError.message : 'no reply' + }` + ); + } + const canonical = canonicalXUrl(args.url); if (!canonical) { throw new Error( diff --git a/test/native-host.test.mjs b/test/native-host.test.mjs index 71a285d..304e296 100644 --- a/test/native-host.test.mjs +++ b/test/native-host.test.mjs @@ -259,6 +259,38 @@ async function cliChecks() { } console.log('ok CLI fails fast when the host closes mid-request'); + // --reload asks once, then waits for a worker to answer ping. A fake host answers both. + const reloadPipe = String.raw`\\.\pipe\sourcecapsule-reload-` + process.pid; + const asked = []; + let supportsReload = true; + const fakeHost = net.createServer((socket) => { + socket.setEncoding('utf8'); + socket.once('data', (line) => { + const message = JSON.parse(line); + asked.push(message.action); + const reply = + message.action === 'reload' && !supportsReload + ? { id: message.id, ok: false, error: 'unknown_action' } + : { id: message.id, ok: true, extensionVersion: 'test' }; + socket.end(`${JSON.stringify(reply)}\n`); + }); + }); + await new Promise((resolve) => fakeHost.listen(reloadPipe, resolve)); + try { + const reloaded = await runCli(['--reload'], reloadPipe); + assert.equal(reloaded.code, 0, `--reload exit code (stderr: ${reloaded.stderr})`); + assert.deepEqual(asked, ['reload', 'ping']); + assert.equal(JSON.parse(reloaded.stdout).reloaded, true); + + supportsReload = false; + const old = await runCli(['--reload'], reloadPipe); + assert.equal(old.code, 1); + assert.match(JSON.parse(old.stdout).message, /brave:\/\/extensions/); + } finally { + fakeHost.close(); + } + console.log('ok --reload waits for the reloaded extension and explains an old build'); + // Bad numeric flags are a usage error before any connection is attempted. Number('5m') // is NaN, and a NaN timer fires immediately, so a typo used to look like a host timeout. const noPipe = String.raw`\\.\pipe\sourcecapsule-absent-` + process.pid;