Bladeren bron

Restore optional in-app Reddit browsing with a reliable modern login flow.

Route sign-in to the new Reddit login page in the main WebView, block broken auth modals, and allow accounts.reddit.com subframes so credential login can complete.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 maand geleden
bovenliggende
commit
a29d8b4e01

+ 20 - 0
Reddit App/Managers/RedditLoginScriptBridge.swift

@@ -0,0 +1,20 @@
+import WebKit
+
+@MainActor
+final class RedditLoginScriptBridge: NSObject, WKScriptMessageHandler {
+    static let shared = RedditLoginScriptBridge()
+
+    var onLoginRequested: (() -> Void)?
+
+    private override init() {
+        super.init()
+    }
+
+    func userContentController(
+        _ userContentController: WKUserContentController,
+        didReceive message: WKScriptMessage
+    ) {
+        guard message.name == RedditLoginBridgeStyle.messageHandlerName else { return }
+        onLoginRequested?()
+    }
+}

+ 72 - 8
Reddit App/Managers/RedditOAuthPanelController.swift

@@ -11,18 +11,48 @@ final class RedditOAuthPanelController: NSObject {
     private var onAuthError: ((String) -> Void)?
     private var finishWorkItem: DispatchWorkItem?
 
+    var isPresenting: Bool { panel != nil }
+
     private override init() {
         super.init()
     }
 
     func completeSignIn(
         callbackURL: URL,
+        parentWindow: NSWindow? = nil,
+        onAuthError: @escaping (String) -> Void,
+        onFinished: @escaping () -> Void
+    ) {
+        presentPanel(
+            url: callbackURL,
+            parentWindow: parentWindow,
+            onAuthError: onAuthError,
+            onFinished: onFinished
+        )
+    }
+
+    func presentLogin(
+        parentWindow: NSWindow? = nil,
+        onAuthError: @escaping (String) -> Void,
+        onFinished: @escaping () -> Void
+    ) {
+        presentPanel(
+            url: RedditWebAuthHelper.redditLoginURL,
+            parentWindow: parentWindow,
+            onAuthError: onAuthError,
+            onFinished: onFinished
+        )
+    }
+
+    private func presentPanel(
+        url: URL,
+        parentWindow: NSWindow? = nil,
         onAuthError: @escaping (String) -> Void,
         onFinished: @escaping () -> Void
     ) {
         dismiss()
 
-        self.onComplete = onFinished
+        onComplete = onFinished
         self.onAuthError = onAuthError
 
         let panel = NSPanel(
@@ -33,10 +63,12 @@ final class RedditOAuthPanelController: NSObject {
         )
         panel.title = "Sign in to Reddit"
         panel.isReleasedWhenClosed = false
-        panel.center()
-        panel.level = .floating
+        panel.delegate = self
 
-        let webView = WKWebView(frame: panel.contentView?.bounds ?? .zero, configuration: RedditWebViewManager.makeConfiguration())
+        let webView = WKWebView(
+            frame: panel.contentView?.bounds ?? .zero,
+            configuration: RedditWebViewManager.makeConfiguration(includeLoginBridge: false)
+        )
         webView.navigationDelegate = self
         webView.uiDelegate = self
         webView.autoresizingMask = [.width, .height]
@@ -45,16 +77,30 @@ final class RedditOAuthPanelController: NSObject {
         self.panel = panel
         self.webView = webView
 
-        panel.makeKeyAndOrderFront(nil)
-        NSApp.activate(ignoringOtherApps: true)
+        if let parentWindow {
+            parentWindow.beginSheet(panel)
+        } else {
+            panel.center()
+            panel.level = .floating
+            panel.makeKeyAndOrderFront(nil)
+        }
 
-        webView.load(URLRequest(url: callbackURL))
+        NSApp.activate(ignoringOtherApps: true)
+        webView.load(URLRequest(url: url))
     }
 
     func dismiss() {
         finishWorkItem?.cancel()
         finishWorkItem = nil
-        panel?.close()
+
+        if let panel {
+            if let parent = panel.sheetParent {
+                parent.endSheet(panel)
+            } else {
+                panel.close()
+            }
+        }
+
         panel = nil
         webView = nil
         onComplete = nil
@@ -97,6 +143,18 @@ final class RedditOAuthPanelController: NSObject {
     }
 }
 
+extension RedditOAuthPanelController: NSWindowDelegate {
+    func windowWillClose(_ notification: Notification) {
+        guard notification.object as? NSPanel === panel else { return }
+        finishWorkItem?.cancel()
+        finishWorkItem = nil
+        panel = nil
+        webView = nil
+        onComplete = nil
+        onAuthError = nil
+    }
+}
+
 extension RedditOAuthPanelController: WKNavigationDelegate {
     func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
         guard let url = webView.url else { return }
@@ -119,6 +177,12 @@ extension RedditOAuthPanelController: WKNavigationDelegate {
             return
         }
 
+        if RedditWebAuthHelper.shouldCompleteOAuthSignIn(for: url) {
+            decisionHandler(.allow)
+            scheduleFinishIfNeeded(for: url)
+            return
+        }
+
         decisionHandler(.allow)
     }
 }

+ 15 - 3
Reddit App/Managers/RedditWebViewManager.swift

@@ -5,7 +5,7 @@ import WebKit
 enum RedditWebViewManager {
     private static let processPool = WKProcessPool()
 
-    static func makeConfiguration() -> WKWebViewConfiguration {
+    static func makeConfiguration(includeLoginBridge: Bool = true) -> WKWebViewConfiguration {
         let configuration = WKWebViewConfiguration()
         configuration.processPool = processPool
         configuration.websiteDataStore = .default()
@@ -33,6 +33,19 @@ enum RedditWebViewManager {
         )
         configuration.userContentController.addUserScript(authChromeScript)
 
+        if includeLoginBridge {
+            let loginBridgeScript = WKUserScript(
+                source: RedditLoginBridgeStyle.injectionScript,
+                injectionTime: .atDocumentStart,
+                forMainFrameOnly: true
+            )
+            configuration.userContentController.addUserScript(loginBridgeScript)
+            configuration.userContentController.add(
+                RedditLoginScriptBridge.shared,
+                name: RedditLoginBridgeStyle.messageHandlerName
+            )
+        }
+
         return configuration
     }
 
@@ -40,8 +53,7 @@ enum RedditWebViewManager {
         let webView = WKWebView(frame: .zero, configuration: makeConfiguration())
         webView.allowsBackForwardNavigationGestures = true
         webView.allowsMagnification = true
-        webView.customUserAgent =
-            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
+        webView.customUserAgent = RedditWebAuthHelper.safariUserAgent
         applyUnderPageBackground(to: webView, isDarkMode: AppearanceManager.shared.isDarkMode)
         return webView
     }

+ 151 - 0
Reddit App/Utilities/RedditLoginBridgeStyle.swift

@@ -0,0 +1,151 @@
+import Foundation
+
+enum RedditLoginBridgeStyle {
+    static let messageHandlerName = "reddoraLogin"
+
+    /// Intercepts Log In and blocks Reddit's embedded auth modal, which cannot complete
+    /// credential sign-in reliably in WKWebView ("Server error. Try again later.").
+    static let injectionScript = """
+    (function() {
+      if (window.__reddoraLoginBridgeInstalled) return;
+      window.__reddoraLoginBridgeInstalled = true;
+
+      const LOGIN_PATTERN = /log\\s*in/i;
+      const HEADER_SELECTORS = 'header, [role="banner"], #header, faceplate-header, nav';
+      const AUTH_MODAL_SELECTORS = [
+        'auth-flow-modal',
+        '[data-testid="login-modal"]',
+        '#credentials-login-popup',
+        'faceplate-modal[slot="login"]',
+        'faceplate-modal[open]'
+      ].join(', ');
+
+      function nodeLabel(node) {
+        if (!(node instanceof Element)) return '';
+        return (
+          (node.textContent || '') + ' ' +
+          (node.getAttribute('aria-label') || '') + ' ' +
+          (node.getAttribute('title') || '')
+        ).trim();
+      }
+
+      function isHeaderLoginTrigger(node) {
+        if (!(node instanceof Element)) return false;
+
+        const clickable = node.matches('button, a, [role="button"], faceplate-tracker, faceplate-button')
+          ? node
+          : node.closest('button, a, [role="button"], faceplate-tracker, faceplate-button');
+        if (!clickable) return false;
+
+        if (clickable.id === 'login-button') return true;
+        if (clickable.getAttribute('data-testid') === 'login-button') return true;
+
+        const label = nodeLabel(clickable);
+        if (!LOGIN_PATTERN.test(label)) return false;
+
+        return !!clickable.closest(HEADER_SELECTORS);
+      }
+
+      function isLoginPage() {
+        const host = window.location.hostname.toLowerCase();
+        return (host === 'www.reddit.com' || host === 'reddit.com' || host === 'new.reddit.com')
+          && /^\\/login\\/?$/i.test(window.location.pathname);
+      }
+
+      function requestInAppLogin() {
+        if (isLoginPage()) return;
+        if (window.__reddoraLoginPanelRequested) return;
+        window.__reddoraLoginPanelRequested = true;
+        setTimeout(function() {
+          window.__reddoraLoginPanelRequested = false;
+        }, 1000);
+
+        if (
+          window.webkit &&
+          window.webkit.messageHandlers &&
+          window.webkit.messageHandlers.reddoraLogin
+        ) {
+          window.webkit.messageHandlers.reddoraLogin.postMessage('open');
+        }
+      }
+
+      function isAuthModal(element) {
+        if (!(element instanceof Element)) return false;
+        if (element.matches('auth-flow-modal, [data-testid="login-modal"], #credentials-login-popup')) {
+          return true;
+        }
+        if (element.matches('faceplate-modal')) {
+          const label = nodeLabel(element);
+          return LOGIN_PATTERN.test(label) || !!element.querySelector('input[type="password"]');
+        }
+        return false;
+      }
+
+      window.__reddoraCloseRedditAuthModal = function() {
+        document.querySelectorAll(AUTH_MODAL_SELECTORS).forEach(function(element) {
+          if (!isAuthModal(element)) return;
+          const closeButton = element.querySelector(
+            '[aria-label="Close"], [aria-label="close"], button[close]'
+          );
+          if (closeButton) {
+            closeButton.click();
+          } else {
+            element.remove();
+          }
+        });
+      };
+
+      function installAuthModalBlocker() {
+        if (isLoginPage()) return;
+
+        const STYLE_ID = 'reddora-auth-block-style';
+        if (!document.getElementById(STYLE_ID)) {
+          const style = document.createElement('style');
+          style.id = STYLE_ID;
+          style.textContent = [
+            'auth-flow-modal,',
+            '[data-testid="login-modal"],',
+            '#credentials-login-popup {',
+            '  display: none !important;',
+            '  pointer-events: none !important;',
+            '}'
+          ].join('\\n');
+          (document.head || document.documentElement).appendChild(style);
+        }
+
+        const observer = new MutationObserver(function() {
+          if (isLoginPage()) return;
+          let foundAuthModal = false;
+          document.querySelectorAll(AUTH_MODAL_SELECTORS).forEach(function(element) {
+            if (!isAuthModal(element)) return;
+            foundAuthModal = true;
+            element.remove();
+          });
+          if (foundAuthModal) {
+            requestInAppLogin();
+          }
+        });
+
+        observer.observe(document.documentElement, { childList: true, subtree: true });
+      }
+
+      document.addEventListener('click', function(event) {
+        if (isLoginPage()) return;
+        const path = event.composedPath();
+        for (let i = 0; i < path.length; i++) {
+          if (!isHeaderLoginTrigger(path[i])) continue;
+
+          event.preventDefault();
+          event.stopImmediatePropagation();
+          window.__reddoraCloseRedditAuthModal();
+          requestInAppLogin();
+          return;
+        }
+      }, true);
+
+      installAuthModalBlocker();
+    })();
+    """
+
+    static let closeAuthModalScript = "window.__reddoraCloseRedditAuthModal && window.__reddoraCloseRedditAuthModal();"
+}

+ 24 - 0
Reddit App/Utilities/RedditWebAuthHelper.swift

@@ -2,6 +2,10 @@ import Foundation
 
 enum RedditWebAuthHelper {
     static let redditHomeURL = URL(string: "https://www.reddit.com")!
+    static let redditLoginURL = URL(string: "https://www.reddit.com/login/")!
+
+    static let safariUserAgent =
+        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15"
 
     private static let oauthHosts = [
         "accounts.google.com",
@@ -100,6 +104,26 @@ enum RedditWebAuthHelper {
         }
     }
 
+    static func isCredentialLoginPage(_ url: URL) -> Bool {
+        guard isRedditURL(url) else { return false }
+        let path = url.path.lowercased()
+        guard path == "/login" || path.hasPrefix("/login/") else { return false }
+        let host = url.host?.lowercased() ?? ""
+        return host == "www.reddit.com" || host == "reddit.com" || host == "new.reddit.com"
+    }
+
+    static func isAccountsRedditHost(_ url: URL) -> Bool {
+        url.host?.lowercased() == "accounts.reddit.com"
+    }
+
+    /// Auth subframes to accounts.reddit.com are required on the modern login page.
+    /// Only redirect subframe auth loads when the main frame is not already on login.
+    static func shouldRedirectAuthSubframe(mainFrameURL: URL?) -> Bool {
+        guard let mainFrameURL else { return true }
+        if isCredentialLoginPage(mainFrameURL) { return false }
+        return !isAuthFlowURL(mainFrameURL)
+    }
+
     static func looksLikeLoggedInURL(_ url: URL) -> Bool {
         guard isRedditURL(url) else { return false }
         if isAuthFlowURL(url) { return false }

+ 5 - 1
Reddit App/ViewModels/FrontPageViewModel.swift

@@ -7,7 +7,7 @@ final class FrontPageViewModel {
 
     private(set) var redditLoadURL = redditHomeURL
     var selectedNavItem: SidebarNavItem?
-    var isRedditActive = true
+    var isRedditActive = false
     var isSettingsActive = false
     var isPaywallPresented = false
     var authErrorMessage: String?
@@ -75,4 +75,8 @@ final class FrontPageViewModel {
         SidebarNavItem(title: "Title Optimizer", subtitle: "Create viral titles", iconKind: .titleOptimizer),
         SidebarNavItem(title: "Comment Writer", subtitle: "Write smart replies", iconKind: .commentWriter),
     ]
+
+    init() {
+        selectedNavItem = createItems.first
+    }
 }

+ 48 - 9
Reddit App/Views/RedditWebView.swift

@@ -24,6 +24,7 @@ struct RedditWebView: NSViewRepresentable {
         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)
@@ -32,6 +33,7 @@ struct RedditWebView: NSViewRepresentable {
 
     func updateNSView(_ container: RedditWebViewContainer, context: Context) {
         context.coordinator.onCanGoBackChange = onCanGoBackChange
+        context.coordinator.installLoginBridge()
 
         if context.coordinator.lastAppliedDarkMode != isDarkMode {
             context.coordinator.lastAppliedDarkMode = isDarkMode
@@ -95,12 +97,21 @@ struct RedditWebView: NSViewRepresentable {
             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)
         }
 
@@ -171,14 +182,30 @@ struct RedditWebView: NSViewRepresentable {
             }
 
             if RedditWebAuthHelper.isOAuthProviderURL(url) {
-                // Always route OAuth through ASWebAuthenticationSession. Reddit's login
-                // modal runs in a subframe; letting WebKit handle Google/Apple SSO there
-                // triggers SOAuthorizationCoordinator subframe errors and breaks sign-in.
                 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)
@@ -212,6 +239,22 @@ struct RedditWebView: NSViewRepresentable {
             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
 
@@ -223,12 +266,8 @@ struct RedditWebView: NSViewRepresentable {
 
                 switch result {
                 case .success(let callbackURL):
-                    RedditOAuthPanelController.shared.completeSignIn(
-                        callbackURL: callbackURL,
-                        onAuthError: onAuthError
-                    ) {
-                        container.load(url: RedditWebAuthHelper.redditHomeURL)
-                    }
+                    RedditOAuthPanelController.shared.dismiss()
+                    container.load(url: callbackURL)
                 case .failure(let error as OAuthError) where error == .cancelled:
                     break
                 case .failure(let error):

+ 4 - 1
Reddit App/Views/SidebarView.swift

@@ -65,7 +65,10 @@ struct SidebarView: View {
 
                 sidebarSection(title: SidebarSection.create.rawValue) {
                     ForEach(viewModel.createItems) { item in
-                        SidebarNavItemView(item: item, isSelected: viewModel.selectedNavItem == item) {
+                        SidebarNavItemView(
+                            item: item,
+                            isSelected: !viewModel.isSettingsActive && !viewModel.isRedditActive && viewModel.selectedNavItem == item
+                        ) {
                             viewModel.selectNavItem(item)
                         }
                     }