RedditWebView.swift 14 KB

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