More strict concurrency fixes

This commit is contained in:
Shadowfacts 2024-01-27 14:58:36 -05:00
parent ba60f92223
commit fc26c9fb54
8 changed files with 217 additions and 215 deletions

View File

@ -15,7 +15,8 @@ public protocol ComposeMastodonContext {
var instanceFeatures: InstanceFeatures { get } var instanceFeatures: InstanceFeatures { get }
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void)
func getCustomEmojis() async -> [Emoji]
@MainActor @MainActor
func searchCachedAccounts(query: String) -> [AccountProtocol] func searchCachedAccounts(query: String) -> [AccountProtocol]

View File

@ -44,11 +44,7 @@ class AutocompleteEmojisController: ViewController {
@MainActor @MainActor
private func queryChanged(_ query: String) async { private func queryChanged(_ query: String) async {
var emojis = await withCheckedContinuation { continuation in var emojis = await composeController.mastodonController.getCustomEmojis()
composeController.mastodonController.getCustomEmojis {
continuation.resume(returning: $0)
}
}
guard !Task.isCancelled else { guard !Task.isCancelled else {
return return
} }

View File

@ -12,7 +12,7 @@ import WebURL
/** /**
The base Mastodon API client. The base Mastodon API client.
*/ */
public class Client { public struct Client: Sendable {
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
@ -20,8 +20,6 @@ public class Client {
let session: URLSession let session: URLSession
public var accessToken: String? public var accessToken: String?
public var appID: String?
public var clientID: String? public var clientID: String?
public var clientSecret: String? public var clientSecret: String?
@ -61,9 +59,11 @@ public class Client {
return encoder 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.baseURL = baseURL
self.accessToken = accessToken self.accessToken = accessToken
self.clientID = clientID
self.clientSecret = clientSecret
self.session = session self.session = session
} }
@ -150,14 +150,7 @@ public class Client {
"scopes" => scopes.scopeString, "scopes" => scopes.scopeString,
"website" => website?.absoluteString "website" => website?.absoluteString
])) ]))
run(request) { result in run(request, completion: completion)
defer { completion(result) }
guard case let .success(application, _) = result else { return }
self.appID = application.id
self.clientID = application.clientID
self.clientSecret = application.clientSecret
}
} }
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) { public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
@ -169,12 +162,7 @@ public class Client {
"redirect_uri" => redirectURI, "redirect_uri" => redirectURI,
"scope" => scopes.scopeString, "scope" => scopes.scopeString,
])) ]))
run(request) { result in run(request, completion: completion)
defer { completion(result) }
guard case let .success(loginSettings, _) = result else { return }
self.accessToken = loginSettings.accessToken
}
} }
public func revokeAccessToken() async throws { public func revokeAccessToken() async throws {
@ -198,21 +186,16 @@ public class Client {
}) })
} }
public func nodeInfo(completion: @escaping Callback<NodeInfo>) { public func nodeInfo() async throws -> NodeInfo {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo") let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
run(wellKnown) { result in let wellKnownResults = try await run(wellKnown).0
switch result { if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
case let .failure(error): let href = WebURL(url.href),
completion(.failure(error)) href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
case let .success(wellKnown, _): return try await run(nodeInfo).0
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), } else {
let href = WebURL(url.href), throw NodeInfoError.noWellKnownLink
href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
self.run(nodeInfo, completion: completion)
}
}
} }
} }
@ -600,4 +583,15 @@ extension Client {
case invalidModel(Swift.Error) case invalidModel(Swift.Error)
case mastodonError(Int, String) case mastodonError(Int, String)
} }
enum NodeInfoError: LocalizedError {
case noWellKnownLink
var errorDescription: String? {
switch self {
case .noWellKnownLink:
return "No well-known link"
}
}
}
} }

View File

@ -29,16 +29,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
Task { @MainActor in Task { @MainActor in
async let instance = try? await run(Client.getInstanceV1()).0 async let instance = try? await run(Client.getInstanceV1()).0
async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in async let nodeInfo = try? await client.nodeInfo()
self.client.nodeInfo { response in
switch response {
case .success(let nodeInfo, _):
continuation.resume(returning: nodeInfo)
case .failure(_):
continuation.resume(returning: nil)
}
}
})
guard let instance = await instance else { return } guard let instance = await instance else { return }
self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo) self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo)
} }
@ -66,15 +57,13 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
} }
@MainActor @MainActor
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) { func getCustomEmojis() async -> [Emoji] {
if let customEmojis { if let customEmojis {
completion(customEmojis) return customEmojis
} else { } else {
Task.detached { @MainActor in let emojis = (try? await self.run(Client.getCustomEmoji()).0) ?? []
let emojis = (try? await self.run(Client.getCustomEmoji()).0) ?? [] self.customEmojis = emojis
self.customEmojis = emojis return emojis
completion(emojis)
}
} }
} }

