service-worker.js

  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});