mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +03:00
`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
346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
/**
|
||
* Impeccable DevTools Extension - Service Worker
|
||
*
|
||
* Routes messages between popup, DevTools panel, and content scripts.
|
||
* Maintains per-tab state and updates the badge. Also owns the offscreen
|
||
* document that hosts the WebAssembly rule core: the content script measures
|
||
* the page (a snapshot), the offscreen document runs the rules over it, the
|
||
* content script draws the result. Neither the popup nor the DevTools panel
|
||
* see any of that; their messages are unchanged.
|
||
*/
|
||
|
||
// Per-tab state: { tabId: { findings, overlaysVisible, injected } }
|
||
const tabState = new Map();
|
||
|
||
// Active DevTools panel connections: { tabId: Set<port> }
|
||
const panelPorts = new Map();
|
||
|
||
const OFFSCREEN_URL = 'offscreen/offscreen.html';
|
||
|
||
function getState(tabId) {
|
||
if (!tabState.has(tabId)) {
|
||
tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false, csInjected: false });
|
||
}
|
||
return tabState.get(tabId);
|
||
}
|
||
|
||
function updateBadge(tabId) {
|
||
const state = tabState.get(tabId);
|
||
// Count total anti-pattern findings (an element may carry several), matching
|
||
// the popup and DevTools panel rather than the flagged-element count.
|
||
const count = state?.findings?.reduce((sum, f) => sum + (f.findings?.length || 0), 0) || 0;
|
||
const text = count > 0 ? String(count) : '';
|
||
chrome.action.setBadgeText({ text, tabId }).catch(() => {});
|
||
chrome.action.setBadgeBackgroundColor({ color: '#d6336c', tabId }).catch(() => {});
|
||
}
|
||
|
||
function notifyPanels(tabId, message) {
|
||
const ports = panelPorts.get(tabId);
|
||
if (ports) {
|
||
for (const port of ports) {
|
||
try { port.postMessage(message); } catch { /* port disconnected */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
async function getSettings() {
|
||
return chrome.storage.sync.get({
|
||
disabledRules: [],
|
||
lineLengthMode: 'strict', // 'strict' = 80, 'lax' = 120
|
||
spotlightBlur: true, // dim/blur the page on hover-highlight
|
||
autoScan: 'panel', // 'panel' = scan when Impeccable UI opens, 'devtools' = scan when DevTools opens
|
||
});
|
||
}
|
||
|
||
async function buildScanConfig() {
|
||
const { disabledRules, lineLengthMode, spotlightBlur } = await getSettings();
|
||
const config = {};
|
||
if (disabledRules.length) config.disabledRules = disabledRules;
|
||
config.lineLengthMax = lineLengthMode === 'lax' ? 120 : 80;
|
||
config.spotlightBlur = spotlightBlur;
|
||
return config;
|
||
}
|
||
|
||
// The offscreen document hosting the WASM core. One per extension; created
|
||
// on the first scan and kept (its rule core stays warm; a closed document
|
||
// would pay the module load again on the next scan).
|
||
let offscreenReady = null;
|
||
async function ensureOffscreenDocument() {
|
||
if (offscreenReady) return offscreenReady;
|
||
offscreenReady = (async () => {
|
||
const contexts = await chrome.runtime.getContexts({
|
||
contextTypes: ['OFFSCREEN_DOCUMENT'],
|
||
documentUrls: [chrome.runtime.getURL(OFFSCREEN_URL)],
|
||
});
|
||
if (contexts.length === 0) {
|
||
await chrome.offscreen.createDocument({
|
||
url: OFFSCREEN_URL,
|
||
// The document exists to run WebAssembly, which no page-side world can
|
||
// under a strict page CSP; WORKERS is the closest listed reason.
|
||
reasons: ['WORKERS'],
|
||
justification: 'Runs the WebAssembly anti-pattern rule core over a page snapshot; page Content-Security-Policies block WebAssembly in content-script and page worlds.',
|
||
});
|
||
}
|
||
// Wait for the core to be instantiated so the first scan does not race it.
|
||
for (let attempt = 0; attempt < 50; attempt++) {
|
||
try {
|
||
const r = await chrome.runtime.sendMessage({ target: 'impeccable-offscreen', action: 'ping' });
|
||
if (r && r.ok) return true;
|
||
if (r && r.error) throw new Error(r.error);
|
||
} catch (err) {
|
||
if (err && /Could not establish connection/.test(err.message || '')) {
|
||
await new Promise(res => setTimeout(res, 50));
|
||
continue;
|
||
}
|
||
throw err;
|
||
}
|
||
await new Promise(res => setTimeout(res, 50));
|
||
}
|
||
throw new Error('offscreen core did not answer');
|
||
})();
|
||
offscreenReady.catch(() => { offscreenReady = null; });
|
||
return offscreenReady;
|
||
}
|
||
|
||
// Inject the content script on-demand. We removed the static content_scripts entry to
|
||
// minimize the always-on footprint; the script is only loaded when the user explicitly
|
||
// engages with the extension (DevTools panel/sidebar opened, popup scan, etc).
|
||
// The three detector pieces (snapshot producer, overlay UI, content script)
|
||
// share the isolated world; each is idempotent.
|
||
async function ensureContentScriptInjected(tabId) {
|
||
const state = getState(tabId);
|
||
if (state.csInjected) return { ok: true };
|
||
try {
|
||
await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
files: ['detector/snapshot.js', 'detector/overlay.js', 'content/content-script.js'],
|
||
injectImmediately: true,
|
||
});
|
||
state.csInjected = true;
|
||
return { ok: true };
|
||
} catch (err) {
|
||
// Common cause: chrome:// pages, the web store, the Chrome Web Store, or
|
||
// file:// pages when "Allow access to file URLs" is off. Keep the real
|
||
// error so the UI can explain what happened.
|
||
return { ok: false, error: err?.message || String(err) };
|
||
}
|
||
}
|
||
|
||
function reportScanFailure(tabId, message) {
|
||
chrome.runtime.sendMessage({ action: 'scan-failed', tabId, message }).catch(() => {});
|
||
notifyPanels(tabId, { action: 'scan-failed', message });
|
||
}
|
||
|
||
async function sendScanToTab(tabId) {
|
||
const { ok, error } = await ensureContentScriptInjected(tabId);
|
||
if (!ok) {
|
||
// Injection was blocked. Tell an open popup why so it can stop showing
|
||
// "Scanning..." and surface a hint. The popup may be closed, so ignore
|
||
// delivery failures.
|
||
let url = '';
|
||
try { url = (await chrome.tabs.get(tabId))?.url || ''; } catch { /* tab gone */ }
|
||
const message = url.startsWith('file:')
|
||
? 'Can’t scan local files. Enable “Allow access to file URLs” for Impeccable in chrome://extensions.'
|
||
: `Couldn’t scan this page${error ? `: ${error}` : '.'}`;
|
||
reportScanFailure(tabId, message);
|
||
return;
|
||
}
|
||
try {
|
||
await ensureOffscreenDocument();
|
||
} catch (err) {
|
||
reportScanFailure(tabId, `Couldn’t start the rule core: ${err?.message || err}`);
|
||
return;
|
||
}
|
||
const config = await buildScanConfig();
|
||
chrome.tabs.sendMessage(tabId, { action: 'scan', config }).catch(() => {});
|
||
}
|
||
|
||
// Handle messages from content scripts and popup
|
||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||
// Content script <-> offscreen document traffic (snapshots, facts, results)
|
||
// is addressed to the offscreen document; it answers, this worker stays out.
|
||
if (msg && msg.target === 'impeccable-offscreen') return false;
|
||
|
||
const tabId = msg.tabId || sender.tab?.id;
|
||
|
||
if (msg.action === 'findings' && tabId) {
|
||
const state = getState(tabId);
|
||
state.findings = msg.findings || [];
|
||
state.injected = true;
|
||
if (msg.stats) state.stats = msg.stats;
|
||
updateBadge(tabId);
|
||
notifyPanels(tabId, { action: 'findings', findings: state.findings });
|
||
// Broadcast for popup
|
||
chrome.runtime.sendMessage({ action: 'findings-updated', tabId, findings: state.findings }).catch(() => {});
|
||
sendResponse({ ok: true });
|
||
}
|
||
|
||
else if (msg.action === 'scan' && tabId) {
|
||
sendScanToTab(tabId);
|
||
sendResponse({ ok: true });
|
||
}
|
||
|
||
else if (msg.action === 'toggle-overlays' && tabId) {
|
||
chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {});
|
||
sendResponse({ ok: true });
|
||
}
|
||
|
||
else if (msg.action === 'page-pointer-active' && tabId) {
|
||
notifyPanels(tabId, { action: 'page-pointer-active' });
|
||
sendResponse({ ok: true });
|
||
}
|
||
|
||
else if (msg.action === 'overlays-toggled' && tabId) {
|
||
const state = getState(tabId);
|
||
state.overlaysVisible = msg.visible;
|
||
notifyPanels(tabId, { action: 'overlays-toggled', visible: msg.visible });
|
||
chrome.runtime.sendMessage({ action: 'overlays-toggled-broadcast', tabId, visible: msg.visible }).catch(() => {});
|
||
sendResponse({ ok: true });
|
||
}
|
||
|
||
else if (msg.action === 'get-state' && tabId) {
|
||
sendResponse(getState(tabId));
|
||
}
|
||
|
||
else if (msg.action === 'detector-error' && tabId) {
|
||
// The content script could not complete a scan (snapshot too large, the
|
||
// core refused the snapshot, ...). Tell the popup and any open panel.
|
||
reportScanFailure(tabId, msg.message || 'The detector could not run on this page.');
|
||
sendResponse({ ok: true });
|
||
}
|
||
|
||
else if (msg.action === 'disabled-rules-changed') {
|
||
// Re-scan all tabs that have been injected
|
||
for (const [tid, state] of tabState) {
|
||
if (state.injected) sendScanToTab(tid);
|
||
}
|
||
sendResponse({ ok: true });
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
// Track which tabs have DevTools open (via the devtools.js lifecycle port)
|
||
const devtoolsTabs = new Set();
|
||
|
||
async function tearDownTab(tabId) {
|
||
devtoolsTabs.delete(tabId);
|
||
// Send the remove command and await it — this keeps the SW alive long enough
|
||
// to actually deliver the message (setTimeout doesn't survive SW termination in MV3).
|
||
try {
|
||
await chrome.tabs.sendMessage(tabId, { action: 'remove' });
|
||
} catch { /* tab might be closed or content script gone */ }
|
||
const state = tabState.get(tabId);
|
||
if (state) {
|
||
state.findings = [];
|
||
state.injected = false;
|
||
state.csInjected = false;
|
||
}
|
||
updateBadge(tabId);
|
||
panelPorts.delete(tabId);
|
||
}
|
||
|
||
// Handle long-lived connections from DevTools pages and panels
|
||
chrome.runtime.onConnect.addListener((port) => {
|
||
// Lifecycle port from devtools.js -- tracks DevTools open/close
|
||
if (port.name.startsWith('impeccable-devtools-')) {
|
||
const tabId = parseInt(port.name.replace('impeccable-devtools-', ''), 10);
|
||
devtoolsTabs.add(tabId);
|
||
|
||
port.onMessage.addListener((msg) => {
|
||
if (msg.action === 'scan') sendScanToTab(tabId);
|
||
// 'ping' is just a keepalive; no action needed
|
||
});
|
||
|
||
port.onDisconnect.addListener(() => {
|
||
// Tear down immediately — defer with setTimeout doesn't work reliably in MV3
|
||
// because the SW can be terminated before the timer fires.
|
||
tearDownTab(tabId);
|
||
});
|
||
}
|
||
|
||
// Panel port from panel.js -- for forwarding findings/state
|
||
if (port.name.startsWith('impeccable-panel-')) {
|
||
const tabId = parseInt(port.name.replace('impeccable-panel-', ''), 10);
|
||
if (!panelPorts.has(tabId)) panelPorts.set(tabId, new Set());
|
||
panelPorts.get(tabId).add(port);
|
||
|
||
// Send current state to newly connected panel
|
||
const state = getState(tabId);
|
||
port.postMessage({ action: 'state', ...state });
|
||
|
||
// If no findings yet, the auto-scan from devtools.js may have been lost -- trigger one
|
||
if (!state.findings.length) {
|
||
sendScanToTab(tabId);
|
||
}
|
||
|
||
port.onMessage.addListener((msg) => {
|
||
if (msg.action === 'scan') {
|
||
sendScanToTab(tabId);
|
||
} else if (msg.action === 'toggle-overlays') {
|
||
chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {});
|
||
} else if (msg.action === 'highlight') {
|
||
chrome.tabs.sendMessage(tabId, { action: 'highlight', selector: msg.selector }).catch(() => {});
|
||
} else if (msg.action === 'unhighlight') {
|
||
chrome.tabs.sendMessage(tabId, { action: 'unhighlight' }).catch(() => {});
|
||
}
|
||
});
|
||
|
||
port.onDisconnect.addListener(() => {
|
||
panelPorts.get(tabId)?.delete(port);
|
||
if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
|
||
});
|
||
}
|
||
|
||
// Sidebar pane port (Elements panel sidebar) -- receives findings updates.
|
||
// Connecting the sidebar is a strong signal of "user engaged with Impeccable"
|
||
// so we trigger a scan if no findings exist yet (matches the panel port behavior).
|
||
if (port.name.startsWith('impeccable-sidebar-')) {
|
||
const tabId = parseInt(port.name.replace('impeccable-sidebar-', ''), 10);
|
||
if (!panelPorts.has(tabId)) panelPorts.set(tabId, new Set());
|
||
panelPorts.get(tabId).add(port);
|
||
|
||
const state = getState(tabId);
|
||
port.postMessage({ action: 'state', ...state });
|
||
if (!state.findings.length) sendScanToTab(tabId);
|
||
|
||
port.onDisconnect.addListener(() => {
|
||
panelPorts.get(tabId)?.delete(port);
|
||
if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
|
||
});
|
||
}
|
||
});
|
||
|
||
// On navigation, reset content-script state for any tracked tab (page reload destroys
|
||
// the content script regardless of which UI surfaced it). Auto-rescan is gated separately
|
||
// on DevTools being open AND the user having previously engaged.
|
||
chrome.webNavigation?.onCompleted?.addListener((details) => {
|
||
if (details.frameId !== 0) return;
|
||
const state = tabState.get(details.tabId);
|
||
if (!state) return;
|
||
|
||
// Capture engagement state BEFORE clearing (used by the auto-rescan branch).
|
||
const wasActive = state.injected || state.findings.length > 0;
|
||
|
||
// Always clear: the content script is gone after reload, full stop. Skipping this when
|
||
// DevTools wasn't open meant the popup-only flow saw a stale csInjected: true on the
|
||
// second click and silently no-op'd against a tab that had no listener.
|
||
state.findings = [];
|
||
state.injected = false;
|
||
state.csInjected = false;
|
||
updateBadge(details.tabId);
|
||
notifyPanels(details.tabId, { action: 'navigated' });
|
||
|
||
// Auto-rescan only when DevTools is the driver — the popup is user-triggered and
|
||
// shouldn't fire scans the user didn't ask for.
|
||
if (devtoolsTabs.has(details.tabId) && wasActive) {
|
||
setTimeout(() => sendScanToTab(details.tabId), 300);
|
||
}
|
||
});
|
||
|
||
// Clean up state when tabs close
|
||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||
tabState.delete(tabId);
|
||
panelPorts.delete(tabId);
|
||
});
|