diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift index 6e0a2cf1d5..f1d0b05ada 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift @@ -15,7 +15,8 @@ public protocol ComposeMastodonContext { var instanceFeatures: InstanceFeatures { get } func run(_ request: Request) async throws -> (Result, Pagination?) - func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) + + func getCustomEmojis() async -> [Emoji] @MainActor func searchCachedAccounts(query: String) -> [AccountProtocol] diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift index 3083d8c6e9..5bdea88fa6 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteEmojisController.swift @@ -44,11 +44,7 @@ class AutocompleteEmojisController: ViewController { @MainActor private func queryChanged(_ query: String) async { - var emojis = await withCheckedContinuation { continuation in - composeController.mastodonController.getCustomEmojis { - continuation.resume(returning: $0) - } - } + var emojis = await composeController.mastodonController.getCustomEmojis() guard !Task.isCancelled else { return } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index c94473dff2..de1e087479 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -12,7 +12,7 @@ import WebURL /** The base Mastodon API client. */ -public class Client { +public struct Client: Sendable { public typealias Callback = (Response) -> Void @@ -20,8 +20,6 @@ public class Client { let session: URLSession public var accessToken: String? - - public var appID: String? public var clientID: String? public var clientSecret: String? @@ -61,9 +59,11 @@ public class Client { return encoder }() - public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) { + public init(baseURL: URL, accessToken: String? = nil, clientID: String? = nil, clientSecret: String? = nil, session: URLSession = .shared) { self.baseURL = baseURL self.accessToken = accessToken + self.clientID = clientID + self.clientSecret = clientSecret self.session = session } @@ -150,14 +150,7 @@ public class Client { "scopes" => scopes.scopeString, "website" => website?.absoluteString ])) - run(request) { result in - defer { completion(result) } - guard case let .success(application, _) = result else { return } - - self.appID = application.id - self.clientID = application.clientID - self.clientSecret = application.clientSecret - } + run(request, completion: completion) } public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback) { @@ -169,12 +162,7 @@ public class Client { "redirect_uri" => redirectURI, "scope" => scopes.scopeString, ])) - run(request) { result in - defer { completion(result) } - guard case let .success(loginSettings, _) = result else { return } - - self.accessToken = loginSettings.accessToken - } + run(request, completion: completion) } public func revokeAccessToken() async throws { @@ -198,21 +186,16 @@ public class Client { }) } - public func nodeInfo(completion: @escaping Callback) { + public func nodeInfo() async throws -> NodeInfo { let wellKnown = Request(method: .get, path: "/.well-known/nodeinfo") - run(wellKnown) { result in - switch result { - case let .failure(error): - completion(.failure(error)) - - case let .success(wellKnown, _): - if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), - let href = WebURL(url.href), - href.host == WebURL(self.baseURL)?.host { - let nodeInfo = Request(method: .get, path: Endpoint(stringLiteral: href.path)) - self.run(nodeInfo, completion: completion) - } - } + let wellKnownResults = try await run(wellKnown).0 + if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), + let href = WebURL(url.href), + href.host == WebURL(self.baseURL)?.host { + let nodeInfo = Request(method: .get, path: Endpoint(stringLiteral: href.path)) + return try await run(nodeInfo).0 + } else { + throw NodeInfoError.noWellKnownLink } } @@ -600,4 +583,15 @@ extension Client { case invalidModel(Swift.Error) case mastodonError(Int, String) } + + enum NodeInfoError: LocalizedError { + case noWellKnownLink + + var errorDescription: String? { + switch self { + case .noWellKnownLink: + return "No well-known link" + } + } + } } diff --git a/ShareExtension/ShareMastodonContext.swift b/ShareExtension/ShareMastodonContext.swift index f8b80c1a58..859f61c86e 100644 --- a/ShareExtension/ShareMastodonContext.swift +++ b/ShareExtension/ShareMastodonContext.swift @@ -29,16 +29,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject { Task { @MainActor in async let instance = try? await run(Client.getInstanceV1()).0 - async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in - self.client.nodeInfo { response in - switch response { - case .success(let nodeInfo, _): - continuation.resume(returning: nodeInfo) - case .failure(_): - continuation.resume(returning: nil) - } - } - }) + async let nodeInfo = try? await client.nodeInfo() guard let instance = await instance else { return } self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo) } @@ -66,15 +57,13 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject { } @MainActor - func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) { + func getCustomEmojis() async -> [Emoji] { if let customEmojis { - completion(customEmojis) + return customEmojis } else { - Task.detached { @MainActor in - let emojis = (try? await self.run(Client.getCustomEmoji()).0) ?? [] - self.customEmojis = emojis - completion(emojis) - } + let emojis = (try? await self.run(Client.getCustomEmoji()).0) ?? [] + self.customEmojis = emojis + return emojis } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 02c5678e06..a08b3fc3c2 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -314,6 +314,7 @@ D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; }; D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; }; + D6DEBA8D2B6579830008629A /* MainThreadBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DEBA8C2B6579830008629A /* MainThreadBox.swift */; }; D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; }; D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; }; D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* Weak.swift */; }; @@ -722,6 +723,7 @@ D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = ""; }; D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = ""; }; D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = ""; }; + D6DEBA8C2B6579830008629A /* MainThreadBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainThreadBox.swift; sourceTree = ""; }; D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = ""; }; D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = ""; }; D6DFC69F242C4CCC00ACC392 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; }; @@ -1502,6 +1504,7 @@ D61F75BC293D099600C0B37F /* Lazy.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D61DC84528F498F200B82C6E /* Logging.swift */, + D6DEBA8C2B6579830008629A /* MainThreadBox.swift */, D6B81F432560390300F6E31D /* MenuController.swift */, D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, @@ -2149,6 +2152,7 @@ D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */, D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */, D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */, + D6DEBA8D2B6579830008629A /* MainThreadBox.swift in Sources */, D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */, D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */, D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */, diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index d11a999963..82ecfe0afc 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -15,8 +15,10 @@ import InstanceFeatures import Sentry #endif import ComposeUI +import OSLog private let oauthScopes = [Scope.read, .write, .follow] +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController") class MastodonController: ObservableObject { @@ -48,26 +50,30 @@ class MastodonController: ObservableObject { nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient) let instanceURL: URL - var accountInfo: UserAccountInfo? - var accountPreferences: AccountPreferences! + let accountInfo: UserAccountInfo? + private(set) var accountPreferences: AccountPreferences! - let client: Client! + private(set) var client: Client! let instanceFeatures = InstanceFeatures() - @Published private(set) var account: AccountMO? - @Published private(set) var instance: InstanceV1? - @Published private var instanceV2: InstanceV2? - @Published private(set) var instanceInfo: InstanceInfo! - @Published private(set) var nodeInfo: NodeInfo! - @Published private(set) var lists: [List] = [] - @Published private(set) var customEmojis: [Emoji]? - @Published private(set) var followedHashtags: [FollowedHashtag] = [] - @Published private(set) var filters: [FilterMO] = [] + @MainActor @Published private(set) var account: AccountMO? + @MainActor @Published private(set) var instance: InstanceV1? + @MainActor @Published private var instanceV2: InstanceV2? + @MainActor @Published private(set) var instanceInfo: InstanceInfo! + @MainActor @Published private(set) var nodeInfo: NodeInfo! + @MainActor @Published private(set) var lists: [List] = [] + @MainActor @Published private(set) var customEmojis: [Emoji]? + @MainActor @Published private(set) var followedHashtags: [FollowedHashtag] = [] + @MainActor @Published private(set) var filters: [FilterMO] = [] private var cancellables = Set() - private var pendingOwnInstanceRequestCallbacks = [(Result) -> Void]() - private var ownInstanceRequest: URLSessionTask? + @MainActor + private var fetchOwnInstanceTask: Task? + @MainActor + private var fetchOwnAccountTask: Task, any Error>? + @MainActor + private var fetchCustomEmojisTask: Task<[Emoji], Never>? var loggedIn: Bool { accountInfo != nil @@ -222,22 +228,37 @@ class MastodonController: ObservableObject { Task { do { - async let ownAccount = try getOwnAccount() - async let ownInstance = try getOwnInstance() - - _ = try await (ownAccount, ownInstance) - - if instanceFeatures.hasMastodonVersion(4, 0, 0) { - async let _ = try? getOwnInstanceV2() - } - - loadLists() - _ = await loadFilters() - await loadServerPreferences() + _ = try await getOwnAccount() } catch { - Logging.general.error("MastodonController initialization failed: \(String(describing: error))") + logger.error("Fetch own account failed: \(String(describing: error))") } } + + let instanceTask = Task { + do { + _ = try await getOwnInstance() + if instanceFeatures.hasMastodonVersion(4, 0, 0) { + _ = try? await getOwnInstanceV2() + } + } catch { + logger.error("Fetch instance failed: \(String(describing: error))") + } + } + + Task { + _ = await instanceTask.value + await loadLists() + } + + Task { + _ = await instanceTask.value + _ = await loadFilters() + } + + Task { + _ = await instanceTask.value + await loadServerPreferences() + } } @MainActor @@ -250,36 +271,43 @@ class MastodonController: ObservableObject { } } - func getOwnAccount(completion: ((Result) -> Void)? = nil) { - if let account { - completion?(.success(account)) - } else { - let request = Client.getSelfAccount() - run(request) { response in - switch response { - case let .failure(error): - completion?(.failure(error)) - - case let .success(account, _): - let context = self.persistentContainer.viewContext - context.perform { - let accountMO: AccountMO - if let existing = self.persistentContainer.account(for: account.id, in: context) { - accountMO = existing - existing.updateFrom(apiAccount: account, container: self.persistentContainer) - } else { - accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context) - } - accountMO.active = true - self.account = accountMO - completion?(.success(accountMO)) - } - } + func getOwnAccount(completion: (@Sendable (Result) -> Void)? = nil) { + Task.detached { + do { + let account = try await self.getOwnAccount() + completion?(.success(account)) + } catch { + completion?(.failure(error)) } } } + @MainActor func getOwnAccount() async throws -> AccountMO { + if let account { + return account + } else if let fetchOwnAccountTask { + return try await fetchOwnAccountTask.value.value + } else { + let task = Task { + let account = try await run(Client.getSelfAccount()).0 + + let context = persistentContainer.viewContext + return await context.perform { + let accountMO: AccountMO + if let existing = self.persistentContainer.account(for: account.id, in: context) { + accountMO = existing + existing.updateFrom(apiAccount: account, container: self.persistentContainer) + } else { + accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context) + } + // TODO: is AccountMO.active used anywhere? + accountMO.active = true + self.account = accountMO + return MainThreadBox(value: accountMO) + } + } + } if let account = account { return account } else { @@ -291,90 +319,60 @@ class MastodonController: ObservableObject { } } - func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) { - getOwnInstanceInternal(retryAttempt: 0) { - if case let .success(instance) = $0 { - completion?(instance) + func getOwnInstance(completion: (@Sendable (InstanceV1) -> Void)? = nil) { + Task.detached { + let ownInstance = try? await self.getOwnInstance() + if let ownInstance { + completion?(ownInstance) } } } @MainActor func getOwnInstance() async throws -> InstanceV1 { - return try await withCheckedThrowingContinuation({ continuation in - getOwnInstanceInternal(retryAttempt: 0) { result in - continuation.resume(with: result) - } - }) - } - - private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result) -> Void)?) { - // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks - assert(Thread.isMainThread) - - if let instance = self.instance { - completion?(.success(instance)) + if let instance { + return instance + } else if let fetchOwnInstanceTask { + return try await fetchOwnInstanceTask.value } else { - if let completion = completion { - pendingOwnInstanceRequestCallbacks.append(completion) - } - - if ownInstanceRequest == nil { - let request = Client.getInstanceV1() - ownInstanceRequest = run(request) { (response) in - switch response { - case .failure(let error): - let delay: DispatchTimeInterval - switch retryAttempt { - case 0: - delay = .seconds(1) - case 1: - delay = .seconds(5) - case 2: - delay = .seconds(30) - case 3: - delay = .seconds(60) - default: - // if we've failed four times, just give up :/ - for completion in self.pendingOwnInstanceRequestCallbacks { - completion(.failure(error)) - } - self.pendingOwnInstanceRequestCallbacks = [] - return - } - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - // completion is nil because in this invocation of getOwnInstanceInternal we've already added it to the pending callbacks array - self.getOwnInstanceInternal(retryAttempt: retryAttempt + 1, completion: nil) - } - - case let .success(instance, _): - DispatchQueue.main.async { - self.ownInstanceRequest = nil - self.instance = instance - - for completion in self.pendingOwnInstanceRequestCallbacks { - completion(.success(instance)) - } - self.pendingOwnInstanceRequestCallbacks = [] - } - } + let task = Task { + let instance = try await retrying("Fetch Own Instance") { + try await run(Client.getInstanceV1()).0 + } + self.instance = instance + + Task { + await fetchNodeInfo() } - client.nodeInfo { result in - switch result { - case let .failure(error): - print("Unable to get node info: \(error)") - - case let .success(nodeInfo, _): - DispatchQueue.main.async { - self.nodeInfo = nodeInfo - } - } - } + return instance } + fetchOwnInstanceTask = task + return try await task.value } } + @MainActor + private func fetchNodeInfo() async { + if let nodeInfo = try? await client.nodeInfo() { + self.nodeInfo = nodeInfo + } + } + + private func retrying(_ label: StaticString, action: () async throws -> T) async throws -> T { + for attempt in 0..<4 { + do { + return try await action() + } catch { + let seconds = UInt64(truncating: pow(2, attempt) as NSNumber) + logger.error("\(label, privacy: .public) failed, waiting \(seconds, privacy: .public)s before retrying. Reason: \(String(describing: error))") + try! await Task.sleep(nanoseconds: seconds * NSEC_PER_SEC) + } + } + return try await action() + } + + @MainActor private func getOwnInstanceV2() async throws { self.instanceV2 = try await client.run(Client.getInstanceV2()).0 } @@ -425,43 +423,43 @@ class MastodonController: ObservableObject { } } - func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) { - if let emojis = self.customEmojis { - completion(emojis) + @MainActor + func getCustomEmojis() async -> [Emoji] { + if let customEmojis { + return customEmojis + } else if let fetchCustomEmojisTask { + return await fetchCustomEmojisTask.value } else { - let request = Client.getCustomEmoji() - run(request) { (response) in - if case let .success(emojis, _) = response { - DispatchQueue.main.async { - self.customEmojis = emojis - } - completion(emojis) - } else { - completion([]) - } + let task = Task { + let emojis = (try? await run(Client.getCustomEmoji()).0) ?? [] + customEmojis = emojis + return emojis } + fetchCustomEmojisTask = task + return await task.value } } - private func loadLists() { + private func loadLists() async { let req = Client.getLists() - run(req) { response in - if case .success(let lists, _) = response { - DispatchQueue.main.async { - self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) - } - let context = self.persistentContainer.backgroundContext - context.perform { - for list in lists { - if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first { - existing.updateFrom(apiList: list) - } else { - _ = ListMO(apiList: list, context: context) - } - } - self.persistentContainer.save(context: context) + guard let (lists, _) = try? await run(req) else { + return + } + let sorted = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) + await MainActor.run { + self.lists = sorted + } + + let context = persistentContainer.backgroundContext + await context.perform { + for list in lists { + if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first { + existing.updateFrom(apiList: list) + } else { + _ = ListMO(apiList: list, context: context) } } + self.persistentContainer.save(context: context) } } diff --git a/Tusker/MainThreadBox.swift b/Tusker/MainThreadBox.swift new file mode 100644 index 0000000000..af6c1c5f01 --- /dev/null +++ b/Tusker/MainThreadBox.swift @@ -0,0 +1,23 @@ +// +// MainThreadBox.swift +// Tusker +// +// Created by Shadowfacts on 1/27/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import Foundation + +struct MainThreadBox: @unchecked Sendable { + private let _value: T + + @MainActor + var value: T { + _value + } + + @MainActor + init(value: T) { + self._value = value + } +} diff --git a/Tusker/Screens/Onboarding/OnboardingViewController.swift b/Tusker/Screens/Onboarding/OnboardingViewController.swift index c3b6037bf9..cbc9de9973 100644 --- a/Tusker/Screens/Onboarding/OnboardingViewController.swift +++ b/Tusker/Screens/Onboarding/OnboardingViewController.swift @@ -145,27 +145,24 @@ class OnboardingViewController: UINavigationController { throw Error.gettingAccessToken(error) } - // construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account - let tempAccountInfo = UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken) - mastodonController.accountInfo = tempAccountInfo + // construct a temporary Client to use to fetch the user's account + let tempClient = Client(baseURL: instanceURL, accessToken: accessToken, session: .appDefault) updateStatus("Checking Credentials") - let ownAccount: AccountMO + let ownAccount: Account do { ownAccount = try await retrying("Getting own account") { - try await mastodonController.getOwnAccount() + try await tempClient.run(Client.getSelfAccount()).0 } } catch { throw Error.gettingOwnAccount(error) } let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken) - mastodonController.accountInfo = accountInfo - self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) } - private func retrying(_ label: StaticString, action: () async throws -> T) async throws -> T { + private func retrying(_ label: StaticString, action: () async throws -> T) async throws -> T { for attempt in 0..<4 { do { return try await action()