-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
Copy pathWebKitViewController.swift
623 lines (526 loc) · 24.1 KB
/
WebKitViewController.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
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
import Foundation
import Gridicons
import UIKit
@preconcurrency import WebKit
import WordPressShared
protocol WebKitAuthenticatable {
var authenticator: RequestAuthenticator? { get }
func authenticatedRequest(for url: URL, on webView: WKWebView, completion: @escaping (URLRequest) -> Void)
}
extension WebKitAuthenticatable {
func authenticatedRequest(for url: URL, on webView: WKWebView, completion: @escaping (URLRequest) -> Void) {
let cookieJar = webView.configuration.websiteDataStore.httpCookieStore
authenticatedRequest(for: url, with: cookieJar, completion: completion)
}
func authenticatedRequest(for url: URL, with cookieJar: CookieJar, completion: @escaping (URLRequest) -> Void) {
guard let authenticator else {
return completion(URLRequest(url: url))
}
DispatchQueue.main.async {
authenticator.request(url: url, cookieJar: cookieJar) { (request) in
completion(request)
}
}
}
}
class WebKitViewController: UIViewController, WebKitAuthenticatable {
@objc let webView: WKWebView
@objc let progressView: WebProgressView = {
let progressView = WebProgressView()
progressView.isHidden = true
return progressView
}()
@objc let titleView = NavigationTitleView()
let analyticsSource: String?
@objc lazy var backButton: UIBarButtonItem = {
let button = UIBarButtonItem(image: UIImage.gridicon(.chevronLeft).imageFlippedForRightToLeftLayoutDirection(),
style: .plain,
target: self,
action: #selector(goBack))
button.title = NSLocalizedString("Back", comment: "Previous web page")
return button
}()
@objc lazy var forwardButton: UIBarButtonItem = {
let button = UIBarButtonItem(image: .gridicon(.chevronRight),
style: .plain,
target: self,
action: #selector(goForward))
button.title = NSLocalizedString("Forward", comment: "Next web page")
return button
}()
@objc lazy var shareButton: UIBarButtonItem = {
let button = UIBarButtonItem(image: .gridicon(.shareiOS),
style: .plain,
target: self,
action: #selector(share))
button.title = NSLocalizedString(SharedStrings.Button.share, comment: "Button label to share a web page")
return button
}()
@objc lazy var safariButton: UIBarButtonItem = {
let button = UIBarButtonItem(image: .gridicon(.globe),
style: .plain,
target: self,
action: #selector(openInSafari))
button.title = NSLocalizedString("Safari", comment: "Button label to open web page in Safari")
button.accessibilityHint = NSLocalizedString("Opens the web page in Safari", comment: "Accessibility hint to open web page in Safari")
return button
}()
@objc lazy var refreshButton: UIBarButtonItem = {
let button = UIBarButtonItem(image: .gridicon(.refresh), style: .plain, target: self, action: #selector(WebKitViewController.refresh))
button.title = NSLocalizedString("Refresh", comment: "Button label to refres a web page")
return button
}()
@objc lazy var closeButton: UIBarButtonItem = {
let button = UIBarButtonItem(image: .gridicon(.cross), style: .plain, target: self, action: #selector(WebKitViewController.close))
button.title = NSLocalizedString("webKit.button.dismiss", value: "Dismiss", comment: "Verb. Dismiss the web view screen.")
return button
}()
@objc var customOptionsButton: UIBarButtonItem?
@objc let url: URL?
@objc let authenticator: RequestAuthenticator?
@objc weak var navigationDelegate: WebNavigationDelegate?
@objc var secureInteraction = false
@objc var addsWPComReferrer = false
@objc var customTitle: String?
@objc var displayStatusInNavigationBar = true
private let opensNewInSafari: Bool
let linkBehavior: LinkBehavior
private var reachabilityObserver: Any?
private var tapLocation = CGPoint(x: 0.0, y: 0.0)
private var widthConstraint: NSLayoutConstraint?
private var stackViewBottomAnchor: NSLayoutConstraint?
private var onClose: (() -> Void)?
private var navBarTitleColor: UIColor {
.label
}
private struct WebViewErrors {
static let frameLoadInterrupted = 102
}
/// Precautionary variable that's in place to make sure the web view doesn't run into an endless loop of reloads if it encounters an error.
private var hasAttemptedAuthRecovery = false
@objc init(configuration: WebViewControllerConfiguration) {
let config = WKWebViewConfiguration()
// The default on iPad is true. We want the iPhone to be true as well.
config.allowsInlineMediaPlayback = true
webView = WKWebView(frame: .zero, configuration: config)
url = configuration.url
customOptionsButton = configuration.optionsButton
secureInteraction = configuration.secureInteraction
addsWPComReferrer = configuration.addsWPComReferrer
customTitle = configuration.customTitle
authenticator = configuration.authenticator
navigationDelegate = configuration.navigationDelegate
linkBehavior = configuration.linkBehavior
opensNewInSafari = configuration.opensNewInSafari
onClose = configuration.onClose
analyticsSource = configuration.analyticsSource
displayStatusInNavigationBar = configuration.displayStatusInNavigationBar
super.init(nibName: nil, bundle: nil)
hidesBottomBarWhenPushed = true
startObservingWebView()
}
fileprivate init(url: URL, parent: WebKitViewController, configuration: WKWebViewConfiguration, source: String? = nil) {
webView = WKWebView(frame: .zero, configuration: configuration)
self.url = url
customOptionsButton = parent.customOptionsButton
secureInteraction = parent.secureInteraction
addsWPComReferrer = parent.addsWPComReferrer
customTitle = parent.customTitle
authenticator = parent.authenticator
navigationDelegate = parent.navigationDelegate
linkBehavior = parent.linkBehavior
opensNewInSafari = parent.opensNewInSafari
analyticsSource = source
super.init(nibName: nil, bundle: nil)
hidesBottomBarWhenPushed = true
startObservingWebView()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.title))
webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.url))
webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress))
webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.isLoading))
}
private func startObservingWebView() {
webView.addObserver(self, forKeyPath: #keyPath(WKWebView.title), options: [.new], context: nil)
webView.addObserver(self, forKeyPath: #keyPath(WKWebView.url), options: [.new], context: nil)
webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: [.new], context: nil)
webView.addObserver(self, forKeyPath: #keyPath(WKWebView.isLoading), options: [], context: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(light: UIAppColor.gray(.shade0), dark: .systemBackground)
let stackView = UIStackView(arrangedSubviews: [
progressView,
webView
])
stackView.axis = .vertical
stackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stackView)
let edgeConstraints = [
view.leadingAnchor.constraint(equalTo: stackView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor),
view.topAnchor.constraint(equalTo: stackView.topAnchor),
view.bottomAnchor.constraint(equalTo: stackView.bottomAnchor),
]
edgeConstraints.forEach({ $0.priority = UILayoutPriority(rawValue: UILayoutPriority.defaultHigh.rawValue - 1) })
NSLayoutConstraint.activate(edgeConstraints)
// we are pinning the top and bottom of the stack view to the safe area to prevent unintentionally hidden content/overlaps (ie cookie acceptance popup) then center the horizontal constraints vertically
let safeArea = self.view.safeAreaLayoutGuide
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
stackView.topAnchor.constraint(equalTo: safeArea.topAnchor).isActive = true
// this constraint saved as a varible so it can be deactivated when the toolbar is hidden, to prevent unintended pinning to the safe area
let stackViewBottom = stackView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor)
stackViewBottomAnchor = stackViewBottom
NSLayoutConstraint.activate([stackViewBottom])
let stackWidthConstraint = stackView.widthAnchor.constraint(equalToConstant: 0)
stackWidthConstraint.priority = UILayoutPriority.defaultLow
widthConstraint = stackWidthConstraint
NSLayoutConstraint.activate([stackWidthConstraint])
configureNavigation()
configureToolbar()
addTapGesture()
webView.customUserAgent = WPUserAgent.wordPress()
webView.navigationDelegate = self
webView.uiDelegate = self
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
loadWebViewRequest()
track(.webKitViewDisplayed)
}
override func contentScrollView(for edge: NSDirectionalRectEdge) -> UIScrollView? {
webView.scrollView
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopWaitingForConnectionRestored()
ReachabilityUtils.dismissNoInternetConnectionNotice()
track(.webKitViewDismissed)
}
@objc func loadWebViewRequest() {
if ReachabilityUtils.alertIsShowing() {
dismiss(animated: false)
}
guard let url else {
return
}
authenticatedRequest(for: url, on: webView) { [weak self] (request) in
self?.load(request: request)
}
}
@objc func load(request: URLRequest) {
var request = request
if addsWPComReferrer {
request.setValue(WPComReferrerURL, forHTTPHeaderField: "Referer")
}
webView.load(request)
}
// MARK: Navigation bar setup
@objc func configureNavigation() {
guard displayStatusInNavigationBar else {
return
}
setupNavBarTitleView()
setupRefreshButton()
// Modal styling
// Proceed only if this Modal, and it's the only view in the stack.
// We're not changing the NavigationBar style, if we're sharing it with someone else!
guard isModal() else {
return
}
setupCloseButton()
}
private func setupRefreshButton() {
if let customOptionsButton {
navigationItem.rightBarButtonItems = [refreshButton, customOptionsButton]
} else if !secureInteraction {
navigationItem.rightBarButtonItem = refreshButton
}
}
private func setupCloseButton() {
navigationItem.leftBarButtonItem = closeButton
}
private func setupNavBarTitleView() {
titleView.titleLabel.text = NSLocalizedString("Loading...", comment: "Loading. Verb")
titleView.titleLabel.textColor = navBarTitleColor
titleView.subtitleLabel.textColor = UIAppColor.neutral(.shade30)
if let title = customTitle {
self.title = title
} else {
navigationItem.titleView = titleView
}
}
// MARK: ToolBar setup
@objc func configureToolbar() {
navigationController?.isToolbarHidden = secureInteraction
guard !secureInteraction else {
// if not a secure interaction/view, no toolbar is displayed, so deactivate constraint pinning stack view to safe area
stackViewBottomAnchor?.isActive = false
return
}
configureToolbarButtons()
}
func configureToolbarButtons() {
let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let items = [
backButton,
space,
forwardButton,
space,
shareButton,
space,
safariButton
]
setToolbarItems(items, animated: false)
}
/// Sets the width of the web preview
/// - Parameter width: The width value to set the webView to
/// - Parameter viewWidth: The view width the webView must fit within, used to manage view transitions, e.g. orientation change
func setWidth(_ width: CGFloat?, viewWidth: CGFloat? = nil) {
if let width {
let horizontalViewBound: CGFloat
if let viewWidth {
horizontalViewBound = viewWidth
} else if let superViewWidth = view.superview?.frame.width {
horizontalViewBound = superViewWidth
} else {
horizontalViewBound = width
}
widthConstraint?.constant = min(width, horizontalViewBound)
widthConstraint?.priority = UILayoutPriority.defaultHigh
} else {
widthConstraint?.priority = UILayoutPriority.defaultLow
}
}
// MARK: Reachability Helpers
private func reloadWhenConnectionRestored() {
reachabilityObserver = ReachabilityUtils.observeOnceInternetAvailable { [weak self] in
self?.loadWebViewRequest()
}
}
private func stopWaitingForConnectionRestored() {
guard let reachabilityObserver else {
return
}
NotificationCenter.default.removeObserver(reachabilityObserver)
self.reachabilityObserver = nil
}
private func addTapGesture() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(webViewTapped(_:)))
tapGesture.delegate = self
webView.addGestureRecognizer(tapGesture)
}
// MARK: User Actions
@objc func close() {
dismiss(animated: true, completion: onClose)
}
@objc func share() {
guard let url = webView.url else {
return
}
let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
activityViewController.modalPresentationStyle = .popover
activityViewController.popoverPresentationController?.barButtonItem = shareButton
activityViewController.completionWithItemsHandler = { (type, completed, _, _) in
if completed, let type = type?.rawValue {
WPActivityDefaults.trackActivityType(type)
}
}
present(activityViewController, animated: true)
track(.webKitViewShareTapped)
}
@objc func refresh() {
webView.reload()
track(.webKitViewReloadTapped)
}
@objc func goBack() {
webView.goBack()
track(.webKitViewNavigatedBack)
}
@objc func goForward() {
webView.goForward()
track(.webKitViewNavigatedForward)
}
@objc func openInSafari() {
guard let url = webView.url else {
return
}
UIApplication.shared.open(url)
track(.webKitViewOpenInSafariTapped)
}
///location is used to present a document menu in tap location on iOS 13
@objc func webViewTapped(_ sender: UITapGestureRecognizer) {
self.tapLocation = sender.location(in: view)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
guard displayStatusInNavigationBar else {
return
}
guard let object = object as? WKWebView,
object == webView,
let keyPath else {
return
}
switch keyPath {
case #keyPath(WKWebView.title):
titleView.titleLabel.text = webView.title
case #keyPath(WKWebView.url):
// If the site has no title, use the url.
if webView.title?.nonEmptyString() == nil {
titleView.titleLabel.text = webView.url?.host
}
titleView.subtitleLabel.text = webView.url?.host
let haveUrl = webView.url != nil
shareButton.isEnabled = haveUrl
safariButton.isEnabled = haveUrl
navigationItem.rightBarButtonItems?.forEach { $0.isEnabled = haveUrl }
case #keyPath(WKWebView.estimatedProgress):
progressView.progress = Float(webView.estimatedProgress)
progressView.isHidden = webView.estimatedProgress == 1
case #keyPath(WKWebView.isLoading):
backButton.isEnabled = webView.canGoBack
forwardButton.isEnabled = webView.canGoForward
default:
assertionFailure("Observed change to web view that we are not handling")
}
if customTitle == nil {
// Set the title for the HUD which shows up on tap+hold w/ accessible font sizes enabled
navigationItem.title = "\(titleView.titleLabel.text ?? "")\n\n\(String(describing: titleView.subtitleLabel.text ?? ""))"
}
// Accessibility values which emulate those found in Safari
navigationItem.accessibilityLabel = NSLocalizedString("Title", comment: "Accessibility label for web page preview title")
navigationItem.titleView?.accessibilityValue = titleView.titleLabel.text
navigationItem.titleView?.accessibilityTraits = .updatesFrequently
}
private func track(_ event: WPAnalyticsEvent) {
let properties: [AnyHashable: Any] = [
"source": analyticsSource ?? "unknown"
]
WPAnalytics.track(event, properties: properties)
}
}
extension WebKitViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let delegate = navigationDelegate {
let policy = delegate.shouldNavigate(request: navigationAction.request)
if let redirect = policy.redirectRequest {
load(request: redirect)
}
decisionHandler(policy.action)
return
}
// Allow request if it is to `wp-login` for 2fa
if let url = navigationAction.request.url, authenticator?.isLogin(url: url) == true {
decisionHandler(.allow)
return
}
// Check for link protocols such as `tel:` and set the correct behavior
if let url = navigationAction.request.url, let scheme = url.scheme {
let linkProtocols = ["tel", "sms", "mailto"]
if linkProtocols.contains(scheme) && UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
decisionHandler(.cancel)
return
}
}
/// Force cross-site navigations to be opened in the web view when the counterpart app is installed.
///
/// The default system behavior (through `decisionHandler`) for cross-site navigation is to open the
/// destination URL in Safari. When both WordPress & Jetpack are installed, this caused the counterpart
/// app to catch the navigation intent and process the URL in the app instead.
///
/// We can remove this workaround when the universal link routes are removed from WordPress.com.
if MigrationAppDetection.isCounterpartAppInstalled,
let originHost = webView.url?.host?.lowercased(),
let destinationHost = navigationAction.request.url?.host?.lowercased(),
navigationAction.navigationType == .linkActivated,
destinationHost.hasSuffix("wordpress.com"),
originHost != destinationHost {
load(request: navigationAction.request)
decisionHandler(.cancel)
return
}
let policy = linkBehavior.handle(navigationAction: navigationAction, for: webView)
decisionHandler(policy)
}
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
guard navigationResponse.isForMainFrame, let authenticator, !hasAttemptedAuthRecovery else {
decisionHandler(.allow)
return
}
let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
authenticator.decideActionFor(response: navigationResponse.response, cookieJar: cookieStore) { [unowned self] action in
switch action {
case .reload:
decisionHandler(.cancel)
/// We've cleared the stored cookies so let's try again.
self.hasAttemptedAuthRecovery = true
self.loadWebViewRequest()
case .allow:
decisionHandler(.allow)
}
}
}
}
extension WebKitViewController: WKUIDelegate {
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if navigationAction.targetFrame == nil,
let url = navigationAction.request.url {
if opensNewInSafari {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
} else {
let controller = WebKitViewController(url: url, parent: self, configuration: configuration, source: analyticsSource)
let navController = UINavigationController(rootViewController: controller)
present(navController, animated: true)
return controller.webView
}
}
return nil
}
func webViewDidClose(_ webView: WKWebView) {
dismiss(animated: true)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
DDLogInfo("\(NSStringFromClass(type(of: self))) Error Loading [\(error)]")
// Don't show Frame Load Interrupted errors
let code = (error as NSError).code
if code == WebViewErrors.frameLoadInterrupted {
return
}
if !ReachabilityUtils.isInternetReachable() {
ReachabilityUtils.showNoInternetConnectionNotice()
reloadWhenConnectionRestored()
} else {
DDLogError("WebView \(webView) didFailProvisionalNavigation: \(error.localizedDescription)")
}
}
}
extension WebKitViewController: UIPopoverPresentationControllerDelegate {
func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) {
handleDocumentMenuPresentation(presented: popoverPresentationController)
}
private func handleDocumentMenuPresentation(presented: UIPopoverPresentationController) {
presented.sourceView = webView
presented.sourceRect = CGRect(origin: tapLocation, size: CGSize(width: 0, height: 0))
}
}
extension WebKitViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
extension WebKitViewController {
/// Returns the view controller wrapped in the navigation controller with
/// light mode suited for presentation of the web pages not optimized for
/// dark mode.
func makeLightNavigationController() -> UINavigationController {
let navigationController = UINavigationController(rootViewController: self)
navigationController.overrideUserInterfaceStyle = .light
navigationController.modalPresentationStyle = .formSheet
return navigationController
}
}