RedditWebView.swift 8.0 KB

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