RedditWebView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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.installLoginBridge()
  25. context.coordinator.startObservingNavigation(in: container.webView)
  26. container.applyUnderPageBackground(isDarkMode: isDarkMode)
  27. container.load(url: url)
  28. return container
  29. }
  30. func updateNSView(_ container: RedditWebViewContainer, context: Context) {
  31. context.coordinator.onCanGoBackChange = onCanGoBackChange
  32. context.coordinator.installLoginBridge()
  33. if context.coordinator.lastAppliedDarkMode != isDarkMode {
  34. context.coordinator.lastAppliedDarkMode = isDarkMode
  35. container.applyUnderPageBackground(isDarkMode: isDarkMode)
  36. }
  37. if context.coordinator.lastGoBackTrigger != goBackTrigger {
  38. context.coordinator.lastGoBackTrigger = goBackTrigger
  39. if container.webView.canGoBack {
  40. container.webView.goBack()
  41. }
  42. }
  43. let urlChanged = context.coordinator.lastLoadedURL != url
  44. let triggerChanged = context.coordinator.lastReloadTrigger != reloadTrigger
  45. guard urlChanged || triggerChanged else { return }
  46. context.coordinator.lastReloadTrigger = reloadTrigger
  47. context.coordinator.lastLoadedURL = url
  48. container.load(url: url)
  49. }
  50. final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
  51. weak var container: RedditWebViewContainer?
  52. var lastReloadTrigger = -1
  53. var lastGoBackTrigger = 0
  54. var lastAppliedDarkMode: Bool?
  55. var lastLoadedURL: URL?
  56. private var prefillWorkItem: DispatchWorkItem?
  57. private var canGoBackObservation: NSKeyValueObservation?
  58. private var urlObservation: NSKeyValueObservation?
  59. var onCanGoBackChange: (Bool) -> Void
  60. private let onAuthError: (String) -> Void
  61. init(
  62. onCanGoBackChange: @escaping (Bool) -> Void,
  63. onAuthError: @escaping (String) -> Void
  64. ) {
  65. self.onCanGoBackChange = onCanGoBackChange
  66. self.onAuthError = onAuthError
  67. }
  68. func startObservingNavigation(in webView: WKWebView) {
  69. guard canGoBackObservation == nil else { return }
  70. canGoBackObservation = webView.observe(\.canGoBack, options: [.initial, .new]) { [weak self] webView, _ in
  71. DispatchQueue.main.async {
  72. self?.reportBackButtonVisibility(for: webView)
  73. }
  74. }
  75. urlObservation = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
  76. DispatchQueue.main.async {
  77. self?.reportBackButtonVisibility(for: webView)
  78. }
  79. }
  80. }
  81. private func reportBackButtonVisibility(for webView: WKWebView) {
  82. let isOnHome = webView.url.map(RedditWebAuthHelper.isRedditHomeURL) ?? false
  83. onCanGoBackChange(webView.canGoBack && !isOnHome)
  84. }
  85. func installLoginBridge() {
  86. RedditLoginScriptBridge.shared.onLoginRequested = { [weak self] in
  87. self?.beginCredentialLogin()
  88. }
  89. }
  90. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  91. startObservingNavigation(in: webView)
  92. reportBackButtonVisibility(for: webView)
  93. webView.evaluateJavaScript(RedditPostBoundaryStyle.injectionScript, completionHandler: nil)
  94. webView.evaluateJavaScript(RedditHomeChromeStyle.injectionScript, completionHandler: nil)
  95. webView.evaluateJavaScript(RedditAuthChromeStyle.injectionScript, completionHandler: nil)
  96. if let url = webView.url, !RedditWebAuthHelper.isCredentialLoginPage(url) {
  97. webView.evaluateJavaScript(RedditLoginBridgeStyle.injectionScript, completionHandler: nil)
  98. }
  99. scheduleSubmitPrefillIfNeeded(on: webView)
  100. }
  101. private func scheduleSubmitPrefillIfNeeded(on webView: WKWebView) {
  102. guard
  103. let url = webView.url,
  104. RedditSubmitURLBuilder.isSubmitPage(url),
  105. let prefill = RedditSubmitPrefillManager.pending
  106. else { return }
  107. prefillWorkItem?.cancel()
  108. let script = RedditSubmitPrefillScript.make(prefill: prefill)
  109. guard !script.isEmpty else {
  110. RedditSubmitPrefillManager.clear()
  111. return
  112. }
  113. attemptSubmitPrefill(on: webView, script: script, remainingAttempts: 6)
  114. }
  115. private func attemptSubmitPrefill(on webView: WKWebView, script: String, remainingAttempts: Int) {
  116. guard remainingAttempts > 0, RedditSubmitPrefillManager.pending != nil else { return }
  117. webView.evaluateJavaScript(script) { result, _ in
  118. let succeeded = (result as? Bool) == true
  119. if succeeded {
  120. RedditSubmitPrefillManager.clear()
  121. return
  122. }
  123. guard remainingAttempts > 1 else {
  124. RedditSubmitPrefillManager.clear()
  125. return
  126. }
  127. let workItem = DispatchWorkItem { [weak self, weak webView] in
  128. guard let self, let webView else { return }
  129. self.attemptSubmitPrefill(on: webView, script: script, remainingAttempts: remainingAttempts - 1)
  130. }
  131. self.prefillWorkItem = workItem
  132. DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: workItem)
  133. }
  134. }
  135. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  136. container?.reloadLastURL()
  137. }
  138. func webView(
  139. _ webView: WKWebView,
  140. didFailProvisionalNavigation navigation: WKNavigation!,
  141. withError error: Error
  142. ) {
  143. let nsError = error as NSError
  144. guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return }
  145. container?.reloadLastURL(after: 1.0)
  146. }
  147. func webView(
  148. _ webView: WKWebView,
  149. decidePolicyFor navigationAction: WKNavigationAction,
  150. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  151. ) {
  152. guard let url = navigationAction.request.url else {
  153. decisionHandler(.allow)
  154. return
  155. }
  156. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  157. decisionHandler(.cancel)
  158. beginOAuthSignIn(url: url)
  159. return
  160. }
  161. let isSubframe = navigationAction.targetFrame.map { !$0.isMainFrame } ?? false
  162. if isSubframe,
  163. let mainURL = webView.url,
  164. RedditWebAuthHelper.isCredentialLoginPage(mainURL),
  165. RedditWebAuthHelper.isAccountsRedditHost(url) {
  166. decisionHandler(.allow)
  167. return
  168. }
  169. if RedditWebAuthHelper.isAuthFlowURL(url), isSubframe {
  170. if RedditWebAuthHelper.shouldRedirectAuthSubframe(mainFrameURL: webView.url) {
  171. decisionHandler(.cancel)
  172. beginCredentialLogin()
  173. return
  174. }
  175. decisionHandler(.allow)
  176. return
  177. }
  178. if navigationAction.targetFrame == nil {
  179. webView.load(URLRequest(url: url))
  180. decisionHandler(.cancel)
  181. return
  182. }
  183. if navigationAction.navigationType == .linkActivated,
  184. !RedditWebAuthHelper.isRedditURL(url) {
  185. NSWorkspace.shared.open(url)
  186. decisionHandler(.cancel)
  187. return
  188. }
  189. decisionHandler(.allow)
  190. }
  191. func webView(
  192. _ webView: WKWebView,
  193. createWebViewWith configuration: WKWebViewConfiguration,
  194. for navigationAction: WKNavigationAction,
  195. windowFeatures: WKWindowFeatures
  196. ) -> WKWebView? {
  197. guard let url = navigationAction.request.url else { return nil }
  198. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  199. beginOAuthSignIn(url: url)
  200. return nil
  201. }
  202. webView.load(URLRequest(url: url))
  203. return nil
  204. }
  205. private func beginCredentialLogin() {
  206. guard let container else { return }
  207. if let url = container.webView.url, RedditWebAuthHelper.isCredentialLoginPage(url) {
  208. return
  209. }
  210. RedditOAuthPanelController.shared.dismiss()
  211. container.webView.evaluateJavaScript(
  212. RedditLoginBridgeStyle.closeAuthModalScript,
  213. completionHandler: nil
  214. )
  215. container.load(url: RedditWebAuthHelper.redditLoginURL)
  216. }
  217. private func beginOAuthSignIn(url: URL) {
  218. let presentationWindow = container?.window
  219. OAuthAuthenticationManager.shared.authenticate(
  220. url: url,
  221. presentationWindow: presentationWindow
  222. ) { [weak self] result in
  223. guard let self, let container = self.container else { return }
  224. switch result {
  225. case .success(let callbackURL):
  226. RedditOAuthPanelController.shared.dismiss()
  227. container.load(url: callbackURL)
  228. case .failure(let error as OAuthError) where error == .cancelled:
  229. break
  230. case .failure(let error):
  231. onAuthError(error.localizedDescription)
  232. }
  233. }
  234. }
  235. }
  236. }
  237. final class RedditWebViewContainer: NSView {
  238. let webView: WKWebView
  239. private var pendingURL: URL?
  240. private var lastLoadedURL: URL?
  241. private var reloadWorkItem: DispatchWorkItem?
  242. init(webView: WKWebView) {
  243. self.webView = webView
  244. super.init(frame: .zero)
  245. wantsLayer = true
  246. addSubview(webView)
  247. webView.translatesAutoresizingMaskIntoConstraints = false
  248. NSLayoutConstraint.activate([
  249. webView.leadingAnchor.constraint(equalTo: leadingAnchor),
  250. webView.trailingAnchor.constraint(equalTo: trailingAnchor),
  251. webView.topAnchor.constraint(equalTo: topAnchor),
  252. webView.bottomAnchor.constraint(equalTo: bottomAnchor),
  253. ])
  254. }
  255. @available(*, unavailable)
  256. required init?(coder: NSCoder) {
  257. fatalError("init(coder:) has not been implemented")
  258. }
  259. func load(url: URL) {
  260. pendingURL = url
  261. lastLoadedURL = url
  262. reloadWorkItem?.cancel()
  263. tryLoadIfReady()
  264. }
  265. func reloadLastURL(after delay: TimeInterval = 0) {
  266. guard let lastLoadedURL else { return }
  267. reloadWorkItem?.cancel()
  268. let workItem = DispatchWorkItem { [weak self] in
  269. self?.load(url: lastLoadedURL)
  270. }
  271. reloadWorkItem = workItem
  272. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  273. }
  274. override func layout() {
  275. super.layout()
  276. tryLoadIfReady()
  277. }
  278. override func viewDidMoveToWindow() {
  279. super.viewDidMoveToWindow()
  280. tryLoadIfReady()
  281. }
  282. private func tryLoadIfReady() {
  283. guard let url = pendingURL else { return }
  284. guard bounds.width > 1, bounds.height > 1 else { return }
  285. pendingURL = nil
  286. webView.load(URLRequest(url: url))
  287. }
  288. func applyUnderPageBackground(isDarkMode: Bool) {
  289. let color = AppearanceManager.webUnderPageBackgroundColor(isDarkMode: isDarkMode)
  290. RedditWebViewManager.applyUnderPageBackground(to: webView, isDarkMode: isDarkMode)
  291. layer?.backgroundColor = color.cgColor
  292. }
  293. }