Coordinate DraftsManager reading writing between processes

This commit is contained in:
Shadowfacts 2023-04-21 17:24:40 -04:00
parent 74a157d26c
commit 2874e4bfd3
9 changed files with 166 additions and 64 deletions

View File

@ -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)
}

View File

@ -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() {

View File

@ -48,7 +48,7 @@ class DraftsController: ViewController {
func closeDrafts() {
isPresented = false
DraftsManager.save()
DraftsManager.shared.save()
}
struct DraftsRepresentable: UIViewControllerRepresentable {

View File

@ -11,75 +11,86 @@ 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<Void, any Error> {
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<Void, any Error> {
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
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)
}
})
} else {
throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts")
}
}
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 })
}
@ -96,9 +107,40 @@ public class DraftsManager: Codable, ObservableObject {
return drafts[id]
}
enum CodingKeys: String, CodingKey {
// MARK: NSFilePresenter
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
@ -110,5 +152,22 @@ public class DraftsManager: Codable, ObservableObject {
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
}
}

View File

@ -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,

View File

@ -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()
}
}

View File

@ -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,

View File

@ -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? {

View File

@ -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<ComposeHostingController.Vie
let controller: ComposeController
let mastodonController: MastodonController
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
private var drawingCompletion: ((PKDrawing) -> Void)?
init(draft: Draft?, mastodonController: MastodonController) {
// self.draftsFileCoordinatorManager = DraftsManagerFileCoordinatorManager()
let draft = draft ?? mastodonController.createDraft()
DraftsManager.shared.load() { _ in
DraftsManager.shared.add(draft)
}
self.controller = ComposeController(
draft: draft,