| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156 |
- 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<URL, Error>) -> Void] = []
- private var authTimeoutTask: Task<Void, Never>?
- private weak var presentationWindow: NSWindow?
- private override init() {
- super.init()
- }
- func authenticate(
- url: URL,
- presentationWindow: NSWindow? = nil,
- completion: @escaping (Result<URL, Error>) -> 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<URL, Error>) -> 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<URL, Error>
- 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<URL, Error>) {
- 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()
- }
- }
- }
|