| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- 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
- }
- }
|