924 lines
36 KiB
Markdown
924 lines
36 KiB
Markdown
---
|
|
source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/ios-tracker-blocking-implementation.mdc
|
|
confidence: 0.9
|
|
namespace: work
|
|
last_synced: 2026-04-28
|
|
alwaysApply: false
|
|
---
|
|
|
|
# iOS Tracker Blocking Implementation Guide
|
|
|
|
## Overview
|
|
|
|
This document provides a comprehensive overview of how content blocking has been implemented on iOS to protect user privacy and block tracking attempts. Our implementation uses a dual-approach strategy that combines the efficiency of WebKit's native content blocking with the flexibility of JavaScript-based protection.
|
|
|
|
**Implementation Strategy:**
|
|
- **Primary Layer**: Content Blocker Rules (WebKit native, compiled to bytecode)
|
|
- **Secondary Layer**: JavaScript injection with Tracker Radar data (gap coverage + surrogates)
|
|
|
|
## Content Blocker Rules
|
|
|
|
### Architecture Overview
|
|
|
|
Content Blocker Rules form the primary layer of our tracker blocking implementation. These rules are converted from our Tracker Radar dataset into Apple's Content Blocker Rules format and compiled by WebKit into efficient bytecode for optimal performance.
|
|
|
|
**Key Characteristics:**
|
|
- ✅ **High Performance**: Rules are compiled to bytecode by WebKit
|
|
- ✅ **Low Memory Footprint**: Optimized for mobile devices
|
|
- ✅ **Battery Efficient**: Minimal CPU overhead
|
|
- ❌ **Limited Flexibility**: Cannot support complex logic or surrogates
|
|
- ❌ **No Runtime Modification**: Rules are static once compiled
|
|
|
|
### Implementation Details
|
|
|
|
#### ContentBlockerRulesManager
|
|
|
|
The `ContentBlockerRulesManager` is responsible for converting Tracker Radar data into Apple's Content Blocker format and managing the rule compilation process.
|
|
|
|
```swift
|
|
import WebKit
|
|
import BrowserServicesKit
|
|
|
|
final class ContentBlockerRulesManager {
|
|
private let trackerDataManager: TrackerDataManager
|
|
private let compilationQueue = DispatchQueue(label: "content-blocker-compilation", qos: .utility)
|
|
|
|
init(trackerDataManager: TrackerDataManager) {
|
|
self.trackerDataManager = trackerDataManager
|
|
}
|
|
|
|
/// Converts Tracker Radar data to Content Blocker Rules format
|
|
func generateContentBlockerRules() async throws -> [WKContentRuleList] {
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
compilationQueue.async {
|
|
do {
|
|
let trackerData = self.trackerDataManager.embeddedTrackerData
|
|
let rules = self.convertToContentBlockerFormat(trackerData)
|
|
let ruleList = try self.compileRules(rules)
|
|
continuation.resume(returning: [ruleList])
|
|
} catch {
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Converts TrackerData to Apple's Content Blocker Rules JSON format
|
|
private func convertToContentBlockerFormat(_ trackerData: TrackerData) -> [[String: Any]] {
|
|
var rules: [[String: Any]] = []
|
|
|
|
for (domain, tracker) in trackerData.trackers {
|
|
for rule in tracker.rules ?? [] {
|
|
let contentBlockerRule: [String: Any] = [
|
|
"trigger": [
|
|
"url-filter": rule.rule,
|
|
"resource-type": rule.resourceTypes,
|
|
"if-domain": rule.whitelist?.compactMap { "*\($0)" }
|
|
].compactMapValues { $0 },
|
|
"action": [
|
|
"type": "block"
|
|
]
|
|
]
|
|
rules.append(contentBlockerRule)
|
|
}
|
|
}
|
|
|
|
return rules
|
|
}
|
|
|
|
/// Compiles rules using WebKit's WKContentRuleListStore
|
|
private func compileRules(_ rules: [[String: Any]]) throws -> WKContentRuleList {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: rules)
|
|
let jsonString = String(data: jsonData, encoding: .utf8)!
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
WKContentRuleListStore.default().compileContentRuleList(
|
|
forIdentifier: "DuckDuckGoContentBlocker",
|
|
encodedContentRuleList: jsonString
|
|
) { ruleList, error in
|
|
if let error = error {
|
|
continuation.resume(throwing: error)
|
|
} else if let ruleList = ruleList {
|
|
continuation.resume(returning: ruleList)
|
|
} else {
|
|
continuation.resume(throwing: ContentBlockerError.compilationFailed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ContentBlockerError: Error {
|
|
case compilationFailed
|
|
case invalidRuleFormat
|
|
case trackerDataUnavailable
|
|
}
|
|
```
|
|
|
|
#### Integration with WKWebView
|
|
|
|
Content Blocker Rules are applied to WKWebView through the `WKUserContentController`:
|
|
|
|
```swift
|
|
extension BrowserWebView {
|
|
func applyContentBlockerRules() async {
|
|
do {
|
|
let ruleLists = try await contentBlockerManager.generateContentBlockerRules()
|
|
|
|
await MainActor.run {
|
|
for ruleList in ruleLists {
|
|
webView.configuration.userContentController.add(ruleList)
|
|
}
|
|
Logger.privacy.info("Applied \(ruleLists.count) content blocker rule lists")
|
|
}
|
|
} catch {
|
|
Logger.privacy.error("Failed to apply content blocker rules: \(error)")
|
|
// Fallback to JavaScript-only blocking
|
|
await applyJavaScriptBlockingOnly()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### ContentBlockerRulesUserScript
|
|
|
|
To understand which resources were blocked by Content Blocker Rules, we inject JavaScript that monitors network requests and infers blocking behavior:
|
|
|
|
```swift
|
|
final class ContentBlockerRulesUserScript {
|
|
static let source = """
|
|
(function() {
|
|
'use strict';
|
|
|
|
const blockedResources = new Set();
|
|
const allowedResources = new Set();
|
|
|
|
// Monitor XMLHttpRequest
|
|
const originalXHROpen = XMLHttpRequest.prototype.open;
|
|
XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
|
|
const startTime = Date.now();
|
|
|
|
this.addEventListener('loadend', function() {
|
|
const duration = Date.now() - startTime;
|
|
|
|
if (this.status === 0 && duration < 10) {
|
|
// Likely blocked by content blocker
|
|
blockedResources.add(url);
|
|
reportBlockedResource(url, 'xhr');
|
|
} else {
|
|
allowedResources.add(url);
|
|
}
|
|
});
|
|
|
|
return originalXHROpen.call(this, method, url, async, user, password);
|
|
};
|
|
|
|
// Monitor Fetch API
|
|
const originalFetch = window.fetch;
|
|
window.fetch = function(input, init) {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
const startTime = Date.now();
|
|
|
|
return originalFetch.call(this, input, init)
|
|
.then(response => {
|
|
allowedResources.add(url);
|
|
return response;
|
|
})
|
|
.catch(error => {
|
|
const duration = Date.now() - startTime;
|
|
if (duration < 10) {
|
|
blockedResources.add(url);
|
|
reportBlockedResource(url, 'fetch');
|
|
}
|
|
throw error;
|
|
});
|
|
};
|
|
|
|
// Monitor image loading
|
|
const originalImageSrc = Object.getOwnPropertyDescriptor(Image.prototype, 'src');
|
|
Object.defineProperty(Image.prototype, 'src', {
|
|
set: function(value) {
|
|
const img = this;
|
|
|
|
img.addEventListener('error', function() {
|
|
if (img.naturalWidth === 0 && img.naturalHeight === 0) {
|
|
blockedResources.add(value);
|
|
reportBlockedResource(value, 'image');
|
|
}
|
|
});
|
|
|
|
img.addEventListener('load', function() {
|
|
allowedResources.add(value);
|
|
});
|
|
|
|
return originalImageSrc.set.call(this, value);
|
|
},
|
|
get: originalImageSrc.get
|
|
});
|
|
|
|
function reportBlockedResource(url, type) {
|
|
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.contentBlocker) {
|
|
window.webkit.messageHandlers.contentBlocker.postMessage({
|
|
type: 'blocked',
|
|
url: url,
|
|
resourceType: type,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
// Report statistics periodically
|
|
setInterval(function() {
|
|
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.contentBlocker) {
|
|
window.webkit.messageHandlers.contentBlocker.postMessage({
|
|
type: 'statistics',
|
|
blocked: blockedResources.size,
|
|
allowed: allowedResources.size,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
}, 5000);
|
|
})();
|
|
"""
|
|
}
|
|
```
|
|
|
|
## JavaScript Injection with Tracker Radar
|
|
|
|
### Purpose and Advantages
|
|
|
|
While Content Blocker Rules provide excellent performance, they have limitations that JavaScript injection can address:
|
|
|
|
**JavaScript Injection Advantages:**
|
|
- ✅ **Surrogate Support**: Can inject replacement scripts to prevent page breakage
|
|
- ✅ **Runtime Logic**: Complex blocking decisions based on page context
|
|
- ✅ **Gap Coverage**: Handles edge cases missed by static rules
|
|
- ✅ **Real-time Updates**: Can adapt to new tracking techniques
|
|
- ❌ **Performance Overhead**: Higher CPU and memory usage
|
|
- ❌ **Battery Impact**: More intensive than native blocking
|
|
|
|
### ContentBlockerUserScript Implementation
|
|
|
|
The `ContentBlockerUserScript` implements the full Tracker Radar blocking algorithm in JavaScript:
|
|
|
|
```swift
|
|
final class ContentBlockerUserScript {
|
|
private let trackerDataManager: TrackerDataManager
|
|
private let surrogateManager: SurrogateManager
|
|
|
|
init(trackerDataManager: TrackerDataManager, surrogateManager: SurrogateManager) {
|
|
self.trackerDataManager = trackerDataManager
|
|
self.surrogateManager = surrogateManager
|
|
}
|
|
|
|
var source: String {
|
|
return generateTrackerBlockingScript()
|
|
}
|
|
|
|
private func generateTrackerBlockingScript() -> String {
|
|
let trackerData = trackerDataManager.embeddedTrackerData
|
|
let surrogates = surrogateManager.allSurrogates
|
|
|
|
return """
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Embedded tracker data
|
|
const TRACKER_DATA = \(trackerData.jsonString);
|
|
const SURROGATES = \(surrogates.jsonString);
|
|
|
|
class TrackerBlocker {
|
|
constructor() {
|
|
this.blockedCount = 0;
|
|
this.allowedCount = 0;
|
|
this.setupInterception();
|
|
}
|
|
|
|
setupInterception() {
|
|
this.interceptXMLHttpRequest();
|
|
this.interceptFetch();
|
|
this.interceptScriptLoading();
|
|
this.interceptImageLoading();
|
|
this.setupMutationObserver();
|
|
}
|
|
|
|
shouldBlockResource(url, type, initiator) {
|
|
try {
|
|
const urlObj = new URL(url, window.location.href);
|
|
const domain = urlObj.hostname;
|
|
|
|
// Check if domain is in tracker list
|
|
const tracker = TRACKER_DATA.trackers[domain];
|
|
if (!tracker) return false;
|
|
|
|
// Apply tracker rules
|
|
if (tracker.rules) {
|
|
for (const rule of tracker.rules) {
|
|
if (this.matchesRule(url, rule, type)) {
|
|
// Check for whitelist exceptions
|
|
if (rule.whitelist && this.matchesWhitelist(window.location.hostname, rule.whitelist)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
} catch (error) {
|
|
console.warn('Error checking tracker status:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
matchesRule(url, rule, resourceType) {
|
|
// Simple regex matching for rule.rule
|
|
try {
|
|
const regex = new RegExp(rule.rule, 'i');
|
|
if (!regex.test(url)) return false;
|
|
|
|
// Check resource type restrictions
|
|
if (rule.resourceTypes && rule.resourceTypes.length > 0) {
|
|
return rule.resourceTypes.includes(resourceType);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
matchesWhitelist(hostname, whitelist) {
|
|
return whitelist.some(domain => {
|
|
if (domain.startsWith('*.')) {
|
|
const suffix = domain.substring(2);
|
|
return hostname === suffix || hostname.endsWith('.' + suffix);
|
|
}
|
|
return hostname === domain;
|
|
});
|
|
}
|
|
|
|
getSurrogate(url) {
|
|
for (const surrogate of SURROGATES) {
|
|
if (surrogate.matches.some(pattern => {
|
|
try {
|
|
const regex = new RegExp(pattern, 'i');
|
|
return regex.test(url);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})) {
|
|
return surrogate.replacement;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interceptXMLHttpRequest() {
|
|
const originalOpen = XMLHttpRequest.prototype.open;
|
|
const self = this;
|
|
|
|
XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
|
|
if (self.shouldBlockResource(url, 'xmlhttprequest', 'script')) {
|
|
self.blockedCount++;
|
|
self.reportBlocked(url, 'xhr');
|
|
|
|
// Simulate blocked request
|
|
setTimeout(() => {
|
|
const event = new Event('error');
|
|
this.dispatchEvent(event);
|
|
}, 0);
|
|
return;
|
|
}
|
|
|
|
self.allowedCount++;
|
|
return originalOpen.call(this, method, url, async, user, password);
|
|
};
|
|
}
|
|
|
|
interceptFetch() {
|
|
const originalFetch = window.fetch;
|
|
const self = this;
|
|
|
|
window.fetch = function(input, init) {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
|
|
if (self.shouldBlockResource(url, 'xmlhttprequest', 'script')) {
|
|
self.blockedCount++;
|
|
self.reportBlocked(url, 'fetch');
|
|
return Promise.reject(new TypeError('Failed to fetch'));
|
|
}
|
|
|
|
self.allowedCount++;
|
|
return originalFetch.call(this, input, init);
|
|
};
|
|
}
|
|
|
|
interceptScriptLoading() {
|
|
const self = this;
|
|
const originalCreateElement = document.createElement;
|
|
|
|
document.createElement = function(tagName) {
|
|
const element = originalCreateElement.call(this, tagName);
|
|
|
|
if (tagName.toLowerCase() === 'script') {
|
|
const originalSrcSetter = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src').set;
|
|
|
|
Object.defineProperty(element, 'src', {
|
|
set: function(value) {
|
|
if (self.shouldBlockResource(value, 'script', 'document')) {
|
|
self.blockedCount++;
|
|
self.reportBlocked(value, 'script');
|
|
|
|
// Check for surrogate
|
|
const surrogate = self.getSurrogate(value);
|
|
if (surrogate) {
|
|
self.injectSurrogate(surrogate);
|
|
}
|
|
return;
|
|
}
|
|
|
|
self.allowedCount++;
|
|
return originalSrcSetter.call(this, value);
|
|
},
|
|
get: function() {
|
|
return this.getAttribute('src');
|
|
}
|
|
});
|
|
}
|
|
|
|
return element;
|
|
};
|
|
}
|
|
|
|
interceptImageLoading() {
|
|
const self = this;
|
|
const originalImageSrc = Object.getOwnPropertyDescriptor(Image.prototype, 'src');
|
|
|
|
Object.defineProperty(Image.prototype, 'src', {
|
|
set: function(value) {
|
|
if (self.shouldBlockResource(value, 'image', 'document')) {
|
|
self.blockedCount++;
|
|
self.reportBlocked(value, 'image');
|
|
return;
|
|
}
|
|
|
|
self.allowedCount++;
|
|
return originalImageSrc.set.call(this, value);
|
|
},
|
|
get: originalImageSrc.get
|
|
});
|
|
}
|
|
|
|
setupMutationObserver() {
|
|
const self = this;
|
|
const observer = new MutationObserver(function(mutations) {
|
|
mutations.forEach(function(mutation) {
|
|
mutation.addedNodes.forEach(function(node) {
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
self.processNewElement(node);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
observer.observe(document.body || document.documentElement, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
}
|
|
|
|
processNewElement(element) {
|
|
// Check scripts
|
|
if (element.tagName === 'SCRIPT' && element.src) {
|
|
if (this.shouldBlockResource(element.src, 'script', 'document')) {
|
|
this.blockedCount++;
|
|
this.reportBlocked(element.src, 'script');
|
|
element.remove();
|
|
|
|
// Inject surrogate if available
|
|
const surrogate = this.getSurrogate(element.src);
|
|
if (surrogate) {
|
|
this.injectSurrogate(surrogate);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check images (tracking pixels)
|
|
if (element.tagName === 'IMG' && element.src) {
|
|
if (this.shouldBlockResource(element.src, 'image', 'document')) {
|
|
this.blockedCount++;
|
|
this.reportBlocked(element.src, 'image');
|
|
element.remove();
|
|
}
|
|
}
|
|
|
|
// Check iframes
|
|
if (element.tagName === 'IFRAME' && element.src) {
|
|
if (this.shouldBlockResource(element.src, 'subdocument', 'document')) {
|
|
this.blockedCount++;
|
|
this.reportBlocked(element.src, 'iframe');
|
|
element.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
injectSurrogate(surrogateCode) {
|
|
try {
|
|
const script = document.createElement('script');
|
|
script.textContent = surrogateCode;
|
|
script.setAttribute('data-surrogate', 'true');
|
|
(document.head || document.documentElement).appendChild(script);
|
|
} catch (error) {
|
|
console.warn('Failed to inject surrogate:', error);
|
|
}
|
|
}
|
|
|
|
reportBlocked(url, type) {
|
|
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.trackerBlocked) {
|
|
window.webkit.messageHandlers.trackerBlocked.postMessage({
|
|
url: url,
|
|
type: type,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
getStatistics() {
|
|
return {
|
|
blocked: this.blockedCount,
|
|
allowed: this.allowedCount
|
|
};
|
|
}
|
|
}
|
|
|
|
// Initialize tracker blocker
|
|
window.duckduckgoTracker = new TrackerBlocker();
|
|
|
|
// Report statistics periodically
|
|
setInterval(function() {
|
|
const stats = window.duckduckgoTracker.getStatistics();
|
|
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.trackerStats) {
|
|
window.webkit.messageHandlers.trackerStats.postMessage(stats);
|
|
}
|
|
}, 10000);
|
|
|
|
})();
|
|
"""
|
|
}
|
|
}
|
|
```
|
|
|
|
### Surrogate Support
|
|
|
|
One of the key advantages of JavaScript injection is the ability to provide surrogate scripts that replace blocked trackers to prevent page breakage:
|
|
|
|
```swift
|
|
final class SurrogateManager {
|
|
private let surrogates: [Surrogate]
|
|
|
|
struct Surrogate: Codable {
|
|
let name: String
|
|
let matches: [String] // Regex patterns
|
|
let replacement: String // JavaScript code
|
|
}
|
|
|
|
var allSurrogates: [Surrogate] {
|
|
return surrogates
|
|
}
|
|
|
|
init() {
|
|
// Load surrogates from embedded data
|
|
self.surrogates = loadEmbeddedSurrogates()
|
|
}
|
|
|
|
private func loadEmbeddedSurrogates() -> [Surrogate] {
|
|
// Common surrogates for popular tracking libraries
|
|
return [
|
|
Surrogate(
|
|
name: "Google Tag Manager",
|
|
matches: ["googletagmanager\\.com/gtm\\.js"],
|
|
replacement: """
|
|
window.dataLayer = window.dataLayer || [];
|
|
function gtag(){dataLayer.push(arguments);}
|
|
gtag('js', new Date());
|
|
gtag('config', 'GA_MEASUREMENT_ID', { 'send_page_view': false });
|
|
"""
|
|
),
|
|
Surrogate(
|
|
name: "Google Analytics",
|
|
matches: ["google-analytics\\.com/analytics\\.js", "googletagmanager\\.com/gtag/js"],
|
|
replacement: """
|
|
window.ga = window.ga || function() {
|
|
(ga.q = ga.q || []).push(arguments);
|
|
};
|
|
ga.l = +new Date;
|
|
ga('create', 'UA-XXXXXXXX-X', 'auto');
|
|
ga('send', 'pageview');
|
|
"""
|
|
),
|
|
Surrogate(
|
|
name: "Facebook Pixel",
|
|
matches: ["connect\\.facebook\\.net/.*?/fbevents\\.js"],
|
|
replacement: """
|
|
window.fbq = function() {};
|
|
window.fbq.push = function() {};
|
|
window.fbq.loaded = true;
|
|
window.fbq.version = '2.0';
|
|
window.fbq.queue = [];
|
|
"""
|
|
)
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
## Hybrid Integration Strategy
|
|
|
|
### Layered Protection Approach
|
|
|
|
The most effective tracker blocking combines both techniques:
|
|
|
|
1. **Content Blocker Rules** handle the majority of tracking requests efficiently
|
|
2. **JavaScript Injection** provides gap coverage and surrogate support
|
|
3. **Message Handlers** coordinate between both layers
|
|
|
|
```swift
|
|
extension BrowserViewController: WKScriptMessageHandler {
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
switch message.name {
|
|
case "trackerBlocked":
|
|
handleTrackerBlocked(message.body)
|
|
case "trackerStats":
|
|
handleTrackerStatistics(message.body)
|
|
case "contentBlocker":
|
|
handleContentBlockerMessage(message.body)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func handleTrackerBlocked(_ messageBody: Any) {
|
|
guard let data = messageBody as? [String: Any],
|
|
let url = data["url"] as? String,
|
|
let type = data["type"] as? String else { return }
|
|
|
|
// Update privacy dashboard
|
|
privacyDashboard.recordBlockedTracker(url: url, type: type)
|
|
|
|
// Update UI indicators
|
|
updateTrackerCountIndicator()
|
|
|
|
Logger.privacy.debug("Blocked tracker: \(url) (\(type))")
|
|
}
|
|
|
|
private func handleTrackerStatistics(_ messageBody: Any) {
|
|
guard let stats = messageBody as? [String: Any],
|
|
let blocked = stats["blocked"] as? Int,
|
|
let allowed = stats["allowed"] as? Int else { return }
|
|
|
|
privacyDashboard.updateStatistics(blocked: blocked, allowed: allowed)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Configuration and Initialization
|
|
|
|
The complete setup combines both blocking techniques:
|
|
|
|
```swift
|
|
extension BrowserWebView {
|
|
func setupTrackerBlocking() async {
|
|
let configuration = webView.configuration
|
|
let userContentController = configuration.userContentController
|
|
|
|
// 1. Apply Content Blocker Rules
|
|
await applyContentBlockerRules()
|
|
|
|
// 2. Add JavaScript-based blocking
|
|
let contentBlockerScript = WKUserScript(
|
|
source: ContentBlockerUserScript(
|
|
trackerDataManager: trackerDataManager,
|
|
surrogateManager: surrogateManager
|
|
).source,
|
|
injectionTime: .atDocumentStart,
|
|
forMainFrameOnly: false
|
|
)
|
|
userContentController.addUserScript(contentBlockerScript)
|
|
|
|
// 3. Add inference script for Content Blocker Rules
|
|
let inferenceScript = WKUserScript(
|
|
source: ContentBlockerRulesUserScript.source,
|
|
injectionTime: .atDocumentStart,
|
|
forMainFrameOnly: false
|
|
)
|
|
userContentController.addUserScript(inferenceScript)
|
|
|
|
// 4. Register message handlers
|
|
userContentController.add(self, name: "trackerBlocked")
|
|
userContentController.add(self, name: "trackerStats")
|
|
userContentController.add(self, name: "contentBlocker")
|
|
|
|
Logger.privacy.info("Tracker blocking initialized with hybrid approach")
|
|
}
|
|
}
|
|
```
|
|
|
|
## Why Not Content Blocking Extension?
|
|
|
|
**Content Blocking Extensions** would apply to every WKWebView in iOS system-wide, but we chose our approach for specific reasons:
|
|
|
|
### UX Control
|
|
- **Manual Activation Required**: Content Blocking Extensions require users to manually enable them in Settings
|
|
- **Limited User Guidance**: Difficult to provide contextual help for activation
|
|
- **Our Approach**: Complete control over blocking state and user experience
|
|
|
|
### Surrogate Support
|
|
- **Extensions Cannot Inject**: Content Blocking Extensions can only block, not replace with surrogates
|
|
- **Page Breakage Risk**: Many sites depend on tracking scripts for functionality
|
|
- **Our Approach**: Intelligent replacement prevents site breakage
|
|
|
|
### Dynamic Configuration
|
|
- **Static Rules Only**: Extensions cannot modify behavior based on user preferences
|
|
- **No A/B Testing**: Cannot experiment with different blocking strategies
|
|
- **Our Approach**: Runtime configuration and experimentation capability
|
|
|
|
## Performance Considerations
|
|
|
|
### Memory Usage
|
|
```swift
|
|
class TrackerBlockingPerformanceMonitor {
|
|
private var lastMemoryWarning: Date?
|
|
|
|
func optimizeForMemoryPressure() {
|
|
// Reduce JavaScript blocker complexity under memory pressure
|
|
if let lastWarning = lastMemoryWarning,
|
|
Date().timeIntervalSince(lastWarning) < 60 {
|
|
// Rely more heavily on Content Blocker Rules
|
|
disableComplexJavaScriptBlocking()
|
|
}
|
|
}
|
|
|
|
func handleMemoryWarning() {
|
|
lastMemoryWarning = Date()
|
|
|
|
// Clear caches
|
|
trackerDataManager.clearCache()
|
|
surrogateManager.clearCache()
|
|
|
|
// Temporarily disable non-essential blocking
|
|
temporarilyReduceBlockingComplexity()
|
|
}
|
|
}
|
|
```
|
|
|
|
### Battery Impact Monitoring
|
|
```swift
|
|
extension BrowserViewController {
|
|
func monitorBlockingPerformance() {
|
|
// Monitor JavaScript execution time
|
|
let startTime = CFAbsoluteTimeGetCurrent()
|
|
|
|
webView.evaluateJavaScript("window.duckduckgoTracker.getStatistics()") { result, error in
|
|
let executionTime = CFAbsoluteTimeGetCurrent() - startTime
|
|
|
|
if executionTime > 0.1 { // 100ms threshold
|
|
Logger.privacy.warning("Tracker blocking JS execution time: \(executionTime)s")
|
|
// Consider optimizing or reducing complexity
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing and Debugging
|
|
|
|
### Unit Testing Content Blocker Rules
|
|
```swift
|
|
class ContentBlockerRulesTests: XCTestCase {
|
|
func testTrackerDataConversion() async throws {
|
|
let mockTrackerData = TrackerData.mock
|
|
let rulesManager = ContentBlockerRulesManager(trackerDataManager: MockTrackerDataManager(data: mockTrackerData))
|
|
|
|
let rules = try await rulesManager.generateContentBlockerRules()
|
|
|
|
XCTAssertFalse(rules.isEmpty)
|
|
XCTAssertEqual(rules.count, 1)
|
|
}
|
|
|
|
func testRuleCompilation() async throws {
|
|
let simpleRule = [
|
|
[
|
|
"trigger": [
|
|
"url-filter": ".*tracker\\.com.*"
|
|
],
|
|
"action": [
|
|
"type": "block"
|
|
]
|
|
]
|
|
]
|
|
|
|
let jsonData = try JSONSerialization.data(withJSONObject: simpleRule)
|
|
let jsonString = String(data: jsonData, encoding: .utf8)!
|
|
|
|
let ruleList = try await withCheckedThrowingContinuation { continuation in
|
|
WKContentRuleListStore.default().compileContentRuleList(
|
|
forIdentifier: "test",
|
|
encodedContentRuleList: jsonString
|
|
) { ruleList, error in
|
|
if let error = error {
|
|
continuation.resume(throwing: error)
|
|
} else if let ruleList = ruleList {
|
|
continuation.resume(returning: ruleList)
|
|
} else {
|
|
continuation.resume(throwing: ContentBlockerError.compilationFailed)
|
|
}
|
|
}
|
|
}
|
|
|
|
XCTAssertNotNil(ruleList)
|
|
}
|
|
}
|
|
```
|
|
|
|
### UI Testing with Tracker Blocking
|
|
```swift
|
|
class TrackerBlockingUITests: XCTestCase {
|
|
func testTrackerCounterUpdates() {
|
|
let app = XCUIApplication()
|
|
app.launch()
|
|
|
|
// Navigate to a page with known trackers
|
|
app.textFields["urlField"].tap()
|
|
app.textFields["urlField"].typeText("https://example.com/page-with-trackers")
|
|
app.buttons["Go"].tap()
|
|
|
|
// Wait for page load and blocking to take effect
|
|
sleep(3)
|
|
|
|
// Check that tracker counter updated
|
|
let trackerCounter = app.staticTexts["trackerCount"]
|
|
XCTAssertTrue(trackerCounter.exists)
|
|
|
|
let counterText = trackerCounter.label
|
|
XCTAssertTrue(counterText.contains("blocked"))
|
|
}
|
|
}
|
|
```
|
|
|
|
## Maintenance and Updates
|
|
|
|
### Tracker Radar Updates
|
|
```swift
|
|
class TrackerDataUpdateManager {
|
|
func checkForUpdates() async {
|
|
let latestVersion = try await fetchLatestTrackerDataVersion()
|
|
let currentVersion = trackerDataManager.currentVersion
|
|
|
|
if latestVersion > currentVersion {
|
|
await downloadAndApplyUpdate(version: latestVersion)
|
|
}
|
|
}
|
|
|
|
private func downloadAndApplyUpdate(version: String) async {
|
|
do {
|
|
let newTrackerData = try await downloadTrackerData(version: version)
|
|
|
|
// Validate data integrity
|
|
guard validateTrackerData(newTrackerData) else {
|
|
throw TrackerDataError.invalidData
|
|
}
|
|
|
|
// Apply update
|
|
trackerDataManager.updateData(newTrackerData)
|
|
|
|
// Regenerate Content Blocker Rules
|
|
await regenerateContentBlockerRules()
|
|
|
|
Logger.privacy.info("Updated tracker data to version \(version)")
|
|
} catch {
|
|
Logger.privacy.error("Failed to update tracker data: \(error)")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Conclusion
|
|
|
|
The iOS tracker blocking implementation provides comprehensive protection through a sophisticated dual-layer approach:
|
|
|
|
- **Content Blocker Rules** deliver high-performance blocking for the majority of tracking attempts
|
|
- **JavaScript injection** ensures complete coverage and enables advanced features like surrogates
|
|
- **Hybrid coordination** maximizes both performance and protection effectiveness
|
|
|
|
This architecture gives us complete control over the user experience while maintaining the privacy protection that DuckDuckGo users expect, without the limitations of system-wide Content Blocking Extensions.
|
|
|
|
**Key Benefits:**
|
|
- ✅ **Performance**: WebKit-optimized native blocking for most requests
|
|
- ✅ **Completeness**: JavaScript layer catches edge cases and provides surrogates
|
|
- ✅ **Control**: Full UX control without requiring manual user configuration
|
|
- ✅ **Flexibility**: Runtime configuration and A/B testing capabilities
|
|
- ✅ **Maintenance**: Easy updates and improvements to blocking logic
|
|
|
|
This implementation forms the foundation of DuckDuckGo's iOS privacy protection, ensuring users browse with confidence knowing their privacy is protected by industry-leading tracker blocking technology.
|