diff --git a/Account/Sources/Account/AccountDelegates/FeedlyAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/FeedlyAccountDelegate.swift index c2278c405..269aaba1f 100644 --- a/Account/Sources/Account/AccountDelegates/FeedlyAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/FeedlyAccountDelegate.swift @@ -7,6 +7,7 @@ // import Foundation +import AuthenticationServices import Articles import Parser import Web @@ -967,3 +968,173 @@ extension FeedlyAccountDelegate: FeedlyAPICallerDelegate { try account.storeCredentials(grant.accessToken) } } + +public protocol FeedlyOAuthAccountAuthorizationOperationDelegate: AnyObject { + + @MainActor func oauthAccountAuthorizationOperation(_ operation: FeedlyOAuthAccountAuthorizationOperation, didCreate account: Account) + @MainActor func oauthAccountAuthorizationOperation(_ operation: FeedlyOAuthAccountAuthorizationOperation, didFailWith error: Error) +} + +public enum FeedlyOAuthAccountAuthorizationOperationError: LocalizedError { + case duplicateAccount + + public var errorDescription: String? { + return NSLocalizedString("There is already a Feedly account with that username created.", comment: "Duplicate Error") + } +} +@MainActor @objc public final class FeedlyOAuthAccountAuthorizationOperation: NSObject { + + public var isCanceled: Bool = false { + didSet { + if isCanceled { + cancel() + } + } + } + + public var completionBlock: ((FeedlyOAuthAccountAuthorizationOperation) -> Void)? + + public weak var presentationAnchor: ASPresentationAnchor? + public weak var delegate: FeedlyOAuthAccountAuthorizationOperationDelegate? + + private let oauthClient: OAuthAuthorizationClient + private var session: ASWebAuthenticationSession? + private let secretsProvider: SecretsProvider + + public init(secretsProvider: SecretsProvider) { + self.secretsProvider = secretsProvider + self.oauthClient = FeedlyAPICaller.API.cloud.oauthAuthorizationClient(secretsProvider: secretsProvider) + } + + @MainActor public func run() { + assert(presentationAnchor != nil, "\(self) outlived presentation anchor.") + + let request = FeedlyAPICaller.oauthAuthorizationCodeGrantRequest(secretsProvider: secretsProvider) + + guard let url = request.url else { + return DispatchQueue.main.async { + self.didEndAuthentication(url: nil, error: URLError(.badURL)) + } + } + + guard let redirectURI = URL(string: oauthClient.redirectURI), let scheme = redirectURI.scheme else { + assertionFailure("Could not get callback URL scheme from \(oauthClient.redirectURI)") + return DispatchQueue.main.async { + self.didEndAuthentication(url: nil, error: URLError(.badURL)) + } + } + + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { url, error in + DispatchQueue.main.async { [weak self] in + self?.didEndAuthentication(url: url, error: error) + } + } + + session.presentationContextProvider = self + + guard session.start() else { + + /// Documentation does not say on why `ASWebAuthenticationSession.start` or `canStart` might return false. + /// Perhaps it has something to do with an inter-process communication failure? No browsers installed? No browsers that support web authentication? + struct UnableToStartASWebAuthenticationSessionError: LocalizedError { + let errorDescription: String? = NSLocalizedString("Unable to start a web authentication session with the default web browser.", + comment: "OAuth - error description - unable to authorize because ASWebAuthenticationSession did not start.") + let recoverySuggestion: String? = NSLocalizedString("Check your default web browser in System Preferences or change it to Safari and try again.", + comment: "OAuth - recovery suggestion - ensure browser selected supports web authentication.") + } + + didFinish(UnableToStartASWebAuthenticationSessionError()) + + return + } + + self.session = session + } + + public func cancel() { + session?.cancel() + } + + private func didEndAuthentication(url: URL?, error: Error?) { + + Task { @MainActor in + guard !isCanceled else { + didFinish() + return + } + + do { + guard let url = url else { + if let error { + throw error + } + throw URLError(.badURL) + } + + let response = try OAuthAuthorizationResponse(url: url, client: self.oauthClient) + + let tokenResponse = try await FeedlyAPICaller.requestOAuthAccessToken(with: response, transport: URLSession.webserviceTransport(), secretsProvider: secretsProvider) + saveAccount(for: tokenResponse) + + } catch is ASWebAuthenticationSessionError { + didFinish() // Primarily, cancellation. + + } catch { + didFinish(error) + } + } + } + + @MainActor private func saveAccount(for grant: OAuthAuthorizationGrant) { + guard !AccountManager.shared.duplicateServiceAccount(type: .feedly, username: grant.accessToken.username) else { + didFinish(FeedlyOAuthAccountAuthorizationOperationError.duplicateAccount) + return + } + + let account = AccountManager.shared.createAccount(type: .feedly) + do { + + // Store the refresh token first because it sends this token to the account delegate. + if let token = grant.refreshToken { + try account.storeCredentials(token) + } + + // Now store the access token because we want the account delegate to use it. + try account.storeCredentials(grant.accessToken) + + delegate?.oauthAccountAuthorizationOperation(self, didCreate: account) + + didFinish() + } catch { + didFinish(error) + } + } + + // MARK: Managing Operation State + + @MainActor private func didFinish() { + assert(Thread.isMainThread) +// operationDelegate?.operationDidComplete(self) + } + + @MainActor private func didFinish(_ error: Error) { + assert(Thread.isMainThread) + delegate?.oauthAccountAuthorizationOperation(self, didFailWith: error) + didFinish() + } +} + +// MARK: - ASWebAuthenticationPresentationContextProviding + +extension FeedlyOAuthAccountAuthorizationOperation: ASWebAuthenticationPresentationContextProviding { + + nonisolated public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + + MainActor.assumeIsolated { + guard let anchor = presentationAnchor else { + fatalError("\(self) has outlived presentation anchor.") + } + return anchor + } + } +} diff --git a/Account/Sources/Account/AccountDelegates/FeedlyOAuthAccountAuthorizationOperation.swift b/Account/Sources/Account/AccountDelegates/FeedlyOAuthAccountAuthorizationOperation.swift deleted file mode 100644 index cd9554fb4..000000000 --- a/Account/Sources/Account/AccountDelegates/FeedlyOAuthAccountAuthorizationOperation.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// OAuthAccountAuthorizationOperation.swift -// NetNewsWire -// -// Created by Kiel Gillard on 8/11/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import Foundation -import AuthenticationServices -import Secrets -import Core -import Feedly - -public protocol FeedlyOAuthAccountAuthorizationOperationDelegate: AnyObject { - - @MainActor func oauthAccountAuthorizationOperation(_ operation: FeedlyOAuthAccountAuthorizationOperation, didCreate account: Account) - @MainActor func oauthAccountAuthorizationOperation(_ operation: FeedlyOAuthAccountAuthorizationOperation, didFailWith error: Error) -} - -public enum FeedlyOAuthAccountAuthorizationOperationError: LocalizedError { - case duplicateAccount - - public var errorDescription: String? { - return NSLocalizedString("There is already a Feedly account with that username created.", comment: "Duplicate Error") - } -} -@MainActor @objc public final class FeedlyOAuthAccountAuthorizationOperation: NSObject { - - public var isCanceled: Bool = false { - didSet { - if isCanceled { - cancel() - } - } - } - - public var completionBlock: ((FeedlyOAuthAccountAuthorizationOperation) -> Void)? - - public weak var presentationAnchor: ASPresentationAnchor? - public weak var delegate: FeedlyOAuthAccountAuthorizationOperationDelegate? - - private let oauthClient: OAuthAuthorizationClient - private var session: ASWebAuthenticationSession? - private let secretsProvider: SecretsProvider - - public init(secretsProvider: SecretsProvider) { - self.secretsProvider = secretsProvider - self.oauthClient = FeedlyAPICaller.API.cloud.oauthAuthorizationClient(secretsProvider: secretsProvider) - } - - @MainActor public func run() { - assert(presentationAnchor != nil, "\(self) outlived presentation anchor.") - - let request = FeedlyAPICaller.oauthAuthorizationCodeGrantRequest(secretsProvider: secretsProvider) - - guard let url = request.url else { - return DispatchQueue.main.async { - self.didEndAuthentication(url: nil, error: URLError(.badURL)) - } - } - - guard let redirectURI = URL(string: oauthClient.redirectURI), let scheme = redirectURI.scheme else { - assertionFailure("Could not get callback URL scheme from \(oauthClient.redirectURI)") - return DispatchQueue.main.async { - self.didEndAuthentication(url: nil, error: URLError(.badURL)) - } - } - - let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { url, error in - DispatchQueue.main.async { [weak self] in - self?.didEndAuthentication(url: url, error: error) - } - } - - session.presentationContextProvider = self - - guard session.start() else { - - /// Documentation does not say on why `ASWebAuthenticationSession.start` or `canStart` might return false. - /// Perhaps it has something to do with an inter-process communication failure? No browsers installed? No browsers that support web authentication? - struct UnableToStartASWebAuthenticationSessionError: LocalizedError { - let errorDescription: String? = NSLocalizedString("Unable to start a web authentication session with the default web browser.", - comment: "OAuth - error description - unable to authorize because ASWebAuthenticationSession did not start.") - let recoverySuggestion: String? = NSLocalizedString("Check your default web browser in System Preferences or change it to Safari and try again.", - comment: "OAuth - recovery suggestion - ensure browser selected supports web authentication.") - } - - didFinish(UnableToStartASWebAuthenticationSessionError()) - - return - } - - self.session = session - } - - public func cancel() { - session?.cancel() - } - - private func didEndAuthentication(url: URL?, error: Error?) { - - Task { @MainActor in - guard !isCanceled else { - didFinish() - return - } - - do { - guard let url = url else { - if let error { - throw error - } - throw URLError(.badURL) - } - - let response = try OAuthAuthorizationResponse(url: url, client: self.oauthClient) - - let tokenResponse = try await FeedlyAPICaller.requestOAuthAccessToken(with: response, transport: URLSession.webserviceTransport(), secretsProvider: secretsProvider) - saveAccount(for: tokenResponse) - - } catch is ASWebAuthenticationSessionError { - didFinish() // Primarily, cancellation. - - } catch { - didFinish(error) - } - } - } - - @MainActor private func saveAccount(for grant: OAuthAuthorizationGrant) { - guard !AccountManager.shared.duplicateServiceAccount(type: .feedly, username: grant.accessToken.username) else { - didFinish(FeedlyOAuthAccountAuthorizationOperationError.duplicateAccount) - return - } - - let account = AccountManager.shared.createAccount(type: .feedly) - do { - - // Store the refresh token first because it sends this token to the account delegate. - if let token = grant.refreshToken { - try account.storeCredentials(token) - } - - // Now store the access token because we want the account delegate to use it. - try account.storeCredentials(grant.accessToken) - - delegate?.oauthAccountAuthorizationOperation(self, didCreate: account) - - didFinish() - } catch { - didFinish(error) - } - } - - // MARK: Managing Operation State - - @MainActor private func didFinish() { - assert(Thread.isMainThread) -// operationDelegate?.operationDidComplete(self) - } - - @MainActor private func didFinish(_ error: Error) { - assert(Thread.isMainThread) - delegate?.oauthAccountAuthorizationOperation(self, didFailWith: error) - didFinish() - } -} - -// MARK: - ASWebAuthenticationPresentationContextProviding - -extension FeedlyOAuthAccountAuthorizationOperation: ASWebAuthenticationPresentationContextProviding { - - nonisolated public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - - MainActor.assumeIsolated { - guard let anchor = presentationAnchor else { - fatalError("\(self) has outlived presentation anchor.") - } - return anchor - } - } -}