RedditWebView.swift 9.6 KB

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