RedditWebView.swift 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  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. let urlChanged = context.coordinator.lastLoadedURL != url
  21. let triggerChanged = context.coordinator.lastReloadTrigger != reloadTrigger
  22. guard urlChanged || triggerChanged else { return }
  23. context.coordinator.lastReloadTrigger = reloadTrigger
  24. context.coordinator.lastLoadedURL = url
  25. container.load(url: url)
  26. }
  27. final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
  28. weak var container: RedditWebViewContainer?
  29. var lastReloadTrigger = -1
  30. var lastLoadedURL: URL?
  31. private var prefillWorkItem: DispatchWorkItem?
  32. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  33. webView.evaluateJavaScript(RedditPostBoundaryStyle.injectionScript, completionHandler: nil)
  34. scheduleSubmitPrefillIfNeeded(on: webView)
  35. }
  36. private func scheduleSubmitPrefillIfNeeded(on webView: WKWebView) {
  37. guard
  38. let url = webView.url,
  39. RedditSubmitURLBuilder.isSubmitPage(url),
  40. let prefill = RedditSubmitPrefillManager.pending
  41. else { return }
  42. prefillWorkItem?.cancel()
  43. let script = RedditSubmitPrefillScript.make(prefill: prefill)
  44. guard !script.isEmpty else {
  45. RedditSubmitPrefillManager.clear()
  46. return
  47. }
  48. attemptSubmitPrefill(on: webView, script: script, remainingAttempts: 6)
  49. }
  50. private func attemptSubmitPrefill(on webView: WKWebView, script: String, remainingAttempts: Int) {
  51. guard remainingAttempts > 0, RedditSubmitPrefillManager.pending != nil else { return }
  52. webView.evaluateJavaScript(script) { result, _ in
  53. let succeeded = (result as? Bool) == true
  54. if succeeded {
  55. RedditSubmitPrefillManager.clear()
  56. return
  57. }
  58. guard remainingAttempts > 1 else {
  59. RedditSubmitPrefillManager.clear()
  60. return
  61. }
  62. let workItem = DispatchWorkItem { [weak self, weak webView] in
  63. guard let self, let webView else { return }
  64. self.attemptSubmitPrefill(on: webView, script: script, remainingAttempts: remainingAttempts - 1)
  65. }
  66. self.prefillWorkItem = workItem
  67. DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: workItem)
  68. }
  69. }
  70. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  71. container?.reloadLastURL()
  72. }
  73. func webView(
  74. _ webView: WKWebView,
  75. didFailProvisionalNavigation navigation: WKNavigation!,
  76. withError error: Error
  77. ) {
  78. let nsError = error as NSError
  79. guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return }
  80. container?.reloadLastURL(after: 1.0)
  81. }
  82. func webView(
  83. _ webView: WKWebView,
  84. decidePolicyFor navigationAction: WKNavigationAction,
  85. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  86. ) {
  87. guard let url = navigationAction.request.url else {
  88. decisionHandler(.allow)
  89. return
  90. }
  91. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  92. beginOAuthSignIn(url: url)
  93. decisionHandler(.cancel)
  94. return
  95. }
  96. if navigationAction.targetFrame == nil {
  97. webView.load(URLRequest(url: url))
  98. decisionHandler(.cancel)
  99. return
  100. }
  101. if navigationAction.navigationType == .linkActivated,
  102. !RedditWebAuthHelper.isRedditURL(url) {
  103. NSWorkspace.shared.open(url)
  104. decisionHandler(.cancel)
  105. return
  106. }
  107. decisionHandler(.allow)
  108. }
  109. func webView(
  110. _ webView: WKWebView,
  111. createWebViewWith configuration: WKWebViewConfiguration,
  112. for navigationAction: WKNavigationAction,
  113. windowFeatures: WKWindowFeatures
  114. ) -> WKWebView? {
  115. guard let url = navigationAction.request.url else { return nil }
  116. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  117. beginOAuthSignIn(url: url)
  118. return nil
  119. }
  120. webView.load(URLRequest(url: url))
  121. return nil
  122. }
  123. private func beginOAuthSignIn(url: URL) {
  124. let presentationWindow = container?.window
  125. OAuthAuthenticationManager.shared.authenticate(
  126. url: url,
  127. presentationWindow: presentationWindow
  128. ) { [weak self] result in
  129. guard let self, let container = self.container else { return }
  130. switch result {
  131. case .success(let callbackURL):
  132. RedditOAuthPanelController.shared.completeSignIn(callbackURL: callbackURL) {
  133. container.load(url: RedditWebAuthHelper.redditHomeURL)
  134. }
  135. case .failure(let error as OAuthError) where error == .cancelled:
  136. break
  137. case .failure:
  138. break
  139. }
  140. }
  141. }
  142. }
  143. }
  144. final class RedditWebViewContainer: NSView {
  145. let webView: WKWebView
  146. private var pendingURL: URL?
  147. private var lastLoadedURL: URL?
  148. private var reloadWorkItem: DispatchWorkItem?
  149. init(webView: WKWebView) {
  150. self.webView = webView
  151. super.init(frame: .zero)
  152. wantsLayer = true
  153. addSubview(webView)
  154. webView.translatesAutoresizingMaskIntoConstraints = false
  155. NSLayoutConstraint.activate([
  156. webView.leadingAnchor.constraint(equalTo: leadingAnchor),
  157. webView.trailingAnchor.constraint(equalTo: trailingAnchor),
  158. webView.topAnchor.constraint(equalTo: topAnchor),
  159. webView.bottomAnchor.constraint(equalTo: bottomAnchor),
  160. ])
  161. }
  162. @available(*, unavailable)
  163. required init?(coder: NSCoder) {
  164. fatalError("init(coder:) has not been implemented")
  165. }
  166. func load(url: URL) {
  167. pendingURL = url
  168. lastLoadedURL = url
  169. reloadWorkItem?.cancel()
  170. tryLoadIfReady()
  171. }
  172. func reloadLastURL(after delay: TimeInterval = 0) {
  173. guard let lastLoadedURL else { return }
  174. reloadWorkItem?.cancel()
  175. let workItem = DispatchWorkItem { [weak self] in
  176. self?.load(url: lastLoadedURL)
  177. }
  178. reloadWorkItem = workItem
  179. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  180. }
  181. override func layout() {
  182. super.layout()
  183. tryLoadIfReady()
  184. }
  185. override func viewDidMoveToWindow() {
  186. super.viewDidMoveToWindow()
  187. tryLoadIfReady()
  188. }
  189. private func tryLoadIfReady() {
  190. guard let url = pendingURL else { return }
  191. guard bounds.width > 1, bounds.height > 1 else { return }
  192. pendingURL = nil
  193. webView.load(URLRequest(url: url))
  194. }
  195. }