// 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 { const key = `tab:${tabId}` return (await chrome.storage.session.get(key))[key] as PageState | undefined } async function setTabState(tabId: number, state: PageState | undefined): Promise { 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() // 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 { 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 } async function getSession(): Promise { return (await chrome.storage.local.get('session')).session as SessionInfo | undefined } async function getSubscriptions(refresh: boolean): Promise { const session = await getSession() if (!session) return null const did = session.did return cached( { 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 = {} 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 { const session = await getSession() if (!session) return null const did = session.did return cached( { 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>() async function getFollows(refresh: boolean): Promise { 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( { 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 { 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 { 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 { 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 }