From 08e74fdaee15b3af6c5ddd0ec96981b4df269ac2 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 2 Apr 2026 11:30:19 +0800 Subject: [PATCH 1/3] fix: harden offline epub finalization --- .../Offline/Services/OfflineManager.swift | 84 +++++++++++++++++-- .../ViewModels/EpubReaderViewModel.swift | 19 +++-- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/KMReader/Features/Offline/Services/OfflineManager.swift b/KMReader/Features/Offline/Services/OfflineManager.swift index 7edb2c28..704d66d4 100644 --- a/KMReader/Features/Offline/Services/OfflineManager.swift +++ b/KMReader/Features/Offline/Services/OfflineManager.swift @@ -870,6 +870,7 @@ actor OfflineManager { instanceId: instanceId, seriesTitle: info.seriesTitle, bookInfo: info.bookInfo, + kind: info.kind, totalTasks: totalTaskCount, completedTasks: completedTaskCount ) @@ -937,6 +938,7 @@ actor OfflineManager { instanceId: instanceId, seriesTitle: info.seriesTitle, bookInfo: info.bookInfo, + kind: info.kind, totalTasks: 1, completedTasks: 0 ) @@ -982,6 +984,7 @@ actor OfflineManager { instanceId: instanceId, seriesTitle: info.seriesTitle, bookInfo: info.bookInfo, + kind: info.kind, totalTasks: 1, completedTasks: 0 ) @@ -1001,13 +1004,15 @@ actor OfflineManager { instanceId: String, seriesTitle: String?, bookInfo: String, + kind: DownloadContentKind, totalTasks: Int, completedTasks: Int ) { backgroundDownloadInfo[bookId] = ( instanceId: instanceId, seriesTitle: seriesTitle, - bookInfo: bookInfo + bookInfo: bookInfo, + kind: kind ) backgroundDownloadTotalTasks[bookId] = totalTasks backgroundDownloadCompletedTasks[bookId] = min(max(completedTasks, 0), totalTasks) @@ -1022,6 +1027,7 @@ actor OfflineManager { backgroundDownloadInfo.removeValue(forKey: bookId) backgroundDownloadTotalTasks.removeValue(forKey: bookId) backgroundDownloadCompletedTasks.removeValue(forKey: bookId) + backgroundDownloadFinalizingBooks.remove(bookId) } #endif @@ -1277,6 +1283,27 @@ actor OfflineManager { return total } + private nonisolated static func directoryContainsFiles(_ url: URL) -> Bool { + guard + let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { + return false + } + + for case let fileURL as URL in enumerator { + let attrs = try? fileURL.resourceValues(forKeys: [.isDirectoryKey]) + if attrs?.isDirectory == false { + return true + } + } + + return false + } + // MARK: - Private Helpers private func refreshQueueStatus(instanceId: String) async { @@ -1409,10 +1436,11 @@ actor OfflineManager { /// Track background download context per book private var backgroundDownloadInfo: [String: ( - instanceId: String, seriesTitle: String?, bookInfo: String + instanceId: String, seriesTitle: String?, bookInfo: String, kind: DownloadContentKind )] = [:] private var backgroundDownloadTotalTasks: [String: Int] = [:] private var backgroundDownloadCompletedTasks: [String: Int] = [:] + private var backgroundDownloadFinalizingBooks: Set = [] private func handleBackgroundDownloadComplete( bookId: String, pageNumber: Int?, fileURL: URL ) async { @@ -1463,7 +1491,9 @@ actor OfflineManager { ) if completedTasks >= totalTasks { - await finalizeBackgroundBookDownload(bookId: bookId, info: info) + logger.debug( + "✅ Final per-file background callback received for book \(bookId); waiting for all-complete callback" + ) } } @@ -1582,19 +1612,35 @@ actor OfflineManager { return } + if backgroundDownloadFinalizingBooks.contains(bookId) { + logger.debug( + "⏭️ Ignore duplicate all-complete callback while finalizing book \(bookId), completed=\(completedTasks)/\(totalTasks)" + ) + return + } + + backgroundDownloadFinalizingBooks.insert(bookId) + logger.debug( + "🔒 Claimed background finalize for book \(bookId), completed=\(completedTasks)/\(totalTasks)" + ) await finalizeBackgroundBookDownload(bookId: bookId, info: info) } private func finalizeBackgroundBookDownload( bookId: String, - info: (instanceId: String, seriesTitle: String?, bookInfo: String) + info: (instanceId: String, seriesTitle: String?, bookInfo: String, kind: DownloadContentKind) ) async { + defer { + backgroundDownloadFinalizingBooks.remove(bookId) + } logger.info("✅ Background downloads finished for book: \(bookId)") let bookDir = bookDirectory(instanceId: info.instanceId, bookId: bookId) // Extract EPUB file if present (single-file download approach) let epubFile = bookDir.appendingPathComponent(Self.epubFileName) if FileManager.default.fileExists(atPath: epubFile.path) { + let recoveryMessage = "Offline EPUB download is incomplete. Please retry downloading this book." + func failExtraction(_ message: String) async { logger.error("❌ \(message): \(bookId)") try? FileManager.default.removeItem(at: bookDir) @@ -1611,7 +1657,10 @@ actor OfflineManager { let manifest = try? await DatabaseOperator.database().fetchWebPubManifest( bookId: bookId, instanceId: info.instanceId) else { - await failExtraction("Missing WebPub manifest. Please retry download.") + logger.error( + "❌ Missing WebPub manifest during background EPUB finalization for book \(bookId), bookDir=\(bookDir.path)" + ) + await failExtraction(recoveryMessage) return } do { @@ -1619,8 +1668,31 @@ actor OfflineManager { epubFile: epubFile, bookId: bookId, manifest: manifest, bookDir: bookDir) try? FileManager.default.removeItem(at: epubFile) } catch { - await failExtraction(error.localizedDescription) + logger.error( + "❌ Background EPUB extraction failed for book \(bookId), file=\(epubFile.lastPathComponent), bookDir=\(bookDir.path), error=\(error.localizedDescription)" + ) + await failExtraction(recoveryMessage) + return + } + } else if !Self.directoryContainsFiles(webPubRootURL(bookDir: bookDir)) { + switch info.kind { + case .epubWebPub: + logger.warning( + "⚠️ EPUB archive and extracted resources are both missing during background finalization for book \(bookId), bookDir=\(bookDir.path)" + ) + try? await DatabaseOperator.database().updateBookDownloadStatus( + bookId: bookId, + instanceId: info.instanceId, + status: .failed(error: "Offline EPUB download is incomplete. Please retry downloading this book.") + ) + try? await DatabaseOperator.database().commit() + clearBackgroundDownloadContext(bookId: bookId) + removeActiveTask(bookId) + await refreshQueueStatus(instanceId: info.instanceId) + await syncDownloadQueue(instanceId: info.instanceId) return + default: + break } } diff --git a/KMReader/Features/Reader/ViewModels/EpubReaderViewModel.swift b/KMReader/Features/Reader/ViewModels/EpubReaderViewModel.swift index 1ab96d78..e474f53f 100644 --- a/KMReader/Features/Reader/ViewModels/EpubReaderViewModel.swift +++ b/KMReader/Features/Reader/ViewModels/EpubReaderViewModel.swift @@ -200,9 +200,7 @@ let database = await DatabaseOperator.databaseIfConfigured(), let manifest = await database.fetchWebPubManifest(bookId: bookId) else { - throw AppErrorType.missingRequiredData( - message: "Missing WebPub manifest. Please re-download this book." - ) + throw AppErrorType.unknown(message: offlineEpubRecoveryMessage()) } logger.debug("WebPub manifest loaded from offline storage") publicationLanguage = manifest.metadata?.language @@ -214,9 +212,7 @@ bookId: bookId ) else { - throw AppErrorType.missingRequiredData( - message: "Offline resources are missing. Please re-download this book." - ) + throw AppErrorType.unknown(message: offlineEpubRecoveryMessage()) } downloadProgress = 1.0 @@ -994,6 +990,7 @@ private func cacheChapterURLs() async throws { chapterURLCache = [:] for (index, link) in readingOrder.enumerated() { + let normalizedHref = Self.normalizedHref(link.href) guard let cachedURL = await OfflineManager.shared.cachedOfflineWebPubResourceURL( instanceId: AppConfig.current.instanceId, @@ -1001,12 +998,20 @@ href: link.href ) else { - throw AppErrorType.invalidFileURL(url: Self.normalizedHref(link.href)) + let rootPath = resourceRootURL?.path ?? "unknown" + logger.error( + "❌ Offline WebPub resource missing for book \(bookId): chapterIndex=\(index), href=\(normalizedHref), originalHref=\(link.href), root=\(rootPath)" + ) + throw AppErrorType.unknown(message: offlineEpubRecoveryMessage()) } chapterURLCache[index] = cachedURL } } + private func offlineEpubRecoveryMessage() -> String { + "Offline EPUB files are incomplete. Please delete and re-download this book." + } + private func loadTextLengthCache() { guard let rootURL = resourceRootURL else { return } let cacheURL = rootURL.appendingPathComponent("text-length.json", isDirectory: false) From dca318684d5bf9badc7dfb7b51acd0b0f529d09b Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 2 Apr 2026 11:30:24 +0800 Subject: [PATCH 2/3] chore: relax bump worktree checks --- Makefile | 12 ++++++------ misc/bump-version.sh | 15 ++++++++------- misc/bump.sh | 15 ++++++++------- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index fa63023b..343b3f50 100644 --- a/Makefile +++ b/Makefile @@ -56,9 +56,9 @@ help: ## Show this help message @echo " make clean - Remove archives and exports" @echo "" @echo "Version commands:" - @echo " make bump - Increment CURRENT_PROJECT_VERSION in project.pbxproj" - @echo " make major - Increment major version (MARKETING_VERSION)" - @echo " make minor - Increment minor version (MARKETING_VERSION)" + @echo " make bump - Increment CURRENT_PROJECT_VERSION and commit only the version file" + @echo " make major - Increment major version and commit only the version file" + @echo " make minor - Increment minor version and commit only the version file" @echo "" build: build-ios build-macos build-tvos ## Build all platforms (iOS, macOS, tvOS) @@ -190,13 +190,13 @@ clean: clean-archives clean-exports ## Remove archives and exports @echo "$(GREEN)Cleaned archives and exports successfully!$(NC)" -bump: ## Increment CURRENT_PROJECT_VERSION in project.pbxproj +bump: ## Increment CURRENT_PROJECT_VERSION and commit only the version file @$(MISC_DIR)/bump.sh -major: ## Increment major version (MARKETING_VERSION) +major: ## Increment major version and commit only the version file @$(MISC_DIR)/bump-version.sh major -minor: ## Increment minor version (MARKETING_VERSION) +minor: ## Increment minor version and commit only the version file @$(MISC_DIR)/bump-version.sh minor format: ## Format Swift files with swift-format diff --git a/misc/bump-version.sh b/misc/bump-version.sh index 66287f20..e5dfa0a7 100755 --- a/misc/bump-version.sh +++ b/misc/bump-version.sh @@ -3,7 +3,7 @@ # Bump marketing version script for KMReader # Usage: ./bump-version.sh [major|minor] # Increments MARKETING_VERSION in project.pbxproj (two-digit version: major.minor) -# Requires a clean git working directory +# Allows unrelated working tree changes, but commits only the version file set -e @@ -18,7 +18,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # Configuration -PROJECT="$PROJECT_ROOT/KMReader.xcodeproj/project.pbxproj" +PROJECT_REL="KMReader.xcodeproj/project.pbxproj" +PROJECT="$PROJECT_ROOT/$PROJECT_REL" # Check argument if [ -z "$1" ]; then @@ -34,10 +35,10 @@ if [ "$VERSION_TYPE" != "major" ] && [ "$VERSION_TYPE" != "minor" ]; then exit 1 fi -# Check if git working directory is clean +# Refuse to bump if the version file itself already has pending changes. cd "$PROJECT_ROOT" -if [ -n "$(git status --porcelain)" ]; then - echo -e "${RED}Error: Working directory is not clean. Please commit or stash your changes first.${NC}" +if ! git diff --quiet -- "$PROJECT_REL" || ! git diff --cached --quiet -- "$PROJECT_REL"; then + echo -e "${RED}Error: $PROJECT_REL already has uncommitted changes. Commit or stash them before bumping.${NC}" exit 1 fi @@ -83,7 +84,7 @@ echo -e "${GREEN}Version bumped successfully!${NC}" # Commit the changes echo -e "${GREEN}Committing changes...${NC}" -git add "$PROJECT" -git commit -m "chore: bump version to $NEXT_VERSION" +git add "$PROJECT_REL" +git commit -m "chore: bump version to $NEXT_VERSION" -- "$PROJECT_REL" echo -e "${GREEN}Version bump committed!${NC}" diff --git a/misc/bump.sh b/misc/bump.sh index 23302325..6fcb6748 100755 --- a/misc/bump.sh +++ b/misc/bump.sh @@ -3,7 +3,7 @@ # Bump version script for KMReader # Usage: ./bump.sh # Increments CURRENT_PROJECT_VERSION in project.pbxproj -# Requires a clean git working directory +# Allows unrelated working tree changes, but commits only the version file set -e @@ -18,12 +18,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # Configuration -PROJECT="$PROJECT_ROOT/KMReader.xcodeproj/project.pbxproj" +PROJECT_REL="KMReader.xcodeproj/project.pbxproj" +PROJECT="$PROJECT_ROOT/$PROJECT_REL" -# Check if git working directory is clean +# Refuse to bump if the version file itself already has pending changes. cd "$PROJECT_ROOT" -if [ -n "$(git status --porcelain)" ]; then - echo -e "${RED}Error: Working directory is not clean. Please commit or stash your changes first.${NC}" +if ! git diff --quiet -- "$PROJECT_REL" || ! git diff --cached --quiet -- "$PROJECT_REL"; then + echo -e "${RED}Error: $PROJECT_REL already has uncommitted changes. Commit or stash them before bumping.${NC}" exit 1 fi @@ -49,7 +50,7 @@ echo -e "${GREEN}Version bumped successfully!${NC}" # Commit the changes echo -e "${GREEN}Committing changes...${NC}" -git add "$PROJECT" -git commit -m "chore: incr build ver to $NEXT_VERSION" +git add "$PROJECT_REL" +git commit -m "chore: incr build ver to $NEXT_VERSION" -- "$PROJECT_REL" echo -e "${GREEN}Version bump committed!${NC}" From f967bbf15402ba298eed133b0b3622a1be09844b Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 2 Apr 2026 11:30:30 +0800 Subject: [PATCH 3/3] chore: incr build ver to 388 --- 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 f3abc8be..3226aad1 100644 --- a/KMReader.xcodeproj/project.pbxproj +++ b/KMReader.xcodeproj/project.pbxproj @@ -438,7 +438,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 387; + CURRENT_PROJECT_VERSION = 388; DEVELOPMENT_TEAM = M777UHWZA4; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -496,7 +496,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 387; + CURRENT_PROJECT_VERSION = 388; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=appletvos*]" = M777UHWZA4; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; @@ -556,7 +556,7 @@ CODE_SIGN_ENTITLEMENTS = KMReaderWidgets/KMReaderWidgets.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 387; + CURRENT_PROJECT_VERSION = 388; DEVELOPMENT_TEAM = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = KMReaderWidgets/Info.plist; @@ -590,7 +590,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 387; + CURRENT_PROJECT_VERSION = 388; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES;