Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
18 kB · 415 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416// MV3 service worker: detection state per tab, toolbar badge states, public// reads (publication records, the user's subscription list), and the worker// half of sign-in (src/signin.ts). The OAuth client itself cannot run in a// worker; it lives in DOM contexts (see src/lib/oauth.ts), which mirror// {did, handle} into chrome.storage.local under `session` for the worker.
import { listRecords, parseAtUri, resolveDid } from './lib/atproto'import { cached, dropAll } from './lib/cache'import { detectPage, dropProbes } from './lib/detection'import { type IconState, badgeFor, iconStateFor, titleFor } from './lib/icon'import { FOLLOWS_CAP, type FollowList, followingDids } from './lib/subscribers'import { welcomeUrl } from './lib/welcome'import { startSignIn } from './signin'import type { FollowSet, Msg, PageState, SessionInfo } from './lib/types'
const SUB_COLLECTION = 'site.standard.graph.subscription'const BLOCK_COLLECTION = 'app.bsky.graph.block'
// --- per-tab state, kept in storage.session so it survives worker restarts ---
async function getTabState(tabId: number): Promise<PageState | undefined> { const key = `tab:${tabId}` return (await chrome.storage.session.get(key))[key] as PageState | undefined}
async function setTabState(tabId: number, state: PageState | undefined): Promise<void> { const key = `tab:${tabId}` if (state) await chrome.storage.session.set({ [key]: state }) else await chrome.storage.session.remove(key) await setBadge(tabId, iconStateFor(state))}
// --- toolbar badge -----------------------------------------------------------//// The icon is always the bare logo (the manifest PNGs, rendered from// brand/sticker.svg); per-tab state is shown with Chrome's native badge// layer, which the toolbar draws on top of the icon at full size instead// of us cramming a dot into the 16px canvas.
/** Last state set per tab; only for transition debug logs. */const lastBadge = new Map<number, IconState>()
// Debug/tooling hook: lets a DevTools session (and the docs capture script,// scripts/capture-status-docs.mjs) read the authoritative state mappings out// of the running worker instead of duplicating them. Inert otherwise.Object.assign(globalThis, { __substandard: { badgeFor, titleFor, iconStateFor } })
async function setBadge(tabId: number, state: IconState): Promise<void> { const prev = lastBadge.get(tabId) if (prev !== state) { console.debug('[substandard] badge', tabId, `${prev ?? '(unset)'} -> ${state}`) lastBadge.set(tabId, state) } try { // Mirror the state into the action title so hover and screen readers get // words, not just a colored glyph; '' restores the manifest default. await chrome.action.setTitle({ tabId, title: titleFor(state) ?? '' }) const badge = badgeFor(state) if (!badge) { await chrome.action.setBadgeText({ tabId, text: '' }) return } await chrome.action.setBadgeBackgroundColor({ tabId, color: badge.background }) await chrome.action.setBadgeTextColor({ tabId, color: badge.color }) await chrome.action.setBadgeText({ tabId, text: badge.text }) } catch { // tab may be gone }}
// --- subscriptions -----------------------------------------------------------
interface SubsCache { /** publication at-uri -> our subscription record rkey */ byPub: Record<string, string>}
async function getSession(): Promise<SessionInfo | undefined> { return (await chrome.storage.local.get('session')).session as SessionInfo | undefined}
async function getSubscriptions(refresh: boolean): Promise<SubsCache | null> { const session = await getSession() if (!session) return null const did = session.did return cached<SubsCache>( { scope: 'own', subject: did, name: 'subs' }, async () => { const { pds } = await resolveDid(did) const records = await listRecords<{ publication?: string }>(pds, did, SUB_COLLECTION) const byPub: Record<string, string> = {} for (const r of records) { const rkey = parseAtUri(r.uri)?.rkey if (rkey && r.value.publication) byPub[r.value.publication] = rkey } return { byPub } }, { refresh }, )}
// --- blocks ------------------------------------------------------------------
interface BlocksCache { /** DIDs the signed-in account has blocked. */ dids: string[]}
/** * The DIDs the signed-in account blocks. Block records are public records in * the user's own repo, so this is the same unauthenticated listRecords read as * the subscription list — the feature needs no extra OAuth scope, and nothing * about the block leaves the browser. * * Nothing indexes a repo by record value, so the whole collection has to be * listed to answer one question. `listRecords` stops at 2000 records, so a * heavy blocker's oldest blocks can fall outside the answer; that costs a * missing warning, never a false one. * * Refresh bypasses the cache like every other read in that path: unblocking * someone and pressing Refresh has to clear the warning, which is worth * re-listing for. */async function getBlocks(refresh: boolean): Promise<BlocksCache | null> { const session = await getSession() if (!session) return null const did = session.did return cached<BlocksCache>( { scope: 'own', subject: did, name: 'blocks' }, async () => { const { pds } = await resolveDid(did) const records = await listRecords<{ subject?: string }>(pds, did, BLOCK_COLLECTION) const dids = records.map((r) => r.value.subject).filter((s): s is string => !!s) console.debug(`[substandard] read ${dids.length} block records for ${did}`) return { dids } }, { refresh }, )}
// --- the viewer's follow set -------------------------------------------------//// The one read in the extension whose size is set by how sociable the user is// rather than by what is on the page: `listRecords` pages a hundred at a time,// so FOLLOWS_CAP follows is a hundred requests. It runs here rather than in the// popup for that reason. The popup asks and draws whatever comes back whenever// it comes back; a walk this long must never be something a window is waiting// on, and the worker outliving the popup means closing it does not throw the// walk away.//// Cached under `graph`, which is a day and on disk (src/lib/cache.ts), so the// full walk is a once-a-day event and not a once-a-popup one.
/** Walks in flight, keyed by account: two askers share one rather than starting two. */const followWalks = new Map<string, Promise<FollowList>>()
async function getFollows(refresh: boolean): Promise<FollowSet | null> { const session = await getSession() if (!session) return null const did = session.did
// A walk that bypassed the cache also satisfies an asker that would have // accepted a cached answer; the reverse is not true. const inFlight = followWalks.get(did) if (inFlight && !refresh) return { did, ...(await inFlight) }
const work = cached<FollowList>( { scope: 'graph', subject: did, name: 'follows' }, async () => { const list = await followingDids(did) // What the cap did, rather than only what the walk found: past it the // set is a prefix, and the cards read absence from it. console.debug( `[substandard] read ${list.dids.length} follow records for ${did}`, list.truncated ? `(stopped at the ${FOLLOWS_CAP} cap)` : '', ) return list }, { refresh }, ) followWalks.set(did, work) try { return { did, ...(await work) } } finally { if (followWalks.get(did) === work) followWalks.delete(did) }}
// --- detection orchestration -------------------------------------------------
/** * `refresh` is the user asking again, so it bypasses every cache in the path — * the well-known probes as well as the subscription list. Anything less makes * the Refresh button a no-op for the case it exists to serve: a publisher who * has just corrected their site and wants to see it. */async function computeState( tabId: number, url: string, hints: { pubHint?: string; docHint?: string }, refresh: boolean,): Promise<PageState> { await setBadge(tabId, 'checking') let state: PageState try { const { pub, doc } = await detectPage(url, hints.pubHint, hints.docHint, refresh) state = { url, fetchedAt: Date.now(), pub, doc } if (!pub && !doc && !navigator.onLine) { // Every probe misses while offline; that is absence of signal, not a // page without a publication. state.error = 'offline' } if (pub) { // Two independent reads of the user's own repo; run them together so // the block check costs no extra wall time on the detection path. const [subs, blocks] = await Promise.all([ getSubscriptions(refresh).catch(() => null), getBlocks(refresh).catch((err: unknown) => { console.debug('[substandard] block list unavailable', err) return null }), ]) state.subscriptionRkey = subs ? (subs.byPub[pub.uri] ?? null) : undefined if (blocks) state.blocked = blocks.dids.includes(pub.did) if (state.blocked) console.debug('[substandard] publication owner is blocked', pub.did) } } catch (err) { // Detection itself blew up (network, resolver, ...): record why, don't // claim "not on standard.site". const kind = navigator.onLine === false ? 'offline' : 'fetch-failed' console.debug('[substandard] detection error', tabId, kind, err) state = { url, fetchedAt: Date.now(), error: kind } } await setTabState(tabId, state) return state}
// --- dropping everything remembered (dev channel) ----------------------------
/** * Everything the extension has remembered about what it fetched: the scoped * caches (src/lib/cache.ts), detection's well-known probe answers * (src/lib/detection.ts), and the per-tab detection states above. * * In the worker rather than in the popup because two thirds of that is the * worker's. The tab states go through `setTabState`, so every badge drops back * to idle as its state does instead of leaving a green dot standing for * something no longer stored anywhere — which is also what makes the drop * visible without opening the popup again. * * The account mirror and the stored reader choice are deliberately untouched: * dropping a cache is not signing out, and not a factory reset. */async function dropCaches(): Promise<{ dropped: number }> { const all = await chrome.storage.session.get(null) const tabIds = Object.keys(all) .filter((k) => k.startsWith('tab:')) .map((k) => Number(k.slice(4))) await Promise.all(tabIds.map((tabId) => setTabState(tabId, undefined))) const [entries, probes] = await Promise.all([dropAll(), dropProbes()]) console.debug( `[substandard] dropped ${entries} cache entr(ies), ${probes} probe answer(s)`, `and ${tabIds.length} tab state(s)`, ) return { dropped: entries + probes + tabIds.length }}
function isHttpUrl(url: string | undefined): url is string { return !!url && /^https?:\/\//.test(url)}
// --- message handling --------------------------------------------------------
chrome.runtime.onMessage.addListener((msg: Msg | { target?: string }, sender, sendResponse) => { // Offscreen-bound messages: stay quiet so our sendResponse(undefined) // cannot win the race against the offscreen document's real response. if ('target' in msg && msg.target) return false handle(msg as Msg, sender) .then(sendResponse) .catch((err: unknown) => { sendResponse({ __error: err instanceof Error ? err.message : String(err) }) }) return true})
async function handle(msg: Msg, sender: chrome.runtime.MessageSender): Promise<unknown> { switch (msg.type) { case 'page-hints': { const tabId = sender.tab?.id const url = sender.tab?.url ?? sender.url if (tabId === undefined || !isHttpUrl(url)) return return computeState(tabId, url, msg, false) } case 'get-state': { const cached = await getTabState(msg.tabId) // Error states are transient; recheck them on every popup open rather // than pinning a stale failure until the user finds Refresh. if (cached && !cached.error && !msg.refresh) return cached const tab = await chrome.tabs.get(msg.tabId).catch(() => undefined) if (!isHttpUrl(tab?.url)) return undefined // Re-read hints from the page so remote edits show up on refresh. const hints = await chrome.tabs .sendMessage(msg.tabId, { type: 'read-hints' }) .catch(() => undefined) return computeState(msg.tabId, tab.url, hints ?? {}, !!msg.refresh) } case 'signin': { // Resolves once the consent window is open; the redirect listeners in // src/signin.ts finish the flow after the popup is gone. return startSignIn(msg.handle) } case 'drop-caches': { return dropCaches() } case 'follows': { // Cosmetic on both sides — a face in the subscriber row, a line on the // owner card — so a failed walk is an absent answer, not an error the // popup has to hold. return getFollows(!!msg.refresh).catch((err: unknown) => { console.debug('[substandard] follow walk failed', err) return null }) } }}
// --- first run ---------------------------------------------------------------//// The one moment a pin nudge can land: Chrome fires this before the user has// had a chance to look at the toolbar, and the badge — the passive half of// what this extension does — is invisible until the icon is pinned. There is// no manifest field and no API to ask for a pin, only the read the page polls// (src/lib/welcome.ts), so asking in words at install time is the whole// mechanism.//// Install only. `update` fires on every auto-update, and a page that opened a// tab each time Chrome updated the extension in the background would be a tab// spawner rather than a nudge.chrome.runtime.onInstalled.addListener(({ reason }) => { if (reason !== chrome.runtime.OnInstalledReason.INSTALL) return console.debug('[substandard] first install; opening the welcome page') void chrome.tabs.create({ url: welcomeUrl() })})
chrome.tabs.onRemoved.addListener((tabId) => { void chrome.storage.session.remove(`tab:${tabId}`) lastBadge.delete(tabId)})
// Clear stale state on plain navigations; the content script re-reports.// Without the "tabs" permission, URLs are only visible where a host// permission applies: a navigation to a non-granted page (chrome://, the// web store) surfaces no url in changeInfo or tabs.get, so an invisible// url after a load starts means "not a page of ours" — clear the badge// rather than leave the previous page's state up.chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { if (changeInfo.status !== 'loading') return if (changeInfo.url) { void chrome.storage.session.remove(`tab:${tabId}`) void setBadge(tabId, isHttpUrl(changeInfo.url) ? 'checking' : 'idle') return } void chrome.tabs .get(tabId) .then((tab) => { // A visible http(s) url with no url change is a same-page reload; // keep the state and let the content script's re-report refresh it. if (isHttpUrl(tab.url)) return void chrome.storage.session.remove(`tab:${tabId}`) void setBadge(tabId, 'idle') }) .catch(() => {})})
// Sign-in/sign-out happens in extension pages, which mirror {did, handle} into// storage.local under `session`. Both the subscription list and the block list// belong to that account, so re-resolve them for every tab we know about: icons// flip between detected/signedout/subscribed, and a block follows the account// that made it rather than lingering from the previous sign-in.chrome.storage.onChanged.addListener((changes, area) => { if (area === 'local' && 'session' in changes) void refreshAccountStates()})
async function refreshAccountStates(): Promise<void> { console.debug('[substandard] session changed; refreshing subscription and block state') // Started here and awaited at the end, so a sign-in is what pays for the // walk instead of the first popup after it — but kept out of the badge work // below, because no icon should wait on a read this long. // // Not a refresh, unlike the two reads below. Those must show the new // account's own actions, so they re-read; the follow set is keyed by DID, so // a new account is already a new entry, and asking for a refresh here would // mean re-walking fifty thousand follows every time a handle or an avatar // changed — `refreshStoredSession` rewrites the mirror for those too. const warm = getFollows(false).catch((err: unknown) => { console.debug('[substandard] could not warm the follow set', err) return null })
const all = await chrome.storage.session.get(null) const [subs, blocks] = await Promise.all([ getSubscriptions(true).catch(() => null), getBlocks(true).catch(() => null), ]) for (const [key, value] of Object.entries(all)) { if (!key.startsWith('tab:')) continue const state = value as PageState if (!state.pub) continue state.subscriptionRkey = subs ? (subs.byPub[state.pub.uri] ?? null) : undefined state.blocked = blocks ? blocks.dids.includes(state.pub.did) : undefined await setTabState(Number(key.slice(4)), state) } // Keeps the worker alive for the walk; the listener is what holds it open. await warm}