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 // 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() let uploadedAttachments = try await uploadAttachments()
@ -59,7 +59,7 @@ class PostService: ObservableObject {
currentStep += 1 currentStep += 1
DraftsManager.shared.remove(self.draft) DraftsManager.shared.remove(self.draft)
DraftsManager.save() DraftsManager.shared.save()
} catch let error as Client.Error { } catch let error as Client.Error {
throw Error.posting(error) throw Error.posting(error)
} }

View File

@ -189,7 +189,7 @@ public final class ComposeController: ViewController {
if !self.draft.hasContent { if !self.draft.hasContent {
DraftsManager.shared.remove(self.draft) DraftsManager.shared.remove(self.draft)
} }
DraftsManager.save() DraftsManager.shared.save()
self.draft = draft self.draft = draft
} }
@ -198,7 +198,7 @@ public final class ComposeController: ViewController {
if !draft.hasContent { if !draft.hasContent {
DraftsManager.shared.remove(draft) DraftsManager.shared.remove(draft)
} }
DraftsManager.save() DraftsManager.shared.save()
} }
func toggleContentWarning() { func toggleContentWarning() {

View File

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

View File

@ -11,75 +11,86 @@ import Combine
import OSLog import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsManager") private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsManager")
private let encoder = PropertyListEncoder()
private let decoder = PropertyListDecoder()
public class DraftsManager: Codable, ObservableObject { public class DraftsManager: NSObject, ObservableObject, NSFilePresenter {
public private(set) static var shared: DraftsManager = {
public private(set) static var shared: DraftsManager = load() 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 appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
private static var archiveURL = appGroupDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") 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() { @Published private var drafts: [UUID: Draft] = [:]
saveQueue.async {
let encoder = PropertyListEncoder() 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 { do {
let data = try encoder.encode(shared) let data = try Data(contentsOf: url)
try data.write(to: archiveURL, options: .noFileProtection) let container = try decoder.decode(DraftsContainer.self, from: data)
DispatchQueue.main.async {
self.merge(from: container)
completion?(nil)
}
} catch { } catch {
logger.error("Save failed: \(String(describing: error))") logger.error("Error loading: \(String(describing: error))")
completion?(error)
} }
} }
} }
static func load() -> DraftsManager { public func migrate(from url: URL) -> Result<Void, any Error> {
let decoder = PropertyListDecoder()
do { do {
let data = try Data(contentsOf: archiveURL) let data = try Data(contentsOf: url)
let draftsManager = try decoder.decode(DraftsManager.self, from: data) let container = try decoder.decode(DraftsContainer.self, from: data)
return draftsManager self.merge(from: container)
} catch { self.save()
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)
} catch { } catch {
logger.error("Error migrating: \(String(describing: error))")
return .failure(error) return .failure(error)
} }
shared = load()
return .success(()) return .success(())
} }
private init() {} public func save(completion: ((Error?) -> Void)? = nil) {
NSFileCoordinator(filePresenter: self).coordinate(writingItemAt: Self.archiveURL, options: .forReplacing, error: nil) { url in
public required init(from decoder: Decoder) throws { do {
let container = try decoder.container(keyedBy: CodingKeys.self) let data = try encoder.encode(DraftsContainer(drafts: self.drafts))
try data.write(to: url, options: .atomic)
if let dict = try? container.decode([UUID: SafeDraft].self, forKey: .drafts) { completion?(nil)
self.drafts = dict.compactMapValues { $0.draft } } catch {
} else if let array = try? container.decode([SafeDraft].self, forKey: .drafts) { logger.error("Error saving: \(String(describing: error))")
self.drafts = array.reduce(into: [:], { partialResult, safeDraft in completion?(error)
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 encode(to encoder: Encoder) throws { // MARK: Drafts API
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(drafts, forKey: .drafts)
}
@Published private var drafts: [UUID: Draft] = [:]
var sorted: [Draft] { var sorted: [Draft] {
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
} }
@ -96,9 +107,40 @@ public class DraftsManager: Codable, ObservableObject {
return drafts[id] 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 case drafts
} }
}
// a container that always succeeds at decoding // a container that always succeeds at decoding
// so if a single draft can't be decoded, we don't lose all drafts // 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) 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 ComposeUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import TuskerPreferences import TuskerPreferences
import Combine
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
private var state: State = .loading private var state: State = .loading
private var draftsPresenterCancellable: AnyCancellable?
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
super.init(coder: coder) super.init(coder: coder)
} }
override func viewDidLoad() { override func viewDidLoad() {
@ -27,6 +29,11 @@ class ShareViewController: UIViewController {
view.tintColor = Preferences.shared.accentColor.color view.tintColor = Preferences.shared.accentColor.color
if let account = UserAccountsManager.shared.getMostRecentAccount() { if let account = UserAccountsManager.shared.getMostRecentAccount() {
NSFileCoordinator.addFilePresenter(DraftsManager.shared)
draftsPresenterCancellable = AnyCancellable({
NSFileCoordinator.removeFilePresenter(DraftsManager.shared)
})
Task { @MainActor in Task { @MainActor in
let draft = await createDraft(account: account) let draft = await createDraft(account: account)
state = .ok state = .ok
@ -51,6 +58,11 @@ class ShareViewController: UIViewController {
} }
private func createDraft(account: UserAccountInfo) async -> Draft { private func createDraft(account: UserAccountInfo) async -> Draft {
await withCheckedContinuation({ continuation in
DraftsManager.shared.load { _ in
continuation.resume()
}
})
let (text, attachments) = await getDraftConfigurationFromExtensionContext() let (text, attachments) = await getDraftConfigurationFromExtensionContext()
let draft = Draft( let draft = Draft(
accountID: account.id, accountID: account.id,

View File

@ -22,6 +22,7 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category:
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
private var draftsFileCoordinatorManager: DraftsManagerFileCoordinatorManager!
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
configureSentry() configureSentry()
@ -64,10 +65,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
if FileManager.default.fileExists(atPath: oldDraftsFile.path) { 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) SentrySDK.capture(error: error)
} }
} }
self.draftsFileCoordinatorManager = DraftsManagerFileCoordinatorManager()
} }
return true 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) { func sceneWillResignActive(_ scene: UIScene) {
DraftsManager.save() DraftsManager.shared.save()
if let window = window, if let window = window,
let nav = window.rootViewController as? UINavigationController, 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). // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
Preferences.save() Preferences.save()
DraftsManager.save() DraftsManager.shared.save()
} }
func sceneDidBecomeActive(_ scene: UIScene) { 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). // This may occur due to temporary interruptions (ex. an incoming phone call).
Preferences.save() Preferences.save()
DraftsManager.save() DraftsManager.shared.save()
} }
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {

View File

@ -6,6 +6,7 @@
// Copyright © 2023 Shadowfacts. All rights reserved. // Copyright © 2023 Shadowfacts. All rights reserved.
// //
import UIKit
import SwiftUI import SwiftUI
import ComposeUI import ComposeUI
import Combine import Combine
@ -27,12 +28,17 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
let controller: ComposeController let controller: ComposeController
let mastodonController: MastodonController let mastodonController: MastodonController
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)? private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
private var drawingCompletion: ((PKDrawing) -> Void)? private var drawingCompletion: ((PKDrawing) -> Void)?
init(draft: Draft?, mastodonController: MastodonController) { init(draft: Draft?, mastodonController: MastodonController) {
// self.draftsFileCoordinatorManager = DraftsManagerFileCoordinatorManager()
let draft = draft ?? mastodonController.createDraft() let draft = draft ?? mastodonController.createDraft()
DraftsManager.shared.load() { _ in
DraftsManager.shared.add(draft) DraftsManager.shared.add(draft)
}
self.controller = ComposeController( self.controller = ComposeController(
draft: draft, draft: draft,