fancy new browser
1import Foundation
2import WebKit
3import MereKit
4
5/// Ad blocker for WebKit using WKContentRuleListStore.
6///
7/// How it works:
8/// - Converts BlockList rules → Apple's content blocker JSON format
9/// - Compiles them into a WKContentRuleList (bytecode, runs inside WebKit — no Swift
10/// callbacks per request, zero performance overhead)
11/// - Applies the compiled list to the shared WKWebViewConfiguration so all
12/// WebKitWebContent instances in this context are blocked automatically
13@MainActor
14public final class WebKitAdBlocker: AdBlockEngine {
15
16 public var isEnabled: Bool = true {
17 didSet { Task { await applyToConfiguration() } }
18 }
19
20 public private(set) var loadedLists: [String: Int] = [:]
21 public private(set) var totalBlockedRequestCount = 0
22
23 private let store: WKContentRuleListStore
24 private let configuration: WKWebViewConfiguration
25 private var compiledLists: [String: WKContentRuleList] = [:]
26
27 /// `store` is keyed to a directory so compiled bytecode survives app restarts.
28 public init(configuration: WKWebViewConfiguration, storageURL: URL? = nil) {
29 self.configuration = configuration
30 self.store = storageURL.map { WKContentRuleListStore(url: $0) }
31 ?? .default()
32 }
33
34 // MARK: - AdBlockEngine
35
36 public func load(_ list: BlockList) async throws {
37 let json = try appleContentBlockerJSON(from: list)
38 let compiled: WKContentRuleList = try await withCheckedThrowingContinuation { continuation in
39 store.compileContentRuleList(forIdentifier: list.name, encodedContentRuleList: json) { result, error in
40 if let error { continuation.resume(throwing: error) }
41 else if let result { continuation.resume(returning: result) }
42 else { continuation.resume(throwing: ContentBlockerError.compilationFailed) }
43 }
44 }
45 compiledLists[list.name] = compiled
46 loadedLists[list.name] = list.blockCount
47 await applyToConfiguration()
48 }
49
50 public func remove(listNamed name: String) async {
51 compiledLists.removeValue(forKey: name)
52 loadedLists.removeValue(forKey: name)
53 store.removeContentRuleList(forIdentifier: name) { _ in }
54 await applyToConfiguration()
55 }
56
57 /// Apply the current enabled rule lists to a freshly created content controller.
58 /// Called by WebKitBrowserContext when making a new tab.
59 public func applyCurrentRules(to controller: WKUserContentController) {
60 guard isEnabled else { return }
61 for list in compiledLists.values { controller.add(list) }
62 }
63
64 // MARK: - Private
65
66 private func applyToConfiguration() async {
67 let controller = configuration.userContentController
68 controller.removeAllContentRuleLists()
69 guard isEnabled else { return }
70 for list in compiledLists.values {
71 controller.add(list)
72 }
73 }
74
75 // MARK: - JSON conversion
76
77 /// Converts our engine-agnostic rules to Apple's content blocker JSON format.
78 /// Spec: https://webkit.org/blog/3476/content-blockers-first-look/
79 private func appleContentBlockerJSON(from list: BlockList) throws -> String {
80 var entries: [[String: Any]] = []
81
82 for rule in list.rules {
83 var trigger: [String: Any] = ["url-filter": rule.urlPattern]
84
85 if !rule.resourceTypes.isEmpty {
86 trigger["resource-type"] = rule.resourceTypes.map { $0.rawValue }
87 }
88 if !rule.ifDomain.isEmpty {
89 trigger["if-domain"] = rule.ifDomain.map { "*\($0)" }
90 }
91 if !rule.unlessDomain.isEmpty {
92 trigger["unless-domain"] = rule.unlessDomain.map { "*\($0)" }
93 }
94
95 let action: [String: Any] = switch rule.action {
96 case .block: ["type": "block"]
97 case .allowList: ["type": "ignore-previous-rules"]
98 }
99
100 entries.append(["trigger": trigger, "action": action])
101
102 // WKContentRuleList has a hard cap of 150k rules per list
103 if entries.count >= 149_000 { break }
104 }
105
106 let data = try JSONSerialization.data(withJSONObject: entries)
107 return String(decoding: data, as: UTF8.self)
108 }
109}
110
111enum ContentBlockerError: Error {
112 case compilationFailed
113}