Two halves of the same coupling, both left behind by this morning's extraction. The pairing UI was Settings/IntegrationsSettings/BrowserRelay.tsx — a PLATFORM screen driving this plugin's pairing, calling /browser/relay-token, and working only because the plugin happens to serve that path. It is a panel here now, beside the tab list rather than buried in settings: it shows whether the extension is connected and how many tabs it holds, which is the first thing anyone looks at when a capture does not work, and that is not configuration you set once. The download link pointed at public/browser-relay-extension.zip, a static file. That file was 58 lines behind the source it claimed to be — built in August from something that predated a February commit, never rebuilt, and serving an extension missing the multi-session recovery path that the same February commit added. Anyone downloading it got the version that cannot recover from exactly the failure they were about to hit. So the archive is built from the extension directory per request. There is no second copy to fall behind, which is the only durable fix for that class. zip.ts is hand-rolled and stored rather than deflated. The route's own history says it used to shell out to `zip`; this manifest's "nothing to declare" is worth more than 30 KB on a download that happens once, and Chrome accepts stored entries. Timestamps are fixed at the format's zero so the same directory always produces identical bytes — which is what makes the ETag meaningful and "did this change" answerable by comparing two files. Verified by unzipping the output and diffing background.js against the source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.5 KiB
TypeScript
123 lines
4.5 KiB
TypeScript
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join, relative } from 'node:path';
|
|
|
|
// A ZIP writer, in about seventy lines, so the extension download can never be stale.
|
|
//
|
|
// ── Why this exists rather than a file in public/ ──
|
|
//
|
|
// It was a file in public/: `browser-relay-extension.zip`, built by hand and served as a static asset.
|
|
// On 2026-08-21 it was found to be 58 lines behind the source it claimed to be — built in August from
|
|
// something that predated a February commit, and never rebuilt since. Anyone who downloaded it got an
|
|
// extension missing the multi-session recovery path, which is exactly the failure they would then hit.
|
|
//
|
|
// A snapshot drifts because nothing makes it not drift. Building from the directory at request time
|
|
// removes the class: there is no second copy to fall behind.
|
|
//
|
|
// ── Why hand-rolled, and why STORED rather than deflated ──
|
|
//
|
|
// The router's own history says the previous version of this shelled out to `zip`. That works and it
|
|
// makes the plugin need a host binary for a convenience feature — this manifest's "nothing to declare"
|
|
// is worth more than the compression. Bun ships no zip writer, and the stored format is genuinely small:
|
|
// a local header per file, a central directory, and an end record.
|
|
//
|
|
// Chrome accepts stored entries. The extension is ~50 KB of text; deflate would save perhaps 30 KB on a
|
|
// download that happens once.
|
|
|
|
/** CRC-32, the one thing in the format that is not just bytes in order. */
|
|
function crc32(bytes: Uint8Array): number {
|
|
let crc = 0xffffffff;
|
|
for (const byte of bytes) {
|
|
crc ^= byte;
|
|
for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
type Entry = { name: string; data: Uint8Array; crc: number; offset: number };
|
|
|
|
const u16 = (v: number) => [v & 0xff, (v >>> 8) & 0xff];
|
|
const u32 = (v: number) => [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff];
|
|
|
|
/** Every file under `dir`, depth first, with paths relative to it. */
|
|
function collect(dir: string, root = dir): { name: string; data: Uint8Array }[] {
|
|
const out: { name: string; data: Uint8Array }[] = [];
|
|
for (const item of readdirSync(dir).sort()) {
|
|
const path = join(dir, item);
|
|
if (statSync(path).isDirectory()) out.push(...collect(path, root));
|
|
else out.push({ name: relative(root, path), data: new Uint8Array(readFileSync(path)) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Zip a directory, stored, with a fixed timestamp.
|
|
*
|
|
* The timestamp is fixed (1980-01-01, the format's own zero) so the same directory always produces
|
|
* byte-identical output. That makes the download cacheable and, more usefully, makes "did this change"
|
|
* answerable by comparing two files rather than unpacking them.
|
|
*/
|
|
export function zipDirectory(dir: string): Uint8Array {
|
|
const files = collect(dir);
|
|
const parts: number[] = [];
|
|
const entries: Entry[] = [];
|
|
|
|
for (const file of files) {
|
|
const nameBytes = [...new TextEncoder().encode(file.name)];
|
|
const crc = crc32(file.data);
|
|
entries.push({ ...file, crc, offset: parts.length });
|
|
parts.push(
|
|
...u32(0x04034b50), // local file header
|
|
...u16(20), // version needed
|
|
...u16(0), // flags
|
|
...u16(0), // method 0 = stored
|
|
...u16(0), // time
|
|
...u16(0x0021), // date — 1980-01-01
|
|
...u32(crc),
|
|
...u32(file.data.length), // compressed size == uncompressed, stored
|
|
...u32(file.data.length),
|
|
...u16(nameBytes.length),
|
|
...u16(0), // extra length
|
|
...nameBytes,
|
|
...file.data,
|
|
);
|
|
}
|
|
|
|
const centralStart = parts.length;
|
|
for (const entry of entries) {
|
|
const nameBytes = [...new TextEncoder().encode(entry.name)];
|
|
parts.push(
|
|
...u32(0x02014b50), // central directory header
|
|
...u16(20), // version made by
|
|
...u16(20), // version needed
|
|
...u16(0),
|
|
...u16(0),
|
|
...u16(0),
|
|
...u16(0x0021),
|
|
...u32(entry.crc),
|
|
...u32(entry.data.length),
|
|
...u32(entry.data.length),
|
|
...u16(nameBytes.length),
|
|
...u16(0), // extra
|
|
...u16(0), // comment
|
|
...u16(0), // disk
|
|
...u16(0), // internal attrs
|
|
...u32(0), // external attrs
|
|
...u32(entry.offset),
|
|
...nameBytes,
|
|
);
|
|
}
|
|
|
|
parts.push(
|
|
...u32(0x06054b50), // end of central directory
|
|
...u16(0),
|
|
...u16(0),
|
|
...u16(entries.length),
|
|
...u16(entries.length),
|
|
...u32(parts.length - centralStart),
|
|
...u32(centralStart),
|
|
...u16(0), // comment length
|
|
);
|
|
|
|
return new Uint8Array(parts);
|
|
}
|