// // MastodonController.swift // Tusker // // Created by Shadowfacts on 8/15/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import Foundation import Pachyderm import Combine import UserAccounts import InstanceFeatures #if canImport(Sentry) import Sentry #endif import ComposeUI import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController") final class MastodonController: ObservableObject, Sendable { static let oauthScopes = [Scope.read, .write, .follow, .push] @MainActor static private(set) var all = [String: MastodonController]() @MainActor static func getForAccount(_ account: UserAccountInfo) -> MastodonController { if let controller = all[account.id] { return controller } else { let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account) all[account.id] = controller return controller } } @MainActor static func removeForAccount(_ account: UserAccountInfo) { all.removeValue(forKey: account.id) } @MainActor static func resetAll() { all = [:] } private let transient: Bool nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient) let instanceURL: URL let accountInfo: UserAccountInfo? @MainActor private(set) var accountPreferences: AccountPreferences! private(set) var client: Client! let instanceFeatures = InstanceFeatures() @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() @MainActor private var fetchOwnInstanceTask: Task? @MainActor private var fetchOwnAccountTask: Task, any Error>? @MainActor private var fetchCustomEmojisTask: Task<[Emoji], Never>? var loggedIn: Bool { accountInfo != nil } // main-actor b/c fetchActiveAccountID and fetchActiveInstance use the viewContext @MainActor init(instanceURL: URL, accountInfo: UserAccountInfo?) { self.instanceURL = instanceURL self.accountInfo = accountInfo self.client = Client(baseURL: instanceURL, session: .appDefault) self.transient = accountInfo == nil self.persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient) self.client.clientID = accountInfo?.clientID self.client.clientSecret = accountInfo?.clientSecret self.client.accessToken = accountInfo?.accessToken if !transient { fetchActiveAccount() fetchActiveInstance() } $instanceInfo .compactMap { $0 } .combineLatest($nodeInfo) .sink { [unowned self] (instance, nodeInfo) in self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo) } .store(in: &cancellables) #if canImport(Sentry) $instanceInfo .compactMap { $0 } .removeDuplicates(by: { $0.version == $1.version }) .combineLatest($nodeInfo.removeDuplicates()) .sink { (instance, nodeInfo) in setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo) } .store(in: &cancellables) #endif $instance .compactMap { $0 } .combineLatest($instanceV2) .sink {[unowned self] (instance, v2) in var info = InstanceInfo(v1: instance) if let v2 { info.update(v2: v2) } self.instanceInfo = info self.updateActiveInstance(from: info) } .store(in: &cancellables) instanceFeatures.featuresUpdated .filter { [unowned self] _ in self.instanceFeatures.canFollowHashtags && self.followedHashtags.isEmpty } .sink { [unowned self] _ in Task { await self.loadFollowedHashtags() } } .store(in: &cancellables) } @MainActor convenience init(instanceURL: URL, transient: Bool) { precondition(transient, "account info must be provided if transient is false") self.init(instanceURL: instanceURL, accountInfo: nil) } @discardableResult func run(_ request: Request, completion: @escaping Client.Callback) -> URLSessionTask? { return client.run(request, completion: completion) } func runResponse(_ request: Request) async -> Response { let response = await withCheckedContinuation({ continuation in client.run(request) { response in continuation.resume(returning: response) } }) return response } func run(_ request: Request) async throws -> (Result, Pagination?) { let response = await runResponse(request) try Task.checkCancellation() switch response { case .failure(let error): throw error case .success(let result, let pagination): return (result, pagination) } } /// - Returns: A tuple of client ID and client secret. func registerApp(reregister: Bool = false) async throws -> (String, String) { if !reregister, 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: MastodonController.oauthScopes) { 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 return (app.clientID, app.clientSecret) } } /// - Returns: The access token func authorize(authorizationCode: String) async throws -> String { return try await withCheckedThrowingContinuation({ continuation in client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth", scopes: MastodonController.oauthScopes) { 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) } } }) } @MainActor func initialize() { precondition(!transient, "Cannot initialize transient MastodonController") // we want this to happen immediately, and synchronously so that the filters (which don't change that often) // are available when Filterers are constructed loadCachedFilters() loadAccountPreferences() lists = loadCachedLists() NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator) .receive(on: DispatchQueue.main) .sink { [unowned self] _ in self.loadAccountPreferences() } .store(in: &cancellables) Task { do { _ = try await getOwnAccount() } catch { 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 private func loadAccountPreferences() { if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first { accountPreferences = existing } else { accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext) persistentContainer.save(context: persistentContainer.viewContext) } } @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 // this closure is declared separately so we can tell the compiler it's Sendable let performBlock: @MainActor @Sendable () -> MainThreadBox = { 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) } // it's safe to remove the MainActor annotation, because this is the view context return await context.perform(unsafeBitCast(performBlock, to: (@Sendable () -> MainThreadBox).self)) } fetchOwnAccountTask = task return try await task.value.value } } 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 { if let instance { return instance } else if let fetchOwnInstanceTask { return try await fetchOwnInstanceTask.value } else { let task = Task { let instance = try await retrying("Fetch Own Instance") { try await run(Client.getInstanceV1()).0 } self.instance = instance Task { await fetchNodeInfo() } 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: @Sendable () 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 } // MainActor because the accountPreferences instance is bound to the view context @MainActor private func loadServerPreferences() async { guard instanceFeatures.hasServerPreferences, let (prefs, _) = try? await run(Client.getPreferences()) else { return } accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true if let accountInfo { UserAccountsManager.shared.updateServerPreferences(accountInfo, defaultLanguage: prefs.postingDefaultLanguage, defaultVisibility: prefs.postingDefaultVisibility.rawValue, defaultFederation: prefs.postingDefaultFederation) } } private func updateActiveInstance(from info: InstanceInfo) { persistentContainer.performBackgroundTask { context in if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { existing.update(from: info) } else { let new = ActiveInstance(context: context) new.update(from: info) } if context.hasChanges { try? context.save() } } } @MainActor private func fetchActiveAccount() { let req = AccountMO.fetchRequest() req.predicate = NSPredicate(format: "active = YES") if let activeAccount = try? persistentContainer.viewContext.fetch(req).first { account = activeAccount } } @MainActor private func fetchActiveInstance() { if let activeInstance = try? persistentContainer.viewContext.fetch(ActiveInstance.fetchRequest()).first { let info = InstanceInfo(activeInstance: activeInstance) self.instanceInfo = info } } @MainActor func getCustomEmojis() async -> [Emoji] { if let customEmojis { return customEmojis } else if let fetchCustomEmojisTask { return await fetchCustomEmojisTask.value } else { let task = Task { let emojis = (try? await run(Client.getCustomEmoji()).0) ?? [] customEmojis = emojis return emojis } fetchCustomEmojisTask = task return await task.value } } private func loadLists() async { let req = Client.getLists() 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) } } private func loadCachedLists() -> [List] { let req = ListMO.fetchRequest() guard let lists = try? persistentContainer.viewContext.fetch(req) else { return [] } return lists.map(\.apiList).sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) } func getCachedList(id: String) -> List? { let req = ListMO.fetchRequest(id: id) return (try? persistentContainer.viewContext.fetch(req).first).map(\.apiList) } @MainActor func addedList(_ list: List) { var new = self.lists new.append(list) new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) self.lists = new } @MainActor func deletedList(_ list: List) { self.lists.removeAll(where: { $0.id == list.id }) } @MainActor func updatedList(_ list: List) { var new = self.lists if let index = new.firstIndex(where: { $0.id == list.id }) { new[index] = list } new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) self.lists = new } @MainActor private func loadFollowedHashtags() async { updateFollowedHashtags() let req = Client.getFollowedHashtags() if let (hashtags, _) = try? await run(req) { self.persistentContainer.updateFollowedHashtags(hashtags) { if case .success(let hashtags) = $0 { self.followedHashtags = hashtags } } } } @MainActor func updateFollowedHashtags() { followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? [] } @MainActor func loadFilters() async { var apiFilters: [AnyFilter]? if instanceFeatures.filtersV2 { let req = Client.getFiltersV2() if let (filters, _) = try? await run(req) { apiFilters = filters.map { .v2($0) } } } else { let req = Client.getFiltersV1() if let (filters, _) = try? await run(req) { apiFilters = filters.map { .v1($0) } } } if let apiFilters { self.persistentContainer.updateFilters(apiFilters) { if case .success(let filters) = $0 { self.filters = filters } } } } @MainActor private func loadCachedFilters() { filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? [] } @MainActor func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft { var acctsToMention = [String]() var visibility = if inReplyToID != nil { Preferences.shared.defaultReplyVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) } else { Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) } var localOnly = instanceFeatures.localOnlyPosts && !accountPreferences!.serverDefaultFederation var contentWarning = "" if let inReplyToID = inReplyToID, let inReplyTo = persistentContainer.status(for: inReplyToID) { acctsToMention.append(inReplyTo.account.acct) acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct)) visibility = min(visibility, inReplyTo.visibility) localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly if !inReplyTo.spoilerText.isEmpty { switch Preferences.shared.contentWarningCopyMode { case .doNotCopy: break case .asIs: contentWarning = inReplyTo.spoilerText case .prependRe: if inReplyTo.spoilerText.lowercased().starts(with: "re:") { contentWarning = inReplyTo.spoilerText } else { contentWarning = "re: \(inReplyTo.spoilerText)" } } } } if let mentioningAcct = mentioningAcct { acctsToMention.append(mentioningAcct) } if let ownAccount = self.account { acctsToMention.removeAll(where: { $0 == ownAccount.acct }) } acctsToMention = acctsToMention.uniques() return DraftsPersistentContainer.shared.createDraft( accountID: accountInfo!.id, text: text ?? acctsToMention.map { "@\($0) " }.joined(), contentWarning: contentWarning, inReplyToID: inReplyToID, visibility: visibility, language: accountPreferences!.serverDefaultLanguage, localOnly: localOnly ) } @MainActor func createDraft(editing status: StatusMO, source: StatusSource) -> Draft { precondition(status.id == source.id) let draft = DraftsPersistentContainer.shared.createEditDraft( accountID: accountInfo!.id, source: source, inReplyToID: status.inReplyToID, visibility: status.visibility, localOnly: status.localOnly, attachments: status.attachments, poll: status.poll ) return draft } } #if canImport(Sentry) private func setInstanceBreadcrumb(instance: InstanceInfo, nodeInfo: NodeInfo?) { let crumb = Breadcrumb(level: .info, category: "MastodonController") crumb.data = [ "instance": [ "version": instance.version ], ] if let nodeInfo { crumb.data!["nodeInfo"] = [ "software": nodeInfo.software.name, "version": nodeInfo.software.version, ] } SentrySDK.addBreadcrumb(crumb) } #endif