|
|
@@ -2,11 +2,12 @@ import AppKit
|
|
|
import AuthenticationServices
|
|
|
import WebKit
|
|
|
|
|
|
-enum OAuthError: LocalizedError {
|
|
|
+enum OAuthError: LocalizedError, Equatable {
|
|
|
case alreadyInProgress
|
|
|
case failedToStart
|
|
|
case cancelled
|
|
|
case invalidCallback
|
|
|
+ case timedOut
|
|
|
case unsupportedPlatform
|
|
|
|
|
|
var errorDescription: String? {
|
|
|
@@ -19,6 +20,8 @@ enum OAuthError: LocalizedError {
|
|
|
"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."
|
|
|
}
|
|
|
@@ -29,8 +32,13 @@ enum OAuthError: LocalizedError {
|
|
|
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() {
|
|
|
@@ -42,9 +50,12 @@ final class OAuthAuthenticationManager: NSObject {
|
|
|
presentationWindow: NSWindow? = nil,
|
|
|
completion: @escaping (Result<URL, Error>) -> Void
|
|
|
) {
|
|
|
- guard !isAuthenticating else {
|
|
|
- // Reddit's Google/Apple buttons can fire both decidePolicyFor and
|
|
|
- // createWebViewWith for the same OAuth URL; ignore the duplicate.
|
|
|
+ if isAuthenticating {
|
|
|
+ if let currentAuthURL, currentAuthURL.absoluteString == url.absoluteString {
|
|
|
+ pendingCompletions.append(completion)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ completion(.failure(OAuthError.alreadyInProgress))
|
|
|
return
|
|
|
}
|
|
|
|
|
|
@@ -52,7 +63,6 @@ final class OAuthAuthenticationManager: NSObject {
|
|
|
self.presentationWindow = presentationWindow
|
|
|
startAuthenticationSession(url: url, completion: completion)
|
|
|
} else {
|
|
|
- NSWorkspace.shared.open(url)
|
|
|
completion(.failure(OAuthError.unsupportedPlatform))
|
|
|
}
|
|
|
}
|
|
|
@@ -63,6 +73,8 @@ final class OAuthAuthenticationManager: NSObject {
|
|
|
completion: @escaping (Result<URL, Error>) -> Void
|
|
|
) {
|
|
|
isAuthenticating = true
|
|
|
+ currentAuthURL = url
|
|
|
+ pendingCompletions = [completion]
|
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
|
let callbackHost = RedditWebAuthHelper.oauthCallbackHost(for: url)
|
|
|
@@ -72,11 +84,7 @@ final class OAuthAuthenticationManager: NSObject {
|
|
|
callback: .https(host: callbackHost, path: "")
|
|
|
) { [weak self] callbackURL, error in
|
|
|
Task { @MainActor in
|
|
|
- self?.finishAuthentication(
|
|
|
- callbackURL: callbackURL,
|
|
|
- error: error,
|
|
|
- completion: completion
|
|
|
- )
|
|
|
+ self?.finishAuthentication(callbackURL: callbackURL, error: error)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -84,41 +92,58 @@ final class OAuthAuthenticationManager: NSObject {
|
|
|
session.presentationContextProvider = self
|
|
|
authSession = session
|
|
|
|
|
|
+ startAuthTimeout()
|
|
|
+
|
|
|
guard session.start() else {
|
|
|
- isAuthenticating = false
|
|
|
- authSession = nil
|
|
|
- presentationWindow = nil
|
|
|
- completion(.failure(OAuthError.failedToStart))
|
|
|
+ deliverResult(.failure(OAuthError.failedToStart))
|
|
|
return
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func finishAuthentication(
|
|
|
- callbackURL: URL?,
|
|
|
- error: Error?,
|
|
|
- completion: @escaping (Result<URL, Error>) -> Void
|
|
|
- ) {
|
|
|
- isAuthenticating = false
|
|
|
- authSession = nil
|
|
|
- presentationWindow = nil
|
|
|
+ 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 {
|
|
|
- completion(.failure(OAuthError.cancelled))
|
|
|
+ result = .failure(OAuthError.cancelled)
|
|
|
} else {
|
|
|
- completion(.failure(error))
|
|
|
+ result = .failure(error)
|
|
|
}
|
|
|
- return
|
|
|
+ } else if let callbackURL, RedditWebAuthHelper.isRedditURL(callbackURL) {
|
|
|
+ result = .success(callbackURL)
|
|
|
+ } else {
|
|
|
+ result = .failure(OAuthError.invalidCallback)
|
|
|
}
|
|
|
|
|
|
- guard let callbackURL, RedditWebAuthHelper.isRedditURL(callbackURL) else {
|
|
|
- completion(.failure(OAuthError.invalidCallback))
|
|
|
- return
|
|
|
- }
|
|
|
+ deliverResult(result)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func deliverResult(_ result: Result<URL, Error>) {
|
|
|
+ authTimeoutTask?.cancel()
|
|
|
+ authTimeoutTask = nil
|
|
|
+ isAuthenticating = false
|
|
|
+ authSession = nil
|
|
|
+ presentationWindow = nil
|
|
|
+ currentAuthURL = nil
|
|
|
|
|
|
- completion(.success(callbackURL))
|
|
|
+ let completions = pendingCompletions
|
|
|
+ pendingCompletions = []
|
|
|
+ completions.forEach { $0(result) }
|
|
|
}
|
|
|
}
|
|
|
|