-
Notifications
You must be signed in to change notification settings - Fork 2.9k
/
ContentBlockerHelper.swift
303 lines (261 loc) · 10.8 KB
/
ContentBlockerHelper.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import WebKit
import Shared
import Deferred
fileprivate let NotificationContentBlockerReloadNeeded = "NotificationContentBlockerReloadNeeded"
@available(iOS 11.0, *)
class ContentBlockerHelper {
static let PrefKeyEnabledState = "prefkey.trackingprotection.enabled"
static let PrefKeyStrength = "prefkey.trackingprotection.strength"
fileprivate let blocklistBasic = ["disconnect-advertising", "disconnect-analytics", "disconnect-social"]
fileprivate let blocklistStrict = ["disconnect-content", "web-fonts"]
fileprivate let ruleStore: WKContentRuleListStore?
fileprivate weak var tab: Tab?
fileprivate weak var profile: Profile?
// Raw values are stored to prefs, be careful changing them.
enum EnabledState: String {
case on
case onInPrivateBrowsing
case off
var settingTitle: String {
switch self {
case .on:
return Strings.TrackingProtectionOptionAlwaysOn
case .onInPrivateBrowsing:
return Strings.TrackingProtectionOptionOnInPrivateBrowsing
case .off:
return Strings.TrackingProtectionOptionAlwaysOff
}
}
static let allOptions: [EnabledState] = [.on, .onInPrivateBrowsing, .off]
}
// Raw values are stored to prefs, be careful changing them.
enum BlockingStrength: String {
case basic
case strict
var settingTitle: String {
switch self {
case .basic:
return Strings.TrackingProtectionOptionBlockListTypeBasic
case .strict:
return Strings.TrackingProtectionOptionBlockListTypeStrict
}
}
var subtitle: String {
switch self {
case .basic:
return Strings.TrackingProtectionOptionBlockListTypeBasicDescription
case .strict:
return Strings.TrackingProtectionOptionBlockListTypeStrictDescription
}
}
static let allOptions: [BlockingStrength] = [.basic, .strict]
}
static func prefsChanged() {
NotificationCenter.default.post(name: Notification.Name(rawValue: NotificationContentBlockerReloadNeeded), object: nil)
}
class func name() -> String {
return "ContentBlockerHelper"
}
private static var heavyInitHasRunOnce = false
init(tab: Tab, profile: Profile) {
self.ruleStore = WKContentRuleListStore.default()
if self.ruleStore == nil {
assert(false, "WKContentRuleListStore unavailable.")
return
}
self.tab = tab
self.profile = profile
NotificationCenter.default.addObserver(self, selector: #selector(ContentBlockerHelper.reloadTab), name: NSNotification.Name(rawValue: NotificationContentBlockerReloadNeeded), object: nil)
addActiveRulesToTab(reloadTab: false)
if ContentBlockerHelper.heavyInitHasRunOnce {
return
}
ContentBlockerHelper.heavyInitHasRunOnce = true
removeOldListsByDateFromStore() {
self.removeOldListsByNameFromStore() {
self.compileListsNotInStore(completion: {})
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
fileprivate var blockingStrengthPref: BlockingStrength {
let pref = profile?.prefs.stringForKey(ContentBlockerHelper.PrefKeyStrength) ?? ""
return BlockingStrength(rawValue: pref) ?? .basic
}
fileprivate var enabledStatePref: EnabledState {
let pref = profile?.prefs.stringForKey(ContentBlockerHelper.PrefKeyEnabledState) ?? ""
return EnabledState(rawValue: pref) ?? .onInPrivateBrowsing
}
@objc func reloadTab() {
addActiveRulesToTab(reloadTab: true)
}
fileprivate func addActiveRulesToTab(reloadTab: Bool) {
guard let ruleStore = ruleStore else { return }
let rules = blocklistBasic + (blockingStrengthPref == .strict ? blocklistStrict : [])
let enabledMode = enabledStatePref
removeAllFromTab()
func addRules() {
var completed = 0
for name in rules {
ruleStore.lookUpContentRuleList(forIdentifier: name) { rule, error in
self.addToTab(contentRuleList: rule, error: error)
completed += 1
if reloadTab && completed == rules.count {
self.tab?.reload()
}
}
}
}
switch enabledStatePref {
case .off:
if reloadTab {
self.tab?.reload()
}
return
case .on:
addRules()
case .onInPrivateBrowsing:
if tab?.isPrivate ?? false {
addRules()
} else {
self.tab?.reload()
}
}
}
func removeAllFromTab() {
tab?.webView?.configuration.userContentController.removeAllContentRuleLists()
}
fileprivate func addToTab(contentRuleList: WKContentRuleList?, error: Error?) {
if let rules = contentRuleList {
tab?.webView?.configuration.userContentController.add(rules)
} else {
print("Content blocker load error: " + (error?.localizedDescription ?? "empty rules"))
assert(false)
}
}
}
// MARK: Private initialization code
// The rule store can compile JSON rule files into a private format which is cached on disk.
// On app boot, we need to check if the ruleStore's data is out-of-date, or if the names of the rule files
// no longer match. Finally, any JSON rule files that aren't in the ruleStore need to be compiled and stored in the
// ruleStore.
@available(iOS 11, *)
extension ContentBlockerHelper {
fileprivate func loadJsonFromBundle(forResource file: String, completion: @escaping (_ jsonString: String) -> Void) {
DispatchQueue.global().async {
guard let path = Bundle.main.path(forResource: file, ofType: "json"),
let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String else {
return
}
DispatchQueue.main.async {
completion(source)
}
}
}
fileprivate func lastModifiedSince1970(forFileAtPath path: String) -> Timestamp? {
do {
let url = URL(fileURLWithPath: path)
let attr = try FileManager.default.attributesOfItem(atPath: url.path)
guard let date = attr[FileAttributeKey.modificationDate] as? Date else { return nil }
return UInt64(1000.0 * date.timeIntervalSince1970)
} catch {
return nil
}
}
fileprivate func dateOfMostRecentBlockerFile() -> Timestamp {
let blocklists = blocklistBasic + blocklistStrict
return blocklists.reduce(Timestamp(0)) { result, filename in
guard let path = Bundle.main.path(forResource: filename, ofType: "json") else { return result }
let date = lastModifiedSince1970(forFileAtPath: path) ?? 0
return date > result ? date : result
}
}
fileprivate func removeAllRulesInStore(completion: @escaping () -> Void) {
guard let ruleStore = ruleStore else { return }
ruleStore.getAvailableContentRuleListIdentifiers { available in
guard let available = available else {
completion()
return
}
let deferreds: [Deferred<Void>] = available.map { filename in
let result = Deferred<Void>()
ruleStore.removeContentRuleList(forIdentifier: filename) { _ in
result.fill()
}
return result
}
all(deferreds).uponQueue(.main) { _ in
completion()
}
}
}
// If any blocker files are newer than the date saved in prefs,
// remove all the content blockers and reload them.
fileprivate func removeOldListsByDateFromStore(completion: @escaping () -> Void) {
let fileDate = self.dateOfMostRecentBlockerFile()
let prefsNewestDate = profile?.prefs.longForKey("blocker-file-date") ?? 0
if prefsNewestDate < 1 || fileDate <= prefsNewestDate {
completion()
return
}
profile?.prefs.setTimestamp(fileDate, forKey: "blocker-file-date")
self.removeAllRulesInStore() {
completion()
}
}
fileprivate func removeOldListsByNameFromStore(completion: @escaping () -> Void) {
guard let ruleStore = ruleStore else { return }
var noMatchingIdentifierFoundForRule = false
ruleStore.getAvailableContentRuleListIdentifiers { available in
guard let available = available else {
completion()
return
}
let blocklists = self.blocklistBasic + self.blocklistStrict
for contentRuleIdentifier in available {
if !blocklists.contains(where: { $0 == contentRuleIdentifier }) {
noMatchingIdentifierFoundForRule = true
break
}
}
let fileDate = self.dateOfMostRecentBlockerFile()
let prefsNewestDate = self.profile?.prefs.timestampForKey("blocker-file-date") ?? 0
if prefsNewestDate > 0 && fileDate <= prefsNewestDate && !noMatchingIdentifierFoundForRule {
completion()
return
}
self.profile?.prefs.setTimestamp(fileDate, forKey: "blocker-file-date")
self.removeAllRulesInStore {
completion()
}
}
}
// Pass true to completion block if the rule store was modified.
fileprivate func compileListsNotInStore(completion: @escaping () -> Void) {
guard let ruleStore = ruleStore else { return }
let blocklists = blocklistBasic + blocklistStrict
let deferreds: [Deferred<Void>] = blocklists.map { filename in
let result = Deferred<Void>()
ruleStore.lookUpContentRuleList(forIdentifier: filename) { contentRuleList, error in
if contentRuleList != nil {
result.fill()
return
}
self.loadJsonFromBundle(forResource: filename) { jsonString in
ruleStore.compileContentRuleList(forIdentifier: filename, encodedContentRuleList: jsonString) { _, _ in
result.fill()
}
}
}
return result
}
all(deferreds).uponQueue(.main) { _ in
completion()
}
}
}