Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions KMReader.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 78 additions & 6 deletions KMReader/Features/Offline/Services/OfflineManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,7 @@ actor OfflineManager {
instanceId: instanceId,
seriesTitle: info.seriesTitle,
bookInfo: info.bookInfo,
kind: info.kind,
totalTasks: totalTaskCount,
completedTasks: completedTaskCount
)
Expand Down Expand Up @@ -937,6 +938,7 @@ actor OfflineManager {
instanceId: instanceId,
seriesTitle: info.seriesTitle,
bookInfo: info.bookInfo,
kind: info.kind,
totalTasks: 1,
completedTasks: 0
)
Expand Down Expand Up @@ -982,6 +984,7 @@ actor OfflineManager {
instanceId: instanceId,
seriesTitle: info.seriesTitle,
bookInfo: info.bookInfo,
kind: info.kind,
totalTasks: 1,
completedTasks: 0
)
Expand All @@ -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)
Expand All @@ -1022,6 +1027,7 @@ actor OfflineManager {
backgroundDownloadInfo.removeValue(forKey: bookId)
backgroundDownloadTotalTasks.removeValue(forKey: bookId)
backgroundDownloadCompletedTasks.removeValue(forKey: bookId)
backgroundDownloadFinalizingBooks.remove(bookId)
}
#endif

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> = []
private func handleBackgroundDownloadComplete(
bookId: String, pageNumber: Int?, fileURL: URL
) async {
Expand Down Expand Up @@ -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"
)
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -1611,16 +1657,42 @@ 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 {
try extractEpubToWebPub(
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
}
}

Expand Down
19 changes: 12 additions & 7 deletions KMReader/Features/Reader/ViewModels/EpubReaderViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -994,19 +990,28 @@
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,
bookId: bookId,
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)
Expand Down
12 changes: 6 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions misc/bump-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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}"
15 changes: 8 additions & 7 deletions misc/bump.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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}"
Loading