RedditWebView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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.applyTheme(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.applyTheme(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. webView.evaluateJavaScript(
  97. RedditThemeStyle.syncScript(isDarkMode: lastAppliedDarkMode ?? AppearanceManager.shared.isDarkMode),
  98. completionHandler: nil
  99. )
  100. if let url = webView.url, !RedditWebAuthHelper.isCredentialLoginPage(url) {
  101. webView.evaluateJavaScript(RedditLoginBridgeStyle.injectionScript, completionHandler: nil)
  102. }
  103. scheduleSubmitPrefillIfNeeded(on: webView)
  104. }
  105. private func scheduleSubmitPrefillIfNeeded(on webView: WKWebView) {
  106. guard
  107. let url = webView.url,
  108. RedditSubmitURLBuilder.isSubmitPage(url),
  109. let prefill = RedditSubmitPrefillManager.pending
  110. else { return }
  111. prefillWorkItem?.cancel()
  112. let script = RedditSubmitPrefillScript.make(prefill: prefill)
  113. guard !script.isEmpty else {
  114. RedditSubmitPrefillManager.clear()
  115. return
  116. }
  117. attemptSubmitPrefill(on: webView, script: script, remainingAttempts: 6)
  118. }
  119. private func attemptSubmitPrefill(on webView: WKWebView, script: String, remainingAttempts: Int) {
  120. guard remainingAttempts > 0, RedditSubmitPrefillManager.pending != nil else { return }
  121. webView.evaluateJavaScript(script) { result, _ in
  122. let succeeded = (result as? Bool) == true
  123. if succeeded {
  124. RedditSubmitPrefillManager.clear()
  125. return
  126. }
  127. guard remainingAttempts > 1 else {
  128. RedditSubmitPrefillManager.clear()
  129. return
  130. }
  131. let workItem = DispatchWorkItem { [weak self, weak webView] in
  132. guard let self, let webView else { return }
  133. self.attemptSubmitPrefill(on: webView, script: script, remainingAttempts: remainingAttempts - 1)
  134. }
  135. self.prefillWorkItem = workItem
  136. DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: workItem)
  137. }
  138. }
  139. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  140. container?.reloadLastURL()
  141. }
  142. func webView(
  143. _ webView: WKWebView,
  144. didFailProvisionalNavigation navigation: WKNavigation!,
  145. withError error: Error
  146. ) {
  147. let nsError = error as NSError
  148. guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return }
  149. container?.reloadLastURL(after: 1.0)
  150. }
  151. func webView(
  152. _ webView: WKWebView,
  153. decidePolicyFor navigationAction: WKNavigationAction,
  154. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  155. ) {
  156. guard let url = navigationAction.request.url else {
  157. decisionHandler(.allow)
  158. return
  159. }
  160. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  161. decisionHandler(.cancel)
  162. beginOAuthSignIn(url: url)
  163. return
  164. }
  165. let isSubframe = navigationAction.targetFrame.map { !$0.isMainFrame } ?? false
  166. if isSubframe,
  167. let mainURL = webView.url,
  168. RedditWebAuthHelper.isCredentialLoginPage(mainURL),
  169. RedditWebAuthHelper.isAccountsRedditHost(url) {
  170. decisionHandler(.allow)
  171. return
  172. }
  173. if RedditWebAuthHelper.isAuthFlowURL(url), isSubframe {
  174. if RedditWebAuthHelper.shouldRedirectAuthSubframe(mainFrameURL: webView.url) {
  175. decisionHandler(.cancel)
  176. beginCredentialLogin()
  177. return
  178. }
  179. decisionHandler(.allow)
  180. return
  181. }
  182. if navigationAction.targetFrame == nil {
  183. webView.load(URLRequest(url: url))
  184. decisionHandler(.cancel)
  185. return
  186. }
  187. if navigationAction.navigationType == .linkActivated,
  188. !RedditWebAuthHelper.isRedditURL(url) {
  189. NSWorkspace.shared.open(url)
  190. decisionHandler(.cancel)
  191. return
  192. }
  193. decisionHandler(.allow)
  194. }
  195. func webView(
  196. _ webView: WKWebView,
  197. createWebViewWith configuration: WKWebViewConfiguration,
  198. for navigationAction: WKNavigationAction,
  199. windowFeatures: WKWindowFeatures
  200. ) -> WKWebView? {
  201. guard let url = navigationAction.request.url else { return nil }
  202. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  203. beginOAuthSignIn(url: url)
  204. return nil
  205. }
  206. webView.load(URLRequest(url: url))
  207. return nil
  208. }
  209. private func beginCredentialLogin() {
  210. let performLogin = { [weak self] in
  211. guard let self, let container = self.container else { return }
  212. if let url = container.webView.url, RedditWebAuthHelper.isCredentialLoginPage(url) {
  213. return
  214. }
  215. RedditOAuthPanelController.shared.dismiss()
  216. container.webView.evaluateJavaScript(
  217. RedditLoginBridgeStyle.closeAuthModalScript,
  218. completionHandler: nil
  219. )
  220. container.load(url: RedditWebAuthHelper.redditLoginURL)
  221. }
  222. if Thread.isMainThread {
  223. performLogin()
  224. } else {
  225. DispatchQueue.main.async(execute: performLogin)
  226. }
  227. }
  228. private func beginOAuthSignIn(url: URL) {
  229. let presentationWindow = container?.window
  230. OAuthAuthenticationManager.shared.authenticate(
  231. url: url,
  232. presentationWindow: presentationWindow
  233. ) { [weak self] result in
  234. guard let self, let container = self.container else { return }
  235. switch result {
  236. case .success(let callbackURL):
  237. RedditOAuthPanelController.shared.dismiss()
  238. container.load(url: callbackURL)
  239. case .failure(let error as OAuthError) where error == .cancelled:
  240. break
  241. case .failure(let error):
  242. onAuthError(error.localizedDescription)
  243. }
  244. }
  245. }
  246. }
  247. }
  248. final class RedditWebViewContainer: NSView {
  249. let webView: WKWebView
  250. private var pendingURL: URL?
  251. private var lastLoadedURL: URL?
  252. private var reloadWorkItem: DispatchWorkItem?
  253. init(webView: WKWebView) {
  254. self.webView = webView
  255. super.init(frame: .zero)
  256. wantsLayer = true
  257. addSubview(webView)
  258. webView.translatesAutoresizingMaskIntoConstraints = false
  259. NSLayoutConstraint.activate([
  260. webView.leadingAnchor.constraint(equalTo: leadingAnchor),
  261. webView.trailingAnchor.constraint(equalTo: trailingAnchor),
  262. webView.topAnchor.constraint(equalTo: topAnchor),
  263. webView.bottomAnchor.constraint(equalTo: bottomAnchor),
  264. ])
  265. }
  266. @available(*, unavailable)
  267. required init?(coder: NSCoder) {
  268. fatalError("init(coder:) has not been implemented")
  269. }
  270. func load(url: URL) {
  271. pendingURL = url
  272. lastLoadedURL = url
  273. reloadWorkItem?.cancel()
  274. tryLoadIfReady()
  275. }
  276. func reloadLastURL(after delay: TimeInterval = 0) {
  277. guard let lastLoadedURL else { return }
  278. reloadWorkItem?.cancel()
  279. let workItem = DispatchWorkItem { [weak self] in
  280. self?.load(url: lastLoadedURL)
  281. }
  282. reloadWorkItem = workItem
  283. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  284. }
  285. override func layout() {
  286. super.layout()
  287. tryLoadIfReady()
  288. }
  289. override func viewDidMoveToWindow() {
  290. super.viewDidMoveToWindow()
  291. tryLoadIfReady()
  292. }
  293. override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
  294. true
  295. }
  296. private func tryLoadIfReady() {
  297. guard let url = pendingURL else { return }
  298. guard bounds.width > 1, bounds.height > 1 else { return }
  299. pendingURL = nil
  300. webView.load(URLRequest(url: url))
  301. }
  302. func applyTheme(isDarkMode: Bool) {
  303. let color = AppearanceManager.webUnderPageBackgroundColor(isDarkMode: isDarkMode)
  304. RedditWebViewManager.applyTheme(to: webView, isDarkMode: isDarkMode)
  305. appearance = NSAppearance(named: isDarkMode ? .darkAqua : .aqua)
  306. layer?.backgroundColor = color.cgColor
  307. }
  308. }