fancy new browser
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 113 lines 4.3 kB view raw
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}