forked from shadowfacts/Tusker
Coordinate DraftsManager reading writing between processes
This commit is contained in:
parent
74a157d26c
commit
2874e4bfd3
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -48,7 +48,7 @@ class DraftsController: ViewController {
|
|||
|
||||
func closeDrafts() {
|
||||
isPresented = false
|
||||
DraftsManager.save()
|
||||
DraftsManager.shared.save()
|
||||
}
|
||||
|
||||
struct DraftsRepresentable: UIViewControllerRepresentable {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue