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
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -11,104 +11,163 @@ 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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
public func add(_ draft: Draft) {
|
public func add(_ draft: Draft) {
|
||||||
drafts[draft.id] = draft
|
drafts[draft.id] = draft
|
||||||
}
|
}
|
||||||
|
|
||||||
public func remove(_ draft: Draft) {
|
public func remove(_ draft: Draft) {
|
||||||
drafts.removeValue(forKey: draft.id)
|
drafts.removeValue(forKey: draft.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getBy(id: UUID) -> Draft? {
|
public func getBy(id: UUID) -> Draft? {
|
||||||
return drafts[id]
|
return drafts[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: NSFilePresenter
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
public var presentedItemURL: URL? {
|
||||||
case drafts
|
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
|
// 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
|
||||||
struct SafeDraft: Decodable {
|
struct SafeDraft: Decodable {
|
||||||
let draft: Draft?
|
let draft: Draft?
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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? {
|
||||||
|
|
|
@ -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.add(draft)
|
DraftsManager.shared.load() { _ in
|
||||||
|
DraftsManager.shared.add(draft)
|
||||||
|
}
|
||||||
|
|
||||||
self.controller = ComposeController(
|
self.controller = ComposeController(
|
||||||
draft: draft,
|
draft: draft,
|
||||||
|
|
Loading…
Reference in New Issue