Files
pbakaus_impeccable/extension/background/service-worker.js
T
1f4021b16c Fix: make Chrome extension toolbar badge count anti-patterns (#264)
The toolbar badge counted flagged elements (state.findings.length) while
the popup and DevTools panel counted total anti-pattern findings, so the
same scan showed two numbers (e.g. 21 vs 34 on the design-system page).
Since the surfaces are labeled "anti-patterns", count total findings in
the badge too so all three agree.

Closes #262

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:50:36 -07:00

288 lines
11 KiB
JavaScript

/**
* Impeccable DevTools Extension - Service Worker
*
* Routes messages between popup, DevTools panel, and content scripts.
* Maintains per-tab state and updates the badge.
*/
// Per-tab state: { tabId: { findings, overlaysVisible, injected } }
const tabState = new Map();
// Active DevTools panel connections: { tabId: Set<port> }
const panelPorts = new Map();
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;
}
// 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).
async function ensureContentScriptInjected(tabId) {
const state = getState(tabId);
if (state.csInjected) return { ok: true };
try {
await chrome.scripting.executeScript({
target: { tabId },
files: ['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) };
}
}
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\u2019t scan local files. Enable \u201CAllow access to file URLs\u201D for Impeccable in chrome://extensions.'
: `Couldn\u2019t scan this page${error ? `: ${error}` : '.'}`;
chrome.runtime.sendMessage({ action: 'scan-failed', tabId, message }).catch(() => {});
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) => {
const tabId = msg.tabId || sender.tab?.id;
if (msg.action === 'findings' && tabId) {
const state = getState(tabId);
state.findings = msg.findings || [];
state.injected = true;
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 === 'inject-fallback' && tabId) {
// CSP fallback: inject detector via chrome.scripting (bypasses page CSP)
chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
files: ['detector/detect.js'],
}).then(() => {
// Detector will post impeccable-ready, content script handles the rest
}).catch((err) => {
console.warn('[impeccable] Fallback injection failed:', err);
});
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);
});