From e0547d1339403558d454a4d68069f82abfc2494f Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 5 Jul 2026 12:36:01 +0800 Subject: [PATCH 1/7] fix(offline): default cover sync scope to dashboard libraries Initialize the missing-cover sync library scope from the current dashboard library selection when opening Offline Tasks. Pass the scope explicitly through the cover sync section so the selected libraries shown in the picker match the IDs used when starting the sync. --- .../Offline/ViewModels/OfflineCoverSyncViewModel.swift | 8 +++----- .../Features/Offline/Views/OfflineCoverSyncSection.swift | 6 +++++- KMReader/Features/Offline/Views/OfflineTasksView.swift | 4 +++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift b/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift index d828849d..992e01ea 100644 --- a/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift +++ b/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift @@ -24,7 +24,7 @@ final class OfflineCoverSyncViewModel { selectedLibraryIds.isEmpty } - func loadLibraryScopeOptions(instanceId: String) async { + func loadLibraryScopeOptions(instanceId: String, defaultLibraryIds: [String]) async { guard !instanceId.isEmpty else { clearLibraryScope() return @@ -38,15 +38,13 @@ final class OfflineCoverSyncViewModel { let loadedLibraries = await database.fetchLibraries(instanceId: instanceId) .filter { $0.id != KomgaLibrary.allLibrariesId } - if libraryScopeInstanceId != instanceId { - libraryScopeInstanceId = instanceId - selectedLibraryIds = [] - } + libraryScopeInstanceId = instanceId if libraries != loadedLibraries { libraries = loadedLibraries } + selectedLibraryIds = Set(defaultLibraryIds) normalizeSelectedLibraryIds() } diff --git a/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift b/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift index fed29771..92c29221 100644 --- a/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift +++ b/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift @@ -9,6 +9,7 @@ struct OfflineCoverSyncSection: View { let viewModel: OfflineCoverSyncViewModel let instanceId: String let isOffline: Bool + let defaultLibraryIds: [String] @State private var isPickerPresented = false private var isStartDisabled: Bool { @@ -77,7 +78,10 @@ struct OfflineCoverSyncSection: View { } } .task(id: instanceId) { - await viewModel.loadLibraryScopeOptions(instanceId: instanceId) + await viewModel.loadLibraryScopeOptions( + instanceId: instanceId, + defaultLibraryIds: defaultLibraryIds + ) } .onChange(of: instanceId) { _, newValue in viewModel.cancelSyncIfContextChanged(instanceId: newValue, isOffline: isOffline) diff --git a/KMReader/Features/Offline/Views/OfflineTasksView.swift b/KMReader/Features/Offline/Views/OfflineTasksView.swift index 7284e523..a0623f30 100644 --- a/KMReader/Features/Offline/Views/OfflineTasksView.swift +++ b/KMReader/Features/Offline/Views/OfflineTasksView.swift @@ -9,6 +9,7 @@ struct OfflineTasksView: View { @AppStorage("currentAccount") private var current: Current = .init() private var instanceId: String { current.instanceId } @AppStorage("isOffline") private var isOffline: Bool = false + @AppStorage("dashboard") private var dashboard: DashboardConfiguration = DashboardConfiguration() @AppStorage("offlinePaused") private var isPaused: Bool = false @AppStorage("offlineAutoDeleteRead") private var autoDeleteRead: Bool = false @AppStorage("offlineFirstReading") private var offlineFirstReading: Bool = false @@ -119,7 +120,8 @@ struct OfflineTasksView: View { OfflineCoverSyncSection( viewModel: coverSyncViewModel, instanceId: instanceId, - isOffline: isOffline + isOffline: isOffline, + defaultLibraryIds: dashboard.libraryIds ) if !downloadingTasks.isEmpty { From abb67ce73385f52be783281e89517d39735acf73 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 5 Jul 2026 12:36:05 +0800 Subject: [PATCH 2/7] chore: incr build ver to 501 --- KMReader.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/KMReader.xcodeproj/project.pbxproj b/KMReader.xcodeproj/project.pbxproj index 1c8d7b02..29cc3717 100644 --- a/KMReader.xcodeproj/project.pbxproj +++ b/KMReader.xcodeproj/project.pbxproj @@ -442,7 +442,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 500; + CURRENT_PROJECT_VERSION = 501; DEVELOPMENT_TEAM = M777UHWZA4; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -500,7 +500,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 500; + CURRENT_PROJECT_VERSION = 501; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=appletvos*]" = M777UHWZA4; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; @@ -560,7 +560,7 @@ CODE_SIGN_ENTITLEMENTS = KMReaderWidgets/KMReaderWidgets.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 500; + CURRENT_PROJECT_VERSION = 501; DEVELOPMENT_TEAM = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = KMReaderWidgets/Info.plist; @@ -594,7 +594,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 500; + CURRENT_PROJECT_VERSION = 501; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; From d450766c90164a186f2b7e268182d31244df6d26 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 5 Jul 2026 12:51:30 +0800 Subject: [PATCH 3/7] fix(offline): preserve manual cover sync scope Apply the dashboard default scope only while the cover sync scope has not been manually changed for the current instance. Refresh the scope loader when dashboard library defaults change so late-loaded server selection can initialize the picker without clobbering manual choices. --- .../ViewModels/OfflineCoverSyncViewModel.swift | 12 +++++++++++- .../Offline/Views/OfflineCoverSyncSection.swift | 6 +++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift b/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift index 992e01ea..f9af6c09 100644 --- a/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift +++ b/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift @@ -17,6 +17,7 @@ final class OfflineCoverSyncViewModel { private(set) var selectedLibraryIds: Set = [] @ObservationIgnored private var syncTask: Task? @ObservationIgnored private var libraryScopeInstanceId: String? + @ObservationIgnored private var hasManualLibrarySelection = false private init() {} @@ -38,13 +39,20 @@ final class OfflineCoverSyncViewModel { let loadedLibraries = await database.fetchLibraries(instanceId: instanceId) .filter { $0.id != KomgaLibrary.allLibrariesId } + let isNewInstanceScope = libraryScopeInstanceId != instanceId libraryScopeInstanceId = instanceId if libraries != loadedLibraries { libraries = loadedLibraries } - selectedLibraryIds = Set(defaultLibraryIds) + if isNewInstanceScope { + hasManualLibrarySelection = false + } + + if isNewInstanceScope || !hasManualLibrarySelection { + selectedLibraryIds = Set(defaultLibraryIds) + } normalizeSelectedLibraryIds() } @@ -54,6 +62,7 @@ final class OfflineCoverSyncViewModel { } func selectLibraries(_ libraryIds: Set) { + hasManualLibrarySelection = true selectedLibraryIds = libraryIds normalizeSelectedLibraryIds() } @@ -153,6 +162,7 @@ final class OfflineCoverSyncViewModel { libraryScopeInstanceId = nil libraries = [] selectedLibraryIds = [] + hasManualLibrarySelection = false } private func normalizeSelectedLibraryIds() { diff --git a/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift b/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift index 92c29221..2875d5ca 100644 --- a/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift +++ b/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift @@ -20,6 +20,10 @@ struct OfflineCoverSyncSection: View { viewModel.isSyncing || isOffline || instanceId.isEmpty } + private var libraryScopeTaskID: [String] { + [instanceId] + defaultLibraryIds.sorted() + } + var body: some View { Section { VStack(alignment: .leading, spacing: 6) { @@ -77,7 +81,7 @@ struct OfflineCoverSyncSection: View { viewModel.selectLibraries(libraryIds) } } - .task(id: instanceId) { + .task(id: libraryScopeTaskID) { await viewModel.loadLibraryScopeOptions( instanceId: instanceId, defaultLibraryIds: defaultLibraryIds From 0080a7f9a104644022b4ceb6a88bd3d5f231d5e5 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 5 Jul 2026 12:58:50 +0800 Subject: [PATCH 4/7] perf(offline): snapshot cached covers during sync Load existing cover thumbnail ids once before the missing-cover sync loop and skip per-target filesystem checks for cached covers. Throttle cached progress updates while keeping missing downloads on the existing ThumbnailCache path for cache limit checks, deduplication, storage, and cleanup. --- .../Core/Storage/Cache/ThumbnailCache.swift | 50 ++++++++++++++++++- .../Services/OfflineCoverSyncService.swift | 40 ++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/KMReader/Core/Storage/Cache/ThumbnailCache.swift b/KMReader/Core/Storage/Cache/ThumbnailCache.swift index 297930ce..87cb0403 100644 --- a/KMReader/Core/Storage/Cache/ThumbnailCache.swift +++ b/KMReader/Core/Storage/Cache/ThumbnailCache.swift @@ -12,7 +12,7 @@ extension Notification.Name { static let thumbnailDidRefresh = Notification.Name("thumbnailDidRefresh") } -nonisolated enum ThumbnailType: String, CaseIterable, Sendable { +nonisolated enum ThumbnailType: String, CaseIterable, Hashable, Sendable { case book case series case collection @@ -298,6 +298,54 @@ actor ThumbnailCache { } } + /// Returns cached cover thumbnail ids matching the requested ids. + func cachedCoverThumbnailIds(matching requestedIdsByType: [ThumbnailType: Set]) async + -> [ThumbnailType: Set] + { + let requestedIdsByType = requestedIdsByType.filter { type, ids in + type != .page && !ids.isEmpty + } + guard !requestedIdsByType.isEmpty else { return [:] } + + let diskCacheURL = CacheNamespace.directory(for: "KomgaThumbnailCache") + + return await Task.detached(priority: .utility) { + var cachedIdsByType: [ThumbnailType: Set] = [:] + let fileManager = FileManager.default + + for (type, requestedIds) in requestedIdsByType { + let typeDirectory = diskCacheURL.appendingPathComponent(type.rawValue, isDirectory: true) + guard + let fileURLs = try? fileManager.contentsOfDirectory( + at: typeDirectory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) + else { + continue + } + + var cachedIds = Set() + for fileURL in fileURLs where fileURL.pathExtension.lowercased() == "jpg" { + let isRegularFile = + (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + guard isRegularFile else { continue } + + let id = fileURL.deletingPathExtension().lastPathComponent + if requestedIds.contains(id) { + cachedIds.insert(id) + } + } + + if !cachedIds.isEmpty { + cachedIdsByType[type] = cachedIds + } + } + + return cachedIdsByType + }.value + } + private static nonisolated func taskCacheKey( id: String, type: ThumbnailType, diff --git a/KMReader/Features/Offline/Services/OfflineCoverSyncService.swift b/KMReader/Features/Offline/Services/OfflineCoverSyncService.swift index e1db054e..4765875f 100644 --- a/KMReader/Features/Offline/Services/OfflineCoverSyncService.swift +++ b/KMReader/Features/Offline/Services/OfflineCoverSyncService.swift @@ -11,6 +11,7 @@ actor OfflineCoverSyncService { static let shared = OfflineCoverSyncService() private let logger = AppLogger(.offline) + private let cachedProgressReportStride = 25 private var isRunning = false private init() {} @@ -33,6 +34,9 @@ actor OfflineCoverSyncService { instanceId: instanceId, libraryIds: libraryIds ) + var cachedThumbnailIds = await ThumbnailCache.shared.cachedCoverThumbnailIds( + matching: targetIdsByType(targets) + ) var summary = OfflineCoverSyncSummary() summary.totalCount = targets.count await reportProgress(summary: summary, onProgress: onProgress) @@ -42,6 +46,13 @@ actor OfflineCoverSyncService { return await stopSync(summary: summary, onProgress: onProgress) } + if cachedThumbnailIds[target.type]?.contains(target.thumbnailId) == true { + summary.existingCount += 1 + summary.checkedCount += 1 + await reportCachedProgressIfNeeded(summary: summary, onProgress: onProgress) + continue + } + do { let result = try await ThumbnailCache.shared.ensureMissingThumbnail( id: target.thumbnailId, @@ -50,16 +61,21 @@ actor OfflineCoverSyncService { switch result { case .cached: + cachedThumbnailIds[target.type, default: []].insert(target.thumbnailId) summary.existingCount += 1 + summary.checkedCount += 1 + await reportCachedProgressIfNeeded(summary: summary, onProgress: onProgress) case .stored: + cachedThumbnailIds[target.type, default: []].insert(target.thumbnailId) summary.storedCount += 1 + summary.checkedCount += 1 + await reportProgress(summary: summary, onProgress: onProgress) case .cacheLimitReached: summary.stoppedAtCacheLimit = true logger.info("⏸️ Stopped offline cover sync because cover cache reached its maximum size") await reportProgress(summary: summary, onProgress: onProgress) return summary } - summary.checkedCount += 1 } catch is CancellationError { return await stopSync(summary: summary, onProgress: onProgress) } catch APIError.offline { @@ -80,10 +96,11 @@ actor OfflineCoverSyncService { logger.warning( "⚠️ Failed to sync offline cover for \(target.type.rawValue) \(target.thumbnailId): \(error.localizedDescription)" ) + await reportProgress(summary: summary, onProgress: onProgress) } - await reportProgress(summary: summary, onProgress: onProgress) } + await reportProgress(summary: summary, onProgress: onProgress) logger.info( "✅ Offline cover sync finished: checked=\(summary.checkedCount), existing=\(summary.existingCount), stored=\(summary.storedCount), failed=\(summary.failedCount)" ) @@ -103,6 +120,25 @@ actor OfflineCoverSyncService { } } + private func targetIdsByType(_ targets: [OfflineCoverSyncTarget]) -> [ThumbnailType: Set] { + var idsByType: [ThumbnailType: Set] = [:] + for target in targets { + idsByType[target.type, default: []].insert(target.thumbnailId) + } + return idsByType + } + + private func reportCachedProgressIfNeeded( + summary: OfflineCoverSyncSummary, + onProgress: OfflineCoverSyncProgressHandler? + ) async { + if summary.checkedCount == summary.totalCount + || summary.checkedCount.isMultiple(of: cachedProgressReportStride) + { + await reportProgress(summary: summary, onProgress: onProgress) + } + } + private func stopSync( summary: OfflineCoverSyncSummary, onProgress: OfflineCoverSyncProgressHandler? From e56e3ad3238e366b43e9dc063359e3baf431c427 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 5 Jul 2026 13:04:21 +0800 Subject: [PATCH 5/7] fix(offline): guard cover sync scope loading Ignore cancelled or stale cover sync scope loads before applying dashboard defaults. Avoid treating unchanged picker applies as manual selection, and keep the sync action disabled until the current library scope has finished loading. --- .../OfflineCoverSyncViewModel.swift | 44 ++++++++++++++++--- .../Views/OfflineCoverSyncSection.swift | 6 ++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift b/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift index f9af6c09..9fb66325 100644 --- a/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift +++ b/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift @@ -15,8 +15,10 @@ final class OfflineCoverSyncViewModel { private(set) var progress: OfflineCoverSyncProgress? private(set) var libraries: [LibraryInfo] = [] private(set) var selectedLibraryIds: Set = [] + private(set) var loadedLibraryScopeTaskID: [String] = [] @ObservationIgnored private var syncTask: Task? @ObservationIgnored private var libraryScopeInstanceId: String? + @ObservationIgnored private var pendingLibraryScopeTaskID: [String] = [] @ObservationIgnored private var hasManualLibrarySelection = false private init() {} @@ -26,18 +28,27 @@ final class OfflineCoverSyncViewModel { } func loadLibraryScopeOptions(instanceId: String, defaultLibraryIds: [String]) async { + let taskID = Self.libraryScopeTaskID( + instanceId: instanceId, + defaultLibraryIds: defaultLibraryIds + ) + pendingLibraryScopeTaskID = taskID + guard !instanceId.isEmpty else { clearLibraryScope() return } + guard !Task.isCancelled else { return } guard let database = try? await DatabaseOperator.database() else { + guard !Task.isCancelled else { return } clearLibraryScope() return } let loadedLibraries = await database.fetchLibraries(instanceId: instanceId) .filter { $0.id != KomgaLibrary.allLibrariesId } + guard !Task.isCancelled, pendingLibraryScopeTaskID == taskID else { return } let isNewInstanceScope = libraryScopeInstanceId != instanceId libraryScopeInstanceId = instanceId @@ -54,6 +65,7 @@ final class OfflineCoverSyncViewModel { selectedLibraryIds = Set(defaultLibraryIds) } normalizeSelectedLibraryIds() + loadedLibraryScopeTaskID = taskID } func selectedLibraryIdsForSync(instanceId: String) -> [String] { @@ -61,10 +73,19 @@ final class OfflineCoverSyncViewModel { return selectedLibraryIds.sorted() } + func hasLoadedLibraryScope(instanceId: String, defaultLibraryIds: [String]) -> Bool { + loadedLibraryScopeTaskID + == Self.libraryScopeTaskID( + instanceId: instanceId, + defaultLibraryIds: defaultLibraryIds + ) + } + func selectLibraries(_ libraryIds: Set) { + let libraryIds = normalizedLibrarySelection(libraryIds) + guard selectedLibraryIds != libraryIds else { return } hasManualLibrarySelection = true selectedLibraryIds = libraryIds - normalizeSelectedLibraryIds() } func startSyncMissingCovers(instanceId: String, libraryIds: [String]) { @@ -162,19 +183,32 @@ final class OfflineCoverSyncViewModel { libraryScopeInstanceId = nil libraries = [] selectedLibraryIds = [] + loadedLibraryScopeTaskID = [] + pendingLibraryScopeTaskID = [] hasManualLibrarySelection = false } private func normalizeSelectedLibraryIds() { + selectedLibraryIds = normalizedLibrarySelection(selectedLibraryIds) + } + + private func normalizedLibrarySelection(_ libraryIds: Set) -> Set { guard !libraries.isEmpty else { - selectedLibraryIds = [] - return + return [] } let validLibraryIds = Set(libraries.map(\.id)) - selectedLibraryIds = selectedLibraryIds.intersection(validLibraryIds) + let selectedLibraryIds = libraryIds.intersection(validLibraryIds) if selectedLibraryIds.count == validLibraryIds.count { - selectedLibraryIds = [] + return [] } + return selectedLibraryIds + } + + private static func libraryScopeTaskID( + instanceId: String, + defaultLibraryIds: [String] + ) -> [String] { + [instanceId] + defaultLibraryIds.sorted() } } diff --git a/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift b/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift index 2875d5ca..2f8ebc6b 100644 --- a/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift +++ b/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift @@ -13,13 +13,17 @@ struct OfflineCoverSyncSection: View { @State private var isPickerPresented = false private var isStartDisabled: Bool { - !viewModel.isSyncing && (isOffline || instanceId.isEmpty) + !viewModel.isSyncing && (isOffline || instanceId.isEmpty || !hasLoadedLibraryScope) } private var isPickerDisabled: Bool { viewModel.isSyncing || isOffline || instanceId.isEmpty } + private var hasLoadedLibraryScope: Bool { + viewModel.hasLoadedLibraryScope(instanceId: instanceId, defaultLibraryIds: defaultLibraryIds) + } + private var libraryScopeTaskID: [String] { [instanceId] + defaultLibraryIds.sorted() } From a37efcff33d8cdb0d387f20dbebbdd93ef320d9a Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 5 Jul 2026 13:09:31 +0800 Subject: [PATCH 6/7] refactor(offline): simplify cover sync scope state Remove the loaded and pending scope task key state from the cover sync view model. Rely on SwiftUI task cancellation checks during scope loading and fall back to the dashboard default library ids if sync starts before scoped options finish loading. --- .../OfflineCoverSyncViewModel.swift | 31 ++----------------- .../Views/OfflineCoverSyncSection.swift | 11 +++---- 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift b/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift index 9fb66325..530a215e 100644 --- a/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift +++ b/KMReader/Features/Offline/ViewModels/OfflineCoverSyncViewModel.swift @@ -15,10 +15,8 @@ final class OfflineCoverSyncViewModel { private(set) var progress: OfflineCoverSyncProgress? private(set) var libraries: [LibraryInfo] = [] private(set) var selectedLibraryIds: Set = [] - private(set) var loadedLibraryScopeTaskID: [String] = [] @ObservationIgnored private var syncTask: Task? @ObservationIgnored private var libraryScopeInstanceId: String? - @ObservationIgnored private var pendingLibraryScopeTaskID: [String] = [] @ObservationIgnored private var hasManualLibrarySelection = false private init() {} @@ -28,12 +26,6 @@ final class OfflineCoverSyncViewModel { } func loadLibraryScopeOptions(instanceId: String, defaultLibraryIds: [String]) async { - let taskID = Self.libraryScopeTaskID( - instanceId: instanceId, - defaultLibraryIds: defaultLibraryIds - ) - pendingLibraryScopeTaskID = taskID - guard !instanceId.isEmpty else { clearLibraryScope() return @@ -48,7 +40,7 @@ final class OfflineCoverSyncViewModel { let loadedLibraries = await database.fetchLibraries(instanceId: instanceId) .filter { $0.id != KomgaLibrary.allLibrariesId } - guard !Task.isCancelled, pendingLibraryScopeTaskID == taskID else { return } + guard !Task.isCancelled else { return } let isNewInstanceScope = libraryScopeInstanceId != instanceId libraryScopeInstanceId = instanceId @@ -65,22 +57,13 @@ final class OfflineCoverSyncViewModel { selectedLibraryIds = Set(defaultLibraryIds) } normalizeSelectedLibraryIds() - loadedLibraryScopeTaskID = taskID } - func selectedLibraryIdsForSync(instanceId: String) -> [String] { - guard libraryScopeInstanceId == instanceId else { return [] } + func selectedLibraryIdsForSync(instanceId: String, defaultLibraryIds: [String]) -> [String] { + guard libraryScopeInstanceId == instanceId else { return defaultLibraryIds.sorted() } return selectedLibraryIds.sorted() } - func hasLoadedLibraryScope(instanceId: String, defaultLibraryIds: [String]) -> Bool { - loadedLibraryScopeTaskID - == Self.libraryScopeTaskID( - instanceId: instanceId, - defaultLibraryIds: defaultLibraryIds - ) - } - func selectLibraries(_ libraryIds: Set) { let libraryIds = normalizedLibrarySelection(libraryIds) guard selectedLibraryIds != libraryIds else { return } @@ -183,8 +166,6 @@ final class OfflineCoverSyncViewModel { libraryScopeInstanceId = nil libraries = [] selectedLibraryIds = [] - loadedLibraryScopeTaskID = [] - pendingLibraryScopeTaskID = [] hasManualLibrarySelection = false } @@ -205,10 +186,4 @@ final class OfflineCoverSyncViewModel { return selectedLibraryIds } - private static func libraryScopeTaskID( - instanceId: String, - defaultLibraryIds: [String] - ) -> [String] { - [instanceId] + defaultLibraryIds.sorted() - } } diff --git a/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift b/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift index 2f8ebc6b..7a42db9d 100644 --- a/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift +++ b/KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift @@ -13,17 +13,13 @@ struct OfflineCoverSyncSection: View { @State private var isPickerPresented = false private var isStartDisabled: Bool { - !viewModel.isSyncing && (isOffline || instanceId.isEmpty || !hasLoadedLibraryScope) + !viewModel.isSyncing && (isOffline || instanceId.isEmpty) } private var isPickerDisabled: Bool { viewModel.isSyncing || isOffline || instanceId.isEmpty } - private var hasLoadedLibraryScope: Bool { - viewModel.hasLoadedLibraryScope(instanceId: instanceId, defaultLibraryIds: defaultLibraryIds) - } - private var libraryScopeTaskID: [String] { [instanceId] + defaultLibraryIds.sorted() } @@ -169,7 +165,10 @@ struct OfflineCoverSyncSection: View { return } - let libraryIds = viewModel.selectedLibraryIdsForSync(instanceId: instanceId) + let libraryIds = viewModel.selectedLibraryIdsForSync( + instanceId: instanceId, + defaultLibraryIds: defaultLibraryIds + ) viewModel.startSyncMissingCovers(instanceId: instanceId, libraryIds: libraryIds) } } From ecf1524638079736656d3cd19b2747bc3360a275 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 5 Jul 2026 13:14:49 +0800 Subject: [PATCH 7/7] perf(offline): time-throttle cached cover progress Throttle cached cover sync progress by elapsed time instead of target count so fast cached scans do not flood MainActor updates. --- .../Services/OfflineCoverSyncService.swift | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/KMReader/Features/Offline/Services/OfflineCoverSyncService.swift b/KMReader/Features/Offline/Services/OfflineCoverSyncService.swift index 4765875f..b44d1c52 100644 --- a/KMReader/Features/Offline/Services/OfflineCoverSyncService.swift +++ b/KMReader/Features/Offline/Services/OfflineCoverSyncService.swift @@ -11,7 +11,7 @@ actor OfflineCoverSyncService { static let shared = OfflineCoverSyncService() private let logger = AppLogger(.offline) - private let cachedProgressReportStride = 25 + private let cachedProgressReportInterval: TimeInterval = 0.15 private var isRunning = false private init() {} @@ -40,6 +40,7 @@ actor OfflineCoverSyncService { var summary = OfflineCoverSyncSummary() summary.totalCount = targets.count await reportProgress(summary: summary, onProgress: onProgress) + var lastCachedProgressReportAt = Date() for target in targets { if shouldStopSync(instanceId: instanceId) { @@ -49,7 +50,11 @@ actor OfflineCoverSyncService { if cachedThumbnailIds[target.type]?.contains(target.thumbnailId) == true { summary.existingCount += 1 summary.checkedCount += 1 - await reportCachedProgressIfNeeded(summary: summary, onProgress: onProgress) + await reportCachedProgressIfNeeded( + summary: summary, + lastReportAt: &lastCachedProgressReportAt, + onProgress: onProgress + ) continue } @@ -64,7 +69,11 @@ actor OfflineCoverSyncService { cachedThumbnailIds[target.type, default: []].insert(target.thumbnailId) summary.existingCount += 1 summary.checkedCount += 1 - await reportCachedProgressIfNeeded(summary: summary, onProgress: onProgress) + await reportCachedProgressIfNeeded( + summary: summary, + lastReportAt: &lastCachedProgressReportAt, + onProgress: onProgress + ) case .stored: cachedThumbnailIds[target.type, default: []].insert(target.thumbnailId) summary.storedCount += 1 @@ -130,13 +139,17 @@ actor OfflineCoverSyncService { private func reportCachedProgressIfNeeded( summary: OfflineCoverSyncSummary, + lastReportAt: inout Date, onProgress: OfflineCoverSyncProgressHandler? ) async { - if summary.checkedCount == summary.totalCount - || summary.checkedCount.isMultiple(of: cachedProgressReportStride) - { - await reportProgress(summary: summary, onProgress: onProgress) - } + let now = Date() + guard + summary.checkedCount == summary.totalCount + || now.timeIntervalSince(lastReportAt) >= cachedProgressReportInterval + else { return } + + lastReportAt = now + await reportProgress(summary: summary, onProgress: onProgress) } private func stopSync(