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
142 changes: 130 additions & 12 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,44 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

import type {
DetectionKind,
DetectionPayload,
GetTabDetectionsRequest,
GetTabDetectionsResponse,
RuleDetectionMessage,
} from "./lib/detection-messages";
import { subscribeEnforcementEnabled } from "./lib/enforcement";
import { startClassifyPortListener } from "./lib/llm-background";
import { ruleStatesStorage } from "./lib/storage";
import { startWebdriverProbeRegistration } from "./lib/webdriver-probe-registration";

// Per-tab, per-frame placeholder counts. Each content script reports its own
// frame's tally; the badge shows the sum across frames for that tab.
const tabCounts = new Map<number, Map<number, number>>();

// 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
// `tabCounts`. 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<number, Map<DetectionKind, DetectionPayload>>();

// 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",
} as const satisfies Record<DetectionKind, string>;

// Pleasant blue — clearly an extension affordance, not a warning/error.
const BADGE_COLOR = "#2563eb";
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";

function totalForTab(tabId: number): number {
const frames = tabCounts.get(tabId);
Expand All @@ -34,17 +62,25 @@ function formatBadge(total: number): string {
return String(total);
}

function setBadge(tabId: number, total: number): void {
const text = formatBadge(total);
function hasDetections(tabId: number): boolean {
return (tabDetections.get(tabId)?.size ?? 0) > 0;
}

function refreshBadge(tabId: number): void {
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) {
chrome.action
.setBadgeBackgroundColor({ tabId, color: BADGE_COLOR })
.catch(() => {
// noop
});
const color = detection ? BADGE_COLOR_DETECTION : BADGE_COLOR_DEFAULT;
chrome.action.setBadgeBackgroundColor({ tabId, color }).catch(() => {
// noop
});
}
}

Expand All @@ -62,16 +98,39 @@ function recordFrameCount(tabId: number, frameId: number, count: number): void {
} else {
frames.set(frameId, count);
}
setBadge(tabId, totalForTab(tabId));
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 {
tabCounts.delete(tabId);
setBadge(tabId, 0);
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) => {
tabCounts.delete(tabId);
tabDetections.delete(tabId);
});

// On a top-level navigation, drop stale per-frame counts so the new document
Expand All @@ -90,11 +149,49 @@ subscribeEnforcementEnabled((enabled) => {
if (enabled) {
return;
}
for (const tabId of tabCounts.keys()) {
clearTab(tabId);
// 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>([
...tabCounts.keys(),
...tabDetections.keys(),
]);
tabCounts.clear();
tabDetections.clear();
for (const tabId of affected) {
refreshBadge(tabId);
}
});

// 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<string, boolean> | 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 };
});

