Extract source persistence coordination

This commit is contained in:
John Burwell 2026-05-29 06:59:50 -05:00
parent 0acf9faa16
commit 1347bb15ae
2 changed files with 164 additions and 93 deletions

View File

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

View File

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