-
Notifications
You must be signed in to change notification settings - Fork 2.9k
/
Tab.swift
573 lines (477 loc) · 19.9 KB
/
Tab.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
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
/* 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 Foundation
import WebKit
import Storage
import Shared
import SwiftyJSON
import XCGLogger
protocol TabContentScript {
static func name() -> String
func scriptMessageHandlerName() -> String?
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage)
}
@objc
protocol TabDelegate {
func tab(_ tab: Tab, didAddSnackbar bar: SnackBar)
func tab(_ tab: Tab, didRemoveSnackbar bar: SnackBar)
func tab(_ tab: Tab, didSelectFindInPageForSelection selection: String)
@objc optional func tab(_ tab: Tab, didCreateWebView webView: WKWebView)
@objc optional func tab(_ tab: Tab, willDeleteWebView webView: WKWebView)
}
@objc
protocol URLChangeDelegate {
func tab(_ tab: Tab, urlDidChangeTo url: URL)
}
struct TabState {
var isPrivate: Bool = false
var desktopSite: Bool = false
var url: URL?
var title: String?
var favicon: Favicon?
}
class Tab: NSObject {
fileprivate var _isPrivate: Bool = false
internal fileprivate(set) var isPrivate: Bool {
get {
return _isPrivate
}
set {
if _isPrivate != newValue {
_isPrivate = newValue
}
}
}
var tabState: TabState {
return TabState(isPrivate: _isPrivate, desktopSite: desktopSite, url: url, title: displayTitle, favicon: displayFavicon)
}
// PageMetadata is derived from the page content itself, and as such lags behind the
// rest of the tab.
var pageMetadata: PageMetadata?
var canonicalURL: URL? {
if let string = pageMetadata?.siteURL,
let siteURL = URL(string: string) {
return siteURL
}
return self.url
}
var userActivity: NSUserActivity?
var webView: WKWebView?
var tabDelegate: TabDelegate?
weak var urlDidChangeDelegate: URLChangeDelegate? // TODO: generalize this.
var bars = [SnackBar]()
var favicons = [Favicon]()
var lastExecutedTime: Timestamp?
var sessionData: SessionData?
fileprivate var lastRequest: URLRequest?
var restoring: Bool = false
var pendingScreenshot = false
var url: URL?
var mimeType: String?
var isEditing: Bool = false
fileprivate var _noImageMode = false
/// Returns true if this tab's URL is known, and it's longer than we want to store.
var urlIsTooLong: Bool {
guard let url = self.url else {
return false
}
return url.absoluteString.lengthOfBytes(using: .utf8) > AppConstants.DB_URL_LENGTH_MAX
}
// Use computed property so @available can be used to guard `noImageMode`.
@available(iOS 11, *)
var noImageMode: Bool {
get { return _noImageMode }
set {
if newValue == _noImageMode {
return
}
_noImageMode = newValue
let helper = (contentBlocker as? ContentBlockerHelper)
helper?.noImageMode(enabled: _noImageMode)
}
}
// There is no 'available macro' on props, we currently just need to store ownership.
var contentBlocker: AnyObject?
/// The last title shown by this tab. Used by the tab tray to show titles for zombie tabs.
var lastTitle: String?
/// Whether or not the desktop site was requested with the last request, reload or navigation. Note that this property needs to
/// be managed by the web view's navigation delegate.
var desktopSite: Bool = false
var readerModeAvailableOrActive: Bool {
if let readerMode = self.getContentScript(name: "ReaderMode") as? ReaderMode {
return readerMode.state != .unavailable
}
return false
}
fileprivate(set) var screenshot: UIImage?
var screenshotUUID: UUID?
// If this tab has been opened from another, its parent will point to the tab from which it was opened
var parent: Tab?
fileprivate var contentScriptManager = TabContentScriptManager()
private(set) var userScriptManager: UserScriptManager?
fileprivate var configuration: WKWebViewConfiguration?
/// Any time a tab tries to make requests to display a Javascript Alert and we are not the active
/// tab instance, queue it for later until we become foregrounded.
fileprivate var alertQueue = [JSAlertInfo]()
init(configuration: WKWebViewConfiguration, isPrivate: Bool = false) {
self.configuration = configuration
super.init()
self.isPrivate = isPrivate
if #available(iOS 11, *) {
if let appDelegate = UIApplication.shared.delegate as? AppDelegate, let profile = appDelegate.profile {
contentBlocker = ContentBlockerHelper(tab: self, profile: profile)
}
}
}
class func toTab(_ tab: Tab) -> RemoteTab? {
if let displayURL = tab.url?.displayURL, RemoteTab.shouldIncludeURL(displayURL) {
let history = Array(tab.historyList.filter(RemoteTab.shouldIncludeURL).reversed())
return RemoteTab(clientGUID: nil,
URL: displayURL,
title: tab.displayTitle,
history: history,
lastUsed: Date.now(),
icon: nil)
} else if let sessionData = tab.sessionData, !sessionData.urls.isEmpty {
let history = Array(sessionData.urls.filter(RemoteTab.shouldIncludeURL).reversed())
if let displayURL = history.first {
return RemoteTab(clientGUID: nil,
URL: displayURL,
title: tab.displayTitle,
history: history,
lastUsed: sessionData.lastUsedTime,
icon: nil)
}
}
return nil
}
weak var navigationDelegate: WKNavigationDelegate? {
didSet {
if let webView = webView {
webView.navigationDelegate = navigationDelegate
}
}
}
func createWebview() {
if webView == nil {
assert(configuration != nil, "Create webview can only be called once")
configuration!.userContentController = WKUserContentController()
configuration!.preferences = WKPreferences()
configuration!.preferences.javaScriptCanOpenWindowsAutomatically = false
configuration!.allowsInlineMediaPlayback = true
let webView = TabWebView(frame: .zero, configuration: configuration!)
webView.delegate = self
configuration = nil
webView.accessibilityLabel = NSLocalizedString("Web content", comment: "Accessibility label for the main web content view")
webView.allowsBackForwardNavigationGestures = true
webView.allowsLinkPreview = false
// Night mode enables this by toggling WKWebView.isOpaque, otherwise this has no effect.
webView.backgroundColor = .black
// Turning off masking allows the web content to flow outside of the scrollView's frame
// which allows the content appear beneath the toolbars in the BrowserViewController
webView.scrollView.layer.masksToBounds = false
webView.navigationDelegate = navigationDelegate
restore(webView)
self.webView = webView
self.webView?.addObserver(self, forKeyPath: KVOConstants.URL.rawValue, options: .new, context: nil)
self.userScriptManager = UserScriptManager(tab: self)
tabDelegate?.tab?(self, didCreateWebView: webView)
}
}
func restore(_ webView: WKWebView) {
// Pulls restored session data from a previous SavedTab to load into the Tab. If it's nil, a session restore
// has already been triggered via custom URL, so we use the last request to trigger it again; otherwise,
// we extract the information needed to restore the tabs and create a NSURLRequest with the custom session restore URL
// to trigger the session restore via custom handlers
if let sessionData = self.sessionData {
restoring = true
var urls = [String]()
for url in sessionData.urls {
urls.append(url.absoluteString)
}
let currentPage = sessionData.currentPage
self.sessionData = nil
var jsonDict = [String: AnyObject]()
jsonDict["history"] = urls as AnyObject?
jsonDict["currentPage"] = currentPage as AnyObject?
guard let json = JSON(jsonDict).stringValue() else {
return
}
let escapedJSON = json.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let restoreURL = URL(string: "\(WebServer.sharedInstance.base)/about/sessionrestore?history=\(escapedJSON)")
lastRequest = PrivilegedRequest(url: restoreURL!) as URLRequest
webView.load(lastRequest!)
} else if let request = lastRequest {
webView.load(request)
} else {
print("creating webview with no lastRequest and no session data: \(self.url?.description ?? "nil")")
}
}
deinit {
if let webView = webView {
webView.removeObserver(self, forKeyPath: KVOConstants.URL.rawValue)
tabDelegate?.tab?(self, willDeleteWebView: webView)
}
contentScriptManager.helpers.removeAll()
}
var loading: Bool {
return webView?.isLoading ?? false
}
var estimatedProgress: Double {
return webView?.estimatedProgress ?? 0
}
var backList: [WKBackForwardListItem]? {
return webView?.backForwardList.backList
}
var forwardList: [WKBackForwardListItem]? {
return webView?.backForwardList.forwardList
}
var historyList: [URL] {
func listToUrl(_ item: WKBackForwardListItem) -> URL { return item.url }
var tabs = self.backList?.map(listToUrl) ?? [URL]()
tabs.append(self.url!)
return tabs
}
var title: String? {
return webView?.title
}
var displayTitle: String {
if let title = webView?.title, !title.isEmpty {
return title
}
// When picking a display title. Tabs with sessionData are pending a restore so show their old title.
// To prevent flickering of the display title. If a tab is restoring make sure to use its lastTitle.
if let url = self.url, url.isAboutHomeURL, sessionData == nil, !restoring {
return ""
}
guard let lastTitle = lastTitle, !lastTitle.isEmpty else {
return self.url?.displayURL?.absoluteString ?? ""
}
return lastTitle
}
var currentInitialURL: URL? {
return self.webView?.backForwardList.currentItem?.initialURL
}
var displayFavicon: Favicon? {
return favicons.max { $0.width! < $1.width! }
}
var canGoBack: Bool {
return webView?.canGoBack ?? false
}
var canGoForward: Bool {
return webView?.canGoForward ?? false
}
func goBack() {
_ = webView?.goBack()
}
func goForward() {
_ = webView?.goForward()
}
func goToBackForwardListItem(_ item: WKBackForwardListItem) {
_ = webView?.go(to: item)
}
@discardableResult func loadRequest(_ request: URLRequest) -> WKNavigation? {
if let webView = webView {
lastRequest = request
return webView.load(request)
}
return nil
}
func stop() {
webView?.stopLoading()
}
func reload() {
let userAgent: String? = desktopSite ? UserAgent.desktopUserAgent() : nil
if (userAgent ?? "") != webView?.customUserAgent,
let currentItem = webView?.backForwardList.currentItem {
webView?.customUserAgent = userAgent
// Reload the initial URL to avoid UA specific redirection
loadRequest(PrivilegedRequest(url: currentItem.initialURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 60) as URLRequest)
return
}
if let _ = webView?.reloadFromOrigin() {
print("reloaded zombified tab from origin")
return
}
if let webView = self.webView {
print("restoring webView from scratch")
restore(webView)
}
}
func addContentScript(_ helper: TabContentScript, name: String) {
contentScriptManager.addContentScript(helper, name: name, forTab: self)
}
func getContentScript(name: String) -> TabContentScript? {
return contentScriptManager.getContentScript(name)
}
func hideContent(_ animated: Bool = false) {
webView?.isUserInteractionEnabled = false
if animated {
UIView.animate(withDuration: 0.25, animations: { () -> Void in
self.webView?.alpha = 0.0
})
} else {
webView?.alpha = 0.0
}
}
func showContent(_ animated: Bool = false) {
webView?.isUserInteractionEnabled = true
if animated {
UIView.animate(withDuration: 0.25, animations: { () -> Void in
self.webView?.alpha = 1.0
})
} else {
webView?.alpha = 1.0
}
}
func addSnackbar(_ bar: SnackBar) {
bars.append(bar)
tabDelegate?.tab(self, didAddSnackbar: bar)
}
func removeSnackbar(_ bar: SnackBar) {
if let index = bars.index(of: bar) {
bars.remove(at: index)
tabDelegate?.tab(self, didRemoveSnackbar: bar)
}
}
func removeAllSnackbars() {
// Enumerate backwards here because we'll remove items from the list as we go.
bars.reversed().forEach { removeSnackbar($0) }
}
func expireSnackbars() {
// Enumerate backwards here because we may remove items from the list as we go.
bars.reversed().filter({ !$0.shouldPersist(self) }).forEach({ removeSnackbar($0) })
}
func setScreenshot(_ screenshot: UIImage?, revUUID: Bool = true) {
self.screenshot = screenshot
if revUUID {
self.screenshotUUID = UUID()
}
}
func toggleDesktopSite() {
desktopSite = !desktopSite
reload()
}
func queueJavascriptAlertPrompt(_ alert: JSAlertInfo) {
alertQueue.append(alert)
}
func dequeueJavascriptAlertPrompt() -> JSAlertInfo? {
guard !alertQueue.isEmpty else {
return nil
}
return alertQueue.removeFirst()
}
func cancelQueuedAlerts() {
alertQueue.forEach { alert in
alert.cancel()
}
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
guard let webView = object as? WKWebView, webView == self.webView,
let path = keyPath, path == KVOConstants.URL.rawValue else {
return assertionFailure("Unhandled KVO key: \(keyPath ?? "nil")")
}
guard let url = self.webView?.url else {
return
}
self.urlDidChangeDelegate?.tab(self, urlDidChangeTo: url)
}
func isDescendentOf(_ ancestor: Tab) -> Bool {
return sequence(first: parent) { $0?.parent }.contains { $0 == ancestor }
}
func setNightMode(_ enabled: Bool) {
webView?.evaluateJavaScript("window.__firefox__.NightMode.setEnabled(\(enabled))", completionHandler: nil)
// For WKWebView background color to take effect, isOpaque must be false, which is counter-intuitive. Default is true.
// The color is previously set to black in the webview init
webView?.isOpaque = !enabled
}
func injectUserScriptWith(fileName: String, type: String = "js", injectionTime: WKUserScriptInjectionTime = .atDocumentEnd, mainFrameOnly: Bool = true) {
guard let webView = self.webView else {
return
}
if let path = Bundle.main.path(forResource: fileName, ofType: type),
let source = try? String(contentsOfFile: path) {
let userScript = WKUserScript(source: source, injectionTime: injectionTime, forMainFrameOnly: mainFrameOnly)
webView.configuration.userContentController.addUserScript(userScript)
}
}
func observeURLChanges(delegate: URLChangeDelegate) {
self.urlDidChangeDelegate = delegate
}
func removeURLChangeObserver(delegate: URLChangeDelegate) {
if let existing = self.urlDidChangeDelegate, existing === delegate {
self.urlDidChangeDelegate = nil
}
}
}
extension Tab: TabWebViewDelegate {
fileprivate func tabWebView(_ tabWebView: TabWebView, didSelectFindInPageForSelection selection: String) {
tabDelegate?.tab(self, didSelectFindInPageForSelection: selection)
}
}
private class TabContentScriptManager: NSObject, WKScriptMessageHandler {
fileprivate var helpers = [String: TabContentScript]()
@objc func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
for helper in helpers.values {
if let scriptMessageHandlerName = helper.scriptMessageHandlerName() {
if scriptMessageHandlerName == message.name {
helper.userContentController(userContentController, didReceiveScriptMessage: message)
return
}
}
}
}
func addContentScript(_ helper: TabContentScript, name: String, forTab tab: Tab) {
if let _ = helpers[name] {
assertionFailure("Duplicate helper added: \(name)")
}
helpers[name] = helper
// If this helper handles script messages, then get the handler name and register it. The Browser
// receives all messages and then dispatches them to the right TabHelper.
if let scriptMessageHandlerName = helper.scriptMessageHandlerName() {
tab.webView?.configuration.userContentController.add(self, name: scriptMessageHandlerName)
}
}
func getContentScript(_ name: String) -> TabContentScript? {
return helpers[name]
}
}
private protocol TabWebViewDelegate: class {
func tabWebView(_ tabWebView: TabWebView, didSelectFindInPageForSelection selection: String)
}
private class TabWebView: WKWebView, MenuHelperInterface {
fileprivate weak var delegate: TabWebViewDelegate?
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return super.canPerformAction(action, withSender: sender) || action == MenuHelper.SelectorFindInPage
}
@objc func menuHelperFindInPage() {
evaluateJavaScript("getSelection().toString()") { result, _ in
let selection = result as? String ?? ""
self.delegate?.tabWebView(self, didSelectFindInPageForSelection: selection)
}
}
fileprivate override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// The find-in-page selection menu only appears if the webview is the first responder.
becomeFirstResponder()
return super.hitTest(point, with: event)
}
}
///
// Temporary fix for Bug 1390871 - NSInvalidArgumentException: -[WKContentView menuHelperFindInPage]: unrecognized selector
//
// This class only exists to contain the swizzledMenuHelperFindInPage. This class is actually never
// instantiated. It only serves as a placeholder for the method. When the method is called, self is
// actually pointing to a WKContentView. Which is not public, but that is fine, we only need to know
// that it is a UIView subclass to access its superview.
//
class TabWebViewMenuHelper: UIView {
@objc func swizzledMenuHelperFindInPage() {
if let tabWebView = superview?.superview as? TabWebView {
tabWebView.evaluateJavaScript("getSelection().toString()") { result, _ in
let selection = result as? String ?? ""
tabWebView.delegate?.tabWebView(tabWebView, didSelectFindInPageForSelection: selection)
}
}
}
}