RedditWebView.swift 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import AppKit
  2. import SwiftUI
  3. import WebKit
  4. struct RedditWebView: NSViewRepresentable {
  5. let url: URL
  6. let reloadTrigger: Int
  7. func makeCoordinator() -> Coordinator {
  8. Coordinator()
  9. }
  10. func makeNSView(context: Context) -> RedditWebViewContainer {
  11. let container = RedditWebViewContainer(webView: RedditWebViewManager.makeWebView())
  12. container.webView.navigationDelegate = context.coordinator
  13. container.webView.uiDelegate = context.coordinator
  14. context.coordinator.container = container
  15. context.coordinator.lastReloadTrigger = reloadTrigger
  16. container.load(url: url)
  17. return container
  18. }
  19. func updateNSView(_ container: RedditWebViewContainer, context: Context) {
  20. guard context.coordinator.lastReloadTrigger != reloadTrigger else { return }
  21. context.coordinator.lastReloadTrigger = reloadTrigger
  22. container.load(url: url)
  23. }
  24. final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
  25. weak var container: RedditWebViewContainer?
  26. var lastReloadTrigger = -1
  27. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  28. webView.evaluateJavaScript(RedditPostBoundaryStyle.injectionScript, completionHandler: nil)
  29. }
  30. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  31. container?.reloadLastURL()
  32. }
  33. func webView(
  34. _ webView: WKWebView,
  35. didFailProvisionalNavigation navigation: WKNavigation!,
  36. withError error: Error
  37. ) {
  38. let nsError = error as NSError
  39. guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return }
  40. container?.reloadLastURL(after: 1.0)
  41. }
  42. func webView(
  43. _ webView: WKWebView,
  44. decidePolicyFor navigationAction: WKNavigationAction,
  45. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  46. ) {
  47. guard let url = navigationAction.request.url else {
  48. decisionHandler(.allow)
  49. return
  50. }
  51. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  52. beginOAuthSignIn(url: url)
  53. decisionHandler(.cancel)
  54. return
  55. }
  56. if navigationAction.targetFrame == nil {
  57. webView.load(URLRequest(url: url))
  58. decisionHandler(.cancel)
  59. return
  60. }
  61. if navigationAction.navigationType == .linkActivated,
  62. !RedditWebAuthHelper.isRedditURL(url) {
  63. NSWorkspace.shared.open(url)
  64. decisionHandler(.cancel)
  65. return
  66. }
  67. decisionHandler(.allow)
  68. }
  69. func webView(
  70. _ webView: WKWebView,
  71. createWebViewWith configuration: WKWebViewConfiguration,
  72. for navigationAction: WKNavigationAction,
  73. windowFeatures: WKWindowFeatures
  74. ) -> WKWebView? {
  75. guard let url = navigationAction.request.url else { return nil }
  76. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  77. beginOAuthSignIn(url: url)
  78. return nil
  79. }
  80. webView.load(URLRequest(url: url))
  81. return nil
  82. }
  83. private func beginOAuthSignIn(url: URL) {
  84. let presentationWindow = container?.window
  85. OAuthAuthenticationManager.shared.authenticate(
  86. url: url,
  87. presentationWindow: presentationWindow
  88. ) { [weak self] result in
  89. guard let self, let container = self.container else { return }
  90. switch result {
  91. case .success(let callbackURL):
  92. RedditOAuthPanelController.shared.completeSignIn(callbackURL: callbackURL) {
  93. container.load(url: RedditWebAuthHelper.redditHomeURL)
  94. }
  95. case .failure(let error as OAuthError) where error == .cancelled:
  96. break
  97. case .failure:
  98. break
  99. }
  100. }
  101. }
  102. }
  103. }
  104. final class RedditWebViewContainer: NSView {
  105. let webView: WKWebView
  106. private var pendingURL: URL?
  107. private var lastLoadedURL: URL?
  108. private var reloadWorkItem: DispatchWorkItem?
  109. init(webView: WKWebView) {
  110. self.webView = webView
  111. super.init(frame: .zero)
  112. wantsLayer = true
  113. addSubview(webView)
  114. webView.translatesAutoresizingMaskIntoConstraints = false
  115. NSLayoutConstraint.activate([
  116. webView.leadingAnchor.constraint(equalTo: leadingAnchor),
  117. webView.trailingAnchor.constraint(equalTo: trailingAnchor),
  118. webView.topAnchor.constraint(equalTo: topAnchor),
  119. webView.bottomAnchor.constraint(equalTo: bottomAnchor),
  120. ])
  121. }
  122. @available(*, unavailable)
  123. required init?(coder: NSCoder) {
  124. fatalError("init(coder:) has not been implemented")
  125. }
  126. func load(url: URL) {
  127. pendingURL = url
  128. lastLoadedURL = url
  129. reloadWorkItem?.cancel()
  130. tryLoadIfReady()
  131. }
  132. func reloadLastURL(after delay: TimeInterval = 0) {
  133. guard let lastLoadedURL else { return }
  134. reloadWorkItem?.cancel()
  135. let workItem = DispatchWorkItem { [weak self] in
  136. self?.load(url: lastLoadedURL)
  137. }
  138. reloadWorkItem = workItem
  139. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  140. }
  141. override func layout() {
  142. super.layout()
  143. tryLoadIfReady()
  144. }
  145. override func viewDidMoveToWindow() {
  146. super.viewDidMoveToWindow()
  147. tryLoadIfReady()
  148. }
  149. private func tryLoadIfReady() {
  150. guard let url = pendingURL else { return }
  151. guard bounds.width > 1, bounds.height > 1 else { return }
  152. pendingURL = nil
  153. webView.load(URLRequest(url: url))
  154. }
  155. }