import AppKit import AuthenticationServices import WebKit enum OAuthError: LocalizedError { case alreadyInProgress case failedToStart case cancelled case invalidCallback 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 .unsupportedPlatform: "Sign-in requires macOS 14.4 or later. Please update your system." } } } @MainActor final class OAuthAuthenticationManager: NSObject { static let shared = OAuthAuthenticationManager() private var authSession: ASWebAuthenticationSession? private var isAuthenticating = false private weak var presentationWindow: NSWindow? private override init() { super.init() } func authenticate( url: URL, presentationWindow: NSWindow? = nil, completion: @escaping (Result) -> Void ) { guard !isAuthenticating else { completion(.failure(OAuthError.alreadyInProgress)) return } if #available(macOS 14.4, *) { self.presentationWindow = presentationWindow startAuthenticationSession(url: url, completion: completion) } else { NSWorkspace.shared.open(url) completion(.failure(OAuthError.unsupportedPlatform)) } } @available(macOS 14.4, *) private func startAuthenticationSession( url: URL, completion: @escaping (Result) -> Void ) { isAuthenticating = true 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, completion: completion ) } } session.prefersEphemeralWebBrowserSession = false session.presentationContextProvider = self authSession = session guard session.start() else { isAuthenticating = false authSession = nil presentationWindow = nil completion(.failure(OAuthError.failedToStart)) return } } private func finishAuthentication( callbackURL: URL?, error: Error?, completion: @escaping (Result) -> Void ) { isAuthenticating = false authSession = nil presentationWindow = nil if let error { let nsError = error as NSError if nsError.domain == ASWebAuthenticationSessionErrorDomain, nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue { completion(.failure(OAuthError.cancelled)) } else { completion(.failure(error)) } return } guard let callbackURL, RedditWebAuthHelper.isRedditURL(callbackURL) else { completion(.failure(OAuthError.invalidCallback)) return } completion(.success(callbackURL)) } } extension OAuthAuthenticationManager: ASWebAuthenticationPresentationContextProviding { nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { MainActor.assumeIsolated { presentationWindow ?? NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first ?? ASPresentationAnchor() } } }