mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 00:26:41 +03:00
build:extension: ship the wasm-core extension shell and vendor its detector from the detector release
`bun run build:extension` was broken on this branch: it still imported the deleted JS engine (cli/engine/registry/antipatterns.mjs, scripts/lib/browser-detector-bundle.js). The shipped shell now matches the new design. The content script only snapshots the DOM; an extension-owned offscreen document runs the WebAssembly rule core over that snapshot, so the scanned page's CSP no longer matters. That replaces the old approach of injecting a JS rules bundle into the page. New files: extension/offscreen/offscreen.html, plus the "offscreen" permission and a 'wasm-unsafe-eval' extension_pages CSP in the manifest. The manifest version stays at 1.3.3. The shell's own manifest carried 2.0.0; feature branches never bump versions, so the bump is a release step. The five generated detector pieces (core.js, core_bg.wasm, snapshot.js, overlay.js, antipatterns.json) are vendored at build time into the gitignored extension/detector/ by the new scripts/lib/detector-bundle.mjs, which resolves them the same three ways crates/core/build.rs resolves the native archive: IMPECCABLE_DETECTOR_LIB/extension-detector/, the ~/.impeccable/detector/<DETECTOR_VERSION>/ cache, then a checksum-verified download of detector-browser-bundle.zip from the detector release. antipatterns.json is no longer regenerated here. The zip packaging is unchanged. The Firefox variant still builds so `web-ext lint` keeps covering the shared shell, but it cannot scan: Gecko has no chrome.offscreen API. The build prints a one-line warning saying so. Also here: a referenced-path check that fails the build when the manifest or the service worker points at a file that is not in extension/, a resolver unit test wired into the core suite, and the detector rule count in the READMEs synced to the 61 the vendored registry carries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
836516a7a0
commit
f2c9aeab5b
@@ -1,9 +1,20 @@
|
||||
/**
|
||||
* Impeccable DevTools Extension - Content Script
|
||||
*
|
||||
* Bridges between the extension messaging system and the page-context detector.
|
||||
* The detector must run in page context (not isolated world) because it needs
|
||||
* access to getComputedStyle, document.styleSheets.cssRules, etc.
|
||||
* Runs in the tab's isolated world with two generated companions injected
|
||||
* before it (detector/snapshot.js, detector/overlay.js). A scan is:
|
||||
*
|
||||
* 1. capture a snapshot of the page (measurement only, snapshot.js);
|
||||
* 2. hand it to the offscreen document, where the WebAssembly rule core
|
||||
* runs the same rules the in-page bundle runs (detector/core.js); the
|
||||
* core asks back for what a snapshot cannot hold — hit tests, image
|
||||
* pixels — and this script answers from the live page;
|
||||
* 3. draw the findings it returns (overlay.js) and report them to the
|
||||
* service worker exactly as before (`findings`, `overlays-toggled`).
|
||||
*
|
||||
* No rule logic lives in this world, so the page's Content-Security-Policy
|
||||
* (which gates WebAssembly in every world an extension can reach) does not
|
||||
* matter: github.com scans like any other page.
|
||||
*
|
||||
* Wrapped in an IIFE with an idempotency flag so re-injection (via
|
||||
* chrome.scripting.executeScript) is a no-op and doesn't cause:
|
||||
@@ -14,61 +25,266 @@
|
||||
if (window.__IMPECCABLE_CS_LOADED__) return;
|
||||
window.__IMPECCABLE_CS_LOADED__ = true;
|
||||
|
||||
let injected = false;
|
||||
let pendingScan = false;
|
||||
const OFFSCREEN = 'impeccable-offscreen';
|
||||
const snapshot = window.__impeccableSnapshot;
|
||||
const createOverlay = window.__impeccableCreateOverlay;
|
||||
// One session id per content-script instance: a re-scan supersedes the
|
||||
// previous run of the same tab in the offscreen document.
|
||||
const sessionId = `tab-${Math.random().toString(36).slice(2)}`;
|
||||
const candidateSessionId = `${sessionId}-lazy`;
|
||||
|
||||
let injected = false; // a scan has produced findings on this page
|
||||
let scanConfig = null;
|
||||
let ui = null;
|
||||
let uiPromise = null;
|
||||
let scanGeneration = 0;
|
||||
let capture = null; // the last capture (elements by snapshot id)
|
||||
let visualIO = null;
|
||||
const groupMap = new Map(); // Element -> findings[]
|
||||
let lazyObserver = null;
|
||||
const lazyPending = new WeakMap();
|
||||
const lazyResolving = new WeakSet();
|
||||
// Measurements of the last scan (snapshot size, core time, ask rounds),
|
||||
// reported alongside the findings for diagnostics.
|
||||
let lastStats = null;
|
||||
|
||||
const send = (msg) => chrome.runtime.sendMessage({ target: OFFSCREEN, ...msg });
|
||||
|
||||
function reportError(err) {
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'detector-error',
|
||||
message: err?.message || String(err),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function ensureUI() {
|
||||
if (ui) return ui;
|
||||
if (!uiPromise) {
|
||||
uiPromise = send({ action: 'antipatterns' }).then((r) => {
|
||||
if (!r || r.error) throw new Error(r?.error || 'rule registry unavailable');
|
||||
ui = createOverlay({ extensionMode: true, antipatterns: r.antipatterns });
|
||||
return ui;
|
||||
});
|
||||
uiPromise.catch(() => { uiPromise = null; });
|
||||
}
|
||||
return uiPromise;
|
||||
}
|
||||
|
||||
// Answer one of the core's questions from the live page.
|
||||
async function answer(askMsg) {
|
||||
if (askMsg.hitTests) return snapshot.answer(askMsg, capture);
|
||||
const io = askMsg.io;
|
||||
if (io && io.kind === 'loadImage') return visualIO.loadImage(io.src);
|
||||
if (io && io.kind === 'readPixel') return visualIO.readPixel(io.ref, io.plan, io.px, io.py);
|
||||
return null;
|
||||
}
|
||||
|
||||
function elementOf(id) {
|
||||
return capture ? capture.elements[id] || null : null;
|
||||
}
|
||||
|
||||
function setGroups(groups) {
|
||||
groupMap.clear();
|
||||
for (const g of groups) {
|
||||
const el = elementOf(g.el);
|
||||
if (el) groupMap.set(el, g.findings);
|
||||
}
|
||||
}
|
||||
|
||||
function groupsForCapture(cap) {
|
||||
const out = [];
|
||||
for (const [el, findings] of groupMap) {
|
||||
const id = snapshot.idOf(el, cap);
|
||||
if (id) out.push({ el: id, findings });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function postFindings(serialized) {
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'findings',
|
||||
findings: serialized,
|
||||
count: serialized.length,
|
||||
stats: lastStats,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function drawGroups(groups, onlyIds) {
|
||||
for (const g of groups) {
|
||||
if (onlyIds && !onlyIds.includes(g.el)) continue;
|
||||
const el = elementOf(g.el);
|
||||
if (!el || el === document.body || el === document.documentElement) continue;
|
||||
ui.highlight(el, g.findings);
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectLazy() {
|
||||
if (lazyObserver) {
|
||||
lazyObserver.disconnect();
|
||||
lazyObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Elements whose visual contrast stayed unresolved only because they were
|
||||
// outside the viewport: re-analyze each when it scrolls into view.
|
||||
function scheduleLazy(candidates, generation) {
|
||||
disconnectLazy();
|
||||
if (!candidates.length || typeof IntersectionObserver === 'undefined') return;
|
||||
lazyObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const el = entry.target;
|
||||
const candidate = lazyPending.get(el);
|
||||
if (!candidate || lazyResolving.has(el)) continue;
|
||||
lazyObserver?.unobserve(el);
|
||||
lazyPending.delete(el);
|
||||
lazyResolving.add(el);
|
||||
waitForPaint()
|
||||
.then(() => analyzeCandidate(candidate, generation))
|
||||
.catch(reportError)
|
||||
.finally(() => lazyResolving.delete(el));
|
||||
}
|
||||
}, { threshold: 0.5 });
|
||||
for (const candidate of candidates) {
|
||||
let el = null;
|
||||
try { el = document.querySelector(candidate.selector); } catch { el = null; }
|
||||
if (!el) continue;
|
||||
lazyPending.set(el, candidate);
|
||||
lazyObserver.observe(el);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForPaint() {
|
||||
return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
}
|
||||
|
||||
// Drive one offscreen session to completion, answering its asks.
|
||||
async function drive(startMsg, sid, onStage) {
|
||||
let r = await send({ ...startMsg, session: sid });
|
||||
for (;;) {
|
||||
if (!r) throw new Error('the rule core did not answer');
|
||||
if (r.error) throw new Error(r.error);
|
||||
if (r.superseded) return null;
|
||||
if (r.ask) {
|
||||
if (lastStats) {
|
||||
if (r.ask.hitTests) { lastStats.rounds += 1; lastStats.hitTests += r.ask.hitTests.length; }
|
||||
else if (r.ask.io) lastStats.ioAsks += 1;
|
||||
}
|
||||
const a = await answer(r.ask);
|
||||
r = await send({ action: 'scan-continue', session: sid, answer: a });
|
||||
continue;
|
||||
}
|
||||
if (r.stage) {
|
||||
await onStage(r);
|
||||
r = await send({ action: 'scan-continue', session: sid, answer: {} });
|
||||
continue;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
async function runScan(config) {
|
||||
const generation = ++scanGeneration;
|
||||
scanConfig = config || null;
|
||||
window.__IMPECCABLE_CONFIG__ = config || {};
|
||||
await ensureUI();
|
||||
if (generation !== scanGeneration) return;
|
||||
disconnectLazy();
|
||||
ui.clearOverlays();
|
||||
const cap = snapshot.capture();
|
||||
if (cap.error) throw new Error(cap.error);
|
||||
capture = cap;
|
||||
visualIO = snapshot.visualIO(cap);
|
||||
lastStats = { elements: cap.stats.elements, bytes: cap.stats.bytes, captureMs: cap.stats.ms, coreMs: null, visualMs: null, rounds: 1, hitTests: 0, ioAsks: 0, unknownStyleProps: [], startedAt: performance.now(), findingsAtMs: null, visualAtMs: null };
|
||||
await drive({ action: 'scan-start', snapshot: cap.json, config: config || {} }, sessionId, async (r) => {
|
||||
if (generation !== scanGeneration) return;
|
||||
if (r.stats) {
|
||||
if (r.stats.coreMs !== undefined) lastStats.coreMs = r.stats.coreMs;
|
||||
if (r.stats.visualMs !== undefined) lastStats.visualMs = r.stats.visualMs;
|
||||
if (r.stats.unknownStyleProps) lastStats.unknownStyleProps = r.stats.unknownStyleProps;
|
||||
}
|
||||
if (r.stage === 'findings') {
|
||||
lastStats.findingsAtMs = performance.now() - lastStats.startedAt;
|
||||
setGroups(r.groups);
|
||||
drawGroups(r.groups);
|
||||
if (r.pageLevel && r.pageLevel.length) ui.showPageBanner(r.pageLevel);
|
||||
injected = true;
|
||||
postFindings(r.serialized);
|
||||
setTimeout(() => ui.setFirstScanDone(), 1000);
|
||||
} else if (r.stage === 'visual') {
|
||||
lastStats.visualAtMs = performance.now() - lastStats.startedAt;
|
||||
setGroups(r.groups);
|
||||
drawGroups(r.groups, r.added);
|
||||
postFindings(r.serialized);
|
||||
scheduleLazy(r.lazy || [], generation);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function analyzeCandidate(candidate, generation) {
|
||||
if (generation !== scanGeneration) return;
|
||||
const cap = snapshot.capture();
|
||||
if (cap.error) throw new Error(cap.error);
|
||||
const priorCapture = capture;
|
||||
const priorIO = visualIO;
|
||||
capture = cap;
|
||||
visualIO = snapshot.visualIO(cap);
|
||||
try {
|
||||
const out = await drive({
|
||||
action: 'analyze-candidate',
|
||||
snapshot: cap.json,
|
||||
candidate,
|
||||
groups: groupsForCapture(cap),
|
||||
}, candidateSessionId, async () => {});
|
||||
if (!out || generation !== scanGeneration) return;
|
||||
if (out.el) {
|
||||
const el = elementOf(out.el);
|
||||
const group = out.groups.find(g => g.el === out.el);
|
||||
if (el && group) {
|
||||
groupMap.set(el, group.findings);
|
||||
if (el !== document.body && el !== document.documentElement) ui.highlight(el, group.findings);
|
||||
if (out.serialized) postFindings(out.serialized);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Later hit-test/pixel asks for the main session (none pending by now)
|
||||
// would need the main capture; restore it.
|
||||
capture = priorCapture || capture;
|
||||
visualIO = priorIO || visualIO;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for commands from the service worker
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.action === 'scan') {
|
||||
scanConfig = msg.config || null;
|
||||
injectAndScan();
|
||||
runScan(msg.config || null).catch((err) => {
|
||||
if (err && err.message === 'superseded') return;
|
||||
reportError(err);
|
||||
});
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.action === 'toggle-overlays') {
|
||||
window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*');
|
||||
if (ui) {
|
||||
const visible = ui.toggleOverlays();
|
||||
chrome.runtime.sendMessage({ action: 'overlays-toggled', visible }).catch(() => {});
|
||||
}
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.action === 'remove') {
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
scanGeneration += 1;
|
||||
disconnectLazy();
|
||||
if (ui) ui.remove();
|
||||
groupMap.clear();
|
||||
injected = false;
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.action === 'highlight') {
|
||||
window.postMessage({ source: 'impeccable-command', action: 'highlight', selector: msg.selector }, '*');
|
||||
if (ui) ui.highlightSelector(msg.selector);
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.action === 'unhighlight') {
|
||||
window.postMessage({ source: 'impeccable-command', action: 'unhighlight' }, '*');
|
||||
if (ui) ui.unspotlight();
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Listen for results and state changes from the detector in page context
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.source !== window || !e.data) return;
|
||||
|
||||
if (e.data.source === 'impeccable-results') {
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'findings',
|
||||
findings: e.data.findings,
|
||||
count: e.data.count,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (e.data.source === 'impeccable-overlays-toggled') {
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'overlays-toggled',
|
||||
visible: e.data.visible,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (e.data.source === 'impeccable-ready') {
|
||||
injected = true;
|
||||
if (pendingScan) {
|
||||
pendingScan = false;
|
||||
sendScanCommand();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Forward "page is active" signal to the extension when the cursor moves over the page.
|
||||
// This is the reliable way to know the user has left the DevTools panel — the panel's
|
||||
// own pointerleave/mouseleave events are unreliable on fast cursor movement.
|
||||
@@ -87,39 +303,10 @@
|
||||
if (location.href === lastUrl) return;
|
||||
lastUrl = location.href;
|
||||
if (injected) {
|
||||
// Detector is still loaded in page context, just re-scan after DOM settles
|
||||
setTimeout(sendScanCommand, 500);
|
||||
// Re-scan after the DOM settles
|
||||
setTimeout(() => runScan(scanConfig).catch(reportError), 500);
|
||||
}
|
||||
}
|
||||
window.addEventListener('popstate', onPossibleNavigation);
|
||||
window.addEventListener('hashchange', onPossibleNavigation);
|
||||
|
||||
function sendScanCommand() {
|
||||
const msg = { source: 'impeccable-command', action: 'scan' };
|
||||
if (scanConfig) msg.config = scanConfig;
|
||||
window.postMessage(msg, '*');
|
||||
}
|
||||
|
||||
function injectAndScan() {
|
||||
if (injected) {
|
||||
sendScanCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the extension flag via a data attribute (CSP-safe: content scripts share the DOM)
|
||||
document.documentElement.dataset.impeccableExtension = 'true';
|
||||
|
||||
// Inject the detector script into page context
|
||||
const script = document.createElement('script');
|
||||
script.src = chrome.runtime.getURL('detector/detect.js');
|
||||
script.dataset.impeccableExtension = 'true';
|
||||
pendingScan = true;
|
||||
script.onload = () => script.remove();
|
||||
script.onerror = () => {
|
||||
script.remove();
|
||||
// Fallback: use chrome.scripting.executeScript for strict CSP pages
|
||||
chrome.runtime.sendMessage({ action: 'inject-fallback' });
|
||||
};
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user