From 266261f1e8233a305bc64044bcb148cbe3446cd6 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Wed, 29 Apr 2026 17:11:16 +0800 Subject: [PATCH 1/4] fix: stream book file downloads Download original book files directly to temporary files instead of buffering the full response in memory. Route offline CBR, EPUB, and PDF downloads through the file streaming path and throttle download progress updates to avoid excessive Observation notifications. --- KMReader/Core/Network/APIClient.swift | 430 +++++++++++++++++- .../Features/Book/Services/BookService.swift | 7 +- .../Services/DownloadProgressTracker.swift | 25 +- .../Offline/Services/OfflineManager.swift | 27 +- 4 files changed, 461 insertions(+), 28 deletions(-) diff --git a/KMReader/Core/Network/APIClient.swift b/KMReader/Core/Network/APIClient.swift index 07e70ab2..740709f2 100644 --- a/KMReader/Core/Network/APIClient.swift +++ b/KMReader/Core/Network/APIClient.swift @@ -792,6 +792,409 @@ class APIClient { } } + private struct DownloadFileResult { + let response: HTTPURLResponse + let temporaryURL: URL + let expectedBytes: Int64? + let receivedBytes: Int64 + } + + private func executeDownloadRequest( + _ request: URLRequest, + destinationURL: URL, + session: URLSession? = nil, + isTemporary: Bool = false, + requestCategory: RequestCategory = .download, + retryCount: Int = 0, + maxRetryCount: Int? = nil, + onProgress: ProgressHandler? = nil + ) async throws -> HTTPURLResponse { + let method = request.httpMethod ?? "GET" + let urlString = request.url?.absoluteString ?? "" + let prefix = isTemporary ? "[TEMP] " : "" + logger.info("📡 \(prefix)\(method) \(urlString)") + + let startTime = Date() + let sessionToUse = session ?? currentSession() + let effectiveMaxRetryCount = maxRetryCount ?? AppConfig.apiRetryCount + let temporaryURL = makeTemporaryDownloadURL(for: destinationURL) + + actor DownloadState { + let onProgress: ProgressHandler? + let urlString: String + var response: HTTPURLResponse? + var expectedBytes: Int64? + var receivedBytes: Int64 = 0 + var downloadedFileURL: URL? + var downloadedFileError: Error? + var didReceiveFile = false + var didComplete = false + var completionError: Error? + var continuation: CheckedContinuation? + var lastUpdate = Date.distantPast + let updateInterval: TimeInterval = 0.1 + + init(onProgress: ProgressHandler?, urlString: String) { + self.onProgress = onProgress + self.urlString = urlString + } + + func setContinuation(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func handleProgress(received: Int64, expected: Int64) { + receivedBytes = received + if expected > 0 { + expectedBytes = expected + } + + let now = Date() + guard now.timeIntervalSince(lastUpdate) >= updateInterval else { return } + lastUpdate = now + onProgress?(receivedBytes, expectedBytes) + } + + func handleDownloadedFile(_ fileURL: URL?, error: Error?) { + didReceiveFile = true + downloadedFileURL = fileURL + downloadedFileError = error + finishIfReady() + } + + func handleComplete(_ error: Error?, response: HTTPURLResponse?) { + didComplete = true + completionError = error + if let response { + self.response = response + let expectedLength = response.expectedContentLength + if expectedLength > 0 { + expectedBytes = expectedLength + } + } + finishIfReady() + } + + private func finishIfReady() { + guard let continuation else { return } + guard didComplete else { return } + + if let completionError { + self.continuation = nil + continuation.resume(throwing: completionError) + return + } + + guard didReceiveFile else { return } + + if let downloadedFileError { + self.continuation = nil + continuation.resume(throwing: downloadedFileError) + return + } + + guard let response else { + self.continuation = nil + continuation.resume(throwing: APIError.invalidResponse(url: urlString)) + return + } + + guard let downloadedFileURL else { + self.continuation = nil + continuation.resume( + throwing: AppErrorType.missingRequiredData(message: "Missing downloaded file.") + ) + return + } + + onProgress?(receivedBytes, expectedBytes) + self.continuation = nil + continuation.resume( + returning: DownloadFileResult( + response: response, + temporaryURL: downloadedFileURL, + expectedBytes: expectedBytes, + receivedBytes: receivedBytes + ) + ) + } + } + + final class DownloadDelegate: NSObject, URLSessionDownloadDelegate { + let state: DownloadState + let urlString: String + let temporaryURL: URL + + init(state: DownloadState, urlString: String, temporaryURL: URL) { + self.state = state + self.urlString = urlString + self.temporaryURL = temporaryURL + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + let moveError: Error? + do { + let directory = temporaryURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } + if FileManager.default.fileExists(atPath: temporaryURL.path) { + try FileManager.default.removeItem(at: temporaryURL) + } + try FileManager.default.moveItem(at: location, to: temporaryURL) + moveError = nil + } catch { + moveError = error + } + + Task { + await state.handleDownloadedFile(moveError == nil ? temporaryURL : nil, error: moveError) + } + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + Task { + await state.handleProgress( + received: totalBytesWritten, + expected: totalBytesExpectedToWrite + ) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + defer { + session.finishTasksAndInvalidate() + } + + let response = task.response as? HTTPURLResponse + Task { await state.handleComplete(error, response: response) } + } + } + + do { + logger.debug("Streaming file download started: url=\(urlString)") + let state = DownloadState(onProgress: onProgress, urlString: urlString) + let delegate = DownloadDelegate( + state: state, + urlString: urlString, + temporaryURL: temporaryURL + ) + let delegateQueue = OperationQueue() + delegateQueue.maxConcurrentOperationCount = 1 + let downloadSession = URLSession( + configuration: sessionToUse.configuration, + delegate: delegate, + delegateQueue: delegateQueue + ) + + let result = try await withCheckedThrowingContinuation { continuation in + Task { + await state.setContinuation(continuation) + let task = downloadSession.downloadTask(with: request) + task.resume() + } + } + + let duration = Date().timeIntervalSince(startTime) + let httpResponse = result.response + let statusEmoji = (200...299).contains(httpResponse.statusCode) ? "✅" : "❌" + let durationMs = String(format: "%.2f", duration * 1000) + + if let sessionToken = httpResponse.value(forHTTPHeaderField: "X-Auth-Token") { + if AppConfig.current.sessionToken != sessionToken { + AppConfig.current.sessionToken = sessionToken + } + } + + logger.info( + "\(statusEmoji) \(prefix)\(httpResponse.statusCode) \(method) \(urlString) (\(durationMs)ms)" + ) + + guard (200...299).contains(httpResponse.statusCode) else { + let responseBody = try? String(contentsOf: result.temporaryURL, encoding: .utf8) + try? FileManager.default.removeItem(at: result.temporaryURL) + + if httpResponse.statusCode == 401 && retryCount == 0 && !isTemporary + && AppConfig.current.authMethod != .apiKey + { + let token = AppConfig.current.authToken + if !token.isEmpty { + logger.info("🔒 Unauthorized, attempting re-login to refresh session...") + do { + let loginRequest = try buildLoginRequest( + path: "/api/v2/users/me", + method: "GET", + queryItems: [URLQueryItem(name: "remember-me", value: "true")], + headers: ["X-Auth-Token": ""], + authToken: token, + useSessionToken: false + ) + + _ = try await reLoginActor.getReLoginTask { [weak self] in + guard let self = self else { + throw APIError.networkError( + AppErrorType.missingRequiredData(message: "APIClient deallocated"), + url: urlString + ) + } + return try await self.executeRequest( + loginRequest, + session: sessionToUse, + isTemporary: false, + requestCategory: .auth, + retryCount: 1, + maxRetryCount: maxRetryCount + ) + } + + logger.info("✅ Re-login successful, retrying original file download...") + onProgress?(0, result.expectedBytes) + return try await executeDownloadRequest( + request, + destinationURL: destinationURL, + session: sessionToUse, + isTemporary: false, + requestCategory: requestCategory, + retryCount: 1, + maxRetryCount: maxRetryCount, + onProgress: onProgress + ) + } catch { + logger.error("❌ Re-login failed: \(error.localizedDescription)") + } + } + } + + let errorMessage = responseBody ?? "Unknown error" + let requestBody = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } + + switch httpResponse.statusCode { + case 400: + throw APIError.badRequest( + message: errorMessage, url: urlString, response: responseBody, request: requestBody) + case 401: + throw APIError.unauthorized(url: urlString) + case 403: + throw APIError.forbidden( + message: errorMessage, url: urlString, response: responseBody, request: requestBody) + case 404: + throw APIError.notFound( + message: errorMessage, url: urlString, response: responseBody, request: requestBody) + case 429: + throw APIError.tooManyRequests( + message: errorMessage, url: urlString, response: responseBody, request: requestBody) + case 500...599: + if retryCount < effectiveMaxRetryCount { + logger.warning( + "⚠️ Server error, retrying (\(retryCount + 1)/\(effectiveMaxRetryCount)): \(httpResponse.statusCode)" + ) + try? await Task.sleep(nanoseconds: 1_000_000_000) + return try await executeDownloadRequest( + request, + destinationURL: destinationURL, + session: session, + isTemporary: isTemporary, + requestCategory: requestCategory, + retryCount: retryCount + 1, + maxRetryCount: maxRetryCount, + onProgress: onProgress + ) + } + throw APIError.serverError( + code: httpResponse.statusCode, message: errorMessage, url: urlString, + response: responseBody, request: requestBody) + default: + throw APIError.httpError( + code: httpResponse.statusCode, message: errorMessage, url: urlString, + response: responseBody, request: requestBody) + } + } + + let destinationDir = destinationURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: destinationDir.path) { + try FileManager.default.createDirectory( + at: destinationDir, + withIntermediateDirectories: true + ) + } + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + try FileManager.default.moveItem(at: result.temporaryURL, to: destinationURL) + + let dataSize = ByteCountFormatter.string( + fromByteCount: result.receivedBytes, + countStyle: .binary + ) + logger.info("\(httpResponse.statusCode) \(method) \(urlString) [\(dataSize)]") + + await offlineFailureTracker.recordSuccess() + return httpResponse + } catch let error as APIError { + try? FileManager.default.removeItem(at: temporaryURL) + throw error + } catch let appError as AppErrorType { + try? FileManager.default.removeItem(at: temporaryURL) + logger.error("❌ Network error for \(urlString): \(appError.description)") + let shouldHandleOffline = !isTemporary && requestCategory != .download + if shouldHandleOffline { + await handleNetworkError(appError, requestCategory: requestCategory) + } + throw APIError.networkError(appError, url: urlString) + } catch let nsError as NSError where nsError.domain == NSURLErrorDomain { + try? FileManager.default.removeItem(at: temporaryURL) + let appError = AppErrorType.from(nsError) + logger.error("❌ Network error for \(urlString): \(appError.description)") + let shouldHandleOffline = !isTemporary && requestCategory != .download + if shouldHandleOffline { + await handleNetworkError(nsError, requestCategory: requestCategory) + } + throw APIError.networkError(appError, url: urlString) + } catch { + try? FileManager.default.removeItem(at: temporaryURL) + if retryCount < effectiveMaxRetryCount && !(error is CancellationError) { + logger.warning( + "⚠️ File download failed, retrying (\(retryCount + 1)/\(effectiveMaxRetryCount)): \(error.localizedDescription)" + ) + try? await Task.sleep(nanoseconds: 1_000_000_000) + return try await executeDownloadRequest( + request, + destinationURL: destinationURL, + session: session, + isTemporary: isTemporary, + requestCategory: requestCategory, + retryCount: retryCount + 1, + maxRetryCount: maxRetryCount, + onProgress: onProgress + ) + } + + logger.error("❌ Network error for \(urlString): \(error.localizedDescription)") + let shouldHandleOffline = !isTemporary && requestCategory != .download + if shouldHandleOffline { + await handleNetworkError(error, requestCategory: requestCategory) + } + throw APIError.networkError(error, url: urlString) + } + } + private func handleNetworkError( _ error: Error, requestCategory: RequestCategory @@ -935,19 +1338,25 @@ class APIClient { return logAndExtractDataResponse(data: data, response: httpResponse, request: urlRequest) } - func requestDataWithProgress( + func requestFileWithProgress( path: String, progressKey: String, + destinationURL: URL, method: String = "GET", - headers: [String: String]? = nil, - ) async throws -> (data: Data, contentType: String?, suggestedFilename: String?) { + headers: [String: String]? = nil + ) async throws -> (contentType: String?, suggestedFilename: String?) { try throwIfOffline() let urlRequest = try buildRequest( - path: path, method: method, headers: headers, category: .download) + path: path, + method: method, + headers: headers, + category: .download + ) let urlString = urlRequest.url?.absoluteString ?? "" - let (data, httpResponse) = try await executeRequest( + let httpResponse = try await executeDownloadRequest( urlRequest, + destinationURL: destinationURL, requestCategory: .download, onProgress: { received, expected in Task { @MainActor in @@ -965,7 +1374,10 @@ class APIClient { } ) - return logAndExtractDataResponse(data: data, response: httpResponse, request: urlRequest) + return ( + httpResponse.value(forHTTPHeaderField: "Content-Type"), + filenameFromContentDisposition(httpResponse.value(forHTTPHeaderField: "Content-Disposition")) + ) } func requestData( @@ -1025,6 +1437,12 @@ class APIClient { return nil } + + private func makeTemporaryDownloadURL(for destinationURL: URL) -> URL { + let directory = destinationURL.deletingLastPathComponent() + let fileName = destinationURL.lastPathComponent + return directory.appendingPathComponent(".\(fileName).\(UUID().uuidString).download") + } } private enum APIClientDateParser { diff --git a/KMReader/Features/Book/Services/BookService.swift b/KMReader/Features/Book/Services/BookService.swift index f0f22743..cf606efe 100644 --- a/KMReader/Features/Book/Services/BookService.swift +++ b/KMReader/Features/Book/Services/BookService.swift @@ -6,7 +6,6 @@ import Foundation struct BookFileDownloadResult { - let data: Data let contentType: String? let suggestedFilename: String? } @@ -207,13 +206,13 @@ class BookService { logger.debug("✅ [Progress/Epub] Request completed: book=\(bookId)") } - func downloadBookFile(bookId: String) async throws -> BookFileDownloadResult { - let result = try await apiClient.requestDataWithProgress( + func downloadBookFile(bookId: String, to destinationURL: URL) async throws -> BookFileDownloadResult { + let result = try await apiClient.requestFileWithProgress( path: "/api/v1/books/\(bookId)/file", progressKey: bookId, + destinationURL: destinationURL ) return BookFileDownloadResult( - data: result.data, contentType: result.contentType, suggestedFilename: result.suggestedFilename ) diff --git a/KMReader/Features/Offline/Services/DownloadProgressTracker.swift b/KMReader/Features/Offline/Services/DownloadProgressTracker.swift index a34c17b2..08a631c0 100644 --- a/KMReader/Features/Offline/Services/DownloadProgressTracker.swift +++ b/KMReader/Features/Offline/Services/DownloadProgressTracker.swift @@ -27,6 +27,10 @@ class DownloadProgressTracker { /// Token to force UI refreshes when queue-related state changes var queueUpdateToken: UUID = UUID() + @ObservationIgnored private var lastProgressUpdate: [String: Date] = [:] + @ObservationIgnored private let progressUpdateInterval: TimeInterval = 1.0 + @ObservationIgnored private let progressUpdateDelta = 0.001 + /// Whether a download is currently active var isDownloading: Bool { currentBookName != nil @@ -58,11 +62,30 @@ class DownloadProgressTracker { private init() {} func updateProgress(bookId: String, value: Double) { - progress[bookId] = value + let clampedValue = min(max(value, 0), 1) + let previousValue = progress[bookId] + let isTerminalValue = clampedValue == 0 || clampedValue == 1 + let hasMeaningfulDelta = previousValue.map { abs($0 - clampedValue) >= progressUpdateDelta } ?? true + + if !isTerminalValue, !hasMeaningfulDelta { + return + } + + let now = Date() + if !isTerminalValue, + let lastUpdate = lastProgressUpdate[bookId], + now.timeIntervalSince(lastUpdate) < progressUpdateInterval + { + return + } + + lastProgressUpdate[bookId] = now + progress[bookId] = clampedValue } func clearProgress(bookId: String) { progress.removeValue(forKey: bookId) + lastProgressUpdate.removeValue(forKey: bookId) } func startDownload(bookName: String) { diff --git a/KMReader/Features/Offline/Services/OfflineManager.swift b/KMReader/Features/Offline/Services/OfflineManager.swift index b321b310..fc6c3e71 100644 --- a/KMReader/Features/Offline/Services/OfflineManager.swift +++ b/KMReader/Features/Offline/Services/OfflineManager.swift @@ -2018,16 +2018,14 @@ actor OfflineManager { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.0) } - let result = try await BookService.shared.downloadBookFile(bookId: bookId) + let archiveFile = bookDir.appendingPathComponent(format.fileName) + _ = try await BookService.shared.downloadBookFile(bookId: bookId, to: archiveFile) + Self.excludeFromBackupIfNeeded(at: archiveFile) await MainActor.run { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.5) } - let archiveFile = bookDir.appendingPathComponent(format.fileName) - try result.data.write(to: archiveFile, options: [.atomic]) - Self.excludeFromBackupIfNeeded(at: archiveFile) - try Task.checkCancellation() try extractImageArchive(archiveFile: archiveFile, format: format, pages: pages, bookDir: bookDir) try? FileManager.default.removeItem(at: archiveFile) @@ -2049,16 +2047,14 @@ actor OfflineManager { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.0) } - let result = try await BookService.shared.downloadBookFile(bookId: bookId) + let epubFile = bookDir.appendingPathComponent(Self.epubFileName) + _ = try await BookService.shared.downloadBookFile(bookId: bookId, to: epubFile) + Self.excludeFromBackupIfNeeded(at: epubFile) await MainActor.run { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.5) } - let epubFile = bookDir.appendingPathComponent(Self.epubFileName) - try result.data.write(to: epubFile, options: [.atomic]) - Self.excludeFromBackupIfNeeded(at: epubFile) - try Task.checkCancellation() try extractEpubDivinaImages(epubFile: epubFile, pages: pages, bookDir: bookDir) try? FileManager.default.removeItem(at: epubFile) @@ -2133,8 +2129,7 @@ actor OfflineManager { private func downloadPdfFile(bookId: String, to bookDir: URL) async throws { let fileURL = bookDir.appendingPathComponent(Self.pdfFileName) - let result = try await BookService.shared.downloadBookFile(bookId: bookId) - try result.data.write(to: fileURL, options: [.atomic]) + _ = try await BookService.shared.downloadBookFile(bookId: bookId, to: fileURL) Self.excludeFromBackupIfNeeded(at: fileURL) await MainActor.run { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 1.0) @@ -2153,16 +2148,14 @@ actor OfflineManager { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.0) } - let result = try await BookService.shared.downloadBookFile(bookId: bookId) + let epubFile = bookDir.appendingPathComponent(Self.epubFileName) + _ = try await BookService.shared.downloadBookFile(bookId: bookId, to: epubFile) + Self.excludeFromBackupIfNeeded(at: epubFile) await MainActor.run { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.5) } - let epubFile = bookDir.appendingPathComponent(Self.epubFileName) - try result.data.write(to: epubFile, options: [.atomic]) - Self.excludeFromBackupIfNeeded(at: epubFile) - try Task.checkCancellation() try extractEpubToWebPub(epubFile: epubFile, bookId: bookId, manifest: manifest, bookDir: bookDir) From b87bad7f15722e2f4f2e28801db7b0409627b777 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Wed, 29 Apr 2026 17:11:20 +0800 Subject: [PATCH 2/4] chore: incr build ver to 406 --- 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 c11af01a..229b5d3d 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 = 405; + CURRENT_PROJECT_VERSION = 406; 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 = 405; + CURRENT_PROJECT_VERSION = 406; 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 = 405; + CURRENT_PROJECT_VERSION = 406; 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 = 405; + CURRENT_PROJECT_VERSION = 406; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; From 906ced4ed9069918341371b4e2ec78274e89b4a1 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Wed, 29 Apr 2026 17:15:08 +0800 Subject: [PATCH 3/4] fix: show processing after file download Show a processing state once a pending offline task has finished the file transfer but still needs extraction or finalization. --- KMReader/Features/Offline/Views/OfflineTasksView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/KMReader/Features/Offline/Views/OfflineTasksView.swift b/KMReader/Features/Offline/Views/OfflineTasksView.swift index a89441a7..273e273c 100644 --- a/KMReader/Features/Offline/Views/OfflineTasksView.swift +++ b/KMReader/Features/Offline/Views/OfflineTasksView.swift @@ -244,8 +244,9 @@ struct OfflineTaskRow: View { switch book.downloadStatus { case .pending: if let progress = progress { - ProgressView(value: progress) { - Text("Downloading \(Int(progress * 100))%") + let isProcessing = progress >= 1 + ProgressView(value: isProcessing ? nil : progress) { + Text(isProcessing ? "Processing offline files..." : "Downloading \(Int(progress * 100))%") .font(.caption) .foregroundColor(.secondary) } From 672f9baef73fda0b3dc739fc8012999503234f9e Mon Sep 17 00:00:00 2001 From: everpcpc Date: Wed, 29 Apr 2026 17:18:57 +0800 Subject: [PATCH 4/4] fix: show offline processing state Update offline task rows and Live Activity state after a file transfer reaches completion but extraction or finalization is still running. Add localized processing text for all supported app languages. --- .../Offline/Services/OfflineManager.swift | 17 ++++- .../Offline/Views/OfflineTasksView.swift | 10 ++- KMReader/Localizable.xcstrings | 64 +++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/KMReader/Features/Offline/Services/OfflineManager.swift b/KMReader/Features/Offline/Services/OfflineManager.swift index fc6c3e71..462ad4bb 100644 --- a/KMReader/Features/Offline/Services/OfflineManager.swift +++ b/KMReader/Features/Offline/Services/OfflineManager.swift @@ -1567,7 +1567,7 @@ actor OfflineManager { await LiveActivityManager.shared.updateActivity( seriesTitle: seriesTitle, bookInfo: bookInfo, - progress: 1.0, + progress: candidate == nil ? 1.0 : 0.0, pendingCount: pendingBooks.count, failedCount: failedCount ) @@ -1820,6 +1820,21 @@ actor OfflineManager { logger.debug( "🔒 Claimed background finalize for book \(bookId), completed=\(completedTasks)/\(totalTasks)" ) + let pendingBooks = + (try? await DatabaseOperator.database().fetchPendingBooks( + instanceId: info.instanceId + )) ?? [] + let failedCount = + (try? await DatabaseOperator.database().fetchFailedBooksCount( + instanceId: info.instanceId + )) ?? 0 + await LiveActivityManager.shared.updateActivity( + seriesTitle: info.seriesTitle, + bookInfo: String(localized: "Processing offline files..."), + progress: 0.0, + pendingCount: pendingBooks.count, + failedCount: failedCount + ) await finalizeBackgroundBookDownload(bookId: bookId, info: info) } diff --git a/KMReader/Features/Offline/Views/OfflineTasksView.swift b/KMReader/Features/Offline/Views/OfflineTasksView.swift index 273e273c..229a595b 100644 --- a/KMReader/Features/Offline/Views/OfflineTasksView.swift +++ b/KMReader/Features/Offline/Views/OfflineTasksView.swift @@ -246,9 +246,13 @@ struct OfflineTaskRow: View { if let progress = progress { let isProcessing = progress >= 1 ProgressView(value: isProcessing ? nil : progress) { - Text(isProcessing ? "Processing offline files..." : "Downloading \(Int(progress * 100))%") - .font(.caption) - .foregroundColor(.secondary) + Text( + isProcessing + ? String(localized: "Processing offline files...") + : "Downloading \(Int(progress * 100))%" + ) + .font(.caption) + .foregroundColor(.secondary) } } else { Text("Pending in queue...") diff --git a/KMReader/Localizable.xcstrings b/KMReader/Localizable.xcstrings index 934f4997..f644aed5 100644 --- a/KMReader/Localizable.xcstrings +++ b/KMReader/Localizable.xcstrings @@ -50207,6 +50207,70 @@ } } }, + "Processing offline files..." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offline-Dateien werden verarbeitet..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Processing offline files..." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Procesando archivos sin conexión..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Traitement des fichiers hors ligne..." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elaborazione dei file offline..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オフラインファイルを処理中..." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오프라인 파일 처리 중..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработка офлайн-файлов..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在处理离线文件..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在處理離線檔案..." + } + } + } + }, "Publication" : { "localizations" : { "de" : {