← back to Homesonspec

apps/mobile/lib/tracker-block.ts

130 lines

/**
 * Runs BEFORE any page script. Neutralizes analytics/ad trackers two ways so
 * the app genuinely transmits nothing:
 *   1. Stub the JS APIs (gtag / dataLayer / fbq / google_tag_manager) into no-ops.
 *   2. Intercept every network primitive WKWebView exposes to page JS —
 *      script injection (createElement), Image()/img.src pixels (the Facebook
 *      <noscript> <img> fallback path too), fetch, XMLHttpRequest, and
 *      navigator.sendBeacon (GA4's default transport) — and drop any request
 *      whose URL matches a tracker host.
 * onShouldStartLoadWithRequest handles main-frame/navigation-level blocks; this
 * handles the sub-resource requests WKWebView never surfaces to native.
 */
/**
 * Built as a FUNCTION of the host list rather than importing it, so this module has
 * zero imports and can be loaded directly by `node --test` (Node's ESM resolver wants
 * explicit file extensions, which the TS/Metro resolver does not use). That is what
 * lets `tracker-block.test.mjs` execute the real script text in a sandbox.
 */
export function buildTrackerBlock(hosts: readonly string[]): string {
  return `
(function() {
  try {
    var HOSTS = ${JSON.stringify(hosts)};
    function blocked(u) {
      try {
        var hostname = new URL(String(u), window.location.href).hostname.toLowerCase();
        for (var i = 0; i < HOSTS.length; i++) {
          if (hostname === HOSTS[i] || hostname.slice(-(HOSTS[i].length + 1)) === '.' + HOSTS[i]) return true;
        }
      } catch (e) {}
      return false;
    }

    // 1. Stub tracker JS APIs
    var noop = function() {};
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push = noop;
    window.gtag = noop;
    window.ga = noop;
    window.google_tag_manager = {};
    window.fbq = function() {}; window.fbq.queue = []; window.fbq.loaded = true;
    window._fbq = window.fbq;

    // 2a. Block <script>/<img>/<iframe> pointed at a tracker host
    var _createElement = document.createElement.bind(document);
    document.createElement = function(tag) {
      var el = _createElement(tag);
      var t = String(tag || '').toLowerCase();
      if (t === 'script' || t === 'img' || t === 'iframe') {
        try {
          var proto = Object.getPrototypeOf(el);
          var desc = Object.getOwnPropertyDescriptor(proto, 'src')
                  || Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'src');
          if (desc && desc.set) {
            Object.defineProperty(el, 'src', {
              configurable: true,
              get: function() { return desc.get ? desc.get.call(el) : ''; },
              set: function(v) { if (!blocked(v)) desc.set.call(el, v); }
            });
          }
        } catch (e) {}
      }
      return el;
    };

    // 2b. Block Image()/new Image().src pixels (Facebook tr pixel, GA hits)
    var _Image = window.Image;
    if (_Image) {
      window.Image = function() {
        var img = new _Image();
        try {
          var d = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
          if (d && d.set) {
            Object.defineProperty(img, 'src', {
              configurable: true,
              get: function() { return d.get.call(img); },
              set: function(v) { if (!blocked(v)) d.set.call(img, v); }
            });
          }
        } catch (e) {}
        return img;
      };
    }

    // 2c. Block fetch
    var _fetch = window.fetch;
    if (_fetch) {
      window.fetch = function(input) {
        var url = (input && input.url) ? input.url : input;
        if (blocked(url)) return Promise.resolve(new Response('', { status: 204 }));
        return _fetch.apply(this, arguments);
      };
    }

    // 2d. Block XHR
    var _open = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function(method, url) {
      this.__blocked = blocked(url);
      return _open.apply(this, arguments);
    };
    var _send = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.send = function() { if (this.__blocked) return; return _send.apply(this, arguments); };

    // 2e. Block sendBeacon (GA4 default transport)
    if (navigator.sendBeacon) {
      var _beacon = navigator.sendBeacon.bind(navigator);
      navigator.sendBeacon = function(url) { if (blocked(url)) return false; return _beacon.apply(this, arguments); };
    }

    // 2f. Strip any tracker <img>/<script> that slips into the DOM (e.g. <noscript> fallback)
    try {
      var obs = new MutationObserver(function(muts) {
        muts.forEach(function(m) {
          for (var i = 0; i < m.addedNodes.length; i++) {
            var n = m.addedNodes[i];
            if (n && n.tagName && (n.tagName === 'IMG' || n.tagName === 'SCRIPT' || n.tagName === 'IFRAME')) {
              var s = n.getAttribute && n.getAttribute('src');
              if (s && blocked(s)) { n.setAttribute('src', ''); if (n.parentNode) n.parentNode.removeChild(n); }
            }
          }
        });
      });
      obs.observe(document.documentElement || document, { childList: true, subtree: true });
    } catch (e) {}
  } catch (e) {}
  true;
})();
`;
}