Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/src/content/docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added extension/icons/icon-off-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added extension/icons/icon-off-24.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added extension/icons/icon-off-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions extension/icons/icon-off.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
56 changes: 34 additions & 22 deletions extension/scripts/build-icons.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,53 @@
// 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";
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}`);
}
190 changes: 171 additions & 19 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -53,6 +67,26 @@ const KNOWN_RULE_IDS = new Set<string>(RULE_IDS);
// problem.
const tabDetections = new Map<number, Map<DetectionKind, DetectionPayload>>();

// 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<number, string>();

// 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<number, string>();

// 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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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<number>([
...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,
Expand Down
Loading
Loading