Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
53 kB · 1276 lines
JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277// Regenerates docs/img/*.png: real screenshots of the popup in each status// state, plus toolbar badge tiles rendered from the badge specs of the// running service worker (via the __substandard debug hook). Runs a real// headless Chrome with the built extension loaded, twice://// - offline: external DNS is mapped to 127.0.0.1 and the pages come from// local fixture servers, so the failure states (no publication, fetch// failed, offline) are real failures and nothing leaves the machine.// - online: real network, real standard.site publications (see REAL), so// every state that shows a publication card shows a real one — the store// listing screenshots are composed from these. The one exception is the// moderated card (see BAD_BLOG): no real publisher is going to model// "blocked, and labeled by two labelers", so that publication is invented// and the labelers' answers about it are stubbed. The labelers themselves// are real, and so is every other step — nothing about how a label reads// is asserted by this script.//// Usage://// npm run capture # builds the extension first, then re-shoots docs/img/// npm run recapture # the above, then every asset derived from the shots//// The captures are of the built extension in dist/, never of the tree directly,// so a run against a stale build is refused (assertFreshBuild) rather than// quietly screenshotting the previous popup.//// The toolbar itself does not render in headless Chrome, so the badge tiles// are drawn by Chrome from the worker's authoritative BadgeSpec values on a// toolbar-like ground; the popup screenshots are the real popup end to end.
import { spawn } from 'node:child_process'import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync,} from 'node:fs'import { createServer } from 'node:http'import { tmpdir } from 'node:os'import path from 'node:path'import process from 'node:process'
// The same constant the extension defaults its labeler list to, so the stubbed// moderation capture cannot end up naming a labeler the popup never asks.import { BSKY_LABELER_DID } from '@atproto/api'
const ROOT = path.resolve(import.meta.dirname, '..')const DIST = path.join(ROOT, 'dist')const OUT = path.join(ROOT, 'docs', 'img')const CHROME = process.env.CHROME_BIN ?? 'google-chrome'// What the popup header should be showing: it reads its own manifest, and the// captures run against the built extension. Filled in by main(), which has// checked dist/ exists by then.let BUILT_VERSION
const FIXTURE_DID = 'did:plc:capturefixture000000000000'const FAKE_AT_URI = `at://${FIXTURE_DID}/site.standard.publication/3kcapture`
// Real publications for the online run. Each reaches detection by a different// route, so the captured states cover the detection paths as well as the// popup layouts (all three confirmed live 2026-08-12).//// Sites and projects only, never a person's personal blog: these pages are// shown in the docs, in the store listing and on substandard.blog, and// nobody consented to being the extension's model. The one publication here// that belongs to an individual is the author's own.const REAL = { // No <link> hint at all — found by the origin well-known probe alone. standardSite: 'https://standard.site/', // Carries a <link rel="site.standard.publication"> hint, and verifies // through the path-scoped /.well-known/site.standard.publication/blog — // the origin well-known is a 404 here. atprotoBlog: 'https://atproto.com/blog', // The extension author's own publication, and the belt-and-braces shape: // it emits the <link> hint *and* answers the origin well-known, so either // route alone would find it. // // Handle -> DID normalization is not exercised by any live capture — this // well-known answers in DID form — and lives in the unit tests instead // (src/lib/detection.test.ts, src/lib/atproto.test.ts). permadeath: 'https://permadeath.com/',}
// A publication nobody should subscribe to: its account is one you blocked,// and two labelers you listen to have labeled it. Invented end to end — an// obvious placeholder name on example.com — because no real publication is// going to model "moderators have judged this one" in the store listing.//// Only the subject is a fixture. The labelers below are real accounts with// real service records, and the labels are stubbed as their *answers* about// this fixture (see stubbedLabels), so the pills are named, ranked and// covered by the labelers' own definitions rather than by anything invented// here.const BAD_BLOG_DID = 'did:plc:0xdeadbeef00000000000000'
/** An inline SVG as a data URI, which is all an <img src> in the popup needs. */function svgDataUri(svg) { return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`}
/** * The fixture's picture, standing in for both the account's avatar and the * publication's icon: a puppy with the no-entry sign over it. Emoji rather * than drawn art, so a placeholder still looks like one and the whole fixture * stays in this file instead of becoming a committed asset. * * Both glyphs are drawn by the capture machine's own color-emoji font (Noto * Color Emoji on Linux). A machine without one shoots tofu boxes here; there * is nothing to assert against, so looking at the shot is the check. */const BAD_BLOG_AVATAR = svgDataUri( '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">' + '<text x="32" y="45" font-size="42" text-anchor="middle">🐶</text>' + '<text x="32" y="48" font-size="52" text-anchor="middle">🚫</text>' + '</svg>',)
const BAD_BLOG = { uri: `at://${BAD_BLOG_DID}/site.standard.publication/3kpuppyhater`, did: BAD_BLOG_DID, handle: 'puppyhater.example.com', // Nothing resolvable: no PDS answers for a fixture DID, so the icon is // inlined above rather than being a blob url on the publisher's own server. pds: 'https://pds.invalid', iconUrl: BAD_BLOG_AVATAR, record: { $type: 'site.standard.publication', url: 'https://puppyhater.example.com/', name: 'We Hate Puppies', description: 'An independent platform for free thought.' }, verified: true,}
// The account behind it. No directory can resolve a fixture DID, so the card// the popup would build from that account's own data is stubbed too — the// popup renders it exactly as it renders a resolved one.const BAD_BLOG_OWNER = { did: BAD_BLOG_DID, handle: BAD_BLOG.handle, claimedHandle: BAD_BLOG.handle, displayName: 'i literally hate puppies', description: 'Also sunshine. And kittens. And rainbows. And puppies.', avatarUrl: BAD_BLOG_AVATAR,}
/** * The labelers whose answers about the fixture are stubbed, and what each one * says. Both values are ones the labeler really declares (`intolerant` is * Bluesky's, `platform-manipulation` is skywatch.blue's), so a labeler that * drops or renames a value fails the capture instead of quietly relabelling * the docs. */const STUB_LABELERS = [ // Bluesky's own moderation service: on the account, not the publication — // what a labeler says about who is publishing. { did: BSKY_LABELER_DID, subject: BAD_BLOG.did, values: ['intolerant'] }, // skywatch.blue, a third-party labeler, on the publication record itself. { did: 'did:plc:e4elbtctnfqocyfcml6h2lf7', subject: BAD_BLOG.uri, values: ['platform-manipulation'], },]
/** Subscribe has taken its first click on a blocked publisher and is armed. */const SUBSCRIBE_ARMED = `document.getElementById('subscribe').classList.contains('confirm')`
// Real detection over the network; generous next to the local fixtures.const SETTLE_MS = 45000
/** * The reader each card capture is preset to open in (storage.local's * `defaultReader`, the key the popup reads), so the set shows the readers the * extension offers rather than the default four times over. * * Every reader offered now has both a publication and an article view (see * src/lib/readers.ts), so any of them can be preset here without the capture * catching a disabled button. The value is the vendored icon the popup draws for that * reader, which is the only thing in the popup's DOM that names the reader by * id — the label comes from the Waypoints catalog. A stored id the catalog no * longer has falls back to the first reader, so checking the icon is what * makes that fail the capture instead of quietly reshooting the default. */const READER_ICONS = { standardReader: 'standard.svg', leaflet: 'leaflet.png', pdsls: 'pdsls.png',}
/** Standard Reader's view of a publication: a real page that is not its home. */function readerUrl(pub) { return `https://standard-reader.app/p/${pub.did}/${pub.uri.split('/').pop()}`}
// --- tiny CDP client over the browser websocket (flattened sessions) --------
class Cdp { /** @param {WebSocket} ws */ constructor(ws) { this.ws = ws this.nextId = 1 this.pending = new Map() /** Event name -> handler; one at a time, which is all the captures need. */ this.events = new Map() ws.addEventListener('message', (ev) => { const msg = JSON.parse(ev.data) if (msg.id && this.pending.has(msg.id)) { const { resolve, reject } = this.pending.get(msg.id) this.pending.delete(msg.id) if (msg.error) reject(new Error(`${msg.error.message} (${msg.error.code})`)) else resolve(msg.result) } else if (msg.method) { this.events.get(msg.method)?.(msg.params, msg.sessionId) } }) }
/** Subscribe to a CDP event. The handler gets (params, sessionId). */ on(method, handler) { this.events.set(method, handler) }
off(method) { this.events.delete(method) }
static connect(url) { return new Promise((resolve, reject) => { const ws = new WebSocket(url) ws.addEventListener('open', () => resolve(new Cdp(ws))) ws.addEventListener('error', () => reject(new Error(`websocket connect failed: ${url}`))) }) }
send(method, params = {}, sessionId = undefined) { const id = this.nextId++ return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }) this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })) }) }
close() { this.ws.close() }}
/** Runtime.evaluate an async expression and return its JSON value. */async function evaluate(cdp, sessionId, expression) { const res = await cdp.send( 'Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId, ) if (res.exceptionDetails) { throw new Error(`evaluate failed: ${res.exceptionDetails.text} ${res.exceptionDetails.exception?.description ?? ''}`) } return res.result.value}
async function poll(fn, what, timeoutMs = 8000) { 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)) }}
// --- when a popup is done -----------------------------------------------------//// These run in the popup, not here: they are stringified into a// Runtime.evaluate (see popupExpression), which is why they take `document`// rather than closing over it, and why the one helper they share is put back// beside them there. Kept as functions so the rule the captures depend on can// be unit tested (capture-status-docs.test.mjs).
/** Every image the popup has actually asked for has finished, one way or another. */export function imagesLanded(document) { // `complete` is true for an image that failed as well as one that loaded, // which is what this wants: a publication whose icon 404s is a real state, // and the popup draws its monogram fallback for it. An `img` with no `src` // is one nothing has been asked of yet — `complete` is true for it too, and // that is the trap this whole file exists to avoid. return [...document.images].every((img) => !img.getAttribute('src') || img.complete)}
/** * A popup with nothing left to wait for: no lookup out, no placeholder * standing, every image landed. * * One question rather than three waits in a row, because each of these * *creates* the next. The account lookup is what gives `#owner-avatar` its * `src`, so an images check that ran before that lookup landed was a check of * a popup that had no avatar yet — it passed, and the shot went out with a * grey circle where the account's picture goes. Asked as one thing, it cannot * be satisfied by three answers that were never true at the same moment. */export function popupSettled(document) { // The card element itself, so a build without it fails here rather than // passing a check that found nothing to object to. if (!document.getElementById('pub')) return false // Every region that reports its own work: the publication card while a card // lookup is out, the status region while the worker has not answered about // the page at all. Nothing else in the popup sets it. if (document.querySelector('[aria-busy]')) return false if (document.querySelector('.loading')) return false return imagesLanded(document)}
/** * The opposite, for the one capture that is *of* the placeholders: its lookups * are held open and never land, so what it waits for is the card saying it is * busy with a placeholder actually standing (src/popup/cards/loading.ts). * Stated rather than assumed, so a capture of the loading state that caught * nothing loading fails instead of shooting an ordinary popup. */export function popupStalled(document) { const pub = document.getElementById('pub') if (!pub || !pub.hasAttribute('aria-busy')) return false if (!document.querySelector('.loading')) return false return imagesLanded(document)}
/** * One of the predicates above as an expression the popup can evaluate. The * helper is declared alongside it: a stringified function arrives with nothing * it referred to, so what it called by name has to be in scope where it lands. */export function popupExpression(predicate) { return `(() => { const imagesLanded = ${imagesLanded} return (${predicate})(document) })()`}
/** * Wait for the popup to reach `predicate`, hold, and ask again — the shot is * taken from a state that survived the hold rather than from one that was true * some hundreds of milliseconds and several round trips before the shutter. */async function settle(cdp, sessionId, predicate, holdMs, what) { const expr = popupExpression(predicate) await poll( async () => { if (!(await evaluate(cdp, sessionId, expr))) return false await new Promise((r) => setTimeout(r, holdMs)) return await evaluate(cdp, sessionId, expr) }, what, SETTLE_MS, )}
// --- fixture servers ---------------------------------------------------------
/** Plain page; its /.well-known probe 404s, so detection finds nothing. */function plainServer() { return createServer((req, res) => { if (req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }) res.end('<!doctype html><title>capture fixture</title><p>capture fixture page</p>') } else { res.writeHead(404) res.end() } })}
/** * Publication-claiming page: the well-known endpoint answers with an at-uri * whose DID cannot resolve (external DNS is mapped to localhost), so record * loading genuinely fails and detection reports fetch-failed. */function claimingServer() { return createServer((req, res) => { if (req.url === '/.well-known/site.standard.publication') { res.writeHead(200, { 'content-type': 'text/plain' }) res.end(FAKE_AT_URI) } else if (req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }) res.end('<!doctype html><title>capture fixture</title><p>claims a publication</p>') } else { res.writeHead(404) res.end() } })}
function listen(server) { return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => resolve(server.address().port)) })}
// --- stubbed labeler answers -------------------------------------------------//// There is no local stand-in for a labeler: it is addressed by DID and the// extension refuses a cleartext labeler endpoint, rightly. So the moderation// capture leaves every step real — resolving each labeler's DID document, its// service record, and interpreting the label value definitions in it — and// stubs only the one request that would otherwise answer "nothing to say// about this fixture": com.atproto.label.queryLabels.
/** Fixed so the capture is reproducible; the popup never shows a label's date. */const LABEL_CTS = '2026-08-12T00:00:00.000Z'
/** How many pills the stub should produce, all labelers together. */const STUB_LABEL_COUNT = STUB_LABELERS.reduce((n, l) => n + l.values.length, 0)
/** A labeler's query host, resolved from its DID document as the popup does. */async function labelerHost(did) { const res = await fetch(`https://plc.directory/${did}`) if (!res.ok) throw new Error(`plc.directory answered ${res.status} for ${did}`) const doc = await res.json() const endpoint = doc.service?.find((s) => s.id.endsWith('#atproto_labeler'))?.serviceEndpoint if (!endpoint) throw new Error(`no labeler service in the DID document for ${did}`) return new URL(endpoint).host}
/** The stub labelers, keyed by the host each one answers queryLabels on. */async function stubLabelersByHost() { const byHost = new Map() for (const labeler of STUB_LABELERS) { const host = await labelerHost(labeler.did) byHost.set(host, labeler) console.log(`capture: stubbing ${host} (${labeler.did}) -> ${labeler.values.join(', ')}`) } return byHost}
/** * Answer one target's queryLabels calls from the stub, leaving every other * request alone. Which labeler answers is decided by the host the popup * called, so the attribution in the pill is the popup's own resolution rather * than something asserted here. Returns the set of labelers actually asked. */async function stubLabelerAnswers(cdp, sessionId, byHost) { const asked = new Set() cdp.on('Fetch.requestPaused', ({ requestId, request }, from) => { if (from !== sessionId) return const labeler = byHost.get(new URL(request.url).host) if (!labeler) { void cdp.send('Fetch.continueRequest', { requestId }, sessionId) return } asked.add(labeler.did) const labels = labeler.values.map((val) => ({ ver: 1, src: labeler.did, uri: labeler.subject, val, cts: LABEL_CTS, })) void cdp.send( 'Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [ { name: 'content-type', value: 'application/json' }, { name: 'access-control-allow-origin', value: '*' }, ], body: Buffer.from(JSON.stringify({ labels })).toString('base64'), }, sessionId, ) }) await cdp.send( 'Fetch.enable', { patterns: [{ urlPattern: '*com.atproto.label.queryLabels*' }] }, sessionId, ) return asked}
/** * Hold some of one popup's lookups open, so a capture can show what it draws * while it waits. The requests are paused and never answered — a slow network, * not a failed one, which is the difference between the state the placeholders * are for and the state the error pills are for. */async function stallRequests(cdp, sessionId, patterns) { cdp.on('Fetch.requestPaused', ({ request }, from) => { if (from !== sessionId) return console.log(`capture: holding ${new URL(request.url).host} open`) }) await cdp.send( 'Fetch.enable', { patterns: patterns.map((p) => ({ urlPattern: `*${p}*` })) }, sessionId, )}
// --- main --------------------------------------------------------------------
/** Files whose contents end up in the loaded extension, newest mtime wins. */function sourceFiles() { const src = path.join(ROOT, 'src') const nested = readdirSync(src, { recursive: true, withFileTypes: true }) .filter((e) => e.isFile()) .map((e) => path.join(e.parentPath ?? e.path, e.name)) return [ ...nested, ...['popup.html', 'offscreen.html', 'vite.config.ts', 'package.json'].map((f) => path.join(ROOT, f), ), ]}
/** * The capture screenshots whatever is in dist/, not the tree it is run from, so * an unbuilt change captures the previous popup. That failure surfaces as a * settle timeout on a state the built popup does not have, which reads as a * broken script rather than a stale build — hence a mtime check up front. * * The version in dist/manifest.json is no help here: it only changes on a * release bump, so a stale build can carry the current version. */function assertFreshBuild() { const built = ['popup.js', 'background.js', 'content.js', 'manifest.json'].map((f) => path.join(DIST, f), ) for (const file of built) { if (!existsSync(file)) { throw new Error(`${path.relative(ROOT, file)} is missing — run \`npm run capture\``) } } const builtAt = Math.min(...built.map((f) => statSync(f).mtimeMs)) const stale = sourceFiles().filter((f) => statSync(f).mtimeMs > builtAt) if (stale.length) { throw new Error( `dist/ is older than ${stale.length} source file(s), starting with ` + `${path.relative(ROOT, stale[0])} — run \`npm run capture\`, which builds first`, ) }}
/** * `--only <name>` re-shoots one capture and leaves the rest of docs/img * alone. Every image here is a real screenshot over the real network, so a * full run rewrites fifteen files to say one thing; this is how a change to * one popup state is captured without a diff full of resampled pixels. */const ONLY = (() => { const at = process.argv.indexOf('--only') if (at === -1) return undefined const name = process.argv[at + 1] if (!name) throw new Error('--only needs the name of a capture, e.g. --only popup-loading') return name})()
/** Whether this run is shooting `name`. */function wanted(name) { return !ONLY || name === ONLY}
async function main() { assertFreshBuild() BUILT_VERSION = JSON.parse(readFileSync(path.join(DIST, 'manifest.json'), 'utf8')).version mkdirSync(OUT, { recursive: true }) // Both runs start whatever `--only` selected; the one whose captures were // all filtered out spends a couple of seconds on a browser it never uses, // which is cheaper than keeping a second list of which run shoots what. await withChrome({ online: false }, captureFixtureStates) await withChrome({ online: true }, captureRealStates) console.log(`capture: wrote images to ${OUT}`)}
/** * Run `fn({ cdp, inWorker, popupUrl })` against a throwaway headless Chrome * with the built extension loaded. Offline runs map all non-loopback DNS to * localhost, where nothing listens. */async function withChrome({ online }, fn) { const profile = path.join(tmpdir(), `substandard-capture-${process.pid}-${online ? 'online' : 'offline'}`) const chrome = spawn(CHROME, [ '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', '--disable-sync', '--enable-unsafe-extension-debugging', `--load-extension=${DIST}`, ...(online ? [] : ['--host-resolver-rules=MAP * 127.0.0.1']), '--window-size=800,700', ]) const wsUrl = await new Promise((resolve, reject) => { let err = '' chrome.stderr.on('data', (chunk) => { err += chunk const m = err.match(/DevTools listening on (ws:\/\/\S+)/) if (m) resolve(m[1]) }) chrome.on('exit', (code) => reject(new Error(`chrome exited early (${code}):\n${err}`))) })
const cdp = await Cdp.connect(wsUrl) try { // The branded Chrome build ignores --load-extension; fall back to the // debugger-mediated load when no extension worker shows up. let worker = await findWorker(cdp, 2000).catch(() => undefined) if (!worker) { console.log('capture: --load-extension ignored; using Extensions.loadUnpacked') await cdp.send('Extensions.loadUnpacked', { path: DIST }) worker = await findWorker(cdp, 8000) } const { sessionId } = await cdp.send('Target.attachToTarget', { targetId: worker.targetId, flatten: true }) const extensionId = new URL(worker.url).host console.log(`capture: extension ${extensionId} worker attached`)
const inWorker = (expr) => evaluate(cdp, sessionId, expr) await poll(() => inWorker('typeof __substandard === "object"'), 'worker debug hook')
await fn({ cdp, inWorker, popupUrl: `chrome-extension://${extensionId}/popup.html` }) } finally { cdp.close() // Wait for Chrome to actually exit before deleting the profile: kill() // only sends the signal, and Chrome rewrites profile files on shutdown, // which resurrects the directory after an early rmSync. const exited = new Promise((resolve) => { if (chrome.exitCode !== null) return resolve() chrome.once('exit', resolve) setTimeout(resolve, 5000).unref() }) chrome.kill() await exited // Even after the main process exits, Chrome's service children (network, // cache) flush profile files behind it; delete until the directory stays // gone instead of racing them once. for (let attempt = 0; attempt < 10; attempt++) { rmSync(profile, { recursive: true, force: true }) await new Promise((r) => setTimeout(r, 300)) if (!existsSync(profile)) break } if (existsSync(profile)) console.warn(`capture: could not fully remove ${profile}`) }}
// --- offline run: badge tiles and the states that are failures ---------------
async function captureFixtureStates({ cdp, inWorker, popupUrl }) { const servers = { plain: plainServer(), claiming: claimingServer() } const urls = { plain: `http://127.0.0.1:${await listen(servers.plain)}/`, claiming: `http://127.0.0.1:${await listen(servers.claiming)}/`, } try { if (BADGE_STATES.some((state) => wanted(`badge-${state}`))) await captureBadges(cdp, inWorker)
/** @type {Scenario[]} */ const scenarios = [ // Natural end-to-end detection: nothing on the page. { name: 'popup-none', url: urls.plain, expectPills: 1, expectCard: false }, // Stacked pills: real fetch-failed detection (error) + expired session (info). { name: 'popup-stack', url: urls.claiming, session: true, expectPills: 2, expectCard: false }, // Real offline detection: the worker's navigator reports no connection. { name: 'popup-offline', url: urls.plain, workerOffline: true, expectPills: 1, expectCard: false }, ] for (const s of scenarios.filter((s) => wanted(s.name))) { await captureScenario(cdp, inWorker, popupUrl, s) } } finally { servers.plain.close() servers.claiming.close() }}
// --- online run: the states that show a publication --------------------------
async function captureRealStates({ cdp, inWorker, popupUrl }) { // Both of these are network setup for one capture each, so `--only` skips // the one it is not shooting. // // Detected up front so the unverified capture can show this publication // from a page that is not its home. const atproto = wanted('popup-unverified') ? await probePublication(inWorker, REAL.atprotoBlog) : undefined // Resolved against the real network, so a labeler that moved its endpoint // fails the capture rather than silently going unstubbed. const stubLabels = wanted('popup-labeled') ? await stubLabelersByHost() : undefined // The moderated publication is invented, so its page is local: a plain page // detection finds nothing on, with the fixture state seeded over it. const plain = plainServer() const plainUrl = `http://127.0.0.1:${await listen(plain)}/`
/** @type {Scenario[]} */ const scenarios = [ // The store listing screenshot: a verified publication, signed out, after // clicking the inert Subscribe. Detection is real end to end — no hint // tag on the page, so the origin well-known probe found it. { name: 'popup-publication', url: REAL.standardSite, reader: 'standardReader', clicks: [{ sel: '#subscribe' }], expectPills: 1, expectCard: true, }, // Signed in and subscribed, on the author's own publication. Detection is // real end to end; only the subscription and the account mirror are // fixtures, so no real subscription record is written. { name: 'popup-subscribed', url: REAL.permadeath, reader: 'leaflet', needsPub: true, state: (detected) => ({ ...detected, subscriptionRkey: '3kcapturesub' }), session: true, popupOffline: true, expectPills: 0, expectCard: true, }, // The publication belongs to an account you blocked, with Subscribe // clicked once so the armed confirm is what the shot shows. Detection is // real, but the owner is rewritten to the capture fixture: the block is // invented, and the docs must not show a real account as blocked. The // icon url is left alone, so the card still loads the real icon. An // organization's publication rather than a person's, for the same reason // the handle is a fixture. { name: 'popup-blocked', url: REAL.atprotoBlog, reader: 'standardReader', needsPub: true, state: (detected) => ({ ...detected, pub: { ...detected.pub, did: FIXTURE_DID, handle: 'blocked.example.com' }, blocked: true, subscriptionRkey: null, }), session: true, popupOffline: true, // The pill is up from the start here; the click only arms the button. settledPills: 1, clicks: [{ sel: '#subscribe', settles: SUBSCRIBE_ARMED }], expectPills: 1, expectCard: true, }, // A real publication seen from a third-party reader, which is not its // verified home: the warn pill links back to the real one. { name: 'popup-unverified', url: atproto && readerUrl(atproto), reader: 'leaflet', state: (detected) => ({ ...detected, pub: { ...atproto, verified: false }, subscriptionRkey: undefined, }), expectPills: 1, expectCard: true, }, // Everything at once, and the only card in the set that is a fixture from // top to bottom: an account you blocked, publishing something two labelers // you listen to have both labeled. The publication is invented (see // BAD_BLOG) and the labels are stubbed answers from real labelers, so the // pill names, their severity and the cover all come from those labelers' // own definitions. // // Both values blur content, so the popup covers the card as soon as the // labels land; the capture clicks "Show anyway" and shoots what a reader // who asked for it sees, the cover having already done its job. { name: 'popup-labeled', url: plainUrl, reader: 'pdsls', session: true, popupOffline: true, owner: BAD_BLOG_OWNER, labelers: STUB_LABELERS.map((l) => l.did), stubLabels, state: (detected) => ({ ...detected, pub: BAD_BLOG, blocked: true, subscriptionRkey: null, }), // Wait for the cover before clicking it away: the labels arrive after // the blocked pill does, and clicking early would shoot an uncovered // card that the cover had never been in front of. settled: `!document.getElementById('label-cover').hidden`, settledPills: 1, clicks: [ // Past the cover, then one click of Subscribe: blocked publishers take // two, and the shot is of the armed confirm the second click would go // through with — the whole point of the state. { sel: '#label-show', settles: `document.getElementById('label-cover').hidden && document.querySelectorAll('#labels .label-pill').length === ${STUB_LABEL_COUNT}`, }, { sel: '#subscribe', settles: SUBSCRIBE_ARMED }, ], expectPills: 1, expectCard: true, }, // The same publication as popup-publication, caught while it is still // waiting: the account lookup and the subscriber count are held open, so // the card shows the placeholders it draws once a lookup has run past // SHOW_AFTER_MS (src/popup/cards/loading.ts). Held open rather than // hurried — the popup sees a slow network, not a failure, which is the // state the placeholders exist for. { name: 'popup-loading', url: REAL.standardSite, reader: 'standardReader', stall: ['plc.directory', 'constellation.microcosm.blue'], // Long enough that the delay has passed with room to spare, whatever // the machine running this is doing. hold: 800, expectPills: 0, expectCard: true, }, ] try { for (const s of scenarios.filter((s) => wanted(s.name))) { await captureScenario(cdp, inWorker, popupUrl, s) } } finally { plain.close() }}
/** * Open a page, let detection run for real, and return the verified PubInfo it * produced. Throws when the page does not verify, so a publication that moved * or went away fails the capture instead of quietly changing the docs. */async function probePublication(inWorker, url) { const tab = await inWorker(`chrome.tabs.create({ url: ${JSON.stringify(url)}, active: true })`) try { await poll( () => inWorker(`chrome.tabs.get(${tab.id}).then((t) => t.status === 'complete')`), `tab load for ${url}`, SETTLE_MS, ) const state = await tabState(inWorker, tab.id, true, `publication detection on ${url}`) if (!state.pub.verified) throw new Error(`${url}: publication detected but not verified`) console.log(`capture: ${url} -> ${state.pub.record.name} ${state.pub.uri}`) return state.pub } finally { await inWorker(`chrome.tabs.remove(${tab.id})`).catch(() => {}) }}
/** Wait for the worker's own detection state for a tab, optionally with a pub. */function tabState(inWorker, tabId, needsPub, what) { const key = `tab:${tabId}` return poll( () => inWorker( `chrome.storage.session.get(${JSON.stringify(key)}).then((r) => { const s = r[${JSON.stringify(key)}] return s && (${!needsPub} || s.pub) ? s : false })`, ), what, SETTLE_MS, )}
async function findWorker(cdp, timeoutMs) { return poll( async () => { const { targetInfos } = await cdp.send('Target.getTargets') return targetInfos.find((t) => t.type === 'service_worker' && t.url.includes('background.js')) }, 'extension service worker', timeoutMs, )}
// --- badge tiles -------------------------------------------------------------
const BADGE_STATES = [ 'none', 'checking', 'detected', 'signedout', 'subscribed', 'detected-unverified', 'signedout-unverified', 'subscribed-unverified', 'failed', 'blocked',]
async function captureBadges(cdp, inWorker) { const iconUri = `data:image/png;base64,${readFileSync(path.join(DIST, 'icons', 'icon32.png')).toString('base64')}` for (const state of BADGE_STATES) { const spec = await inWorker(`__substandard.badgeFor(${JSON.stringify(state)})`) const badge = spec ? spec.text === '•' ? // Chrome renders the checking dot compactly; keep the tile honest. `<span class="bdg dot" style="background:${spec.background}"></span>` : `<span class="bdg" style="background:${spec.background};color:${spec.color}">${spec.text}</span>` : '' const html = `<!doctype html><meta charset="utf-8"><style> * { margin: 0; box-sizing: border-box; } body { width: 84px; height: 68px; background: #e8eaed; border: 1px solid #d0d3d8; border-radius: 10px; position: relative; display: flex; align-items: center; justify-content: center; } img { width: 32px; height: 32px; } .bdg { position: absolute; right: 10px; bottom: 8px; min-width: 16px; height: 16px; padding: 0 3px; border-radius: 3px; font: 700 11px/16px system-ui, sans-serif; text-align: center; box-shadow: 0 0 0 1.5px #e8eaed; } .bdg.dot { min-width: 9px; width: 9px; height: 9px; border-radius: 2.5px; padding: 0; right: 13px; bottom: 11px; } </style><img src="${iconUri}" alt="">${badge}` const png = await screenshotPage(cdp, `data:text/html,${encodeURIComponent(html)}`, { width: 84, height: 68, }) writeFileSync(path.join(OUT, `badge-${state}.png`), png) console.log(`capture: badge-${state}.png ${spec ? `"${spec.text}" on ${spec.background}` : '(no badge)'}`) }}
/** Docs show the canonical light rendering; headless follows the host theme. */function forceLightScheme(cdp, sessionId) { return cdp.send( 'Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] }, sessionId, )}
async function screenshotPage(cdp, url, { width, height }) { const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }) const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }) try { await cdp.send('Page.enable', {}, sessionId) await cdp.send( 'Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 2, mobile: false }, sessionId, ) await forceLightScheme(cdp, sessionId) await cdp.send('Page.navigate', { url }, sessionId) await poll(() => evaluate(cdp, sessionId, 'document.readyState === "complete"'), `load of ${url.slice(0, 40)}`) await new Promise((r) => setTimeout(r, 150)) const shot = await cdp.send( 'Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width, height, scale: 1 } }, sessionId, ) return Buffer.from(shot.data, 'base64') } finally { await cdp.send('Target.closeTarget', { targetId }).catch(() => {}) }}
// --- popup scenarios ---------------------------------------------------------
/** * @typedef {object} Scenario * @property {string} name output basename in docs/img/ * @property {string} url page the popup opens over * @property {(state: object) => object} [state] seed for the tab's detection * state, built from the state the worker detected on its own * @property {boolean} [needsPub] wait for real detection to find a * publication before seeding (the seed derives from it) * @property {boolean} [session] install the fixture account mirror * @property {keyof READER_ICONS} [reader] reader this capture is preset to * open in, checked against the button the popup rendered * @property {boolean} [popupOffline] * @property {boolean} [workerOffline] * @property {object} [owner] publication-owner card to seed the popup's * own cache with, for a fixture account no directory can resolve; its * `handle` is what the rendered card is checked against afterwards * @property {string[]} [labelers] labeler DIDs to put in storage.local, so * the popup asks these rather than only Bluesky's own moderation service * @property {Map<string, object>} [stubLabels] stub labeler answers by host * (stubLabelersByHost); queryLabels is served from these for this popup * @property {string[]} [stall] url substrings whose requests this popup never * gets an answer to, for capturing what it shows while it waits * @property {number} [hold] ms the popup must stay settled for before the * shot (default 200); a capture of a transient state waits out its own delays * @property {{sel: string, settles?: string}[]} [clicks] clicked in order once * the popup settles, each waiting for its own `settles` expression (or for * the popup to reach expectPills) before the next one * @property {number} [settledPills] pills expected before the clicks (0 when * a click is what raises them) * @property {string} [settled] extra expression that must be true before * the popup counts as settled (and before any click) * @property {number} expectPills * @property {boolean} expectCard */
/** What the popup is showing right now, for a settle that never happened. */function popupShape(cdp, sessionId) { return evaluate( cdp, sessionId, // "missing" rather than a boolean for the sections: an element this popup // does not have at all means the loaded build is not the one being // captured, and that has to be distinguishable from it being hidden. `(() => { const where = (id) => { const el = document.getElementById(id) return !el ? 'missing' : el.hidden ? 'hidden' : 'shown' } return JSON.stringify({ readyState: document.readyState, pills: [...document.querySelectorAll('#status .pill')].map((p) => p.className + ': ' + p.textContent), card: where('pub'), cover: where('label-cover'), labels: [...document.querySelectorAll('#labels .label-pill')].map((p) => p.textContent), }) })()`, )}
/** @param {Scenario} s */async function captureScenario(cdp, inWorker, popupUrl, s) { // Fresh storage per scenario; the fixture session mirror has no restorable // OAuth session behind it, which is exactly what makes the expiry pill real. await inWorker('chrome.storage.session.clear().then(() => chrome.storage.local.clear())') if (s.session) { await inWorker( `chrome.storage.local.set({ session: { did: 'did:plc:capturefixture000000000000', handle: 'you.example.com' } })`, ) } if (s.labelers) { // The key labelerDids() reads: with no restorable session behind the // fixture mirror, the popup falls back to this list rather than the // account's own labeler subscriptions. await inWorker(`chrome.storage.local.set({ labelers: ${JSON.stringify(s.labelers)} })`) } if (s.reader) { if (!READER_ICONS[s.reader]) throw new Error(`${s.name}: no icon known for reader ${s.reader}`) await inWorker(`chrome.storage.local.set({ defaultReader: ${JSON.stringify(s.reader)} })`) } // Put the worker's connection state where this scenario wants it. Restoring // means putting the *native* accessor back: deleting it leaves // navigator.onLine undefined, which computeState reads as being offline, so // every capture after the first would have claimed a disconnected browser. // The descriptor is stashed on the worker's globalThis, and re-read from a // pristine prototype whenever the worker has been restarted since. await inWorker(`(() => { const proto = WorkerNavigator.prototype globalThis.__captureOnLine ??= Object.getOwnPropertyDescriptor(proto, 'onLine') Object.defineProperty( proto, 'onLine', ${s.workerOffline ? `{ get: () => false, configurable: true }` : `globalThis.__captureOnLine`}, ) return navigator.onLine })()`)
const tab = await inWorker(`chrome.tabs.create({ url: ${JSON.stringify(s.url)}, active: true })`) await poll( () => inWorker(`chrome.tabs.get(${tab.id}).then((t) => t.status === 'complete')`), `tab load for ${s.name}`, SETTLE_MS, ) if (s.state) { // Seed only once the worker has written its own state for the tab: a // late content-script report would otherwise land on top of the seed. const detected = await tabState(inWorker, tab.id, !!s.needsPub, `detection state for ${s.name}`) const seeded = s.state(detected) await inWorker(`chrome.storage.session.set({ 'tab:${tab.id}': ${JSON.stringify(seeded)} })`) } if (s.owner) { // The popup's own cache entry, in the layout src/lib/cache.ts writes: // `<scope>:<subject>/<name>` holding `{ at, value }`. Hand-written, because // this script cannot import the extension's TypeScript — so the capture // checks afterwards that the card really came from the seed (see below) // instead of trusting this key to still be the right one. await inWorker( `chrome.storage.session.set({ 'world:${s.owner.did}/owner': { at: Date.now(), value: ${JSON.stringify(s.owner)} } })`, ) }
// Open the popup page in its own target, keeping the fixture tab active so // the popup's active-tab query sees it, not itself. const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }) const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }) try { await cdp.send('Page.enable', {}, sessionId) // Roomier than any popup: the popup sizes itself (body has a fixed width // in popup.css), and the shot is clipped to what it actually took, so // widening the popup can never crop the captures again. await cdp.send( 'Emulation.setDeviceMetricsOverride', { width: 600, height: 900, deviceScaleFactor: 2, mobile: false }, sessionId, ) await forceLightScheme(cdp, sessionId) if (s.popupOffline) { // Keeps probeSessionExpiry from treating the fixture session (which has // no restorable OAuth state) as expired in this scenario. await cdp.send( 'Page.addScriptToEvaluateOnNewDocument', { source: `Object.defineProperty(Navigator.prototype, 'onLine', { get: () => false })` }, sessionId, ) } const asked = s.stubLabels ? await stubLabelerAnswers(cdp, sessionId, s.stubLabels) : undefined if (s.stall) await stallRequests(cdp, sessionId, s.stall) await inWorker(`chrome.tabs.update(${tab.id}, { active: true })`) await cdp.send('Page.navigate', { url: popupUrl }, sessionId)
// A clicking scenario usually settles pill-free first and a click raises // the rest; a scenario whose pills are already up says so with settledPills. const clicks = s.clicks ?? [] const settledPills = clicks.length ? (s.settledPills ?? 0) : s.expectPills // Every capture is of a popup that has finished: no card may still be // standing a placeholder where its lookup will go // (src/popup/cards/loading.ts). The one capture that is *of* those // placeholders waits for exactly the opposite. const ready = `(() => { const pub = document.getElementById('pub') if (!pub || document.readyState !== 'complete') return false const pills = document.querySelectorAll('#status .pill').length return pills === ${settledPills} && !pub.hidden === ${s.expectCard} && (${s.settled ?? 'true'}) })()` try { await poll(() => evaluate(cdp, sessionId, ready), `${s.name} to settle`, SETTLE_MS) } catch (err) { // Say what the popup actually looked like: which condition held out is // otherwise invisible behind one timeout message. throw new Error(`${err.message}\n popup: ${await popupShape(cdp, sessionId)}`) } for (const { sel, settles } of clicks) { await evaluate(cdp, sessionId, `(document.querySelector(${JSON.stringify(sel)}).click(), true)`) const settled = settles ?? `document.querySelectorAll('#status .pill').length === ${s.expectPills}` try { await poll( () => evaluate(cdp, sessionId, settled), `${s.name} to settle after clicking ${sel}`, SETTLE_MS, ) } catch (err) { throw new Error(`${err.message}\n popup: ${await popupShape(cdp, sessionId)}`) } } // Every capture but one is of a popup that has finished: no lookup still // out, no placeholder still standing, and every image — the publication // icon, the account avatar, the subscriber faces, all real blob fetches // off somebody's PDS — landed. The exception is the capture that is *of* // the placeholders, whose lookups are held open and never land. // // The hold is inside this: the popup goes on changing after every wait // here, and the shutter is the far side of the hold and a few more round // trips, so what the gate saw has to still be true when it fires. await settle( cdp, sessionId, s.stall ? popupStalled : popupSettled, s.hold ?? 200, s.stall ? `${s.name} to be caught mid-load` : `${s.name} to finish loading`, )
if (asked) { // Every stub labeler must have been asked, and every stubbed label must // have become a pill: a labeler that stopped defining one of these // values would otherwise quietly drop out of the shot. const missed = [...s.stubLabels.values()].filter((l) => !asked.has(l.did)) if (missed.length) { throw new Error(`${s.name}: never asked ${missed.map((l) => l.did).join(', ')}`) } const names = await evaluate( cdp, sessionId, `[...document.querySelectorAll('#labels .label-pill')].map((p) => p.textContent)`, ) if (names.length !== STUB_LABEL_COUNT) { throw new Error(`${s.name}: ${names.length} label pill(s), expected ${STUB_LABEL_COUNT}`) } console.log(`capture: ${s.name} labels ${names.join(' | ')}`) }
// The header's version indicator is in every one of these shots, so a // header that stopped filling it would go out in the docs and the store // listing before anyone noticed the gap. const version = await evaluate( cdp, sessionId, `document.getElementById('version').textContent`, ) if (version !== `v${BUILT_VERSION}`) { throw new Error(`${s.name}: version reads "${version}", expected "v${BUILT_VERSION}"`) }
if (s.reader) { // The preset has to be the reader in the shot: an id the catalog dropped // falls back to the first reader, and one with no publication view // renders disabled. Either would be a capture of the wrong button. const open = await evaluate( cdp, sessionId, `(() => { const btn = document.getElementById('open-default') if (!btn) return null return { icon: btn.querySelector('img.reader-icon')?.getAttribute('src') ?? null, label: btn.textContent, disabled: btn.disabled, } })()`, ) const want = `/vendored/${READER_ICONS[s.reader]}` if (!open || open.icon !== want || open.disabled) { throw new Error( `${s.name}: expected the ${s.reader} button (${want}), got ${JSON.stringify(open)}`, ) } console.log(`capture: ${s.name} opens in ${s.reader} — "${open.label}"`) }
if (s.owner) { // The seeded card has to be the card that rendered. A fixture DID // resolves to nothing, so a seed the popup ignored does not fail — the // card quietly falls back to the bare DID, which is how a cache-key // change once reached the committed screenshots. // // The handle line, not the name: a blocked account's card deliberately // does not show the name it was seeded with (blockedIdentity in // src/lib/profile.ts), and the handle is what survives either way. const shown = await evaluate( cdp, sessionId, `document.getElementById('owner-handle')?.textContent`, ) if (shown !== `@${s.owner.handle}`) { throw new Error( `${s.name}: owner card shows ${JSON.stringify(shown)}, not the seeded ` + `${JSON.stringify(`@${s.owner.handle}`)} — the popup's cache layout moved ` + `(see storageKey in src/lib/cache.ts)`, ) } }
const box = await evaluate( cdp, sessionId, `(() => { const r = document.body.getBoundingClientRect(); return { w: r.width, h: r.height } })()`, ) const width = Math.ceil(box.w) const height = Math.min(Math.ceil(box.h) + 1, 900) const shot = await cdp.send( 'Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width, height, scale: 1 } }, sessionId, ) writeFileSync(path.join(OUT, `${s.name}.png`), Buffer.from(shot.data, 'base64')) console.log( `capture: ${s.name}.png ${width}x${height} (${s.expectPills} pill(s), card=${s.expectCard})`, ) } finally { cdp.off('Fetch.requestPaused') await cdp.send('Target.closeTarget', { targetId }).catch(() => {}) await inWorker(`chrome.tabs.remove(${tab.id})`).catch(() => {}) }}
// Importable for its own tests (capture-status-docs.test.mjs); only a direct// run shoots anything.if (process.argv[1] === import.meta.filename) { main().catch((err) => { console.error(err) process.exit(1) })}