RedditWebView.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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. private var urlObservation: NSKeyValueObservation?
  49. var onCanGoBackChange: (Bool) -> Void
  50. private let onAuthError: (String) -> Void
  51. init(
  52. onCanGoBackChange: @escaping (Bool) -> Void,
  53. onAuthError: @escaping (String) -> Void
  54. ) {
  55. self.onCanGoBackChange = onCanGoBackChange
  56. self.onAuthError = onAuthError
  57. }
  58. func startObservingNavigation(in webView: WKWebView) {
  59. guard canGoBackObservation == nil else { return }
  60. canGoBackObservation = webView.observe(\.canGoBack, options: [.initial, .new]) { [weak self] webView, _ in
  61. DispatchQueue.main.async {
  62. self?.reportBackButtonVisibility(for: webView)
  63. }
  64. }
  65. urlObservation = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
  66. DispatchQueue.main.async {
  67. self?.reportBackButtonVisibility(for: webView)
  68. }
  69. }
  70. }
  71. private func reportBackButtonVisibility(for webView: WKWebView) {
  72. let isOnHome = webView.url.map(RedditWebAuthHelper.isRedditHomeURL) ?? false
  73. onCanGoBackChange(webView.canGoBack && !isOnHome)
  74. }
  75. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  76. startObservingNavigation(in: webView)
  77. reportBackButtonVisibility(for: webView)
  78. webView.evaluateJavaScript(RedditPostBoundaryStyle.injectionScript, completionHandler: nil)
  79. webView.evaluateJavaScript(RedditHomeChromeStyle.injectionScript, completionHandler: nil)
  80. webView.evaluateJavaScript(RedditAuthChromeStyle.injectionScript, completionHandler: nil)
  81. scheduleSubmitPrefillIfNeeded(on: webView)
  82. }
  83. private func scheduleSubmitPrefillIfNeeded(on webView: WKWebView) {
  84. guard
  85. let url = webView.url,
  86. RedditSubmitURLBuilder.isSubmitPage(url),
  87. let prefill = RedditSubmitPrefillManager.pending
  88. else { return }
  89. prefillWorkItem?.cancel()
  90. let script = RedditSubmitPrefillScript.make(prefill: prefill)
  91. guard !script.isEmpty else {
  92. RedditSubmitPrefillManager.clear()
  93. return
  94. }
  95. attemptSubmitPrefill(on: webView, script: script, remainingAttempts: 6)
  96. }
  97. private func attemptSubmitPrefill(on webView: WKWebView, script: String, remainingAttempts: Int) {
  98. guard remainingAttempts > 0, RedditSubmitPrefillManager.pending != nil else { return }
  99. webView.evaluateJavaScript(script) { result, _ in
  100. let succeeded = (result as? Bool) == true
  101. if succeeded {
  102. RedditSubmitPrefillManager.clear()
  103. return
  104. }
  105. guard remainingAttempts > 1 else {
  106. RedditSubmitPrefillManager.clear()
  107. return
  108. }
  109. let workItem = DispatchWorkItem { [weak self, weak webView] in
  110. guard let self, let webView else { return }
  111. self.attemptSubmitPrefill(on: webView, script: script, remainingAttempts: remainingAttempts - 1)
  112. }
  113. self.prefillWorkItem = workItem
  114. DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: workItem)
  115. }
  116. }
  117. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  118. container?.reloadLastURL()
  119. }
  120. func webView(
  121. _ webView: WKWebView,
  122. didFailProvisionalNavigation navigation: WKNavigation!,
  123. withError error: Error
  124. ) {
  125. let nsError = error as NSError
  126. guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return }
  127. container?.reloadLastURL(after: 1.0)
  128. }
  129. func webView(
  130. _ webView: WKWebView,
  131. decidePolicyFor navigationAction: WKNavigationAction,
  132. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  133. ) {
  134. guard let url = navigationAction.request.url else {
  135. decisionHandler(.allow)
  136. return
  137. }
  138. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  139. // Always route OAuth through ASWebAuthenticationSession. Reddit's login
  140. // modal runs in a subframe; letting WebKit handle Google/Apple SSO there
  141. // triggers SOAuthorizationCoordinator subframe errors and breaks sign-in.
  142. decisionHandler(.cancel)
  143. beginOAuthSignIn(url: url)
  144. return
  145. }
  146. if navigationAction.targetFrame == nil {
  147. webView.load(URLRequest(url: url))
  148. decisionHandler(.cancel)
  149. return
  150. }
  151. if navigationAction.navigationType == .linkActivated,
  152. !RedditWebAuthHelper.isRedditURL(url) {
  153. NSWorkspace.shared.open(url)
  154. decisionHandler(.cancel)
  155. return
  156. }
  157. decisionHandler(.allow)
  158. }
  159. func webView(
  160. _ webView: WKWebView,
  161. createWebViewWith configuration: WKWebViewConfiguration,
  162. for navigationAction: WKNavigationAction,
  163. windowFeatures: WKWindowFeatures
  164. ) -> WKWebView? {
  165. guard let url = navigationAction.request.url else { return nil }
  166. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  167. beginOAuthSignIn(url: url)
  168. return nil
  169. }
  170. webView.load(URLRequest(url: url))
  171. return nil
  172. }
  173. private func beginOAuthSignIn(url: URL) {
  174. let presentationWindow = container?.window
  175. OAuthAuthenticationManager.shared.authenticate(
  176. url: url,
  177. presentationWindow: presentationWindow
  178. ) { [weak self] result in
  179. guard let self, let container = self.container else { return }
  180. switch result {
  181. case .success(let callbackURL):
  182. RedditOAuthPanelController.shared.completeSignIn(
  183. callbackURL: callbackURL,
  184. onAuthError: onAuthError
  185. ) {
  186. container.load(url: RedditWebAuthHelper.redditHomeURL)
  187. }
  188. case .failure(let error as OAuthError) where error == .cancelled:
  189. break
  190. case .failure(let error):
  191. onAuthError(error.localizedDescription)
  192. }
  193. }
  194. }
  195. }
  196. }
  197. final class RedditWebViewContainer: NSView {
  198. let webView: WKWebView
  199. private var pendingURL: URL?
  200. private var lastLoadedURL: URL?
  201. private var reloadWorkItem: DispatchWorkItem?
  202. init(webView: WKWebView) {
  203. self.webView = webView
  204. super.init(frame: .zero)
  205. wantsLayer = true
  206. addSubview(webView)
  207. webView.translatesAutoresizingMaskIntoConstraints = false
  208. NSLayoutConstraint.activate([
  209. webView.leadingAnchor.constraint(equalTo: leadingAnchor),
  210. webView.trailingAnchor.constraint(equalTo: trailingAnchor),
  211. webView.topAnchor.constraint(equalTo: topAnchor),
  212. webView.bottomAnchor.constraint(equalTo: bottomAnchor),
  213. ])
  214. }
  215. @available(*, unavailable)
  216. required init?(coder: NSCoder) {
  217. fatalError("init(coder:) has not been implemented")
  218. }
  219. func load(url: URL) {
  220. pendingURL = url
  221. lastLoadedURL = url
  222. reloadWorkItem?.cancel()
  223. tryLoadIfReady()
  224. }
  225. func reloadLastURL(after delay: TimeInterval = 0) {
  226. guard let lastLoadedURL else { return }
  227. reloadWorkItem?.cancel()
  228. let workItem = DispatchWorkItem { [weak self] in
  229. self?.load(url: lastLoadedURL)
  230. }
  231. reloadWorkItem = workItem
  232. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  233. }
  234. override func layout() {
  235. super.layout()
  236. tryLoadIfReady()
  237. }
  238. override func viewDidMoveToWindow() {
  239. super.viewDidMoveToWindow()
  240. tryLoadIfReady()
  241. }
  242. private func tryLoadIfReady() {
  243. guard let url = pendingURL else { return }
  244. guard bounds.width > 1, bounds.height > 1 else { return }
  245. pendingURL = nil
  246. webView.load(URLRequest(url: url))
  247. }
  248. }