diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift index 84067649..9a5bb91c 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -33,7 +33,7 @@ class PostService: ObservableObject { } // save before posting, so if a crash occurs during network request, the status won't be lost - DraftsManager.save() + DraftsManager.shared.save() let uploadedAttachments = try await uploadAttachments() @@ -59,7 +59,7 @@ class PostService: ObservableObject { currentStep += 1 DraftsManager.shared.remove(self.draft) - DraftsManager.save() + DraftsManager.shared.save() } catch let error as Client.Error { throw Error.posting(error) } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 153f1365..79a73b21 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -189,7 +189,7 @@ public final class ComposeController: ViewController { if !self.draft.hasContent { DraftsManager.shared.remove(self.draft) } - DraftsManager.save() + DraftsManager.shared.save() self.draft = draft } @@ -198,7 +198,7 @@ public final class ComposeController: ViewController { if !draft.hasContent { DraftsManager.shared.remove(draft) } - DraftsManager.save() + DraftsManager.shared.save() } func toggleContentWarning() { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift index 8ee4c017..da97bce3 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift @@ -48,7 +48,7 @@ class DraftsController: ViewController { func closeDrafts() { isPresented = false - DraftsManager.save() + DraftsManager.shared.save() } struct DraftsRepresentable: UIViewControllerRepresentable { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift index e37cdff7..98cc9d16 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Model/DraftsManager.swift @@ -11,104 +11,163 @@ import Combine import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsManager") +private let encoder = PropertyListEncoder() +private let decoder = PropertyListDecoder() -public class DraftsManager: Codable, ObservableObject { - - public private(set) static var shared: DraftsManager = load() +public class DraftsManager: NSObject, ObservableObject, NSFilePresenter { + public private(set) static var shared: DraftsManager = { + let draftsManager = DraftsManager(url: DraftsManager.archiveURL) + draftsManager.load() + return draftsManager + }() private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! private static var archiveURL = appGroupDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") - private static let saveQueue = DispatchQueue(label: "DraftsManager", qos: .utility) + private var url: URL - public static func save() { - saveQueue.async { - let encoder = PropertyListEncoder() + @Published private var drafts: [UUID: Draft] = [:] + + public init(url: URL) { + self.url = url + + super.init() + } + + private func merge(from container: DraftsContainer) { + for draft in container.drafts.values { + if let existing = self.drafts[draft.id] { + existing.merge(from: draft) + } else { + self.drafts[draft.id] = draft + } + } + for id in self.drafts.keys where !container.drafts.keys.contains(id) { + self.drafts.removeValue(forKey: id) + } + } + + public func load(completion: ((Error?) -> Void)? = nil) { + NSFileCoordinator(filePresenter: self).coordinate(readingItemAt: self.url, options: [], error: nil) { url in do { - let data = try encoder.encode(shared) - try data.write(to: archiveURL, options: .noFileProtection) + let data = try Data(contentsOf: url) + let container = try decoder.decode(DraftsContainer.self, from: data) + DispatchQueue.main.async { + self.merge(from: container) + completion?(nil) + } } catch { - logger.error("Save failed: \(String(describing: error))") + logger.error("Error loading: \(String(describing: error))") + completion?(error) } } } - static func load() -> DraftsManager { - let decoder = PropertyListDecoder() + public func migrate(from url: URL) -> Result { do { - let data = try Data(contentsOf: archiveURL) - let draftsManager = try decoder.decode(DraftsManager.self, from: data) - return draftsManager - } catch { - logger.error("Load failed: \(String(describing: error))") - return DraftsManager() - } - } - - public static func migrate(from url: URL) -> Result { - do { - try? FileManager.default.removeItem(at: archiveURL) - try FileManager.default.moveItem(at: url, to: archiveURL) + let data = try Data(contentsOf: url) + let container = try decoder.decode(DraftsContainer.self, from: data) + self.merge(from: container) + self.save() } catch { + logger.error("Error migrating: \(String(describing: error))") return .failure(error) } - shared = load() return .success(()) } - private init() {} - - public required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - if let dict = try? container.decode([UUID: SafeDraft].self, forKey: .drafts) { - self.drafts = dict.compactMapValues { $0.draft } - } else if let array = try? container.decode([SafeDraft].self, forKey: .drafts) { - self.drafts = array.reduce(into: [:], { partialResult, safeDraft in - if let draft = safeDraft.draft { - partialResult[draft.id] = draft - } - }) - } else { - throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts") + public func save(completion: ((Error?) -> Void)? = nil) { + NSFileCoordinator(filePresenter: self).coordinate(writingItemAt: Self.archiveURL, options: .forReplacing, error: nil) { url in + do { + let data = try encoder.encode(DraftsContainer(drafts: self.drafts)) + try data.write(to: url, options: .atomic) + completion?(nil) + } catch { + logger.error("Error saving: \(String(describing: error))") + completion?(error) + } } } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(drafts, forKey: .drafts) - } + // MARK: Drafts API - @Published private var drafts: [UUID: Draft] = [:] var sorted: [Draft] { return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) } - + public func add(_ draft: Draft) { drafts[draft.id] = draft } - + public func remove(_ draft: Draft) { drafts.removeValue(forKey: draft.id) } - + public func getBy(id: UUID) -> Draft? { return drafts[id] } + + // MARK: NSFilePresenter - enum CodingKeys: String, CodingKey { - case drafts + public var presentedItemURL: URL? { + url + } + + public let presentedItemOperationQueue = OperationQueue() + + public func presentedItemDidMove(to newURL: URL) { + self.url = newURL + } + + public func presentedItemDidChange() { + self.load() + } + + // MARK: Supporting Types + + struct DraftsContainer: Codable { + let drafts: [UUID: Draft] + + init(drafts: [UUID: Draft]) { + self.drafts = drafts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft) + } + + enum CodingKeys: CodingKey { + case drafts + } } // a container that always succeeds at decoding // so if a single draft can't be decoded, we don't lose all drafts struct SafeDraft: Decodable { let draft: Draft? - + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() self.draft = try? container.decode(Draft.self) } } - +} + +private extension Draft { + func merge(from other: Draft) { + self.lastModified = other.lastModified + + self.accountID = other.accountID + self.text = other.text + self.contentWarningEnabled = other.contentWarningEnabled + self.contentWarning = other.contentWarning + self.attachments = other.attachments + self.inReplyToID = other.inReplyToID + self.visibility = other.visibility + self.poll = other.poll + self.localOnly = other.localOnly + + self.initialText = other.initialText + } } diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index a2b46750..e93625ac 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -11,14 +11,16 @@ import UserAccounts import ComposeUI import UniformTypeIdentifiers import TuskerPreferences +import Combine class ShareViewController: UIViewController { private var state: State = .loading + private var draftsPresenterCancellable: AnyCancellable? + required init?(coder: NSCoder) { super.init(coder: coder) - } override func viewDidLoad() { @@ -27,6 +29,11 @@ class ShareViewController: UIViewController { view.tintColor = Preferences.shared.accentColor.color if let account = UserAccountsManager.shared.getMostRecentAccount() { + NSFileCoordinator.addFilePresenter(DraftsManager.shared) + draftsPresenterCancellable = AnyCancellable({ + NSFileCoordinator.removeFilePresenter(DraftsManager.shared) + }) + Task { @MainActor in let draft = await createDraft(account: account) state = .ok @@ -51,6 +58,11 @@ class ShareViewController: UIViewController { } private func createDraft(account: UserAccountInfo) async -> Draft { + await withCheckedContinuation({ continuation in + DraftsManager.shared.load { _ in + continuation.resume() + } + }) let (text, attachments) = await getDraftConfigurationFromExtensionContext() let draft = Draft( accountID: account.id, diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index 890ba40f..061fa4f5 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -22,6 +22,7 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { + private var draftsFileCoordinatorManager: DraftsManagerFileCoordinatorManager! func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { configureSentry() @@ -64,10 +65,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { DispatchQueue.global(qos: .userInitiated).async { let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") if FileManager.default.fileExists(atPath: oldDraftsFile.path) { - if case .failure(let error) = DraftsManager.migrate(from: oldDraftsFile) { + if case .failure(let error) = DraftsManager.shared.migrate(from: oldDraftsFile) { SentrySDK.capture(error: error) } } + + self.draftsFileCoordinatorManager = DraftsManagerFileCoordinatorManager() } return true @@ -211,3 +214,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } + +private class DraftsManagerFileCoordinatorManager { + init() { + NSFileCoordinator.addFilePresenter(DraftsManager.shared) + + NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + } + + deinit { + NSFileCoordinator.removeFilePresenter(DraftsManager.shared) + } + + @objc private func didEnterBackground() { + NSFileCoordinator.removeFilePresenter(DraftsManager.shared) + } + + @objc private func willEnterForeground() { + NSFileCoordinator.addFilePresenter(DraftsManager.shared) + DraftsManager.shared.load() + } +} diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index 339c6253..8ad25119 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -77,7 +77,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg } func sceneWillResignActive(_ scene: UIScene) { - DraftsManager.save() + DraftsManager.shared.save() if let window = window, let nav = window.rootViewController as? UINavigationController, diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 61739176..a7a2312e 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -88,7 +88,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). Preferences.save() - DraftsManager.save() + DraftsManager.shared.save() } func sceneDidBecomeActive(_ scene: UIScene) { @@ -101,7 +101,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // This may occur due to temporary interruptions (ex. an incoming phone call). Preferences.save() - DraftsManager.save() + DraftsManager.shared.save() } func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index 8ccfdc9c..abcd476d 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -6,6 +6,7 @@ // Copyright © 2023 Shadowfacts. All rights reserved. // +import UIKit import SwiftUI import ComposeUI import Combine @@ -27,12 +28,17 @@ class ComposeHostingController: UIHostingController Void)? private var drawingCompletion: ((PKDrawing) -> Void)? init(draft: Draft?, mastodonController: MastodonController) { +// self.draftsFileCoordinatorManager = DraftsManagerFileCoordinatorManager() + let draft = draft ?? mastodonController.createDraft() - DraftsManager.shared.add(draft) + DraftsManager.shared.load() { _ in + DraftsManager.shared.add(draft) + } self.controller = ComposeController( draft: draft,