chrome.runtime.onMessage.addListener(
(rawMessage: unknown, sender, sendResponse) => {
if (!rawMessage || typeof rawMessage !== "object") {
Expand Down Expand Up @@ -124,6 +221,27 @@ chrome.runtime.onMessage.addListener(
return undefined;
}

if (message.type === "rule-detection") {
const tabId = sender.tab?.id;
if (typeof tabId === "number") {
recordDetection(tabId, (message as RuleDetectionMessage).payload);
}
return undefined;
}

if (message.type === "get-tab-detections") {
const request = message as unknown as GetTabDetectionsRequest;
const entry =
typeof request.tabId === "number"
? tabDetections.get(request.tabId)
: undefined;
const response: GetTabDetectionsResponse = {
detections: entry ? [...entry.values()] : [],
};
sendResponse(response);
return undefined;
}

return undefined;
},
);
Expand Down
48 changes: 48 additions & 0 deletions extension/src/lib/detection-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

// Shared payload + message types for surfacing rule detections to the
// popup. Rules that hide their findings in the a11y tree (sr-only
// landmarks) emit a `rule-detection` message at the same moment they
// stamp the landmark; the background keeps a per-tab record so the popup
// can render a human-visible "Detected on this page" list. Imported by
// the rule modules (content world), the background service worker, and
// the popup — keep this file dependency-free so it stays trivially
// bundleable into each of those three entry points.

import type { RoachMotelDifficulty } from "../rules/site-data.generated";

export type DetectionKind = "roach-motel" | "webdriver-probe";

export interface RoachMotelDetectionPayload {
kind: "roach-motel";
host: string;
url: string;
difficulty: RoachMotelDifficulty;
cancellationUrl: string | null;
source: "curated" | "justdeleteme";
}

export interface WebdriverProbeDetectionPayload {
kind: "webdriver-probe";
host: string;
url: string;
}

export type DetectionPayload =
| RoachMotelDetectionPayload
| WebdriverProbeDetectionPayload;

export interface RuleDetectionMessage {
type: "rule-detection";
payload: DetectionPayload;
}

export interface GetTabDetectionsRequest {
type: "get-tab-detections";
tabId: number;
}

export interface GetTabDetectionsResponse {
detections: DetectionPayload[];
}
60 changes: 60 additions & 0 deletions extension/src/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,66 @@
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.detections {
margin: 0 0 12px;
}
.detections__heading {
margin: 0 0 6px;
padding-bottom: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
border-bottom: 1px solid #e4e4e7;
}
.detections__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.detection {
padding: 8px 10px;
border: 1px solid #fde68a;
background: #fffbeb;
border-radius: 6px;
font-size: 12px;
}
.detection strong {
display: block;
font-weight: 600;
color: #92400e;
}
.detection__host {
margin: 2px 0 0;
font-size: 11px;
color: #555;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.detection__detail {
margin: 4px 0 0;
font-size: 11px;
color: #666;
line-height: 1.3;
}
.detection__cancel {
display: inline-block;
margin: 4px 0 0;
font-size: 11px;
color: #2563eb;
text-decoration: none;
}
.detection__cancel:hover {
text-decoration: underline;
}
.detection__source {
margin: 4px 0 0;
font-size: 10px;
color: #888;
}
.rule-groups {
display: flex;
flex-direction: column;
Expand Down
70 changes: 70 additions & 0 deletions extension/src/popup/DetectionsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

import type { DetectionPayload } from "../lib/detection-messages";
import { useTabDetections } from "./use-tab-detections";

const DIFFICULTY_LABEL: Record<"hard" | "very-hard" | "impossible", string> = {
hard: "hard",
"very-hard": "very hard",
impossible: "effectively impossible",
};

const SOURCE_LABEL: Record<"curated" | "justdeleteme", string> = {
curated: "FTC enforcement / consumer-press list",
justdeleteme: "JustDeleteMe directory",
};

export function DetectionsSection() {
const detections = useTabDetections();
if (detections === null || detections.length === 0) {
return null;
}
return (
<section className="detections">
<h2 className="detections__heading">Heads up</h2>
<ul className="detections__list">
{detections.map((detection) => (
<DetectionItem key={detection.kind} detection={detection} />
))}
</ul>
</section>
);
}

function DetectionItem({ detection }: { detection: DetectionPayload }) {
if (detection.kind === "roach-motel") {
return (
<li className="detection">
<strong>Hard to cancel</strong>
<p className="detection__host">{detection.host}</p>
<p className="detection__detail">
Cancellation is {DIFFICULTY_LABEL[detection.difficulty]} on this site.
</p>
{detection.cancellationUrl !== null && (
<a
className="detection__cancel"
href={detection.cancellationUrl}
target="_blank"
rel="noopener noreferrer"
>
How to cancel
</a>
)}
<p className="detection__source">
Source: {SOURCE_LABEL[detection.source]}
</p>
</li>
);
}
return (
<li className="detection">
<strong>navigator.webdriver read</strong>
<p className="detection__host">{detection.host}</p>
<p className="detection__detail">
This site can distinguish AI-agent traffic from human traffic and may
serve different content to agents.
</p>
</li>
);
}
Loading
Loading