|
|
@@ -0,0 +1,130 @@
|
|
|
+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<URL, Error>) -> 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<URL, Error>) -> 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<URL, Error>) -> 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()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|