From 685e502350e36535d68278ccb0fcc659b51503bf Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Tue, 9 Jun 2026 21:22:50 -0400 Subject: [PATCH 1/3] Refactor: split background worker by concern (tracker / router / lifecycle / badge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `background.ts` had grown to 672 lines mixing five concerns: per-tab in-memory state, badge/icon rendering, state mutators, the typed message router, and the chrome.tabs / storage lifecycle wiring. Split into focused modules under `lib/background/`, leaving the entry point as 43 lines of pure wiring. - `badge.ts` — toolbar badge/icon painting (chrome.action side effects only) - `tab-tracker.ts` — `createTabTracker()` owns all per-tab state + the badge refresh that reads across it - `message-handlers.ts` — `registerBackgroundMethods(tracker)`, the typed content/popup→background router - `lifecycle.ts` — `startBackgroundLifecycle(tracker)`: tab listeners, storage seeds/subscriptions, the recovery-pause session bridge Behavior-preserving: the enforcement subscription and its startup seed are unified onto `setEnforcementEnabled` (the clear-stale-maps branch is a no-op on empty maps at seed time). Session-store writes and content broadcasts stay in the lifecycle layer; the tracker owns only in-memory cache. Background-purity check still passes (new modules import only `RuleId` from `rules/rule-metadata`). A side benefit: the badge/tracker helpers were locked in the untestable entry bundle and are now unit-testable. Co-Authored-By: Claude Opus 4.8 (1M context) --- extension/src/background.ts | 679 +----------------- extension/src/lib/background/badge.ts | 93 +++ extension/src/lib/background/lifecycle.ts | 212 ++++++ .../src/lib/background/message-handlers.ts | 168 +++++ extension/src/lib/background/tab-tracker.ts | 353 +++++++++ 5 files changed, 851 insertions(+), 654 deletions(-) create mode 100644 extension/src/lib/background/badge.ts create mode 100644 extension/src/lib/background/lifecycle.ts create mode 100644 extension/src/lib/background/message-handlers.ts create mode 100644 extension/src/lib/background/tab-tracker.ts diff --git a/extension/src/background.ts b/extension/src/background.ts index 1a1023c..ed3e6eb 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,665 +1,36 @@ // Copyright (c) 2026 PixieBrix, Inc. // Licensed under PolyForm Shield 1.0.0 — see LICENSE. -import { debugTraceStorage } from "./lib/debug-trace"; -import { toExportedRecord } from "./lib/debug-trace-export"; -import { - appendEvent as appendDebugTraceEvent, - clearTab as clearDebugTraceTab, - getEventsForTab as getDebugTraceForTab, -} from "./lib/debug-trace-store"; -import type { - DebugTraceEntry, - DetectionKind, - DetectionPayload, - GetTabRuleCountsResponse, - RuleCountEntry, -} from "./lib/detection-messages"; -import { - getEnforcementEnabled, - subscribeEnforcementEnabled, -} from "./lib/enforcement"; +// Background service-worker entry point. Pure orchestration — it imports no +// rule implementation file (enforced by scripts/check-background-purity.ts; +// rule files touch DOM constructors that throw in a worker). The work is split +// by concern under lib/background/: +// - tab-tracker per-tab toolbar/popup state + the badge refresh +// - message-handlers the typed content/popup → background router +// - lifecycle chrome.tabs listeners, storage seeds, the pause bridge +// This file just constructs the tracker and starts each piece. + +import { startBackgroundLifecycle } from "./lib/background/lifecycle"; +import { registerBackgroundMethods } from "./lib/background/message-handlers"; +import { createTabTracker } from "./lib/background/tab-tracker"; import { startClassifyPortListener } from "./lib/llm-background"; -import { log } from "./lib/log"; -import type { RuleCountMap } from "./lib/message-schemas"; -import { - debugTraceEntrySchema, - detectionPayloadSchema, - injectTypeSchema, - ruleCountsSchema, - tabIdSchema, - validatedNotification, -} from "./lib/message-schemas"; -import type { MessengerMeta } from "./lib/messenger"; -import { notifyTabPause, registerMethods } from "./lib/messenger"; -import { - dispatchPageWorldInject, - startPageWorldHooks, -} from "./lib/page-world-hooks"; -import { siteDenylistStorage } from "./lib/site-denylist"; -import { ruleStatesStorage } from "./lib/storage"; -import type { TabPause } from "./lib/tab-pause"; -import { isPauseActive, tabPauseMap } from "./lib/tab-pause"; -import type { ProtectionState } from "./lib/toolbar-protection"; -import { - ACTION_ICON_OFF, - ACTION_ICON_ON, - actionTitle, - computeProtectionState, - PROTECTION_OFF_BADGE_COLOR, - PROTECTION_OFF_BADGE_TEXT, - protectionAppearanceKey, -} from "./lib/toolbar-protection"; -import type { RuleId } from "./rules/rule-metadata"; +import { startPageWorldHooks } from "./lib/page-world-hooks"; -// Per-tab, per-frame, per-rule footprint counts. Each content script reports -// its own frame's tally grouped by rule id; the badge shows the cross-frame -// sum across all rules, and the popup renders per-rule entries derived from -// the same map. The reported counts are sanitized against the known rule ids -// by `ruleCountsSchema` before they reach `recordFrameRuleCounts`. -const tabRuleCounts = new Map>(); +// Single owner of all per-tab in-memory state; the router and lifecycle below +// are thin shells over its operations. +const tracker = createTabTracker(); -// Per-tab record of rule detections surfaced to the popup. One entry per -// kind per tab — both contributing rules are topFrameOnly and self-dedupe -// per document, so a single payload per kind is the natural shape. Held in -// memory, cleared on top-level navigation and tab close, same posture as -// `tabRuleCounts`. A service-worker restart drops it; the popup briefly -// shows "Nothing flagged" on a page that did have detections until the -// next re-apply. Promote to chrome.storage.session if that becomes a -// problem. -const tabDetections = new Map>(); +// The typed message router (content/popup → background). See +// lib/background/message-handlers.ts. +registerBackgroundMethods(tracker); -// Inputs to the per-tab "am I protected here?" signal (spec 0010 FR-2a): -// the global enforcement kill-switch and the per-site denylist. Seeded from -// storage at startup and kept current by the subscriptions below. Defaults -// match the fail-open posture — assume protection is on until storage -// resolves so we never flash an "off" badge on a tab that's actually -// protected. -let enforcementEnabled = true; -let denylist: readonly string[] = []; - -// Last top-frame URL seen per tab, so the background can evaluate the -// denylist for any tab without round-tripping the page. Captured from -// tabs.onUpdated / onActivated / a startup tabs.query; dropped on tab close. -const tabUrls = new Map(); - -// In-memory mirror of the tab-scoped recovery pause map (ADR-0019), so -// `refreshBadge` stays sync and the content bridge can resolve liveness without -// an async read. Hydrated from `tabPauseMap` at startup and kept current by its -// `onChanged`. The authoritative store is `chrome.storage.session`; this is the -// same cache posture as `enforcementEnabled` / `denylist`. -const tabPauses = new Map(); - -// Memo of the icon/title appearance last applied per tab, keyed by -// `protectionAppearanceKey`, so we only call setIcon/setTitle when the -// on/off state actually flips. The numeric count badge still refreshes on -// every rule-count message. -const tabAppearanceKey = new Map(); - -// Maps each detection kind to the rule id that produces it. Used to clear -// stale entries when a user toggles the rule off mid-session. -const DETECTION_KIND_TO_RULE_ID = { - "roach-motel": "roach-motel-annotate", - "webdriver-probe": "webdriver-probe-annotate", - "closed-shadow-root": "closed-shadow-root-annotate", -} as const satisfies Record; - -// Pleasant blue — clearly an extension affordance, not a warning/error. -const BADGE_COLOR_DEFAULT = "#2563eb"; -// Amber — tab has a roach-motel / webdriver-probe detection worth seeing -// in the popup. Matches the .enforcement--off palette in popup.html so the -// "something to look at" signal is visually consistent across surfaces. -const BADGE_COLOR_DETECTION = "#f59e0b"; - -// Cross-frame sum per rule for a tab. Frames may overlap on rule ids when -// the same rule fires in multiple frames (subframes, shadow trees) — we -// add their contributions. Returned object only contains rules with a -// non-zero footprint. -function summedCountsForTab(tabId: number): RuleCountMap { - const frames = tabRuleCounts.get(tabId); - const summed: RuleCountMap = {}; - if (!frames) { - return summed; - } - for (const frameCounts of frames.values()) { - for (const [ruleId, count] of Object.entries(frameCounts) as [ - RuleId, - number, - ][]) { - summed[ruleId] = (summed[ruleId] ?? 0) + count; - } - } - return summed; -} - -function totalForTab(tabId: number): number { - let total = 0; - for (const count of Object.values(summedCountsForTab(tabId))) { - total += count; - } - return total; -} - -function formatBadge(total: number): string { - if (total <= 0) { - return ""; - } - if (total > 999) { - return "999+"; - } - return String(total); -} - -function hasDetections(tabId: number): boolean { - return (tabDetections.get(tabId)?.size ?? 0) > 0; -} - -function refreshBadge(tabId: number): void { - const state = computeProtectionState({ - enforcementEnabled, - tabUrl: tabUrls.get(tabId) ?? null, - denylist, - paused: isPauseActive(tabPauses.get(tabId) ?? null, Date.now()), - }); - applyProtectionAppearance(tabId, state); - if (state.off) { - // Rules don't run (denylisted) or were revealed (global off) on this - // tab, so a count would be misleading. Show the explicit "off" badge - // instead, so a clean protected page and an unprotected page never look - // identical — that ambiguity is the whole reason this signal exists. - chrome.action - .setBadgeText({ tabId, text: PROTECTION_OFF_BADGE_TEXT }) - .catch(() => { - // noop - }); - chrome.action - .setBadgeBackgroundColor({ tabId, color: PROTECTION_OFF_BADGE_COLOR }) - .catch(() => { - // noop - }); - return; - } - const placeholderText = formatBadge(totalForTab(tabId)); - const detection = hasDetections(tabId); - // Detection without a placeholder count gets a single "!" so the badge - // still shows up. Otherwise keep the existing count text — the color - // change alone signals "open the popup." - const text = placeholderText || (detection ? "!" : ""); - chrome.action.setBadgeText({ tabId, text }).catch(() => { - // noop - }); - if (text) { - const color = detection ? BADGE_COLOR_DETECTION : BADGE_COLOR_DEFAULT; - chrome.action.setBadgeBackgroundColor({ tabId, color }).catch(() => { - // noop - }); - } -} - -// Swap the toolbar icon + tooltip to match the tab's protection state, but -// only when it changed — reissuing setIcon on every rule-count message would -// be needless churn. The greyed icon is the primary "you're unprotected -// here" signal; the badge reinforces it. Per-tab action settings persist for -// the tab's lifetime, so each tab the user might look at must be painted at -// least once (see refreshAllTabs and the tab listeners below). -function applyProtectionAppearance( - tabId: number, - state: ProtectionState, -): void { - const key = protectionAppearanceKey(state); - if (tabAppearanceKey.get(tabId) === key) { - return; - } - tabAppearanceKey.set(tabId, key); - chrome.action - .setIcon({ tabId, path: state.off ? ACTION_ICON_OFF : ACTION_ICON_ON }) - .catch(() => { - // Restricted tabs (chrome://, Web Store) reject setIcon — swallow it. - }); - chrome.action.setTitle({ tabId, title: actionTitle(state) }).catch(() => { - // noop - }); -} - -// Re-evaluate every open tab's toolbar appearance. Used when a *global* -// input to the protection signal changes (enforcement toggle, denylist -// edit): per-tab icons persist, so every tab the user might switch to has to -// be repainted, not just the ones we're tracking counts for. -function refreshAllTabs(): void { - chrome.tabs - .query({}) - .then((tabs) => { - for (const tab of tabs) { - if (typeof tab.id !== "number") { - continue; - } - if (typeof tab.url === "string") { - tabUrls.set(tab.id, tab.url); - } - refreshBadge(tab.id); - } - }) - .catch(() => { - // noop — tabs.query rejection shouldn't surface. - }); -} - -function recordFrameRuleCounts( - tabId: number, - frameId: number, - counts: RuleCountMap, -): void { - let frames = tabRuleCounts.get(tabId); - const hasAnyCount = Object.values(counts).some((value) => value > 0); - if (!hasAnyCount) { - if (!frames) { - refreshBadge(tabId); - return; - } - frames.delete(frameId); - if (frames.size === 0) { - tabRuleCounts.delete(tabId); - } - refreshBadge(tabId); - return; - } - if (!frames) { - frames = new Map(); - tabRuleCounts.set(tabId, frames); - } - frames.set(frameId, counts); - refreshBadge(tabId); -} - -function recordDetection(tabId: number, payload: DetectionPayload): void { - let entry = tabDetections.get(tabId); - if (!entry) { - entry = new Map(); - tabDetections.set(tabId, entry); - } - entry.set(payload.kind, payload); - refreshBadge(tabId); -} - -function clearTab(tabId: number): void { - tabRuleCounts.delete(tabId); - tabDetections.delete(tabId); - refreshBadge(tabId); -} - -function clearDetectionsOfKind(kind: DetectionKind): void { - for (const [tabId, entry] of tabDetections) { - if (entry.delete(kind)) { - if (entry.size === 0) { - tabDetections.delete(tabId); - } - refreshBadge(tabId); - } - } -} - -chrome.tabs.onRemoved.addListener((tabId) => { - tabRuleCounts.delete(tabId); - tabDetections.delete(tabId); - tabUrls.delete(tabId); - tabAppearanceKey.delete(tabId); - // Drop any recovery pause for the closed tab. The cache delete is immediate; - // the session-storage remove is fire-and-forget (the value is auto-cleared on - // browser restart regardless, this just keeps it tidy within the session). - if (tabPauses.delete(tabId)) { - void tabPauseMap.remove(String(tabId)).catch(() => { - // noop - }); - } - // Fire-and-forget — IDB write may outlive the listener context. - void clearDebugTraceTab(tabId).catch(() => { - // noop - }); -}); - -// On a top-level navigation, drop stale per-frame counts so the new document -// starts from zero. The content script will report fresh numbers as rules run. -// The debug trace is *not* cleared — instead a `navigation` entry is appended -// so a single export can span multiple page loads in the same tab. Gated on -// the same toggle that gates content-script emission so the trace stays empty -// when the toggle is off. -chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - // Keep the cached top-frame URL current so the denylist evaluation in - // refreshBadge sees the right URL — including the new document during a - // "loading" event, before clearTab repaints below. - if (typeof tab.url === "string") { - tabUrls.set(tabId, tab.url); - } - if (changeInfo.status !== "loading") { - // A client-side URL change (SPA pushState / hash) arrives without a - // fresh "loading" status. The denylist is host-scoped so this rarely - // flips protection, but re-evaluate so the toolbar stays correct on - // cross-host in-page navigations. - if (typeof changeInfo.url === "string") { - refreshBadge(tabId); - } - return; - } - // A top-frame navigation ends a "page"-scoped reveal (the panic button is - // current-page-only) and reaps any timed "tab" pause that has since expired. - // Active timed pauses survive so a multi-page flow stays unblocked. - const pause = tabPauses.get(tabId); - if (pause && (pause.scope === "page" || !isPauseActive(pause, Date.now()))) { - tabPauses.delete(tabId); - void tabPauseMap.remove(String(tabId)).catch(() => { - // noop - }); - } - clearTab(tabId); - void (async () => { - try { - if (!(await debugTraceStorage.get())) { - return; - } - const entry: DebugTraceEntry = { - type: "navigation", - url: tab.url ?? null, - timestamp: Date.now(), - }; - // Frame id 0 — top-level navigation is always the main frame. - await appendDebugTraceEvent(tabId, 0, entry); - } catch { - // noop — storage read or IDB write rejection shouldn't surface. - } - })(); -}); - -// A newly-activated tab may be one we opened before the service worker -// started (no onUpdated seen) — learn its URL and paint its toolbar state so -// the icon/badge is right the moment the user looks at it. -chrome.tabs.onActivated.addListener(({ tabId }) => { - chrome.tabs - .get(tabId) - .then((tab) => { - if (typeof tab.url === "string") { - tabUrls.set(tabId, tab.url); - } - refreshBadge(tabId); - }) - .catch(() => { - // Tab may have closed between the event and the get — ignore. - }); -}); - -// Re-render every tab's toolbar state when global enforcement is toggled. -// When disabled, the rule engine reveals everything and the cached per-tab -// counts/detections are immediately stale, so drop them — neither the badge -// nor the popup should show a number for paused rules. refreshAllTabs then -// repaints every open tab (not just the tracked ones) so the greyed "off" -// icon reaches tabs we weren't counting. -subscribeEnforcementEnabled((enabled) => { - enforcementEnabled = enabled; - if (!enabled) { - tabRuleCounts.clear(); - tabDetections.clear(); - } - refreshAllTabs(); -}); - -// The per-site denylist is the other input to the protection signal. A -// denylist edit can flip any open tab between protected and off, so repaint -// them all. (The content-side rule engine reacts to the same storage change -// independently; this is purely the toolbar's view of it.) -siteDenylistStorage.subscribe((next) => { - denylist = next; - refreshAllTabs(); -}); - -// Seed the protection-signal caches from storage, then paint every open tab. -// Service-worker IIFE — no top-level await, so chain `.get().then(...)`. -// eslint-disable-next-line unicorn/prefer-top-level-await -- IIFE bundle, no TLA -void getEnforcementEnabled().then((enabled) => { - enforcementEnabled = enabled; - refreshAllTabs(); -}); -// eslint-disable-next-line unicorn/prefer-top-level-await -- IIFE bundle, no TLA -void siteDenylistStorage.get().then((next) => { - denylist = next; - refreshAllTabs(); -}); - -// The tab-scoped recovery pause (ADR-0019) is the third input to the protection -// signal — but only the popup and background write it, and content scripts -// can't observe the session area, so the background bridges every change to the -// tab's frames. On a popup edit (reveal/pause/snooze, or "Resume now") push the -// new liveness so a still-open page reveals or re-enforces without a reload; a -// *timed* expiry produces no write and hence no push, which is what leaves the -// open page revealed until its next navigation. -// webext-storage types the change value as non-undefined, but it IS undefined -// when the entry was removed (resume / nav-clear / tab close). A wider param -// type is contravariantly assignable — no cast needed. -tabPauseMap.onChanged((key, value: TabPause | undefined) => { - const tabId = Number(key); - if (!Number.isInteger(tabId)) { - return; - } - if (value === undefined) { - tabPauses.delete(tabId); - } else { - tabPauses.set(tabId, value); - } - const paused = isPauseActive(tabPauses.get(tabId) ?? null, Date.now()); - // Broadcast to every frame in the tab. The notifier is fire-and-forget: a tab - // with no content script (restricted URL) or one that has closed just drops - // the message. - notifyTabPause(tabId, paused); - refreshBadge(tabId); -}); -// Hydrate the cache from the session store so a service-worker restart doesn't -// drop a still-active pause from the toolbar signal. Async generator over the -// map's entries; tabIds are the secondary keys. -// eslint-disable-next-line unicorn/prefer-top-level-await -- IIFE bundle, no TLA -void (async () => { - try { - for await (const [key, value] of tabPauseMap.entries()) { - const tabId = Number(key); - if (Number.isInteger(tabId)) { - tabPauses.set(tabId, value); - } - } - refreshAllTabs(); - } catch { - // Session storage unreadable at startup — the caches stay empty and the - // signal fails open to "protected", same posture as the other seeds. - } -})(); - -// When a user disables one of the detection-producing rules mid-session, -// drop the now-stale entries from every tab so the popup matches the -// current rule selection. Detections for the other (still-enabled) rule -// stay put. Seed `previousRuleStates` from storage before subscribing — -// `subscribe` only fires on changes, never with the current value, so -// without a seed the first off-transition would compare against `null` -// and skip the clear. We can't use top-level await here (the bundler -// emits IIFE for the service worker), so chain `.get().then(...)`. -let previousRuleStates: Record | null = null; -ruleStatesStorage.subscribe((next) => { - const previous = previousRuleStates; - previousRuleStates = { ...next }; - if (previous === null) { - return; - } - for (const [kind, ruleId] of Object.entries(DETECTION_KIND_TO_RULE_ID) as [ - DetectionKind, - string, - ][]) { - if (previous[ruleId] === true && next[ruleId] === false) { - clearDetectionsOfKind(kind); - } - } -}); -// eslint-disable-next-line unicorn/prefer-top-level-await -- IIFE bundle, no TLA -void ruleStatesStorage.get().then((initial) => { - previousRuleStates ??= { ...initial }; -}); - -// The typed message router. Every method below is the receiving half of a -// `lib/messenger.ts` call; the sender's payload is decoded through its -// `message-schemas.ts` validator before it touches any state. `this.trace[0]` -// is the immediate sender (`chrome.runtime.MessageSender`) — the content -// script's tabId / frameId / url are read from there, never from the payload. -registerMethods({ - // Record a rule's a11y-tree detection so the popup can surface it. - recordDetection: validatedNotification( - detectionPayloadSchema, - (payload, meta) => { - const tabId = meta.trace[0]?.tab?.id; - if (typeof tabId === "number") { - recordDetection(tabId, payload); - } - }, - ), - - // Per-frame rule footprint. `ruleCountsSchema` has already dropped unknown - // rule ids and non-positive counts, so a misbehaving content script can't - // poison the badge or popup. - reportRuleCounts: validatedNotification(ruleCountsSchema, (counts, meta) => { - const tabId = meta.trace[0]?.tab?.id; - const frameId = meta.trace[0]?.frameId; - if (typeof tabId === "number" && typeof frameId === "number") { - recordFrameRuleCounts(tabId, frameId, counts); - } - }), - - // Dev-mode trace event → IndexedDB. Fire-and-forget: the IDB write is async - // but the handler shouldn't block on disk; pruning happens inside - // `appendEvent`. - reportDebugTraceEvent: validatedNotification( - debugTraceEntrySchema, - (entry, meta) => { - const tabId = meta.trace[0]?.tab?.id; - const frameId = meta.trace[0]?.frameId; - if (typeof tabId !== "number" || typeof frameId !== "number") { - return; - } - // `entry` is a validated debug-trace entry; the cast only bridges - // `exactOptionalPropertyTypes` (zod infers `key?: T | undefined` for the - // optional fields where the interface declares `key?: T`). - void appendDebugTraceEvent( - tabId, - frameId, - entry as DebugTraceEntry, - ).catch((error: unknown) => { - log.error("debug-trace IDB write failed", { error }); - }); - }, - ), - - // Run a page-world install fn on the tab the user was already viewing when - // they toggled a rule on — dynamic registrations only take effect on the next - // navigation. The table and the per-install `__abs_*_installed` page-world - // guards live in `lib/page-world-hooks.ts`. - requestPageWorldInject: validatedNotification( - injectTypeSchema, - (injectType, meta) => { - const sender = meta.trace[0]; - if (sender) { - dispatchPageWorldInject(injectType, sender); - } - }, - ), - - // Subframe content scripts ask this once at startup so they can evaluate the - // per-site denylist (ADR-0018) against the tab's top-frame URL instead of - // their own iframe URL. `sender.tab.url` requires host permission — we have - // , so this is just a property read. A frame whose tab URL the - // background can't resolve gets `null` and falls back to "URL unknown → fail - // open." - getTabUrl(this: MessengerMeta) { - return this.trace[0]?.tab?.url ?? null; - }, - - // Content scripts ask this once at rule-engine init to seed the tab-scoped - // recovery pause (ADR-0019). Read the session store directly rather than the - // in-memory cache so a request that lands before startup hydration still - // resolves accurately. The background applies the `expiresAt` check so the - // content side only ever sees a boolean. - async getTabPause(this: MessengerMeta) { - const tabId = this.trace[0]?.tab?.id; - if (typeof tabId !== "number") { - return false; - } - try { - const value = await tabPauseMap.get(String(tabId)); - return isPauseActive(value ?? null, Date.now()); - } catch { - return false; - } - }, - - // Combined per-rule + detection snapshot the popup renders on open. - getTabRuleCounts( - this: MessengerMeta, - tabId: number, - ): GetTabRuleCountsResponse { - const parsed = tabIdSchema.safeParse(tabId); - return parsed.success - ? buildRuleCountsResponse(parsed.data) - : { entries: [], detections: [] }; - }, - - // Page-world `__abs_dumpTrace` bridge → IDB read. Flattened to the public - // wire shape: `addedAt` is internal IDB bookkeeping and the tabId/frameId - // live on the entry, matching `extension/data/debug-trace.schema.json`. - async getTabDebugTrace(this: MessengerMeta) { - const tabId = this.trace[0]?.tab?.id; - if (typeof tabId !== "number") { - return { entries: [] }; - } - try { - const stored = await getDebugTraceForTab(tabId); - return { entries: stored.map((record) => toExportedRecord(record)) }; - } catch (error: unknown) { - log.error("get-tab-debug-trace IDB read failed", { error }); - return { entries: [] }; - } - }, - - // Popup / page badge → open the options page. - async openOptions() { - await new Promise((resolve) => { - chrome.runtime.openOptionsPage(() => { - resolve(); - }); - }); - return { ok: true } as const; - }, -}); - -// Build the combined per-rule + detection snapshot the popup renders. -// Entries are sorted by count desc, breaking ties by rule id for a stable -// render across reopens. Detection-producing rules contribute the rich -// payload to `detections` and (when their landmark-stamped node carries a -// RULE_ATTR/HIDDEN_ATTR) also surface in `entries` via the per-frame -// reporter — the popup is free to render them in both surfaces, since the -// "Heads up" cards convey site-level context the bare count can't. -function buildRuleCountsResponse(tabId: number): GetTabRuleCountsResponse { - const summed = summedCountsForTab(tabId); - const entries: RuleCountEntry[] = []; - for (const [ruleId, count] of Object.entries(summed) as [RuleId, number][]) { - if (count > 0) { - entries.push({ ruleId, count }); - } - } - entries.sort((a, b) => { - if (b.count !== a.count) { - return b.count - a.count; - } - return a.ruleId.localeCompare(b.ruleId); - }); - const detectionEntries = tabDetections.get(tabId); - const detections = detectionEntries ? [...detectionEntries.values()] : []; - return { entries, detections }; -} +// chrome.tabs listeners, storage subscriptions, startup seeds, and the +// tab-scoped recovery-pause session bridge. See lib/background/lifecycle.ts. +startBackgroundLifecycle(tracker); // Classify requests use a long-lived port instead of sendMessage so the // content-side abort can propagate to the background's fetch. See -// `lib/llm-background.ts` for the per-port AbortController wiring. +// lib/llm-background.ts for the per-port AbortController wiring. startClassifyPortListener(); // Register/unregister every page-world (`world: "MAIN"`, @@ -667,6 +38,6 @@ startClassifyPortListener(); // rule-gated ones, global enforcement) changes — the webdriver probe, the // checkout-checkbox defense, the shadow-root probe, and the `__abs_dumpTrace` // bridge. Each must run before the page's first script to wrap a page-world -// prototype the isolated-world rule engine can't reach. The table of hooks -// and their register/unregister life-cycle lives in `lib/page-world-hooks.ts`. +// prototype the isolated-world rule engine can't reach. The table of hooks and +// their register/unregister life-cycle lives in lib/page-world-hooks.ts. startPageWorldHooks(); diff --git a/extension/src/lib/background/badge.ts b/extension/src/lib/background/badge.ts new file mode 100644 index 0000000..634ebcb --- /dev/null +++ b/extension/src/lib/background/badge.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Toolbar badge + icon painting for the background worker. The decision logic +// — whether a tab is protected and why — lives in `toolbar-protection.ts`; +// this module is purely the `chrome.action` side effects, isolated here so +// `tab-tracker.ts` reads as state management rather than a wall of +// fire-and-forget chrome calls. Every call swallows rejection: restricted tabs +// (chrome://, Web Store) and tabs that close mid-flight reject, and none of it +// should surface. + +import type { ProtectionState } from "../toolbar-protection"; +import { + ACTION_ICON_OFF, + ACTION_ICON_ON, + actionTitle, + PROTECTION_OFF_BADGE_COLOR, + PROTECTION_OFF_BADGE_TEXT, +} from "../toolbar-protection"; + +// Pleasant blue — clearly an extension affordance, not a warning/error. +export const BADGE_COLOR_DEFAULT = "#2563eb"; +// Amber — tab has a roach-motel / webdriver-probe detection worth seeing in +// the popup. Matches the .enforcement--off palette in popup.html so the +// "something to look at" signal is visually consistent across surfaces. +export const BADGE_COLOR_DETECTION = "#f59e0b"; + +// Badge text for a cross-frame rule-count total. Empty string clears the +// badge; counts past 999 collapse to "999+" so the text stays legible. +export function formatBadge(total: number): string { + if (total <= 0) { + return ""; + } + if (total > 999) { + return "999+"; + } + return String(total); +} + +// Paint the explicit "off" badge. Rules don't run (denylisted) or were +// revealed (global off) on this tab, so a count would be misleading — the +// "off" badge keeps a clean protected page and an unprotected page from ever +// looking identical, which is the whole reason this signal exists. +export function paintProtectionOffBadge(tabId: number): void { + chrome.action + .setBadgeText({ tabId, text: PROTECTION_OFF_BADGE_TEXT }) + .catch(() => { + // noop + }); + chrome.action + .setBadgeBackgroundColor({ tabId, color: PROTECTION_OFF_BADGE_COLOR }) + .catch(() => { + // noop + }); +} + +// Paint the activity/detection badge. Empty `text` clears it; a non-empty text +// picks the amber detection color when `detection` is set, else blue — the +// color change alone signals "open the popup." +export function paintCountBadge( + tabId: number, + text: string, + detection: boolean, +): void { + chrome.action.setBadgeText({ tabId, text }).catch(() => { + // noop + }); + if (text) { + const color = detection ? BADGE_COLOR_DETECTION : BADGE_COLOR_DEFAULT; + chrome.action.setBadgeBackgroundColor({ tabId, color }).catch(() => { + // noop + }); + } +} + +// Swap the toolbar icon + tooltip to match the tab's protection state. The +// greyed icon is the primary "you're unprotected here" signal; the badge +// reinforces it. The caller (`tab-tracker`) memoizes by +// `protectionAppearanceKey` so this only fires when the on/off state flips — +// reissuing setIcon on every rule-count message would be needless churn. +export function paintProtectionAppearance( + tabId: number, + state: ProtectionState, +): void { + chrome.action + .setIcon({ tabId, path: state.off ? ACTION_ICON_OFF : ACTION_ICON_ON }) + .catch(() => { + // Restricted tabs (chrome://, Web Store) reject setIcon — swallow it. + }); + chrome.action.setTitle({ tabId, title: actionTitle(state) }).catch(() => { + // noop + }); +} diff --git a/extension/src/lib/background/lifecycle.ts b/extension/src/lib/background/lifecycle.ts new file mode 100644 index 0000000..b273c7c --- /dev/null +++ b/extension/src/lib/background/lifecycle.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Startup wiring for the background worker: the `chrome.tabs` lifecycle +// listeners, the storage subscriptions that feed the protection signal, and the +// session-store bridge for the tab-scoped recovery pause (ADR-0019). All per-tab +// state lives in the `TabTracker`; this module translates browser and storage +// events into tracker operations plus the side effects the tracker deliberately +// doesn't own — session-store writes and the content broadcast (content scripts +// can't observe the session area, so the background bridges every change). + +import { debugTraceStorage } from "../debug-trace"; +import { + appendEvent as appendDebugTraceEvent, + clearTab as clearDebugTraceTab, +} from "../debug-trace-store"; +import type { DebugTraceEntry, DetectionKind } from "../detection-messages"; +import { + getEnforcementEnabled, + subscribeEnforcementEnabled, +} from "../enforcement"; +import { notifyTabPause } from "../messenger"; +import { siteDenylistStorage } from "../site-denylist"; +import { ruleStatesStorage } from "../storage"; +import type { TabPause } from "../tab-pause"; +import { isPauseActive, tabPauseMap } from "../tab-pause"; +import type { TabTracker } from "./tab-tracker"; + +// Maps each detection kind to the rule id that produces it. Used to clear stale +// entries when a user toggles the rule off mid-session. +const DETECTION_KIND_TO_RULE_ID = { + "roach-motel": "roach-motel-annotate", + "webdriver-probe": "webdriver-probe-annotate", + "closed-shadow-root": "closed-shadow-root-annotate", +} as const satisfies Record; + +export function startBackgroundLifecycle(tracker: TabTracker): void { + chrome.tabs.onRemoved.addListener((tabId) => { + // The cache delete is immediate; the session-storage remove is + // fire-and-forget (the value is auto-cleared on browser restart regardless, + // this just keeps it tidy within the session). + if (tracker.removeTab(tabId)) { + void tabPauseMap.remove(String(tabId)).catch(() => { + // noop + }); + } + // Fire-and-forget — IDB write may outlive the listener context. + void clearDebugTraceTab(tabId).catch(() => { + // noop + }); + }); + + // On a top-level navigation, drop stale per-frame counts so the new document + // starts from zero. The content script will report fresh numbers as rules + // run. The debug trace is *not* cleared — instead a `navigation` entry is + // appended so a single export can span multiple page loads in the same tab. + // Gated on the same toggle that gates content-script emission so the trace + // stays empty when the toggle is off. + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + // Keep the cached top-frame URL current so the denylist evaluation in + // refreshBadge sees the right URL — including the new document during a + // "loading" event, before clearTab repaints below. + if (typeof tab.url === "string") { + tracker.setTabUrl(tabId, tab.url); + } + if (changeInfo.status !== "loading") { + // A client-side URL change (SPA pushState / hash) arrives without a fresh + // "loading" status. The denylist is host-scoped so this rarely flips + // protection, but re-evaluate so the toolbar stays correct on cross-host + // in-page navigations. + if (typeof changeInfo.url === "string") { + tracker.refreshBadge(tabId); + } + return; + } + // A top-frame navigation ends a "page"-scoped reveal (the panic button is + // current-page-only) and reaps any timed "tab" pause that has since + // expired. Active timed pauses survive so a multi-page flow stays unblocked. + const pause = tracker.getTabPause(tabId); + if ( + pause && + (pause.scope === "page" || !isPauseActive(pause, Date.now())) + ) { + tracker.setTabPause(tabId, undefined); + void tabPauseMap.remove(String(tabId)).catch(() => { + // noop + }); + } + tracker.clearTab(tabId); + void (async () => { + try { + if (!(await debugTraceStorage.get())) { + return; + } + const entry: DebugTraceEntry = { + type: "navigation", + url: tab.url ?? null, + timestamp: Date.now(), + }; + // Frame id 0 — top-level navigation is always the main frame. + await appendDebugTraceEvent(tabId, 0, entry); + } catch { + // noop — storage read or IDB write rejection shouldn't surface. + } + })(); + }); + + // A newly-activated tab may be one we opened before the service worker + // started (no onUpdated seen) — learn its URL and paint its toolbar state so + // the icon/badge is right the moment the user looks at it. + chrome.tabs.onActivated.addListener(({ tabId }) => { + chrome.tabs + .get(tabId) + .then((tab) => { + if (typeof tab.url === "string") { + tracker.setTabUrl(tabId, tab.url); + } + tracker.refreshBadge(tabId); + }) + .catch(() => { + // Tab may have closed between the event and the get — ignore. + }); + }); + + // Re-render every tab's toolbar state when global enforcement is toggled, and + // when the per-site denylist changes — both are global inputs to the + // protection signal. The tracker drops now-stale counts on a global-off and + // repaints every open tab. + subscribeEnforcementEnabled((enabled) => { + tracker.setEnforcementEnabled(enabled); + }); + siteDenylistStorage.subscribe((next) => { + tracker.setDenylist(next); + }); + + // Seed the protection-signal caches from storage, then paint every open tab. + // Service-worker IIFE — no top-level await, so chain `.get().then(...)`. + void getEnforcementEnabled().then((enabled) => { + tracker.setEnforcementEnabled(enabled); + }); + void siteDenylistStorage.get().then((next) => { + tracker.setDenylist(next); + }); + + // The tab-scoped recovery pause (ADR-0019) is the third input to the + // protection signal — but only the popup and background write it, and content + // scripts can't observe the session area, so the background bridges every + // change to the tab's frames. On a popup edit (reveal/pause/snooze, or + // "Resume now") push the new liveness so a still-open page reveals or + // re-enforces without a reload; a *timed* expiry produces no write and hence + // no push, which is what leaves the open page revealed until its next + // navigation. + // webext-storage types the change value as non-undefined, but it IS undefined + // when the entry was removed (resume / nav-clear / tab close). A wider param + // type is contravariantly assignable — no cast needed. + tabPauseMap.onChanged((key, value: TabPause | undefined) => { + const tabId = Number(key); + if (!Number.isInteger(tabId)) { + return; + } + tracker.setTabPause(tabId, value); + const paused = isPauseActive(tracker.getTabPause(tabId), Date.now()); + // Broadcast to every frame in the tab. The notifier is fire-and-forget: a + // tab with no content script (restricted URL) or one that has closed just + // drops the message. + notifyTabPause(tabId, paused); + tracker.refreshBadge(tabId); + }); + // Hydrate the cache from the session store so a service-worker restart + // doesn't drop a still-active pause from the toolbar signal. Async generator + // over the map's entries; tabIds are the secondary keys. + void (async () => { + try { + for await (const [key, value] of tabPauseMap.entries()) { + const tabId = Number(key); + if (Number.isInteger(tabId)) { + tracker.setTabPause(tabId, value); + } + } + tracker.refreshAllTabs(); + } catch { + // Session storage unreadable at startup — the caches stay empty and the + // signal fails open to "protected", same posture as the other seeds. + } + })(); + + // When a user disables one of the detection-producing rules mid-session, drop + // the now-stale entries from every tab so the popup matches the current rule + // selection. Detections for the other (still-enabled) rule stay put. Seed + // `previousRuleStates` from storage before subscribing — `subscribe` only + // fires on changes, never with the current value, so without a seed the first + // off-transition would compare against `null` and skip the clear. + let previousRuleStates: Record | null = null; + ruleStatesStorage.subscribe((next) => { + const previous = previousRuleStates; + previousRuleStates = { ...next }; + if (previous === null) { + return; + } + for (const [kind, ruleId] of Object.entries(DETECTION_KIND_TO_RULE_ID) as [ + DetectionKind, + string, + ][]) { + if (previous[ruleId] === true && next[ruleId] === false) { + tracker.clearDetectionsOfKind(kind); + } + } + }); + void ruleStatesStorage.get().then((initial) => { + previousRuleStates ??= { ...initial }; + }); +} diff --git a/extension/src/lib/background/message-handlers.ts b/extension/src/lib/background/message-handlers.ts new file mode 100644 index 0000000..419515f --- /dev/null +++ b/extension/src/lib/background/message-handlers.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// The typed message router for the background worker. Every method here is the +// receiving half of a `lib/messenger.ts` call; the sender's payload is decoded +// through its `message-schemas.ts` validator before it touches any state. +// `meta.trace[0]` is the immediate sender (`chrome.runtime.MessageSender`) — +// the content script's tabId / frameId / url are read from there, never from +// the payload. State-touching methods delegate to the `TabTracker`; the rest +// are self-contained reads. `registerMethods` merges across call sites (see +// `effective-enforcement.ts`), so this block can live apart from the entry. + +import { toExportedRecord } from "../debug-trace-export"; +import { + appendEvent as appendDebugTraceEvent, + getEventsForTab as getDebugTraceForTab, +} from "../debug-trace-store"; +import type { + DebugTraceEntry, + GetTabRuleCountsResponse, +} from "../detection-messages"; +import { log } from "../log"; +import { + debugTraceEntrySchema, + detectionPayloadSchema, + injectTypeSchema, + ruleCountsSchema, + tabIdSchema, + validatedNotification, +} from "../message-schemas"; +import type { MessengerMeta } from "../messenger"; +import { registerMethods } from "../messenger"; +import { dispatchPageWorldInject } from "../page-world-hooks"; +import { isPauseActive, tabPauseMap } from "../tab-pause"; +import type { TabTracker } from "./tab-tracker"; + +export function registerBackgroundMethods(tracker: TabTracker): void { + registerMethods({ + // Record a rule's a11y-tree detection so the popup can surface it. + recordDetection: validatedNotification( + detectionPayloadSchema, + (payload, meta) => { + const tabId = meta.trace[0]?.tab?.id; + if (typeof tabId === "number") { + tracker.recordDetection(tabId, payload); + } + }, + ), + + // Per-frame rule footprint. `ruleCountsSchema` has already dropped unknown + // rule ids and non-positive counts, so a misbehaving content script can't + // poison the badge or popup. + reportRuleCounts: validatedNotification( + ruleCountsSchema, + (counts, meta) => { + const tabId = meta.trace[0]?.tab?.id; + const frameId = meta.trace[0]?.frameId; + if (typeof tabId === "number" && typeof frameId === "number") { + tracker.recordFrameRuleCounts(tabId, frameId, counts); + } + }, + ), + + // Dev-mode trace event → IndexedDB. Fire-and-forget: the IDB write is async + // but the handler shouldn't block on disk; pruning happens inside + // `appendEvent`. + reportDebugTraceEvent: validatedNotification( + debugTraceEntrySchema, + (entry, meta) => { + const tabId = meta.trace[0]?.tab?.id; + const frameId = meta.trace[0]?.frameId; + if (typeof tabId !== "number" || typeof frameId !== "number") { + return; + } + // `entry` is a validated debug-trace entry; the cast only bridges + // `exactOptionalPropertyTypes` (zod infers `key?: T | undefined` for + // the optional fields where the interface declares `key?: T`). + void appendDebugTraceEvent( + tabId, + frameId, + entry as DebugTraceEntry, + ).catch((error: unknown) => { + log.error("debug-trace IDB write failed", { error }); + }); + }, + ), + + // Run a page-world install fn on the tab the user was already viewing when + // they toggled a rule on — dynamic registrations only take effect on the + // next navigation. The table and the per-install `__abs_*_installed` + // page-world guards live in `lib/page-world-hooks.ts`. + requestPageWorldInject: validatedNotification( + injectTypeSchema, + (injectType, meta) => { + const sender = meta.trace[0]; + if (sender) { + dispatchPageWorldInject(injectType, sender); + } + }, + ), + + // Subframe content scripts ask this once at startup so they can evaluate + // the per-site denylist (ADR-0018) against the tab's top-frame URL instead + // of their own iframe URL. `sender.tab.url` requires host permission — we + // have , so this is just a property read. A frame whose tab URL + // the background can't resolve gets `null` and falls back to "URL unknown → + // fail open." + getTabUrl(this: MessengerMeta) { + return this.trace[0]?.tab?.url ?? null; + }, + + // Content scripts ask this once at rule-engine init to seed the tab-scoped + // recovery pause (ADR-0019). Read the session store directly rather than + // the in-memory cache so a request that lands before startup hydration + // still resolves accurately. The background applies the `expiresAt` check + // so the content side only ever sees a boolean. + async getTabPause(this: MessengerMeta) { + const tabId = this.trace[0]?.tab?.id; + if (typeof tabId !== "number") { + return false; + } + try { + const value = await tabPauseMap.get(String(tabId)); + return isPauseActive(value ?? null, Date.now()); + } catch { + return false; + } + }, + + // Combined per-rule + detection snapshot the popup renders on open. + getTabRuleCounts( + this: MessengerMeta, + tabId: number, + ): GetTabRuleCountsResponse { + const parsed = tabIdSchema.safeParse(tabId); + return parsed.success + ? tracker.buildRuleCountsResponse(parsed.data) + : { entries: [], detections: [] }; + }, + + // Page-world `__abs_dumpTrace` bridge → IDB read. Flattened to the public + // wire shape: `addedAt` is internal IDB bookkeeping and the tabId/frameId + // live on the entry, matching `extension/data/debug-trace.schema.json`. + async getTabDebugTrace(this: MessengerMeta) { + const tabId = this.trace[0]?.tab?.id; + if (typeof tabId !== "number") { + return { entries: [] }; + } + try { + const stored = await getDebugTraceForTab(tabId); + return { entries: stored.map((record) => toExportedRecord(record)) }; + } catch (error: unknown) { + log.error("get-tab-debug-trace IDB read failed", { error }); + return { entries: [] }; + } + }, + + // Popup / page badge → open the options page. + async openOptions() { + await new Promise((resolve) => { + chrome.runtime.openOptionsPage(() => { + resolve(); + }); + }); + return { ok: true } as const; + }, + }); +} diff --git a/extension/src/lib/background/tab-tracker.ts b/extension/src/lib/background/tab-tracker.ts new file mode 100644 index 0000000..7396e7f --- /dev/null +++ b/extension/src/lib/background/tab-tracker.ts @@ -0,0 +1,353 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Per-tab in-memory state for the toolbar/popup model, plus the badge refresh +// that reads across it. The background worker's `chrome.tabs` listeners +// (`lifecycle.ts`) and message handlers (`message-handlers.ts`) are thin shells +// over the operations exposed here; keeping the state in one factory leaves +// `background.ts` to wiring and makes this layer unit-testable with a chrome +// mock. All maps are dropped on a service-worker restart — the same fail-open +// posture every cache below assumes. + +import type { RuleId } from "../../rules/rule-metadata"; +import type { + DetectionKind, + DetectionPayload, + GetTabRuleCountsResponse, + RuleCountEntry, +} from "../detection-messages"; +import type { RuleCountMap } from "../message-schemas"; +import type { TabPause } from "../tab-pause"; +import { isPauseActive } from "../tab-pause"; +import { + computeProtectionState, + protectionAppearanceKey, +} from "../toolbar-protection"; +import { + formatBadge, + paintCountBadge, + paintProtectionAppearance, + paintProtectionOffBadge, +} from "./badge"; + +export interface TabTracker { + // ── state mutations driven by content messages ── + recordDetection(tabId: number, payload: DetectionPayload): void; + recordFrameRuleCounts( + tabId: number, + frameId: number, + counts: RuleCountMap, + ): void; + clearTab(tabId: number): void; + clearDetectionsOfKind(kind: DetectionKind): void; + buildRuleCountsResponse(tabId: number): GetTabRuleCountsResponse; + + // ── protection-signal inputs (lifecycle owns the storage side) ── + setTabUrl(tabId: number, url: string): void; + setEnforcementEnabled(enabled: boolean): void; + setDenylist(next: readonly string[]): void; + /** + * Update the cached recovery-pause liveness for a tab (`undefined` clears + * it). Pure cache write — the session store and the content broadcast are + * the lifecycle layer's responsibility, since content scripts can't observe + * the session area. + */ + setTabPause(tabId: number, pause: TabPause | undefined): void; + getTabPause(tabId: number): TabPause | null; + + // ── lifecycle ── + /** + * Drop every in-memory trace of a closed tab. Returns whether the tab had a + * cached recovery pause, so the caller can mirror the removal to the session + * store. Does not repaint — the tab is gone. + */ + removeTab(tabId: number): boolean; + refreshBadge(tabId: number): void; + refreshAllTabs(): void; +} + +export function createTabTracker(): TabTracker { + // Per-tab, per-frame, per-rule footprint counts. Each content script reports + // its own frame's tally grouped by rule id; the badge shows the cross-frame + // sum across all rules, and the popup renders per-rule entries derived from + // the same map. The reported counts are sanitized against the known rule ids + // by `ruleCountsSchema` before they reach `recordFrameRuleCounts`. + const tabRuleCounts = new Map>(); + + // Per-tab record of rule detections surfaced to the popup. One entry per + // kind per tab — both contributing rules are topFrameOnly and self-dedupe + // per document, so a single payload per kind is the natural shape. Cleared + // on top-level navigation and tab close, same posture as `tabRuleCounts`. A + // service-worker restart drops it; the popup briefly shows "Nothing flagged" + // on a page that did have detections until the next re-apply. Promote to + // chrome.storage.session if that becomes a problem. + const tabDetections = new Map>(); + + // Inputs to the per-tab "am I protected here?" signal (spec 0010 FR-2a): the + // global enforcement kill-switch and the per-site denylist. Seeded from + // storage at startup by `lifecycle.ts` and kept current by its subscriptions. + // Defaults match the fail-open posture — assume protection is on until + // storage resolves so we never flash an "off" badge on a tab that's actually + // protected. + let enforcementEnabled = true; + let denylist: readonly string[] = []; + + // Last top-frame URL seen per tab, so the background can evaluate the + // denylist for any tab without round-tripping the page. Captured from + // tabs.onUpdated / onActivated / a startup tabs.query; dropped on tab close. + const tabUrls = new Map(); + + // In-memory mirror of the tab-scoped recovery pause map (ADR-0019), so + // `refreshBadge` stays sync and the content bridge can resolve liveness + // without an async read. Hydrated from `tabPauseMap` at startup and kept + // current by its `onChanged` (both in `lifecycle.ts`). The authoritative + // store is `chrome.storage.session`; same cache posture as the two above. + const tabPauses = new Map(); + + // Memo of the icon/title appearance last applied per tab, keyed by + // `protectionAppearanceKey`, so we only call setIcon/setTitle when the + // on/off state actually flips. The numeric count badge still refreshes on + // every rule-count message. + const tabAppearanceKey = new Map(); + + // Cross-frame sum per rule for a tab. Frames may overlap on rule ids when + // the same rule fires in multiple frames (subframes, shadow trees) — we add + // their contributions. Returned object only contains rules with a non-zero + // footprint. + function summedCountsForTab(tabId: number): RuleCountMap { + const frames = tabRuleCounts.get(tabId); + const summed: RuleCountMap = {}; + if (!frames) { + return summed; + } + for (const frameCounts of frames.values()) { + for (const [ruleId, count] of Object.entries(frameCounts) as [ + RuleId, + number, + ][]) { + summed[ruleId] = (summed[ruleId] ?? 0) + count; + } + } + return summed; + } + + function totalForTab(tabId: number): number { + let total = 0; + for (const count of Object.values(summedCountsForTab(tabId))) { + total += count; + } + return total; + } + + function hasDetections(tabId: number): boolean { + return (tabDetections.get(tabId)?.size ?? 0) > 0; + } + + // Swap the toolbar icon + tooltip to match the tab's protection state, but + // only when it changed — the memo here is what lets `paintProtectionAppearance` + // stay a pure side effect. Per-tab action settings persist for the tab's + // lifetime, so each tab the user might look at must be painted at least once + // (see refreshAllTabs and the tab listeners in `lifecycle.ts`). + function applyProtectionAppearance( + tabId: number, + state: ReturnType, + ): void { + const key = protectionAppearanceKey(state); + if (tabAppearanceKey.get(tabId) === key) { + return; + } + tabAppearanceKey.set(tabId, key); + paintProtectionAppearance(tabId, state); + } + + function refreshBadge(tabId: number): void { + const state = computeProtectionState({ + enforcementEnabled, + tabUrl: tabUrls.get(tabId) ?? null, + denylist, + paused: isPauseActive(tabPauses.get(tabId) ?? null, Date.now()), + }); + applyProtectionAppearance(tabId, state); + if (state.off) { + paintProtectionOffBadge(tabId); + return; + } + const placeholderText = formatBadge(totalForTab(tabId)); + const detection = hasDetections(tabId); + // Detection without a placeholder count gets a single "!" so the badge + // still shows up. Otherwise keep the existing count text. + const text = placeholderText || (detection ? "!" : ""); + paintCountBadge(tabId, text, detection); + } + + // Re-evaluate every open tab's toolbar appearance. Used when a *global* + // input to the protection signal changes (enforcement toggle, denylist + // edit): per-tab icons persist, so every tab the user might switch to has to + // be repainted, not just the ones we're tracking counts for. + function refreshAllTabs(): void { + chrome.tabs + .query({}) + .then((tabs) => { + for (const tab of tabs) { + if (typeof tab.id !== "number") { + continue; + } + if (typeof tab.url === "string") { + tabUrls.set(tab.id, tab.url); + } + refreshBadge(tab.id); + } + }) + .catch(() => { + // noop — tabs.query rejection shouldn't surface. + }); + } + + function recordFrameRuleCounts( + tabId: number, + frameId: number, + counts: RuleCountMap, + ): void { + let frames = tabRuleCounts.get(tabId); + const hasAnyCount = Object.values(counts).some((value) => value > 0); + if (!hasAnyCount) { + if (!frames) { + refreshBadge(tabId); + return; + } + frames.delete(frameId); + if (frames.size === 0) { + tabRuleCounts.delete(tabId); + } + refreshBadge(tabId); + return; + } + if (!frames) { + frames = new Map(); + tabRuleCounts.set(tabId, frames); + } + frames.set(frameId, counts); + refreshBadge(tabId); + } + + function recordDetection(tabId: number, payload: DetectionPayload): void { + let entry = tabDetections.get(tabId); + if (!entry) { + entry = new Map(); + tabDetections.set(tabId, entry); + } + entry.set(payload.kind, payload); + refreshBadge(tabId); + } + + function clearTab(tabId: number): void { + tabRuleCounts.delete(tabId); + tabDetections.delete(tabId); + refreshBadge(tabId); + } + + function clearDetectionsOfKind(kind: DetectionKind): void { + for (const [tabId, entry] of tabDetections) { + if (entry.delete(kind)) { + if (entry.size === 0) { + tabDetections.delete(tabId); + } + refreshBadge(tabId); + } + } + } + + // Build the combined per-rule + detection snapshot the popup renders. Entries + // are sorted by count desc, breaking ties by rule id for a stable render + // across reopens. Detection-producing rules contribute the rich payload to + // `detections` and (when their landmark-stamped node carries a + // RULE_ATTR/HIDDEN_ATTR) also surface in `entries` via the per-frame reporter + // — the popup is free to render them in both surfaces, since the "Heads up" + // cards convey site-level context the bare count can't. + function buildRuleCountsResponse(tabId: number): GetTabRuleCountsResponse { + const summed = summedCountsForTab(tabId); + const entries: RuleCountEntry[] = []; + for (const [ruleId, count] of Object.entries(summed) as [ + RuleId, + number, + ][]) { + if (count > 0) { + entries.push({ ruleId, count }); + } + } + entries.sort((a, b) => { + if (b.count !== a.count) { + return b.count - a.count; + } + return a.ruleId.localeCompare(b.ruleId); + }); + const detectionEntries = tabDetections.get(tabId); + const detections = detectionEntries ? [...detectionEntries.values()] : []; + return { entries, detections }; + } + + function setTabUrl(tabId: number, url: string): void { + tabUrls.set(tabId, url); + } + + // Re-render every tab's toolbar state when global enforcement is toggled. + // When disabled, the rule engine reveals everything and the cached per-tab + // counts/detections are immediately stale, so drop them — neither the badge + // nor the popup should show a number for paused rules. refreshAllTabs then + // repaints every open tab so the greyed "off" icon reaches tabs we weren't + // counting. Also used for the startup seed, where the maps are empty and the + // clear is a harmless no-op. + function setEnforcementEnabled(enabled: boolean): void { + enforcementEnabled = enabled; + if (!enabled) { + tabRuleCounts.clear(); + tabDetections.clear(); + } + refreshAllTabs(); + } + + // The per-site denylist is the other input to the protection signal. A + // denylist edit can flip any open tab between protected and off, so repaint + // them all. (The content-side rule engine reacts to the same storage change + // independently; this is purely the toolbar's view of it.) + function setDenylist(next: readonly string[]): void { + denylist = next; + refreshAllTabs(); + } + + function setTabPause(tabId: number, pause: TabPause | undefined): void { + if (pause === undefined) { + tabPauses.delete(tabId); + } else { + tabPauses.set(tabId, pause); + } + } + + function getTabPause(tabId: number): TabPause | null { + return tabPauses.get(tabId) ?? null; + } + + function removeTab(tabId: number): boolean { + tabRuleCounts.delete(tabId); + tabDetections.delete(tabId); + tabUrls.delete(tabId); + tabAppearanceKey.delete(tabId); + return tabPauses.delete(tabId); + } + + return { + recordDetection, + recordFrameRuleCounts, + clearTab, + clearDetectionsOfKind, + buildRuleCountsResponse, + setTabUrl, + setEnforcementEnabled, + setDenylist, + setTabPause, + getTabPause, + removeTab, + refreshBadge, + refreshAllTabs, + }; +} From 157993d1138560286a982f359a12a73ffe9e556b Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Tue, 9 Jun 2026 21:27:13 -0400 Subject: [PATCH 2/3] Test: focused unit tests for the extracted tab tracker Cover the behaviors the refactor had to preserve: cross-frame badge summing, the "999+" cap, frame decrement on an empty re-report, the "!" detection badge and its color precedence, the sorted popup snapshot, and the removeTab/global-off paths. These were untestable while inlined in the service-worker entry bundle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../background/__tests__/tab-tracker.test.ts | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 extension/src/lib/background/__tests__/tab-tracker.test.ts diff --git a/extension/src/lib/background/__tests__/tab-tracker.test.ts b/extension/src/lib/background/__tests__/tab-tracker.test.ts new file mode 100644 index 0000000..22f2ecd --- /dev/null +++ b/extension/src/lib/background/__tests__/tab-tracker.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Focused tests for the per-tab tracker that `background.ts` constructs. These +// cover the cross-frame badge math, the detection badge, and the popup snapshot +// — the behaviors that previously lived inline in the (untestable) service-worker +// entry bundle and that the split into `lib/background/*` had to preserve. + +import { BADGE_COLOR_DEFAULT, BADGE_COLOR_DETECTION } from "../badge"; +import { createTabTracker } from "../tab-tracker"; + +const TAB = 1; + +// `jest-webextension-mock` stubs chrome.action.* / chrome.tabs.query as +// jest.fn() returning undefined, but production code chains `.then`/`.catch` on +// them (real chrome returns promises). Resolve them so the paint side effects +// don't throw; `refreshAllTabs` queries an empty tab list. +beforeEach(() => { + (chrome.tabs.query as jest.Mock).mockResolvedValue([]); + for (const setter of [ + chrome.action.setBadgeText, + chrome.action.setBadgeBackgroundColor, + chrome.action.setIcon, + chrome.action.setTitle, + ]) { + (setter as jest.Mock).mockResolvedValue(undefined); + } +}); + +// Most-recent value passed for `tabId` to a single-object-arg chrome.action +// setter, reading the typed field off the recorded call. +function lastValueForTab( + setter: typeof chrome.action.setBadgeText, + tabId: number, + field: "text" | "color", +): string | undefined { + const calls = (setter as jest.Mock).mock.calls as Array< + [{ tabId?: number; text?: string; color?: string }] + >; + for (let i = calls.length - 1; i >= 0; i--) { + if (calls[i][0].tabId === tabId) { + return calls[i][0][field]; + } + } + return undefined; +} + +function lastBadgeText(tabId: number): string | undefined { + return lastValueForTab(chrome.action.setBadgeText, tabId, "text"); +} + +function lastBadgeColor(tabId: number): string | undefined { + return lastValueForTab(chrome.action.setBadgeBackgroundColor, tabId, "color"); +} + +describe("createTabTracker — badge math", () => { + it("sums a rule's footprint across frames into the badge total", () => { + const tracker = createTabTracker(); + tracker.recordFrameRuleCounts(TAB, 0, { "pii-redact": 2 }); + tracker.recordFrameRuleCounts(TAB, 7, { + "pii-redact": 3, + "secrets-redact": 1, + }); + + // 2 + 3 + 1 = 6 across both frames and both rules. + expect(lastBadgeText(TAB)).toBe("6"); + expect(lastBadgeColor(TAB)).toBe(BADGE_COLOR_DEFAULT); + }); + + it("collapses totals past 999 to '999+'", () => { + const tracker = createTabTracker(); + tracker.recordFrameRuleCounts(TAB, 0, { "pii-redact": 1500 }); + expect(lastBadgeText(TAB)).toBe("999+"); + }); + + it("decrements a frame's contribution when it re-reports zero", () => { + const tracker = createTabTracker(); + tracker.recordFrameRuleCounts(TAB, 0, { "pii-redact": 4 }); + tracker.recordFrameRuleCounts(TAB, 1, { "pii-redact": 5 }); + expect(lastBadgeText(TAB)).toBe("9"); + + // Frame 1's document went away (pagehide → empty report). + tracker.recordFrameRuleCounts(TAB, 1, {}); + expect(lastBadgeText(TAB)).toBe("4"); + }); +}); + +describe("createTabTracker — detections", () => { + it("shows a '!' detection badge in the detection color when there is no count", () => { + const tracker = createTabTracker(); + tracker.recordDetection(TAB, { + kind: "webdriver-probe", + host: "example.com", + url: "https://example.com/", + }); + + expect(lastBadgeText(TAB)).toBe("!"); + expect(lastBadgeColor(TAB)).toBe(BADGE_COLOR_DETECTION); + }); + + it("keeps the count text but switches to the detection color when both are present", () => { + const tracker = createTabTracker(); + tracker.recordFrameRuleCounts(TAB, 0, { "pii-redact": 3 }); + tracker.recordDetection(TAB, { + kind: "webdriver-probe", + host: "example.com", + url: "https://example.com/", + }); + + expect(lastBadgeText(TAB)).toBe("3"); + expect(lastBadgeColor(TAB)).toBe(BADGE_COLOR_DETECTION); + }); + + it("clears only the detections of the toggled-off kind", () => { + const tracker = createTabTracker(); + tracker.recordDetection(TAB, { + kind: "webdriver-probe", + host: "example.com", + url: "https://example.com/", + }); + tracker.recordDetection(TAB, { + kind: "closed-shadow-root", + host: "example.com", + url: "https://example.com/", + }); + + tracker.clearDetectionsOfKind("webdriver-probe"); + + const { detections } = tracker.buildRuleCountsResponse(TAB); + expect(detections.map((d) => d.kind)).toEqual(["closed-shadow-root"]); + }); +}); + +describe("createTabTracker — popup snapshot", () => { + it("sorts entries by count desc, breaking ties by rule id, and folds in detections", () => { + const tracker = createTabTracker(); + tracker.recordFrameRuleCounts(TAB, 0, { + "secrets-redact": 2, + "pii-redact": 2, + "comments-redact": 9, + }); + tracker.recordDetection(TAB, { + kind: "webdriver-probe", + host: "example.com", + url: "https://example.com/", + }); + + const { entries, detections } = tracker.buildRuleCountsResponse(TAB); + expect(entries).toEqual([ + { ruleId: "comments-redact", count: 9 }, + // tie at 2 → lexicographic by rule id + { ruleId: "pii-redact", count: 2 }, + { ruleId: "secrets-redact", count: 2 }, + ]); + expect(detections).toHaveLength(1); + }); + + it("returns an empty snapshot for an untracked tab", () => { + const tracker = createTabTracker(); + expect(tracker.buildRuleCountsResponse(999)).toEqual({ + entries: [], + detections: [], + }); + }); +}); + +describe("createTabTracker — lifecycle", () => { + it("reports whether a removed tab had a cached recovery pause", () => { + const tracker = createTabTracker(); + expect(tracker.removeTab(TAB)).toBe(false); + + tracker.setTabPause(TAB, { + scope: "tab", + expiresAt: null, + }); + expect(tracker.removeTab(TAB)).toBe(true); + // Second removal — cache already cleared. + expect(tracker.removeTab(TAB)).toBe(false); + }); + + it("drops cached counts and paints the 'off' badge when enforcement goes global-off", () => { + const tracker = createTabTracker(); + tracker.recordFrameRuleCounts(TAB, 0, { "pii-redact": 5 }); + expect(lastBadgeText(TAB)).toBe("5"); + + tracker.setEnforcementEnabled(false); + // The tracker repaints this tab directly via refreshBadge; the global + // refreshAllTabs (chrome.tabs.query → []) covers tabs we aren't counting. + tracker.refreshBadge(TAB); + expect(lastBadgeText(TAB)).toBe("off"); + + // Re-enabling should start from a clean count (the stale 5 was dropped). + tracker.setEnforcementEnabled(true); + tracker.refreshBadge(TAB); + expect(lastBadgeText(TAB)).toBe(""); + }); +}); From 55d5a4f4746bff7d7a16d39cb357df3424a26a86 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Tue, 9 Jun 2026 21:32:30 -0400 Subject: [PATCH 3/3] Fix: typecheck/lint errors in tab-tracker test under strict tsconfig CI's tsconfig.test.json typecheck (noUncheckedIndexedAccess + the precise chrome.action signatures) caught what `bun run check` alone missed: the mock-call helper was typed against `setBadgeText` (rejecting `setBadgeBackgroundColor`) and indexed `mock.calls` without guarding the array access. Type the helper param as `jest.Mock` and guard the index. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../background/__tests__/tab-tracker.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/extension/src/lib/background/__tests__/tab-tracker.test.ts b/extension/src/lib/background/__tests__/tab-tracker.test.ts index 22f2ecd..849f467 100644 --- a/extension/src/lib/background/__tests__/tab-tracker.test.ts +++ b/extension/src/lib/background/__tests__/tab-tracker.test.ts @@ -30,27 +30,36 @@ beforeEach(() => { // Most-recent value passed for `tabId` to a single-object-arg chrome.action // setter, reading the typed field off the recorded call. function lastValueForTab( - setter: typeof chrome.action.setBadgeText, + setter: jest.Mock, tabId: number, field: "text" | "color", ): string | undefined { - const calls = (setter as jest.Mock).mock.calls as Array< + const calls = setter.mock.calls as Array< [{ tabId?: number; text?: string; color?: string }] >; for (let i = calls.length - 1; i >= 0; i--) { - if (calls[i][0].tabId === tabId) { - return calls[i][0][field]; + const details = calls[i]?.[0]; + if (details?.tabId === tabId) { + return details[field]; } } return undefined; } function lastBadgeText(tabId: number): string | undefined { - return lastValueForTab(chrome.action.setBadgeText, tabId, "text"); + return lastValueForTab( + chrome.action.setBadgeText as jest.Mock, + tabId, + "text", + ); } function lastBadgeColor(tabId: number): string | undefined { - return lastValueForTab(chrome.action.setBadgeBackgroundColor, tabId, "color"); + return lastValueForTab( + chrome.action.setBadgeBackgroundColor as jest.Mock, + tabId, + "color", + ); } describe("createTabTracker — badge math", () => {