Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions extension-src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}` };
}
Expand Down
40 changes: 40 additions & 0 deletions scripts/sourcecapsule-capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions test/native-host.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading