← 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;
})();
`;
}