mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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>
288 lines
11 KiB
JavaScript
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);
|
|
});
|