diff --git a/Tusker/Controllers/MastodonController.swift b/Tusker/Controllers/MastodonController.swift index 09478903..d6a02862 100644 --- a/Tusker/Controllers/MastodonController.swift +++ b/Tusker/Controllers/MastodonController.swift @@ -67,28 +67,41 @@ class MastodonController: ObservableObject { return client.run(request, completion: completion) } - func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) { - guard client.clientID == nil, - client.clientSecret == nil else { - - completion(client.clientID!, client.clientSecret!) - return - } - - client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: [.read, .write, .follow]) { response in - guard case let .success(app, _) = response else { fatalError() } + /// - Returns: A tuple of client ID and client secret. + func registerApp() async throws -> (String, String) { + if let clientID = client.clientID, + let clientSecret = client.clientSecret { + return (clientID, clientSecret) + } else { + let app: RegisteredApplication = try await withCheckedThrowingContinuation({ continuation in + client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: [.read, .write, .follow]) { response in + switch response { + case .failure(let error): + continuation.resume(throwing: error) + case .success(let app, _): + continuation.resume(returning: app) + } + } + }) self.client.clientID = app.clientID self.client.clientSecret = app.clientSecret - completion(app.clientID, app.clientSecret) + return (app.clientID, app.clientSecret) } } - func authorize(authorizationCode: String, completion: @escaping (_ accessToken: String) -> Void) { - client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth") { response in - guard case let .success(settings, _) = response else { fatalError() } - self.client.accessToken = settings.accessToken - completion(settings.accessToken) - } + /// - Returns: The access token + func authorize(authorizationCode: String) async throws -> String { + return try await withCheckedThrowingContinuation({ continuation in + client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth") { response in + switch response { + case .failure(let error): + continuation.resume(throwing: error) + case .success(let settings, _): + self.client.accessToken = settings.accessToken + continuation.resume(returning: settings.accessToken) + } + } + }) } func getOwnAccount(completion: ((Result) -> Void)? = nil) { @@ -120,6 +133,18 @@ class MastodonController: ObservableObject { } } + func getOwnAccount() async throws -> Account { + if let account = account { + return account + } else { + return try await withCheckedThrowingContinuation({ continuation in + self.getOwnAccount { result in + continuation.resume(with: result) + } + }) + } + } + func getOwnInstance(completion: ((Instance) -> Void)? = nil) { getOwnInstanceInternal(retryAttempt: 0, completion: completion) } diff --git a/Tusker/Screens/Onboarding/OnboardingViewController.swift b/Tusker/Screens/Onboarding/OnboardingViewController.swift index e660fba6..2ff330ca 100644 --- a/Tusker/Screens/Onboarding/OnboardingViewController.swift +++ b/Tusker/Screens/Onboarding/OnboardingViewController.swift @@ -8,8 +8,10 @@ import UIKit import AuthenticationServices +import Pachyderm protocol OnboardingViewControllerDelegate { + @MainActor func didFinishOnboarding(account: LocalData.UserAccountInfo) } @@ -40,60 +42,110 @@ class OnboardingViewController: UINavigationController { instanceSelector.delegate = self } + + @MainActor + private func tryLoginTo(instanceURL: URL) async throws { + let mastodonController = MastodonController(instanceURL: instanceURL) + let clientID: String + let clientSecret: String + do { + (clientID, clientSecret) = try await mastodonController.registerApp() + } catch { + throw Error.registeringApp(error) + } + let authCode = try await getAuthorizationCode(instanceURL: instanceURL, clientID: clientID) + let accessToken: String + do { + accessToken = try await mastodonController.authorize(authorizationCode: authCode) + } catch { + throw Error.gettingAccessToken(error) + } + + // construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account + let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken) + mastodonController.accountInfo = tempAccountInfo + + let ownAccount: Account + do { + ownAccount = try await mastodonController.getOwnAccount() + } catch { + throw Error.gettingOwnAccount(error) + } + + let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken) + mastodonController.accountInfo = accountInfo + + self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) + } + + @MainActor + private func getAuthorizationCode(instanceURL: URL, clientID: String) async throws -> String { + var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)! + components.path = "/oauth/authorize" + components.queryItems = [ + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: "read write follow"), + URLQueryItem(name: "redirect_uri", value: "tusker://oauth") + ] + let authorizeURL = components.url! + + return try await withCheckedThrowingContinuation({ continuation in + self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in + if let error = error { + continuation.resume(throwing: Error.authenticationSessionError(error)) + } else if let url = url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let item = components.queryItems?.first(where: { $0.name == "code" }), + let code = item.value { + continuation.resume(returning: code) + } else { + continuation.resume(throwing: Error.noAuthorizationCode) + } + }) + // Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance. + self.authenticationSession!.prefersEphemeralWebBrowserSession = true + self.authenticationSession!.presentationContextProvider = self + self.authenticationSession!.start() + }) + } } +extension OnboardingViewController { + enum Error: Swift.Error { + case registeringApp(Swift.Error) + case authenticationSessionError(Swift.Error) + case noAuthorizationCode + case gettingAccessToken(Swift.Error) + case gettingOwnAccount(Swift.Error) + + var localizedDescription: String { + switch self { + case .registeringApp(let error): + return "Couldn't register app: \(error)" + case .authenticationSessionError(let error): + return error.localizedDescription + case .noAuthorizationCode: + return "No authorization code" + case .gettingAccessToken(let error): + return "Couldn't get access token: \(error)" + case .gettingOwnAccount(let error): + return "Couldn't fetch account: \(error)" + } + } + } +} + extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate { func didSelectInstance(url instanceURL: URL) { - let mastodonController = MastodonController(instanceURL: instanceURL) - mastodonController.registerApp { (clientID, clientSecret) in - - var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)! - components.path = "/oauth/authorize" - components.queryItems = [ - URLQueryItem(name: "client_id", value: clientID), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "scope", value: "read write follow"), - URLQueryItem(name: "redirect_uri", value: "tusker://oauth") - ] - let authorizeURL = components.url! - - self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker") { url, error in - guard error == nil, - let url = url, - let components = URLComponents(url: url, resolvingAgainstBaseURL: true), - let item = components.queryItems?.first(where: { $0.name == "code" }), - let authCode = item.value else { return } - - mastodonController.authorize(authorizationCode: authCode) { (accessToken) in - // construct a temporary UserAccountInfo instance for the MastodonController to use to fetch it's own account - let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken) - mastodonController.accountInfo = tempAccountInfo - - mastodonController.getOwnAccount { (result) in - DispatchQueue.main.async { - switch result { - case let .failure(error): - let alert = UIAlertController(title: "Unable to Verify Credentials", message: "Your account could not be fetched at this time: \(error.localizedDescription)", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil)) - self.present(alert, animated: true) - - case let .success(account): - // this needs to happen on the main thread because it publishes a new value for the ObservableObject - let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: account.username, accessToken: accessToken) - mastodonController.accountInfo = accountInfo - - self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) - } - } - } - } - } - DispatchQueue.main.async { - // Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance. - self.authenticationSession!.prefersEphemeralWebBrowserSession = true - self.authenticationSession!.presentationContextProvider = self - self.authenticationSession!.start() + Task { + do { + try await self.tryLoginTo(instanceURL: instanceURL) + } catch let error as Error { + let alert = UIAlertController(title: "Error Logging In", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Ok", style: .default)) + self.present(alert, animated: true) } } }