Extract source persistence coordination
This commit is contained in:
parent
0acf9faa16
commit
1347bb15ae
@ -10,7 +10,7 @@ import Foundation
|
|||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting {
|
||||||
private static let enrichmentWorkerCount = 4
|
private static let enrichmentWorkerCount = 4
|
||||||
private static let sizeWorkerCount = 2
|
private static let sizeWorkerCount = 2
|
||||||
private static let minimumVisibleScanDuration: TimeInterval = 0.8
|
private static let minimumVisibleScanDuration: TimeInterval = 0.8
|
||||||
@ -27,7 +27,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
|
|
||||||
@Published var sources: [MinecraftSource] = []
|
@Published var sources: [MinecraftSource] = []
|
||||||
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
||||||
@Published private(set) var isRestoringPersistedSources = true
|
@Published var isRestoringPersistedSources = true
|
||||||
|
|
||||||
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||||
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
|
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
|
||||||
@ -54,7 +54,10 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
self.notificationService = notificationService ?? ScanNotificationService.shared
|
self.notificationService = notificationService ?? ScanNotificationService.shared
|
||||||
|
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
await self?.restorePersistedSources()
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
localSourceRefreshTask = Task { [weak self] in
|
localSourceRefreshTask = Task { [weak self] in
|
||||||
@ -110,7 +113,10 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await persistVisibleSourcesForShutdown()
|
await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown(
|
||||||
|
from: visibleSources,
|
||||||
|
using: persistenceStore
|
||||||
|
)
|
||||||
shutdown()
|
shutdown()
|
||||||
try? await Task.sleep(for: .seconds(timeout))
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
}
|
}
|
||||||
@ -194,7 +200,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
scanTasks[sourceID]?.cancel()
|
scanTasks[sourceID]?.cancel()
|
||||||
scanTasks[sourceID] = nil
|
scanTasks[sourceID] = nil
|
||||||
sources.removeAll { $0.id == sourceID }
|
sources.removeAll { $0.id == sourceID }
|
||||||
deletePersistedSource(withID: sourceID)
|
SourcePersistenceCoordinator.deletePersistedSource(withID: sourceID, using: persistenceStore)
|
||||||
if let removedSource {
|
if let removedSource {
|
||||||
purgeCachedArtifacts(for: removedSource)
|
purgeCachedArtifacts(for: removedSource)
|
||||||
}
|
}
|
||||||
@ -249,7 +255,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rebuildNormalizedIndex(for sourceID: URL) {
|
func rebuildNormalizedIndex(for sourceID: URL) {
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
let rawItems = source.rawItems.sorted(by: WorldScanner.sortItems)
|
let rawItems = source.rawItems.sorted(by: WorldScanner.sortItems)
|
||||||
source.rawItems = rawItems
|
source.rawItems = rawItems
|
||||||
@ -504,7 +510,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshLocalSources() async {
|
func refreshLocalSources() async {
|
||||||
guard !hasActiveScan else {
|
guard !hasActiveScan else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -557,7 +563,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshConnectedDevices() async {
|
func refreshConnectedDevices() async {
|
||||||
guard !isShuttingDown else {
|
guard !isShuttingDown else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -786,41 +792,23 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restorePersistedSources() async {
|
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
|
||||||
defer {
|
WorldScanner.collectionSnapshots(in: sourceURL)
|
||||||
isRestoringPersistedSources = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let records: [PersistedSourceRecord]
|
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String {
|
||||||
do {
|
|
||||||
records = try await persistenceStore.loadSources()
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for record in records {
|
|
||||||
let source = SourceRestoration.restoredSource(from: record) { [connectedDeviceSourceFactory] device, container in
|
|
||||||
connectedDeviceSourceFactory.displayName(for: device, container: container)
|
connectedDeviceSourceFactory.displayName(for: device, container: container)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendRestoredSource(_ source: MinecraftSource) {
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
rebuildNormalizedIndex(for: source.id)
|
rebuildNormalizedIndex(for: source.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
for record in records where record.needsRepair {
|
func applyRestoredItems(_ items: [MinecraftContentItem], from record: PersistedSourceRecord) {
|
||||||
Task.detached(priority: .utility) { [persistenceStore] in
|
|
||||||
try? await persistenceStore.repair(record: record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
|
||||||
await Task.yield()
|
|
||||||
|
|
||||||
for record in records {
|
|
||||||
let restoredItems = await SourceRestoration.restoreCachedImages(in: record.rawItems)
|
|
||||||
updateSource(record.sourceID) { source in
|
updateSource(record.sourceID) { source in
|
||||||
SourceRestoration.applyRestoredItemState(
|
SourceRestoration.applyRestoredItemState(
|
||||||
restoredItems,
|
items,
|
||||||
lastScanDate: record.lastScanDate,
|
lastScanDate: record.lastScanDate,
|
||||||
snapshot: record.snapshot,
|
snapshot: record.snapshot,
|
||||||
to: &source
|
to: &source
|
||||||
@ -829,27 +817,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
rebuildNormalizedIndex(for: record.sourceID)
|
rebuildNormalizedIndex(for: record.sourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshConnectedDevices()
|
func sortSourcesByDisplayName() {
|
||||||
await refreshLocalSources()
|
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
|
||||||
scheduleRestoredSourceRefreshes(records: records)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleRestoredSourceRefreshes(records: [PersistedSourceRecord]) {
|
|
||||||
let persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.sourceID, $0) })
|
|
||||||
|
|
||||||
for source in sources {
|
|
||||||
if let refreshReason = SourceRestoration.startupRefreshReason(
|
|
||||||
for: source,
|
|
||||||
persistedRecord: persistedRecordsByID[source.id],
|
|
||||||
currentCollectionSnapshots: currentCollectionSnapshots(for:)
|
|
||||||
) {
|
|
||||||
queueAutomaticSync(for: source.id, reason: refreshReason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
|
|
||||||
WorldScanner.collectionSnapshots(in: sourceURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildDisplayItems(
|
private func buildDisplayItems(
|
||||||
@ -891,30 +860,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func persistSourceIfAvailable(withID sourceID: URL) {
|
func persistSourceIfAvailable(withID sourceID: URL) {
|
||||||
guard let source = source(withID: sourceID) else {
|
SourcePersistenceCoordinator.persistSourceIfAvailable(
|
||||||
return
|
withID: sourceID,
|
||||||
|
on: self,
|
||||||
|
using: persistenceStore
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let persistedSource = source
|
func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval? = nil) {
|
||||||
Task {
|
|
||||||
try? await persistenceStore.save(source: persistedSource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistVisibleSourcesForShutdown() async {
|
|
||||||
let persistedSources = sources
|
|
||||||
for source in persistedSources {
|
|
||||||
try? await persistenceStore.save(source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deletePersistedSource(withID sourceID: URL) {
|
|
||||||
Task {
|
|
||||||
try? await persistenceStore.deleteSource(withID: sourceID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval? = nil) {
|
|
||||||
guard !isShuttingDown else {
|
guard !isShuttingDown else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// SourcePersistenceCoordinator.swift
|
||||||
|
// World Manager for Minecraft
|
||||||
|
//
|
||||||
|
// Created by OpenAI Codex on 2026-05-29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol SourcePersistenceHosting: AnyObject {
|
||||||
|
var visibleSources: [MinecraftSource] { get }
|
||||||
|
var isRestoringPersistedSources: Bool { get set }
|
||||||
|
|
||||||
|
func source(withID sourceID: URL) -> MinecraftSource?
|
||||||
|
func appendRestoredSource(_ source: MinecraftSource)
|
||||||
|
func applyRestoredItems(_ items: [MinecraftContentItem], from record: PersistedSourceRecord)
|
||||||
|
func sortSourcesByDisplayName()
|
||||||
|
func refreshConnectedDevices() async
|
||||||
|
func refreshLocalSources() async
|
||||||
|
func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval?)
|
||||||
|
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot]
|
||||||
|
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SourcePersistenceCoordinator {
|
||||||
|
static func restoreSources(
|
||||||
|
on host: SourcePersistenceHosting,
|
||||||
|
using persistenceStore: SourcePersistenceStore
|
||||||
|
) async {
|
||||||
|
defer {
|
||||||
|
host.isRestoringPersistedSources = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let records: [PersistedSourceRecord]
|
||||||
|
do {
|
||||||
|
records = try await persistenceStore.loadSources()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for record in records {
|
||||||
|
let source = SourceRestoration.restoredSource(from: record) { device, container in
|
||||||
|
host.connectedDeviceDisplayName(for: device, container: container)
|
||||||
|
}
|
||||||
|
|
||||||
|
host.appendRestoredSource(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
for record in records where record.needsRepair {
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
try? await persistenceStore.repair(record: record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host.sortSourcesByDisplayName()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
for record in records {
|
||||||
|
let restoredItems = await SourceRestoration.restoreCachedImages(in: record.rawItems)
|
||||||
|
host.applyRestoredItems(restoredItems, from: record)
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.refreshConnectedDevices()
|
||||||
|
await host.refreshLocalSources()
|
||||||
|
scheduleRestoredSourceRefreshes(records: records, on: host)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func persistSourceIfAvailable(
|
||||||
|
withID sourceID: URL,
|
||||||
|
on host: SourcePersistenceHosting,
|
||||||
|
using persistenceStore: SourcePersistenceStore
|
||||||
|
) {
|
||||||
|
guard let source = host.source(withID: sourceID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let persistedSource = source
|
||||||
|
Task {
|
||||||
|
try? await persistenceStore.save(source: persistedSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func persistVisibleSourcesForShutdown(
|
||||||
|
from sources: [MinecraftSource],
|
||||||
|
using persistenceStore: SourcePersistenceStore
|
||||||
|
) async {
|
||||||
|
for source in sources {
|
||||||
|
try? await persistenceStore.save(source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deletePersistedSource(
|
||||||
|
withID sourceID: URL,
|
||||||
|
using persistenceStore: SourcePersistenceStore
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
try? await persistenceStore.deleteSource(withID: sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scheduleRestoredSourceRefreshes(
|
||||||
|
records: [PersistedSourceRecord],
|
||||||
|
on host: SourcePersistenceHosting
|
||||||
|
) {
|
||||||
|
let persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.sourceID, $0) })
|
||||||
|
|
||||||
|
for source in host.visibleSources {
|
||||||
|
if let refreshReason = SourceRestoration.startupRefreshReason(
|
||||||
|
for: source,
|
||||||
|
persistedRecord: persistedRecordsByID[source.id],
|
||||||
|
currentCollectionSnapshots: host.currentCollectionSnapshots(for:)
|
||||||
|
) {
|
||||||
|
host.queueAutomaticSync(for: source.id, reason: refreshReason, debounce: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user