RedditWebView.swift 14 KB

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