View File

@ -314,6 +314,7 @@
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; }; D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.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 */; }; D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; }; D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* Weak.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 = "<group>"; }; D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; }; D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; }; D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainThreadBox.swift; sourceTree = "<group>"; };
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; }; D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; }; D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; }; D6DFC69F242C4CCC00ACC392 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
@ -1502,6 +1504,7 @@
D61F75BC293D099600C0B37F /* Lazy.swift */, D61F75BC293D099600C0B37F /* Lazy.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */, D61DC84528F498F200B82C6E /* Logging.swift */,
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */, D6B81F432560390300F6E31D /* MenuController.swift */,
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */, D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
@ -2149,6 +2152,7 @@
D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */, D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */,
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */, D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */,
D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */, D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */,
D6DEBA8D2B6579830008629A /* MainThreadBox.swift in Sources */,
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */, D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */, D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */, D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,

View File

@ -15,8 +15,10 @@ import InstanceFeatures
import Sentry import Sentry
#endif #endif
import ComposeUI import ComposeUI
import OSLog
private let oauthScopes = [Scope.read, .write, .follow] private let oauthScopes = [Scope.read, .write, .follow]
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController")
class MastodonController: ObservableObject { class MastodonController: ObservableObject {
@ -48,26 +50,30 @@ class MastodonController: ObservableObject {
nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient) nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL let instanceURL: URL
var accountInfo: UserAccountInfo? let accountInfo: UserAccountInfo?
var accountPreferences: AccountPreferences! private(set) var accountPreferences: AccountPreferences!
let client: Client! private(set) var client: Client!
let instanceFeatures = InstanceFeatures() let instanceFeatures = InstanceFeatures()
@Published private(set) var account: AccountMO? @MainActor @Published private(set) var account: AccountMO?
@Published private(set) var instance: InstanceV1? @MainActor @Published private(set) var instance: InstanceV1?
@Published private var instanceV2: InstanceV2? @MainActor @Published private var instanceV2: InstanceV2?
@Published private(set) var instanceInfo: InstanceInfo! @MainActor @Published private(set) var instanceInfo: InstanceInfo!
@Published private(set) var nodeInfo: NodeInfo! @MainActor @Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var lists: [List] = [] @MainActor @Published private(set) var lists: [List] = []
@Published private(set) var customEmojis: [Emoji]? @MainActor @Published private(set) var customEmojis: [Emoji]?
@Published private(set) var followedHashtags: [FollowedHashtag] = [] @MainActor @Published private(set) var followedHashtags: [FollowedHashtag] = []
@Published private(set) var filters: [FilterMO] = [] @MainActor @Published private(set) var filters: [FilterMO] = []
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]() @MainActor
private var ownInstanceRequest: URLSessionTask? private var fetchOwnInstanceTask: Task<InstanceV1, any Error>?
@MainActor
private var fetchOwnAccountTask: Task<MainThreadBox<AccountMO>, any Error>?
@MainActor
private var fetchCustomEmojisTask: Task<[Emoji], Never>?
var loggedIn: Bool { var loggedIn: Bool {
accountInfo != nil accountInfo != nil
@ -222,22 +228,37 @@ class MastodonController: ObservableObject {
Task { Task {
do { do {
async let ownAccount = try getOwnAccount() _ = try await 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()
} catch { } 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 @MainActor
@ -250,36 +271,43 @@ class MastodonController: ObservableObject {
} }
} }
func getOwnAccount(completion: ((Result<AccountMO, Client.Error>) -> Void)? = nil) { func getOwnAccount(completion: (@Sendable (Result<AccountMO, any Error>) -> Void)? = nil) {
if let account { Task.detached {
completion?(.success(account)) do {
} else { let account = try await self.getOwnAccount()
let request = Client.getSelfAccount() completion?(.success(account))
run(request) { response in } catch {
switch response { completion?(.failure(error))
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))
}
}
} }
} }
} }
@MainActor
func getOwnAccount() async throws -> AccountMO { 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 { if let account = account {
return account return account
} else { } else {
@ -291,90 +319,60 @@ class MastodonController: ObservableObject {
} }
} }
func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) { func getOwnInstance(completion: (@Sendable (InstanceV1) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0) { Task.detached {
if case let .success(instance) = $0 { let ownInstance = try? await self.getOwnInstance()
completion?(instance) if let ownInstance {
completion?(ownInstance)
} }
} }
} }
@MainActor @MainActor
func getOwnInstance() async throws -> InstanceV1 { func getOwnInstance() async throws -> InstanceV1 {
return try await withCheckedThrowingContinuation({ continuation in if let instance {
getOwnInstanceInternal(retryAttempt: 0) { result in return instance
continuation.resume(with: result) } else if let fetchOwnInstanceTask {
} return try await fetchOwnInstanceTask.value
})
}
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<InstanceV1, Client.Error>) -> 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))
} else { } else {
if let completion = completion { let task = Task {
pendingOwnInstanceRequestCallbacks.append(completion) let instance = try await retrying("Fetch Own Instance") {
} try await run(Client.getInstanceV1()).0
}
if ownInstanceRequest == nil { self.instance = instance
let request = Client.getInstanceV1()
ownInstanceRequest = run(request) { (response) in Task {
switch response { await fetchNodeInfo()
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 = []
}
}
} }
client.nodeInfo { result in return instance
switch result {
case let .failure(error):
print("Unable to get node info: \(error)")
case let .success(nodeInfo, _):
DispatchQueue.main.async {
self.nodeInfo = nodeInfo
}
}
}
} }
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<T: Sendable>(_ 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 { private func getOwnInstanceV2() async throws {
self.instanceV2 = try await client.run(Client.getInstanceV2()).0 self.instanceV2 = try await client.run(Client.getInstanceV2()).0
} }
@ -425,43 +423,43 @@ class MastodonController: ObservableObject {
} }
} }
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) { @MainActor
if let emojis = self.customEmojis { func getCustomEmojis() async -> [Emoji] {
completion(emojis) if let customEmojis {
return customEmojis
} else if let fetchCustomEmojisTask {
return await fetchCustomEmojisTask.value
} else { } else {
let request = Client.getCustomEmoji() let task = Task {
run(request) { (response) in let emojis = (try? await run(Client.getCustomEmoji()).0) ?? []
if case let .success(emojis, _) = response { customEmojis = emojis
DispatchQueue.main.async { return emojis
self.customEmojis = emojis
}
completion(emojis)
} else {
completion([])
}
} }
fetchCustomEmojisTask = task
return await task.value
} }
} }
private func loadLists() { private func loadLists() async {
let req = Client.getLists() let req = Client.getLists()
run(req) { response in guard let (lists, _) = try? await run(req) else {
if case .success(let lists, _) = response { return
DispatchQueue.main.async { }
self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) let sorted = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
} await MainActor.run {
let context = self.persistentContainer.backgroundContext self.lists = sorted
context.perform { }
for list in lists {
if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first { let context = persistentContainer.backgroundContext
existing.updateFrom(apiList: list) await context.perform {
} else { for list in lists {
_ = ListMO(apiList: list, context: context) if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first {
} existing.updateFrom(apiList: list)
} } else {
self.persistentContainer.save(context: context) _ = ListMO(apiList: list, context: context)
} }
} }
self.persistentContainer.save(context: context)
} }
} }

