import AppKit import AuthenticationServices import WebKit enum OAuthError: LocalizedError, Equatable { case alreadyInProgress case failedToStart case cancelled case invalidCallback case timedOut case unsupportedPlatform var errorDescription: String? { switch self { case .alreadyInProgress: "Sign-in is already in progress." case .failedToStart: "Could not start sign-in." case .cancelled: "Sign-in was cancelled." case .invalidCallback: "Sign-in did not return to Reddit." case .timedOut: "Sign-in timed out. Please try again." case .unsupportedPlatform: "Sign-in requires macOS 14.4 or later. Please update your system." } } } @MainActor final class OAuthAuthenticationManager: NSObject { static let shared = OAuthAuthenticationManager() private static let authTimeoutSeconds: UInt64 = 300 private var authSession: ASWebAuthenticationSession? private var isAuthenticating = false private var currentAuthURL: URL? private var pendingCompletions: [(Result) -> Void] = [] private var authTimeoutTask: Task? private weak var presentationWindow: NSWindow? private override init() { super.init() } func authenticate( url: URL, presentationWindow: NSWindow? = nil, completion: @escaping (Result) -> Void ) { if isAuthenticating { if let currentAuthURL, currentAuthURL.absoluteString == url.absoluteString { pendingCompletions.append(completion) return } completion(.failure(OAuthError.alreadyInProgress)) return } if #available(macOS 14.4, *) { self.presentationWindow = presentationWindow startAuthenticationSession(url: url, completion: completion) } else { completion(.failure(OAuthError.unsupportedPlatform)) } } @available(macOS 14.4, *) private func startAuthenticationSession( url: URL, completion: @escaping (Result) -> Void ) { isAuthenticating = true currentAuthURL = url pendingCompletions = [completion] NSApp.activate(ignoringOtherApps: true) let callbackHost = RedditWebAuthHelper.oauthCallbackHost(for: url) let session = ASWebAuthenticationSession( url: url, callback: .https(host: callbackHost, path: "") ) { [weak self] callbackURL, error in Task { @MainActor in self?.finishAuthentication(callbackURL: callbackURL, error: error) } } session.prefersEphemeralWebBrowserSession = false session.presentationContextProvider = self authSession = session startAuthTimeout() guard session.start() else { deliverResult(.failure(OAuthError.failedToStart)) return } } private func startAuthTimeout() { authTimeoutTask?.cancel() authTimeoutTask = Task { [weak self] in try? await Task.sleep(nanoseconds: Self.authTimeoutSeconds * 1_000_000_000) guard !Task.isCancelled else { return } await MainActor.run { guard let self, self.isAuthenticating else { return } self.authSession?.cancel() self.deliverResult(.failure(OAuthError.timedOut)) } } } private func finishAuthentication(callbackURL: URL?, error: Error?) { let result: Result if let error { let nsError = error as NSError if nsError.domain == ASWebAuthenticationSessionErrorDomain, nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue { result = .failure(OAuthError.cancelled) } else { result = .failure(error) } } else if let callbackURL, RedditWebAuthHelper.isRedditURL(callbackURL) { result = .success(callbackURL) } else { result = .failure(OAuthError.invalidCallback) } deliverResult(result) } private func deliverResult(_ result: Result) { authTimeoutTask?.cancel() authTimeoutTask = nil isAuthenticating = false authSession = nil presentationWindow = nil currentAuthURL = nil let completions = pendingCompletions pendingCompletions = [] completions.forEach { $0(result) } } } extension OAuthAuthenticationManager: ASWebAuthenticationPresentationContextProviding { nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { MainActor.assumeIsolated { presentationWindow ?? NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first ?? ASPresentationAnchor() } } }