RedditWebView.swift 11 KB

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