1/**
2 * Impeccable DevTools Extension - Service Worker
3 *
4 * Routes messages between popup, DevTools panel, and content scripts.
5 * Maintains per-tab state and updates the badge.
6 */
7
8// Per-tab state: { tabId: { findings, overlaysVisible, injected } }
9const tabState = new Map();
10
11// Active DevTools panel connections: { tabId: Set<port> }
12const panelPorts = new Map();
13
14function getState(tabId) {
15 if (!tabState.has(tabId)) {
16 tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false, csInjected: false });
17 }
18 return tabState.get(tabId);
19}
20
21function updateBadge(tabId) {
22 const state = tabState.get(tabId);
23 const count = state?.findings?.length || 0;
24 const text = count > 0 ? String(count) : '';
25 chrome.action.setBadgeText({ text, tabId }).catch(() => {});
26 chrome.action.setBadgeBackgroundColor({ color: '#d6336c', tabId }).catch(() => {});
27}
28
29function notifyPanels(tabId, message) {
30 const ports = panelPorts.get(tabId);
31 if (ports) {
32 for (const port of ports) {
33 try { port.postMessage(message); } catch { /* port disconnected */ }
34 }
35 }
36}
37
38async function getSettings() {
39 return chrome.storage.sync.get({
40 disabledRules: [],
41 lineLengthMode: 'strict', // 'strict' = 80, 'lax' = 120
42 spotlightBlur: true, // dim/blur the page on hover-highlight
43 autoScan: 'panel', // 'panel' = scan when Impeccable UI opens, 'devtools' = scan when DevTools opens
44 });
45}
46
47async function buildScanConfig() {
48 const { disabledRules, lineLengthMode, spotlightBlur } = await getSettings();
49 const config = {};
50 if (disabledRules.length) config.disabledRules = disabledRules;
51 config.lineLengthMax = lineLengthMode === 'lax' ? 120 : 80;
52 config.spotlightBlur = spotlightBlur;
53 return config;
54}
55
56// Inject the content script on-demand. We removed the static content_scripts entry to
57// minimize the always-on footprint; the script is only loaded when the user explicitly
58// engages with the extension (DevTools panel/sidebar opened, popup scan, etc).
59async function ensureContentScriptInjected(tabId) {
60 const state = getState(tabId);
61 if (state.csInjected) return true;
62 try {
63 await chrome.scripting.executeScript({
64 target: { tabId },
65 files: ['content/content-script.js'],
66 injectImmediately: true,
67 });
68 state.csInjected = true;
69 return true;
70 } catch (err) {
71 // Common cause: chrome:// pages, the web store, or other restricted URLs
72 return false;
73 }
74}
75
76async function sendScanToTab(tabId) {
77 const ok = await ensureContentScriptInjected(tabId);
78 if (!ok) return;
79 const config = await buildScanConfig();
80 chrome.tabs.sendMessage(tabId, { action: 'scan', config }).catch(() => {});
81}
82
83// Handle messages from content scripts and popup
84chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
85 const tabId = msg.tabId || sender.tab?.id;
86
87 if (msg.action === 'findings' && tabId) {
88 const state = getState(tabId);
89 state.findings = msg.findings || [];
90 state.injected = true;
91 updateBadge(tabId);
92 notifyPanels(tabId, { action: 'findings', findings: state.findings });
93 // Broadcast for popup
94 chrome.runtime.sendMessage({ action: 'findings-updated', tabId, findings: state.findings }).catch(() => {});
95 sendResponse({ ok: true });
96 }
97
98 else if (msg.action === 'scan' && tabId) {
99 sendScanToTab(tabId);
100 sendResponse({ ok: true });
101 }
102
103 else if (msg.action === 'toggle-overlays' && tabId) {
104 chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {});
105 sendResponse({ ok: true });
106 }
107
108 else if (msg.action === 'page-pointer-active' && tabId) {
109 notifyPanels(tabId, { action: 'page-pointer-active' });
110 sendResponse({ ok: true });
111 }
112
113 else if (msg.action === 'overlays-toggled' && tabId) {
114 const state = getState(tabId);
115 state.overlaysVisible = msg.visible;
116 notifyPanels(tabId, { action: 'overlays-toggled', visible: msg.visible });
117 chrome.runtime.sendMessage({ action: 'overlays-toggled-broadcast', tabId, visible: msg.visible }).catch(() => {});
118 sendResponse({ ok: true });
119 }
120
121 else if (msg.action === 'get-state' && tabId) {
122 sendResponse(getState(tabId));
123 }
124
125 else if (msg.action === 'inject-fallback' && tabId) {
126 // CSP fallback: inject detector via chrome.scripting (bypasses page CSP)
127 chrome.scripting.executeScript({
128 target: { tabId },
129 world: 'MAIN',
130 files: ['detector/detect.js'],
131 }).then(() => {
132 // Detector will post impeccable-ready, content script handles the rest
133 }).catch((err) => {
134 console.warn('[impeccable] Fallback injection failed:', err);
135 });
136 sendResponse({ ok: true });
137 }
138
139 else if (msg.action === 'disabled-rules-changed') {
140 // Re-scan all tabs that have been injected
141 for (const [tid, state] of tabState) {
142 if (state.injected) sendScanToTab(tid);
143 }
144 sendResponse({ ok: true });
145 }
146
147 return true;
148});
149
150// Track which tabs have DevTools open (via the devtools.js lifecycle port)
151const devtoolsTabs = new Set();
152
153async function tearDownTab(tabId) {
154 devtoolsTabs.delete(tabId);
155 // Send the remove command and await it — this keeps the SW alive long enough
156 // to actually deliver the message (setTimeout doesn't survive SW termination in MV3).
157 try {
158 await chrome.tabs.sendMessage(tabId, { action: 'remove' });
159 } catch { /* tab might be closed or content script gone */ }
160 const state = tabState.get(tabId);
161 if (state) {
162 state.findings = [];
163 state.injected = false;
164 state.csInjected = false;
165 }
166 updateBadge(tabId);
167 panelPorts.delete(tabId);
168}
169
170// Handle long-lived connections from DevTools pages and panels
171chrome.runtime.onConnect.addListener((port) => {
172 // Lifecycle port from devtools.js -- tracks DevTools open/close
173 if (port.name.startsWith('impeccable-devtools-')) {
174 const tabId = parseInt(port.name.replace('impeccable-devtools-', ''), 10);
175 devtoolsTabs.add(tabId);
176
177 port.onMessage.addListener((msg) => {
178 if (msg.action === 'scan') sendScanToTab(tabId);
179 // 'ping' is just a keepalive; no action needed
180 });
181
182 port.onDisconnect.addListener(() => {
183 // Tear down immediately — defer with setTimeout doesn't work reliably in MV3
184 // because the SW can be terminated before the timer fires.
185 tearDownTab(tabId);
186 });
187 }
188
189 // Panel port from panel.js -- for forwarding findings/state
190 if (port.name.startsWith('impeccable-panel-')) {
191 const tabId = parseInt(port.name.replace('impeccable-panel-', ''), 10);
192 if (!panelPorts.has(tabId)) panelPorts.set(tabId, new Set());
193 panelPorts.get(tabId).add(port);
194
195 // Send current state to newly connected panel
196 const state = getState(tabId);
197 port.postMessage({ action: 'state', ...state });
198
199 // If no findings yet, the auto-scan from devtools.js may have been lost -- trigger one
200 if (!state.findings.length) {
201 sendScanToTab(tabId);
202 }
203
204 port.onMessage.addListener((msg) => {
205 if (msg.action === 'scan') {
206 sendScanToTab(tabId);
207 } else if (msg.action === 'toggle-overlays') {
208 chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {});
209 } else if (msg.action === 'highlight') {
210 chrome.tabs.sendMessage(tabId, { action: 'highlight', selector: msg.selector }).catch(() => {});
211 } else if (msg.action === 'unhighlight') {
212 chrome.tabs.sendMessage(tabId, { action: 'unhighlight' }).catch(() => {});
213 }
214 });
215
216 port.onDisconnect.addListener(() => {
217 panelPorts.get(tabId)?.delete(port);
218 if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
219 });
220 }
221
222 // Sidebar pane port (Elements panel sidebar) -- receives findings updates.
223 // Connecting the sidebar is a strong signal of "user engaged with Impeccable"
224 // so we trigger a scan if no findings exist yet (matches the panel port behavior).
225 if (port.name.startsWith('impeccable-sidebar-')) {
226 const tabId = parseInt(port.name.replace('impeccable-sidebar-', ''), 10);
227 if (!panelPorts.has(tabId)) panelPorts.set(tabId, new Set());
228 panelPorts.get(tabId).add(port);
229
230 const state = getState(tabId);
231 port.postMessage({ action: 'state', ...state });
232 if (!state.findings.length) sendScanToTab(tabId);
233
234 port.onDisconnect.addListener(() => {
235 panelPorts.get(tabId)?.delete(port);
236 if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
237 });
238 }
239});
240
241// Re-scan on navigation (only if DevTools is open AND user was actively scanning)
242chrome.webNavigation?.onCompleted?.addListener((details) => {
243 if (details.frameId !== 0) return;
244 if (!devtoolsTabs.has(details.tabId)) return;
245 const state = tabState.get(details.tabId);
246 if (!state) return;
247 // Only re-scan if the user has actively engaged (had findings or injected previously)
248 const wasActive = state.injected || state.findings.length > 0;
249 state.findings = [];
250 state.injected = false;
251 state.csInjected = false; // page reload destroys the content script
252 updateBadge(details.tabId);
253 notifyPanels(details.tabId, { action: 'navigated' });
254 if (wasActive) {
255 setTimeout(() => sendScanToTab(details.tabId), 300);
256 }
257});
258
259// Clean up state when tabs close
260chrome.tabs.onRemoved.addListener((tabId) => {
261 tabState.delete(tabId);
262 panelPorts.delete(tabId);
263});