diff --git a/docs/src/content/docs/install.md b/docs/src/content/docs/install.md index 7177949..9b7cce7 100644 --- a/docs/src/content/docs/install.md +++ b/docs/src/content/docs/install.md @@ -59,6 +59,22 @@ Build output is written to `extension/dist/`. The extension is now active. Reload any open tabs to pick up the content script. +### Reading the toolbar icon + +The toolbar icon answers "am I protected on this tab?" at a glance, so you don't +have to open the popup: + +- **Blue shield, no badge** — protected, nothing scrubbed on this page yet. +- **Blue shield with a number** — protected; the number is how many things the + shield acted on across all frames of the tab. A count over 999 shows as + `999+`. +- **Amber badge** — a site-level detection worth opening the popup for (for + example, a hard-to-cancel subscription). When there's a detection but no + count, the badge shows `!`. +- **Greyed shield with an `off` badge** — enforcement is paused on this tab, + either because you turned the master switch off (every tab) or because the + site is on your *Disable on this site* list. Hover the icon to see which. + ## Customizing defaults at build time Which rules ship on by default is enumerated in diff --git a/extension/icons/icon-off-16.png b/extension/icons/icon-off-16.png new file mode 100644 index 0000000..cf1fdf0 Binary files /dev/null and b/extension/icons/icon-off-16.png differ diff --git a/extension/icons/icon-off-24.png b/extension/icons/icon-off-24.png new file mode 100644 index 0000000..3444e92 Binary files /dev/null and b/extension/icons/icon-off-24.png differ diff --git a/extension/icons/icon-off-32.png b/extension/icons/icon-off-32.png new file mode 100644 index 0000000..d6a6fb1 Binary files /dev/null and b/extension/icons/icon-off-32.png differ diff --git a/extension/icons/icon-off.svg b/extension/icons/icon-off.svg new file mode 100644 index 0000000..eb4f3e4 --- /dev/null +++ b/extension/icons/icon-off.svg @@ -0,0 +1,5 @@ + + Codestin Search App + + + diff --git a/extension/scripts/build-icons.ts b/extension/scripts/build-icons.ts index 28242e2..d4c2913 100644 --- a/extension/scripts/build-icons.ts +++ b/extension/scripts/build-icons.ts @@ -1,11 +1,11 @@ // Copyright (c) 2026 PixieBrix, Inc. // Licensed under PolyForm Shield 1.0.0 — see LICENSE. -// Renders extension/icons/icon.svg to PNGs at the sizes the manifest and the +// Renders extension/icons/*.svg to PNGs at the sizes the manifest and the // Chrome Web Store listing need. Generated files are committed alongside the -// SVG so contributors without the renderer dep can still build the extension. +// SVGs so contributors without the renderer dep can still build the extension. // -// Run manually with `bun run build-icons` after editing icon.svg. +// Run manually with `bun run build-icons` after editing a source SVG. import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -13,29 +13,41 @@ import { Resvg } from "@resvg/resvg-js"; const ROOT = join(import.meta.dir, ".."); const ICONS = join(ROOT, "icons"); -const SOURCE = join(ICONS, "icon.svg"); -// 16/24/32 cover the toolbar action; 48 is Chrome's extensions page tile; -// 128 is required by the Chrome Web Store store-listing icon. -const SIZES = [16, 24, 32, 48, 128]; +// Each source renders to `${prefix}-${size}.png`. +// - icon.svg: the blue shield (manifest default_icon + Web Store listing). +// 16/24/32 cover the toolbar action; 48 is Chrome's extensions page +// tile; 128 is required by the Chrome Web Store store-listing icon. +// - icon-off.svg: the greyed "enforcement off" variant the background +// swaps in via chrome.action.setIcon (spec 0010 FR-2a). Toolbar sizes +// only — it is never used on the extensions page or the store listing. +const SOURCES = [ + { svg: "icon.svg", prefix: "icon", sizes: [16, 24, 32, 48, 128] }, + { svg: "icon-off.svg", prefix: "icon-off", sizes: [16, 24, 32] }, +] as const; -export function generateIcons(): void { - if (!existsSync(SOURCE)) { - throw new Error(`Missing source SVG at ${SOURCE}`); - } - const svg = readFileSync(SOURCE); - - for (const size of SIZES) { - const png = new Resvg(svg, { - fitTo: { mode: "width", value: size }, - }) - .render() - .asPng(); - writeFileSync(join(ICONS, `icon-${size}.png`), png); +export function generateIcons(): number { + let written = 0; + for (const { svg: file, prefix, sizes } of SOURCES) { + const source = join(ICONS, file); + if (!existsSync(source)) { + throw new Error(`Missing source SVG at ${source}`); + } + const svg = readFileSync(source); + for (const size of sizes) { + const png = new Resvg(svg, { + fitTo: { mode: "width", value: size }, + }) + .render() + .asPng(); + writeFileSync(join(ICONS, `${prefix}-${size}.png`), png); + written += 1; + } } + return written; } if (import.meta.main) { - generateIcons(); - console.log(`Generated ${SIZES.length} icon PNGs in ${ICONS}`); + const count = generateIcons(); + console.log(`Generated ${count} icon PNGs in ${ICONS}`); } diff --git a/extension/src/background.ts b/extension/src/background.ts index 67cb8c8..a852798 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -24,12 +24,26 @@ import type { RuleDetectionMessage, } from "./lib/detection-messages"; import { startDumpTraceBridgeRegistration } from "./lib/dump-trace-bridge-registration"; -import { subscribeEnforcementEnabled } from "./lib/enforcement"; +import { + getEnforcementEnabled, + subscribeEnforcementEnabled, +} from "./lib/enforcement"; import { startClassifyPortListener } from "./lib/llm-background"; import { log } from "./lib/log"; import { startShadowRootProbeRegistration } from "./lib/shadow-root-probe-registration"; import { installShadowRootProbe } from "./lib/shadow-root-probe-source"; +import { siteDenylistStorage } from "./lib/site-denylist"; import { ruleStatesStorage } from "./lib/storage"; +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 { startWebdriverProbeRegistration } from "./lib/webdriver-probe-registration"; import { installProbe } from "./lib/webdriver-probe-source"; import type { RuleId } from "./rules/rule-metadata"; @@ -53,6 +67,26 @@ const KNOWN_RULE_IDS = new Set(RULE_IDS); // 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 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(); + +// 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 = { @@ -112,6 +146,29 @@ function hasDetections(tabId: number): boolean { } function refreshBadge(tabId: number): void { + const state = computeProtectionState({ + enforcementEnabled, + tabUrl: tabUrls.get(tabId) ?? null, + denylist, + }); + 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 @@ -129,6 +186,54 @@ function refreshBadge(tabId: number): void { } } +// 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, @@ -186,6 +291,8 @@ function clearDetectionsOfKind(kind: DetectionKind): void { chrome.tabs.onRemoved.addListener((tabId) => { tabRuleCounts.delete(tabId); tabDetections.delete(tabId); + tabUrls.delete(tabId); + tabAppearanceKey.delete(tabId); // Fire-and-forget — IDB write may outlive the listener context. void clearDebugTraceTab(tabId).catch(() => { // noop @@ -199,7 +306,20 @@ chrome.tabs.onRemoved.addListener((tabId) => { // 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; } clearTab(tabId); @@ -221,26 +341,58 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { })(); }); -// Re-render every tab's badge when enforcement is toggled. When disabled, the -// rule engine reveals everything in each frame, which will eventually push -// zero counts back — but doing this synchronously keeps the badge from -// looking stale for the duration of those mutation observer cycles. +// 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) => { - if (enabled) { - return; - } - // Snapshot the union of tabs we're tracking before clearing — the badge - // for each needs to refresh, and we don't want to iterate a map we're - // mutating. - const affected = new Set([ - ...tabRuleCounts.keys(), - ...tabDetections.keys(), - ]); - tabRuleCounts.clear(); - tabDetections.clear(); - for (const tabId of affected) { - refreshBadge(tabId); + 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(); }); // When a user disables one of the detection-producing rules mid-session, diff --git a/extension/src/lib/__tests__/toolbar-protection.test.ts b/extension/src/lib/__tests__/toolbar-protection.test.ts new file mode 100644 index 0000000..44d4ff4 --- /dev/null +++ b/extension/src/lib/__tests__/toolbar-protection.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +import { + ACTION_ICON_OFF, + ACTION_ICON_ON, + actionTitle, + computeProtectionState, + PROTECTION_OFF_BADGE_COLOR, + PROTECTION_OFF_BADGE_TEXT, + protectionAppearanceKey, +} from "../toolbar-protection"; + +const DENYLIST = ["https://denied.test/*"]; + +describe("computeProtectionState", () => { + it("is off (global) when enforcement is disabled, regardless of URL", () => { + expect( + computeProtectionState({ + enforcementEnabled: false, + tabUrl: "https://allowed.test/page", + denylist: DENYLIST, + }), + ).toEqual({ off: true, reason: "global" }); + }); + + it("global off wins even on a denylisted URL", () => { + expect( + computeProtectionState({ + enforcementEnabled: false, + tabUrl: "https://denied.test/page", + denylist: DENYLIST, + }), + ).toEqual({ off: true, reason: "global" }); + }); + + it("is off (site) when enforcement is on but the URL is denylisted", () => { + expect( + computeProtectionState({ + enforcementEnabled: true, + tabUrl: "https://denied.test/checkout", + denylist: DENYLIST, + }), + ).toEqual({ off: true, reason: "site" }); + }); + + it("is on when enforcement is on and the URL is not denylisted", () => { + expect( + computeProtectionState({ + enforcementEnabled: true, + tabUrl: "https://allowed.test/page", + denylist: DENYLIST, + }), + ).toEqual({ off: false }); + }); + + it("fails open (on) when the tab URL is unknown", () => { + expect( + computeProtectionState({ + enforcementEnabled: true, + tabUrl: null, + denylist: DENYLIST, + }), + ).toEqual({ off: false }); + }); + + it("is on when the denylist is empty", () => { + expect( + computeProtectionState({ + enforcementEnabled: true, + tabUrl: "https://anything.test/", + denylist: [], + }), + ).toEqual({ off: false }); + }); +}); + +describe("actionTitle", () => { + it("is the plain product name when protected", () => { + expect(actionTitle({ off: false })).toBe("Agent Browser Shield"); + }); + + it("names the global kill-switch when off for all tabs", () => { + expect(actionTitle({ off: true, reason: "global" })).toContain("all tabs"); + }); + + it("names the per-site scope when off on one site", () => { + expect(actionTitle({ off: true, reason: "site" })).toContain( + "on this site", + ); + }); +}); + +describe("protectionAppearanceKey", () => { + it("collapses the protected state to a single key", () => { + expect(protectionAppearanceKey({ off: false })).toBe("on"); + }); + + it("distinguishes the two off reasons so a global→site flip repaints", () => { + expect(protectionAppearanceKey({ off: true, reason: "global" })).not.toBe( + protectionAppearanceKey({ off: true, reason: "site" }), + ); + }); +}); + +describe("appearance constants", () => { + it("exposes the toolbar sizes for both icon variants", () => { + for (const size of [16, 24, 32]) { + expect(ACTION_ICON_ON[size]).toMatch(/icon-\d+\.png$/); + expect(ACTION_ICON_OFF[size]).toMatch(/icon-off-\d+\.png$/); + } + }); + + it("uses a neutral (non-amber) off-badge color", () => { + expect(PROTECTION_OFF_BADGE_TEXT).toBe("off"); + // Distinct from the amber detection color so the badge meanings don't + // collide. + expect(PROTECTION_OFF_BADGE_COLOR).not.toBe("#f59e0b"); + }); +}); diff --git a/extension/src/lib/toolbar-protection.ts b/extension/src/lib/toolbar-protection.ts new file mode 100644 index 0000000..da651d9 --- /dev/null +++ b/extension/src/lib/toolbar-protection.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Pure inputs → toolbar-appearance logic for the "am I protected here?" +// signal (spec 0010 FR-2a). The background worker owns the chrome.action +// side effects; everything decision-shaped lives here so it's unit-testable +// without a chrome mock. +// +// A tab is "off" when the global enforcement kill-switch is off (every tab) +// or when the active tab's top-frame URL matches the per-site denylist +// (ADR-0018) — the same `globalEnforcement && !matchesDenylist(url)` rule +// the content-side `effective-enforcement.ts` computes, recomputed here so +// the toolbar can reflect it without a round-trip to the page. + +import { matchesDenylist } from "./site-denylist"; + +export type ProtectionState = + | { readonly off: false } + | { readonly off: true; readonly reason: "global" | "site" }; + +// `tabUrl` is null when the background hasn't yet learned the tab's URL +// (e.g. a tab open before the service worker started, before the first +// tabs.get / onUpdated). Treat unknown as protected — fail open, matching +// the rule engine, rather than flashing an "off" badge on a tab whose state +// we can't actually determine. +export function computeProtectionState(input: { + enforcementEnabled: boolean; + tabUrl: string | null; + denylist: readonly string[]; +}): ProtectionState { + if (!input.enforcementEnabled) { + return { off: true, reason: "global" }; + } + if (input.tabUrl !== null && matchesDenylist(input.tabUrl, input.denylist)) { + return { off: true, reason: "site" }; + } + return { off: false }; +} + +// Toolbar action icon variants, as chrome.action.setIcon expects them +// (paths relative to the extension root). The "on" set is the manifest +// default_icon (blue shield); the "off" set is the desaturated grey shield +// rendered from icons/icon-off.svg by scripts/build-icons.ts. +export const ACTION_ICON_ON: Record = { + 16: "icons/icon-16.png", + 24: "icons/icon-24.png", + 32: "icons/icon-32.png", +}; + +export const ACTION_ICON_OFF: Record = { + 16: "icons/icon-off-16.png", + 24: "icons/icon-off-24.png", + 32: "icons/icon-off-32.png", +}; + +// Neutral slate for the "off" badge — deliberately NOT the amber detection +// color (`#f59e0b`) so the three badge meanings stay distinct: blue = an +// activity count, amber = a detection worth opening the popup for, grey = +// the shield is inactive on this tab. Grey also pairs with the greyed-out +// icon variant; "disabled" reads as desaturated, not as a warning. +export const PROTECTION_OFF_BADGE_TEXT = "off"; +export const PROTECTION_OFF_BADGE_COLOR = "#6b7280"; + +// Toolbar tooltip. Differentiates global vs per-site so hovering the action +// answers *why* the shield is off without opening the popup. +export function actionTitle(state: ProtectionState): string { + if (!state.off) { + return "Agent Browser Shield"; + } + return state.reason === "global" + ? "Agent Browser Shield — enforcement off (all tabs)" + : "Agent Browser Shield — enforcement off on this site"; +} + +// Stable key for the icon/title appearance so the background only issues +// setIcon/setTitle when the on/off state actually flips — the numeric count +// badge still refreshes on every rule-count message. +export function protectionAppearanceKey(state: ProtectionState): string { + return state.off ? `off:${state.reason}` : "on"; +} diff --git a/specs/0010-extension-ui-and-controls.md b/specs/0010-extension-ui-and-controls.md index 06ec1bb..ab11628 100644 --- a/specs/0010-extension-ui-and-controls.md +++ b/specs/0010-extension-ui-and-controls.md @@ -28,6 +28,10 @@ all-or-nothing, and users pick "nothing." - As a **person who wants to see what the shield did on this page**, I want a badge with a per-tab activity count and a popup listing per-rule footprint, so that I can verify protection is working at a glance. +- As a **person who can't remember whether I paused the shield here**, I want + the toolbar icon itself to go grey with an *off* badge when enforcement is off + — globally or just on this site — so that "am I protected on this tab?" is + answerable without opening the popup. - As a **person hitting a false positive**, I want a one-click enforcement pause that disables every rule for every tab without losing my per-rule selections, so that I can finish my task and restore protection later. @@ -67,6 +71,18 @@ all-or-nothing, and users pick "nothing." worth opening the popup for (roach-motel-annotate, webdriver-probe-annotate, closed-shadow-root-annotate). When a detection is present without an activity count, the badge text falls back to `!` so the badge stays visible. +- **FR-2a.** The toolbar action reflects per-tab **protection state** so a + protected page and an unprotected page never look identical. A tab is *off* + when global enforcement is off (FR-5) **or** the active tab's top-frame URL + matches the per-site denylist (FR-7c). On an *off* tab the action shows the + greyed icon variant (`icons/icon-off-*.png`), an `off` badge in neutral grey + (`#6b7280` — deliberately not the amber detection color, so the three badge + meanings stay distinct), and a tooltip naming the scope (*"enforcement off + (all tabs)"* vs *"enforcement off on this site"*). The signal is recomputed + when enforcement toggles, the denylist changes, or the tab navigates; the + icon/tooltip are only re-issued when the on/off state flips, while the count + badge still refreshes on every rule-count message. The greyed icon is the + primary signal; the badge and tooltip reinforce it. - **FR-3.** Top-level navigation drops stale per-frame counts so the new document starts from zero; content scripts in the new document report fresh numbers as rules run. @@ -225,6 +241,13 @@ all-or-nothing, and users pick "nothing." - FR-1, FR-2, FR-3: `extension/src/background.ts` (`refreshBadge`, `recordFrameRuleCounts`, `recordDetection`, `clearTab`). +- FR-2a: `extension/src/lib/toolbar-protection.ts` (pure + `computeProtectionState` / `actionTitle` / appearance constants, tested in + `extension/src/lib/__tests__/toolbar-protection.test.ts`), + `extension/src/background.ts` (`applyProtectionAppearance`, `refreshAllTabs`, + the `tabUrls` cache, and the enforcement/denylist subscriptions that drive + it), `extension/icons/icon-off.svg` (rendered to PNGs by + `extension/scripts/build-icons.ts`). - FR-4, FR-5, FR-6, FR-7: `extension/src/popup/Popup.tsx`, `extension/src/popup/PerRuleCountsSection.tsx`, `extension/src/popup/DetectionsSection.tsx`,