RedditWebView.swift 8.4 KB

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