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
@MainActor
final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting {
private static let enrichmentWorkerCount = 4
private static let sizeWorkerCount = 2
private static let minimumVisibleScanDuration: TimeInterval = 0.8
@ -27,7 +27,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
@Published var sources: [MinecraftSource] = []
@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 automaticSyncTasks: [URL: Task<Void, Never>] = [:]
@ -54,7 +54,10 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
self.notificationService = notificationService ?? ScanNotificationService.shared
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
@ -110,7 +113,10 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
return
}
await persistVisibleSourcesForShutdown()
await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown(
from: visibleSources,
using: persistenceStore
)
shutdown()
try? await Task.sleep(for: .seconds(timeout))
}
@ -194,7 +200,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
scanTasks[sourceID]?.cancel()
scanTasks[sourceID] = nil
sources.removeAll { $0.id == sourceID }
deletePersistedSource(withID: sourceID)
SourcePersistenceCoordinator.deletePersistedSource(withID: sourceID, using: persistenceStore)
if let 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
let rawItems = source.rawItems.sorted(by: WorldScanner.sortItems)
source.rawItems = rawItems
@ -504,7 +510,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
}
}
private func refreshLocalSources() async {
func refreshLocalSources() async {
guard !hasActiveScan else {
return
}
@ -557,7 +563,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
}
}
private func refreshConnectedDevices() async {
func refreshConnectedDevices() async {
guard !isShuttingDown else {
return
}
@ -786,72 +792,35 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
}
}
private func restorePersistedSources() async {
defer {
isRestoringPersistedSources = false
}
let records: [PersistedSourceRecord]
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)
}
sources.append(source)
rebuildNormalizedIndex(for: source.id)
}
for record in records where record.needsRepair {
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
SourceRestoration.applyRestoredItemState(
restoredItems,
lastScanDate: record.lastScanDate,
snapshot: record.snapshot,
to: &source
)
}
rebuildNormalizedIndex(for: record.sourceID)
}
await refreshConnectedDevices()
await refreshLocalSources()
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] {
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
WorldScanner.collectionSnapshots(in: sourceURL)
}
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String {
connectedDeviceSourceFactory.displayName(for: device, container: container)
}
func appendRestoredSource(_ source: MinecraftSource) {
sources.append(source)
rebuildNormalizedIndex(for: source.id)
}
func applyRestoredItems(_ items: [MinecraftContentItem], from record: PersistedSourceRecord) {
updateSource(record.sourceID) { source in
SourceRestoration.applyRestoredItemState(
items,
lastScanDate: record.lastScanDate,
snapshot: record.snapshot,
to: &source
)
}
rebuildNormalizedIndex(for: record.sourceID)
}
func sortSourcesByDisplayName() {
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
}
private func buildDisplayItems(
from rawItems: [MinecraftContentItem],
logicalPacks: [LogicalPack],
@ -891,30 +860,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting {
}
func persistSourceIfAvailable(withID sourceID: URL) {
guard let source = source(withID: sourceID) else {
return
}
let persistedSource = source
Task {
try? await persistenceStore.save(source: persistedSource)
}
SourcePersistenceCoordinator.persistSourceIfAvailable(
withID: sourceID,
on: self,
using: persistenceStore
)
}
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) {
func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval? = nil) {
guard !isShuttingDown else {
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)
}
}
}
}