content-script.js

  1/**
  2 * Impeccable DevTools Extension - Content Script
  3 *
  4 * Bridges between the extension messaging system and the page-context detector.
  5 * The detector must run in page context (not isolated world) because it needs
  6 * access to getComputedStyle, document.styleSheets.cssRules, etc.
  7 *
  8 * Wrapped in an IIFE with an idempotency flag so re-injection (via
  9 * chrome.scripting.executeScript) is a no-op and doesn't cause:
 10 *   - SyntaxError: Identifier 'foo' has already been declared
 11 *   - Duplicate event listeners accumulating over time
 12 */
 13(function () {
 14  if (window.__IMPECCABLE_CS_LOADED__) return;
 15  window.__IMPECCABLE_CS_LOADED__ = true;
 16
 17  let injected = false;
 18  let pendingScan = false;
 19  let scanConfig = null;
 20
 21  // Listen for commands from the service worker
 22  chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
 23    if (msg.action === 'scan') {
 24      scanConfig = msg.config || null;
 25      injectAndScan();
 26      sendResponse({ ok: true });
 27    } else if (msg.action === 'toggle-overlays') {
 28      window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*');
 29      sendResponse({ ok: true });
 30    } else if (msg.action === 'remove') {
 31      window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
 32      injected = false;
 33      sendResponse({ ok: true });
 34    } else if (msg.action === 'highlight') {
 35      window.postMessage({ source: 'impeccable-command', action: 'highlight', selector: msg.selector }, '*');
 36      sendResponse({ ok: true });
 37    } else if (msg.action === 'unhighlight') {
 38      window.postMessage({ source: 'impeccable-command', action: 'unhighlight' }, '*');
 39      sendResponse({ ok: true });
 40    }
 41    return true;
 42  });
 43
 44  // Listen for results and state changes from the detector in page context
 45  window.addEventListener('message', (e) => {
 46    if (e.source !== window || !e.data) return;
 47
 48    if (e.data.source === 'impeccable-results') {
 49      chrome.runtime.sendMessage({
 50        action: 'findings',
 51        findings: e.data.findings,
 52        count: e.data.count,
 53      }).catch(() => {});
 54    }
 55
 56    if (e.data.source === 'impeccable-overlays-toggled') {
 57      chrome.runtime.sendMessage({
 58        action: 'overlays-toggled',
 59        visible: e.data.visible,
 60      }).catch(() => {});
 61    }
 62
 63    if (e.data.source === 'impeccable-ready') {
 64      injected = true;
 65      if (pendingScan) {
 66        pendingScan = false;
 67        sendScanCommand();
 68      }
 69    }
 70  });
 71
 72  // Forward "page is active" signal to the extension when the cursor moves over the page.
 73  // This is the reliable way to know the user has left the DevTools panel — the panel's
 74  // own pointerleave/mouseleave events are unreliable on fast cursor movement.
 75  let lastPageActive = 0;
 76  document.addEventListener('pointermove', () => {
 77    const now = Date.now();
 78    if (now - lastPageActive < 150) return; // throttle
 79    lastPageActive = now;
 80    chrome.runtime.sendMessage({ action: 'page-pointer-active' }).catch(() => {});
 81  }, { passive: true, capture: true });
 82
 83  // SPA navigation detection (pushState/replaceState don't fire events, but
 84  // popstate and hashchange cover back/forward and hash navigation)
 85  let lastUrl = location.href;
 86  function onPossibleNavigation() {
 87    if (location.href === lastUrl) return;
 88    lastUrl = location.href;
 89    if (injected) {
 90      // Detector is still loaded in page context, just re-scan after DOM settles
 91      setTimeout(sendScanCommand, 500);
 92    }
 93  }
 94  window.addEventListener('popstate', onPossibleNavigation);
 95  window.addEventListener('hashchange', onPossibleNavigation);
 96
 97  function sendScanCommand() {
 98    const msg = { source: 'impeccable-command', action: 'scan' };
 99    if (scanConfig) msg.config = scanConfig;
100    window.postMessage(msg, '*');
101  }
102
103  function injectAndScan() {
104    if (injected) {
105      sendScanCommand();
106      return;
107    }
108
109    // Set the extension flag via a data attribute (CSP-safe: content scripts share the DOM)
110    document.documentElement.dataset.impeccableExtension = 'true';
111
112    // Inject the detector script into page context
113    const script = document.createElement('script');
114    script.src = chrome.runtime.getURL('detector/detect.js');
115    script.dataset.impeccableExtension = 'true';
116    pendingScan = true;
117    script.onload = () => script.remove();
118    script.onerror = () => {
119      script.remove();
120      // Fallback: use chrome.scripting.executeScript for strict CSP pages
121      chrome.runtime.sendMessage({ action: 'inject-fallback' });
122    };
123    (document.head || document.documentElement).appendChild(script);
124  }
125})();