import AppKit import SwiftUI import WebKit struct RedditWebView: NSViewRepresentable { let url: URL let reloadTrigger: Int var isDarkMode: Bool var goBackTrigger: Int = 0 var onCanGoBackChange: (Bool) -> Void = { _ in } var onAuthError: (String) -> Void = { _ in } func makeCoordinator() -> Coordinator { Coordinator( onCanGoBackChange: onCanGoBackChange, onAuthError: onAuthError ) } func makeNSView(context: Context) -> RedditWebViewContainer { let container = RedditWebViewContainer(webView: RedditWebViewManager.makeWebView()) container.webView.navigationDelegate = context.coordinator container.webView.uiDelegate = context.coordinator context.coordinator.container = container context.coordinator.lastReloadTrigger = reloadTrigger context.coordinator.lastAppliedDarkMode = isDarkMode context.coordinator.installLoginBridge() context.coordinator.startObservingNavigation(in: container.webView) container.applyUnderPageBackground(isDarkMode: isDarkMode) container.load(url: url) return container } func updateNSView(_ container: RedditWebViewContainer, context: Context) { context.coordinator.onCanGoBackChange = onCanGoBackChange context.coordinator.installLoginBridge() if context.coordinator.lastAppliedDarkMode != isDarkMode { context.coordinator.lastAppliedDarkMode = isDarkMode container.applyUnderPageBackground(isDarkMode: isDarkMode) } if context.coordinator.lastGoBackTrigger != goBackTrigger { context.coordinator.lastGoBackTrigger = goBackTrigger if container.webView.canGoBack { container.webView.goBack() } } let urlChanged = context.coordinator.lastLoadedURL != url let triggerChanged = context.coordinator.lastReloadTrigger != reloadTrigger guard urlChanged || triggerChanged else { return } context.coordinator.lastReloadTrigger = reloadTrigger context.coordinator.lastLoadedURL = url container.load(url: url) } final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { weak var container: RedditWebViewContainer? var lastReloadTrigger = -1 var lastGoBackTrigger = 0 var lastAppliedDarkMode: Bool? var lastLoadedURL: URL? private var prefillWorkItem: DispatchWorkItem? private var canGoBackObservation: NSKeyValueObservation? private var urlObservation: NSKeyValueObservation? var onCanGoBackChange: (Bool) -> Void private let onAuthError: (String) -> Void init( onCanGoBackChange: @escaping (Bool) -> Void, onAuthError: @escaping (String) -> Void ) { self.onCanGoBackChange = onCanGoBackChange self.onAuthError = onAuthError } func startObservingNavigation(in webView: WKWebView) { guard canGoBackObservation == nil else { return } canGoBackObservation = webView.observe(\.canGoBack, options: [.initial, .new]) { [weak self] webView, _ in DispatchQueue.main.async { self?.reportBackButtonVisibility(for: webView) } } urlObservation = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in DispatchQueue.main.async { self?.reportBackButtonVisibility(for: webView) } } } private func reportBackButtonVisibility(for webView: WKWebView) { let isOnHome = webView.url.map(RedditWebAuthHelper.isRedditHomeURL) ?? false onCanGoBackChange(webView.canGoBack && !isOnHome) } func installLoginBridge() { RedditLoginScriptBridge.shared.onLoginRequested = { [weak self] in self?.beginCredentialLogin() } } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { startObservingNavigation(in: webView) reportBackButtonVisibility(for: webView) webView.evaluateJavaScript(RedditPostBoundaryStyle.injectionScript, completionHandler: nil) webView.evaluateJavaScript(RedditHomeChromeStyle.injectionScript, completionHandler: nil) webView.evaluateJavaScript(RedditAuthChromeStyle.injectionScript, completionHandler: nil) if let url = webView.url, !RedditWebAuthHelper.isCredentialLoginPage(url) { webView.evaluateJavaScript(RedditLoginBridgeStyle.injectionScript, completionHandler: nil) } scheduleSubmitPrefillIfNeeded(on: webView) } private func scheduleSubmitPrefillIfNeeded(on webView: WKWebView) { guard let url = webView.url, RedditSubmitURLBuilder.isSubmitPage(url), let prefill = RedditSubmitPrefillManager.pending else { return } prefillWorkItem?.cancel() let script = RedditSubmitPrefillScript.make(prefill: prefill) guard !script.isEmpty else { RedditSubmitPrefillManager.clear() return } attemptSubmitPrefill(on: webView, script: script, remainingAttempts: 6) } private func attemptSubmitPrefill(on webView: WKWebView, script: String, remainingAttempts: Int) { guard remainingAttempts > 0, RedditSubmitPrefillManager.pending != nil else { return } webView.evaluateJavaScript(script) { result, _ in let succeeded = (result as? Bool) == true if succeeded { RedditSubmitPrefillManager.clear() return } guard remainingAttempts > 1 else { RedditSubmitPrefillManager.clear() return } let workItem = DispatchWorkItem { [weak self, weak webView] in guard let self, let webView else { return } self.attemptSubmitPrefill(on: webView, script: script, remainingAttempts: remainingAttempts - 1) } self.prefillWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: workItem) } } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { container?.reloadLastURL() } func webView( _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error ) { let nsError = error as NSError guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return } container?.reloadLastURL(after: 1.0) } func webView( _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { guard let url = navigationAction.request.url else { decisionHandler(.allow) return } if RedditWebAuthHelper.isOAuthProviderURL(url) { decisionHandler(.cancel) beginOAuthSignIn(url: url) return } let isSubframe = navigationAction.targetFrame.map { !$0.isMainFrame } ?? false if isSubframe, let mainURL = webView.url, RedditWebAuthHelper.isCredentialLoginPage(mainURL), RedditWebAuthHelper.isAccountsRedditHost(url) { decisionHandler(.allow) return } if RedditWebAuthHelper.isAuthFlowURL(url), isSubframe { if RedditWebAuthHelper.shouldRedirectAuthSubframe(mainFrameURL: webView.url) { decisionHandler(.cancel) beginCredentialLogin() return } decisionHandler(.allow) return } if navigationAction.targetFrame == nil { webView.load(URLRequest(url: url)) decisionHandler(.cancel) return } if navigationAction.navigationType == .linkActivated, !RedditWebAuthHelper.isRedditURL(url) { NSWorkspace.shared.open(url) decisionHandler(.cancel) return } decisionHandler(.allow) } func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { guard let url = navigationAction.request.url else { return nil } if RedditWebAuthHelper.isOAuthProviderURL(url) { beginOAuthSignIn(url: url) return nil } webView.load(URLRequest(url: url)) return nil } private func beginCredentialLogin() { guard let container else { return } if let url = container.webView.url, RedditWebAuthHelper.isCredentialLoginPage(url) { return } RedditOAuthPanelController.shared.dismiss() container.webView.evaluateJavaScript( RedditLoginBridgeStyle.closeAuthModalScript, completionHandler: nil ) container.load(url: RedditWebAuthHelper.redditLoginURL) } private func beginOAuthSignIn(url: URL) { let presentationWindow = container?.window OAuthAuthenticationManager.shared.authenticate( url: url, presentationWindow: presentationWindow ) { [weak self] result in guard let self, let container = self.container else { return } switch result { case .success(let callbackURL): RedditOAuthPanelController.shared.dismiss() container.load(url: callbackURL) case .failure(let error as OAuthError) where error == .cancelled: break case .failure(let error): onAuthError(error.localizedDescription) } } } } } final class RedditWebViewContainer: NSView { let webView: WKWebView private var pendingURL: URL? private var lastLoadedURL: URL? private var reloadWorkItem: DispatchWorkItem? init(webView: WKWebView) { self.webView = webView super.init(frame: .zero) wantsLayer = true addSubview(webView) webView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ webView.leadingAnchor.constraint(equalTo: leadingAnchor), webView.trailingAnchor.constraint(equalTo: trailingAnchor), webView.topAnchor.constraint(equalTo: topAnchor), webView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func load(url: URL) { pendingURL = url lastLoadedURL = url reloadWorkItem?.cancel() tryLoadIfReady() } func reloadLastURL(after delay: TimeInterval = 0) { guard let lastLoadedURL else { return } reloadWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.load(url: lastLoadedURL) } reloadWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } override func layout() { super.layout() tryLoadIfReady() } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() tryLoadIfReady() } private func tryLoadIfReady() { guard let url = pendingURL else { return } guard bounds.width > 1, bounds.height > 1 else { return } pendingURL = nil webView.load(URLRequest(url: url)) } func applyUnderPageBackground(isDarkMode: Bool) { let color = AppearanceManager.webUnderPageBackgroundColor(isDarkMode: isDarkMode) RedditWebViewManager.applyUnderPageBackground(to: webView, isDarkMode: isDarkMode) layer?.backgroundColor = color.cgColor } }