View File

@ -0,0 +1,23 @@
//
// MainThreadBox.swift
// Tusker
//
// Created by Shadowfacts on 1/27/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
struct MainThreadBox<T>: @unchecked Sendable {
private let _value: T
@MainActor
var value: T {
_value
}
@MainActor
init(value: T) {
self._value = value
}
}

View File

@ -145,27 +145,24 @@ class OnboardingViewController: UINavigationController {
throw Error.gettingAccessToken(error) throw Error.gettingAccessToken(error)
} }
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account // construct a temporary Client to use to fetch the user's account
let tempAccountInfo = UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken) let tempClient = Client(baseURL: instanceURL, accessToken: accessToken, session: .appDefault)
mastodonController.accountInfo = tempAccountInfo
updateStatus("Checking Credentials") updateStatus("Checking Credentials")
let ownAccount: AccountMO let ownAccount: Account
do { do {
ownAccount = try await retrying("Getting own account") { ownAccount = try await retrying("Getting own account") {
try await mastodonController.getOwnAccount() try await tempClient.run(Client.getSelfAccount()).0
} }
} catch { } catch {
throw Error.gettingOwnAccount(error) throw Error.gettingOwnAccount(error)
} }
let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken) let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken)
mastodonController.accountInfo = accountInfo
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
} }
private func retrying<T>(_ label: StaticString, action: () async throws -> T) async throws -> T { private func retrying<T: Sendable>(_ label: StaticString, action: () async throws -> T) async throws -> T {
for attempt in 0..<4 { for attempt in 0..<4 {
do { do {
return try await action() return try await action()