diff --git a/App/HoshiReader.swift b/App/HoshiReader.swift index bff068b6..bbab1f51 100644 --- a/App/HoshiReader.swift +++ b/App/HoshiReader.swift @@ -14,7 +14,8 @@ import WebKit struct HoshiReaderApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Environment(\.scenePhase) private var scenePhase - @State private var userConfig = UserConfig() + @AppStorage("cloudKitStatus") private var cloudKitStatus = CloudKitStatus.none + @State private var userConfig = UserConfig.shared @State private var pendingImportURL: URL? @State private var pendingRemoteImportURL: URL? @State private var pendingLookup: String? @@ -45,6 +46,11 @@ struct HoshiReaderApp: App { _ = DictionaryManager.shared _ = GoogleDriveHandler.shared WebViewPreloader.shared.warmup() + if userConfig.enableCloudKitSync { + Task { + await CloudKitSyncManager.shared.initialize() + } + } } var body: some Scene { @@ -99,6 +105,9 @@ struct HoshiReaderApp: App { } shortcutHandler.pendingType = nil } + .task { + await observeCloudKitEvents() + } } } @@ -124,6 +133,28 @@ struct HoshiReaderApp: App { pendingImportURL = url } } + + private func observeCloudKitEvents() async { + for await event in await CloudKitSyncManager.shared.events() { + if case let .account(accountEvent) = event { + switch accountEvent { + case .signOut: + userConfig.enableCloudKitSync = false + cloudKitStatus = .signOut + case .signIn: + fallthrough + case .accountChanged: + cloudKitStatus = .none + } + } else if case let .error(syncError) = event { + switch syncError { + case .quotaExceeded: + userConfig.enableCloudKitSync = false + cloudKitStatus = .quotaExceeded + } + } + } + } } @Observable diff --git a/Core/BookStorage.swift b/Core/BookStorage.swift index 9438b7e5..ff22dbfe 100644 --- a/Core/BookStorage.swift +++ b/Core/BookStorage.swift @@ -9,6 +9,7 @@ import EPUBKit import Foundation import ZIPFoundation +import OSLog nonisolated enum FileNames: Sendable { static let metadata = "metadata.json" @@ -21,6 +22,11 @@ nonisolated enum FileNames: Sendable { static let highlights = "highlights.json" } +nonisolated enum CloudKitSyncTarget { + case book(UUID) + case shelves +} + struct BookStorage { nonisolated static let migratedDocumentsKey = "migratedToAppSupport" nonisolated static let migratedBooksKey = "migratedBooks" @@ -169,6 +175,10 @@ struct BookStorage { try getAppDirectory().appendingPathComponent("Books") } + nonisolated static func getCloudKitSyncDirectory() throws -> URL { + try getAppDirectory().appendingPathComponent("CloudKitSync") + } + @discardableResult static func copySecurityScopedFile(from fileURL: URL, to destinationPath: String? = nil) throws -> URL { guard fileURL.startAccessingSecurityScopedResource() else { @@ -211,24 +221,48 @@ struct BookStorage { try FileManager.default.copyItem(at: source, to: destination) } - static func delete(at url: URL) throws { + nonisolated static func delete(at url: URL) throws { guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else { return } try FileManager.default.removeItem(at: url) } - static func save(_ object: T, inside directory: URL, as fileName: String) throws { + static func save(_ object: T, inside directory: URL, as fileName: String, sync: CloudKitSyncTarget? = nil) throws { let targetURL = directory.appendingPathComponent(fileName) + try saveLocal(object, url: targetURL) + + guard UserConfig.shared.enableCloudKitSync, let sync else { return } + switch sync { + case .shelves: + Task { + await CloudKitSyncManager.shared.saveCloudShelves() + } + case .book(let uuid): + guard let fileType = CloudKitFileType(fileName: fileName) else { + return + } + Task { + await CloudKitSyncManager.shared.saveCloudFile( + uuid: uuid, + fileType: fileType, + fileName: fileName, + folderName: directory.lastPathComponent, + ) + } + } + } + + nonisolated static func saveLocal(_ object: T, url: URL) throws { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted let data = try encoder.encode(object) - try data.write(to: targetURL, options: .atomic) + try data.write(to: url, options: .atomic) } - static func load(_ type: T.Type, from url: URL) -> T? { + nonisolated static func load(_ type: T.Type, from url: URL) -> T? { guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)), let data = try? Data(contentsOf: url) else { return nil @@ -276,12 +310,12 @@ struct BookStorage { load([Highlight].self, from: root.appendingPathComponent(FileNames.highlights)) } - static func loadShelves() -> [BookShelf]? { + nonisolated static func loadShelves() -> [BookShelf]? { guard let booksDirectory = try? getBooksDirectory() else { return nil } return load([BookShelf].self, from: booksDirectory.appendingPathComponent(FileNames.shelves)) } - static func loadAllBooks() throws -> [BookMetadata] { + nonisolated static func loadAllBooks() throws -> [BookMetadata] { let booksDirectory = try getBooksDirectory() if !FileManager.default.fileExists(atPath: booksDirectory.path(percentEncoded: false)) { diff --git a/Core/DictionaryManager.swift b/Core/DictionaryManager.swift index 4628a95c..03d10d35 100644 --- a/Core/DictionaryManager.swift +++ b/Core/DictionaryManager.swift @@ -473,9 +473,7 @@ class DictionaryManager { return } - let interval = UserDefaults.standard.string(forKey: "dictionaryUpdateInterval") - .flatMap(DictionaryUpdateInterval.init)? - .timeInterval ?? DictionaryUpdateInterval.weekly.timeInterval + let interval = UserConfig.shared.dictionaryUpdateInterval.timeInterval let lastUpdate = UserDefaults.standard.object(forKey: "lastDictionaryUpdate") as? Date ?? .distantPast guard Date().timeIntervalSince(lastUpdate) >= interval else { return diff --git a/Core/UserConfig.swift b/Core/UserConfig.swift index dbe629eb..047be0d9 100644 --- a/Core/UserConfig.swift +++ b/Core/UserConfig.swift @@ -68,6 +68,9 @@ enum Themes: String, CaseIterable, Codable { @Observable class UserConfig { + + static let shared = UserConfig() + var bookshelfSortOption: SortOption { didSet { UserDefaults.standard.set(bookshelfSortOption.rawValue, forKey: "bookshelfSortOption") } } @@ -156,6 +159,21 @@ class UserConfig { didSet { UserDefaults.standard.set(syncUploadBooks, forKey: "syncUploadBooks") } } + var enableCloudKitSync: Bool { + didSet { + UserDefaults.standard.set(enableCloudKitSync, forKey: "enableCloudKitSync") + if enableCloudKitSync { + Task { + await CloudKitSyncManager.shared.initialize() + } + } else { + Task { + await CloudKitSyncManager.shared.disableSync() + } + } + } + } + var theme: Themes { didSet { UserDefaults.standard.set(theme.rawValue, forKey: "theme") } } @@ -448,6 +466,8 @@ class UserConfig { self.googleClientId = defaults.object(forKey: "googleClientId") as? String ?? "" self.syncUploadBooks = defaults.object(forKey: "syncUploadBooks") as? Bool ?? true + self.enableCloudKitSync = defaults.object(forKey: "enableCloudKitSync") as? Bool ?? false + self.theme = defaults.string(forKey: "theme") .flatMap(Themes.init) ?? .system self.uiTheme = defaults.string(forKey: "uiTheme") diff --git a/Features/Bookshelf/BookCell.swift b/Features/Bookshelf/BookCell.swift index 3c7ef11f..75ebd8dd 100644 --- a/Features/Bookshelf/BookCell.swift +++ b/Features/Bookshelf/BookCell.swift @@ -13,6 +13,7 @@ struct BookCell: View { @State private var markReadConfirmation = false @State private var showRenameAlert = false @State private var renameText = "" + @State private var downloaded = true let book: BookMetadata var viewModel: BookshelfViewModel var currentShelf: String? @@ -40,7 +41,18 @@ struct BookCell: View { onSelect() } } label: { - BookView(book: book, progress: viewModel.progress(for: book), isSelected: isSelecting && isSelected) + BookView(book: book, progress: viewModel.progress(for: book), downloaded: downloaded, isSelected: isSelecting && isSelected) + } + .task(id: userConfig.enableCloudKitSync) { + refreshEpubState(book: book) + guard userConfig.enableCloudKitSync else { return } + + for await event in await CloudKitSyncManager.shared.events() { + if case let .epubDownloaded(uuid: uuid) = event, + uuid == book.id { + refreshEpubState(book: book) + } + } } .buttonStyle(.plain) .contextMenu(isSelecting ? nil : ContextMenu { @@ -65,6 +77,17 @@ struct BookCell: View { } } + if userConfig.enableCloudKitSync && !downloaded { + Button { + Task { + guard let cloudEpub = CloudKitBookEpub(from: book) else { return } + await CloudKitSyncManager.shared.downloadCloudEpub(cloudEpub) + } + } label: { + Label("Download from iCloud", systemImage: "icloud") + } + } + if userConfig.enableSync { if userConfig.syncMode == .manual { Menu { @@ -171,4 +194,18 @@ struct BookCell: View { } } } + + func refreshEpubState(book: BookMetadata) { + Task.detached { + guard let fileName = book.epub, + let booksDir = try? BookStorage.getBooksDirectory() else { + return + } + let epubURL = booksDir.appending(path: book.folder).appending(path: fileName) + let downloaded = FileManager.default.fileExists(atPath: epubURL.path(percentEncoded: false)) + await MainActor.run { + self.downloaded = downloaded + } + } + } } diff --git a/Features/Bookshelf/BookView.swift b/Features/Bookshelf/BookView.swift index 770fe5c5..e38ba177 100644 --- a/Features/Bookshelf/BookView.swift +++ b/Features/Bookshelf/BookView.swift @@ -9,9 +9,19 @@ import SwiftUI struct BookView: View { + @Environment(UserConfig.self) var userConfig let book: BookMetadata let progress: Double + let downloaded: Bool var isSelected: Bool = false + + var titleText: Text { + if userConfig.enableCloudKitSync && !downloaded { + return Text(Image(systemName: "icloud")) + Text(" ") + Text(book.displayTitle) + } else { + return Text(book.displayTitle) + } + } var body: some View { VStack(spacing: 6) { @@ -21,7 +31,7 @@ struct BookView: View { isSelected: isSelected ) - Text(book.displayTitle) + titleText .font(.system(size: 16)) .lineLimit(2) .frame(height: 40, alignment: .top) diff --git a/Features/Bookshelf/BookshelfView.swift b/Features/Bookshelf/BookshelfView.swift index bf5bfa62..06bb0fb4 100644 --- a/Features/Bookshelf/BookshelfView.swift +++ b/Features/Bookshelf/BookshelfView.swift @@ -86,6 +86,13 @@ struct BookshelfView: View { } } } + .task(id: userConfig.enableCloudKitSync) { + guard userConfig.enableCloudKitSync else { return } + + for await _ in await CloudKitSyncManager.shared.events() { + viewModel.loadBooks() + } + } .fileImporter( isPresented: $viewModel.isImporting, allowedContentTypes: [.epub], diff --git a/Features/Bookshelf/BookshelfViewModel.swift b/Features/Bookshelf/BookshelfViewModel.swift index 8449690e..0de5aec0 100644 --- a/Features/Bookshelf/BookshelfViewModel.swift +++ b/Features/Bookshelf/BookshelfViewModel.swift @@ -44,7 +44,7 @@ class BookshelfViewModel { func saveShelves() { guard let directory = try? BookStorage.getBooksDirectory() else { return } - try? BookStorage.save(shelves, inside: directory, as: FileNames.shelves) + try? BookStorage.save(shelves, inside: directory, as: FileNames.shelves, sync: .shelves) } func createShelf(name: String) { @@ -169,12 +169,20 @@ class BookshelfViewModel { for i in shelves.indices { shelves[i].bookIds.removeAll { $0 == book.id } } + deleteCloudBook(book) saveShelves() } catch { showError(message: error.localizedDescription) } } + func deleteCloudBook(_ book: BookMetadata) { + guard UserConfig.shared.enableCloudKitSync else { return } + Task { + await CloudKitSyncManager.shared.deleteCloudBook(book) + } + } + func renameBook(_ book: BookMetadata, title: String) { guard let index = books.firstIndex(where: { $0.id == book.id }) else { return @@ -182,7 +190,7 @@ class BookshelfViewModel { let bookURL = try! BookStorage.getBooksDirectory().appendingPathComponent(book.folder) books[index].renamedTitle = title.isEmpty ? nil : title - try? BookStorage.save(books[index], inside: bookURL, as: FileNames.metadata) + try? BookStorage.save(books[index], inside: bookURL, as: FileNames.metadata, sync: .book(book.id)) } func importBook(result: Result) { @@ -305,7 +313,7 @@ class BookshelfViewModel { } } let title = await GoogleDriveHandler.desanitizeTtuFilename(folder.name) - let book = await BookMetadata(title: title, cover: cover, folder: folder.id, lastAccess: .distantPast) + let book = BookMetadata(title: title, cover: cover, folder: folder.id, lastAccess: .distantPast) return (book, files) } } @@ -405,7 +413,7 @@ class BookshelfViewModel { lastModified: Date() ) - try? BookStorage.save(bookmark, inside: url, as: FileNames.bookmark) + try? BookStorage.save(bookmark, inside: url, as: FileNames.bookmark, sync: .book(book.id)) loadBookProgress() } @@ -524,8 +532,25 @@ class BookshelfViewModel { let bookinfo = BookProcessor.process(document: document) - try BookStorage.save(metadata, inside: bookFolder, as: FileNames.metadata) - try BookStorage.save(bookinfo, inside: bookFolder, as: FileNames.bookinfo) + try BookStorage.save(metadata, inside: bookFolder, as: FileNames.metadata, sync: .book(metadata.id)) + try BookStorage.save(bookinfo, inside: bookFolder, as: FileNames.bookinfo, sync: .book(metadata.id)) + + if UserConfig.shared.enableCloudKitSync { + Task.detached { + let folderName = bookFolder.lastPathComponent + if let coverURL { + await CloudKitSyncManager.shared.saveCloudFile( + uuid: metadata.id, + fileType: .cover, + fileName: (coverURL as NSString).lastPathComponent, + folderName: folderName, + ) + } + if let cloudKitEpub = CloudKitBookEpub(from: metadata) { + await CloudKitSyncManager.shared.saveCloudEpub(cloudKitEpub) + } + } + } } catch { try? BookStorage.delete(at: localURL) try? BookStorage.delete(at: bookFolder) diff --git a/Features/Reader/ReaderView/ReaderView.swift b/Features/Reader/ReaderView/ReaderView.swift index 7bba45c9..a4e638d4 100644 --- a/Features/Reader/ReaderView/ReaderView.swift +++ b/Features/Reader/ReaderView/ReaderView.swift @@ -75,6 +75,7 @@ struct ReaderView: View { @Environment(\.dismissReader) private var dismissReader @Environment(\.colorScheme) private var systemColorScheme @Environment(UserConfig.self) private var userConfig + @Environment(\.dismiss) private var dismiss @State private var viewModel: ReaderViewModel @State private var focusMode = false @State private var inactiveSince: Date? @@ -736,6 +737,13 @@ struct ReaderView: View { .task { await viewModel.syncOnOpen() } + .task(id: userConfig.enableCloudKitSync) { + guard userConfig.enableCloudKitSync else { return } + + for await event in await CloudKitSyncManager.shared.events() { + viewModel.handleCloudKitSync(event: event, dismiss: dismiss) + } + } .onChange(of: readerTextColor) { _, hex in viewModel.bridge.send(.updateTextColor(hex)) } .onChange(of: sasayakiTextColor) { _, _ in updateSasayakiColors() } .onChange(of: sasayakiBackgroundColor) { _, _ in updateSasayakiColors() } diff --git a/Features/Reader/ReaderView/ReaderViewModel.swift b/Features/Reader/ReaderView/ReaderViewModel.swift index 63afffab..fccf422a 100644 --- a/Features/Reader/ReaderView/ReaderViewModel.swift +++ b/Features/Reader/ReaderView/ReaderViewModel.swift @@ -69,7 +69,7 @@ class ReaderLoaderViewModel { var bookCopy = self.book bookCopy.lastAccess = Date() - try? BookStorage.save(bookCopy, inside: root, as: FileNames.metadata) + try? BookStorage.save(bookCopy, inside: root, as: FileNames.metadata, sync: .book(book.id)) self.document = doc } @@ -326,6 +326,32 @@ class ReaderViewModel { flushStats() } + func handleCloudKitSync(event: CloudKitSyncManager.Event, dismiss: DismissAction) { + switch event { + case .sent(let uuid, _): + guard uuid == book.id else { return } + isPaused = false + case .fetched(let uuid): + guard uuid == book.id else { return } + highlights = BookStorage.loadHighlights(root: rootURL) ?? [] + syncHighlights() + reloadAfterImport() + loadCurrentChapter() + resetTrackingBaseline() + isPaused = true + case .delete(let deleteEvent): + switch deleteEvent { + case .book(uuid: let uuid): + guard uuid == book.id else { return } + dismiss() + case .zones: + break + } + case .account, .error, .epubDownloaded: + break + } + } + func jumpToCharacter(_ characterCount: Int) { guard let result = bookInfo.resolveCharacterPosition(characterCount) else { return } recordPosition() @@ -575,7 +601,7 @@ class ReaderViewModel { characterCount: currentCharacter, lastModified: Date() ) - try? BookStorage.save(bookmark, inside: rootURL, as: FileNames.bookmark) + try? BookStorage.save(bookmark, inside: rootURL, as: FileNames.bookmark, sync: .book(book.id)) scheduleAutoExport() } @@ -708,7 +734,7 @@ class ReaderViewModel { } stats = Self.deduplicateStatistics(stats) - try? BookStorage.save(stats, inside: rootURL, as: FileNames.statistics) + try? BookStorage.save(stats, inside: rootURL, as: FileNames.statistics, sync: .book(book.id)) scheduleAutoExport() } @@ -738,7 +764,7 @@ class ReaderViewModel { } private func saveHighlights() { - try? BookStorage.save(highlights, inside: rootURL, as: FileNames.highlights) + try? BookStorage.save(highlights, inside: rootURL, as: FileNames.highlights, sync: .book(book.id)) } private func syncHighlights() { diff --git a/Features/Sasayaki/SasayakiPlayer.swift b/Features/Sasayaki/SasayakiPlayer.swift index 8947067e..b5ee1058 100644 --- a/Features/Sasayaki/SasayakiPlayer.swift +++ b/Features/Sasayaki/SasayakiPlayer.swift @@ -93,7 +93,7 @@ class SasayakiPlayer { } } } - var autoScroll: Bool { UserDefaults.standard.object(forKey: "sasayakiAutoScroll") as? Bool ?? true } + var autoScroll: Bool { UserConfig.shared.sasayakiAutoScroll } var currentCue: SasayakiMatch? var pendingCue: SasayakiMatch? @@ -494,7 +494,7 @@ class SasayakiPlayer { private func savePlayback() { playback.delay = delay playback.rate = rate - try? BookStorage.save(playback, inside: rootURL, as: FileNames.sasayakiPlayback) + try? BookStorage.save(playback, inside: rootURL, as: FileNames.sasayakiPlayback, sync: bookMetadata.map { .book($0.id) }) } private func updateCue(for time: Double) { @@ -561,7 +561,7 @@ class SasayakiPlayer { Task { @MainActor in self?.togglePlayback() } return .success } - if UserDefaults.standard.bool(forKey: "sasayakiSkipControls") { + if UserConfig.shared.sasayakiSkipControls { center.skipBackwardCommand.preferredIntervals = [NSNumber(value: skipInterval)] center.skipBackwardCommand.addTarget { [weak self] _ in Task { @MainActor in self?.skip(forward: false) } diff --git a/Features/Settings/AdvancedView.swift b/Features/Settings/AdvancedView.swift index 4bbb6380..ecd1d0cd 100644 --- a/Features/Settings/AdvancedView.swift +++ b/Features/Settings/AdvancedView.swift @@ -38,7 +38,14 @@ struct AdvancedView: View { NavigationLink { SyncView() } label: { - Label("ッツ Sync", systemImage: "cloud") + Label("ッツ Sync", systemImage: "externaldrive.badge.icloud") + } + .foregroundStyle(.primary) + + NavigationLink { + CloudKitSyncView() + } label: { + Label("iCloud Sync", systemImage: "icloud") } .foregroundStyle(.primary) diff --git a/Features/Settings/BackupView.swift b/Features/Settings/BackupView.swift index 1642360d..f2e8ff8e 100644 --- a/Features/Settings/BackupView.swift +++ b/Features/Settings/BackupView.swift @@ -193,11 +193,12 @@ struct BackupView: View { guard let bookdataZip = files.first(where: { $0.lastPathComponent.hasPrefix("bookdata_") && $0.pathExtension == "zip" }) else { continue } let bookFolder = try TtuConverter.convertFromTtu(bookData: bookdataZip, to: booksDirectory) + let bookId = BookStorage.loadMetadata(root: bookFolder)?.id if let statsFile = files.first(where: { $0.lastPathComponent.hasPrefix("statistics_") }) { let statsData = try Data(contentsOf: statsFile) let stats = try JSONDecoder().decode([Statistics].self, from: statsData) - try BookStorage.save(stats, inside: bookFolder, as: FileNames.statistics) + try BookStorage.save(stats, inside: bookFolder, as: FileNames.statistics, sync: bookId.map { .book($0) }) } if let progressFile = files.first(where: { $0.lastPathComponent.hasPrefix("progress_") }) { @@ -213,7 +214,7 @@ struct BackupView: View { characterCount: progress.exploredCharCount, lastModified: progress.lastBookmarkModified ) - try BookStorage.save(bookmark, inside: bookFolder, as: FileNames.bookmark) + try BookStorage.save(bookmark, inside: bookFolder, as: FileNames.bookmark, sync: bookId.map { .book($0) }) } } } diff --git a/Features/Settings/CloudKitSyncView.swift b/Features/Settings/CloudKitSyncView.swift new file mode 100644 index 00000000..806ee9db --- /dev/null +++ b/Features/Settings/CloudKitSyncView.swift @@ -0,0 +1,81 @@ +// +// SyncView.swift +// Hoshi Reader +// +// Copyright © 2026 Manhhao. +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI +import CloudKit + +struct CloudKitSyncView: View { + @Environment(UserConfig.self) var userConfig + @AppStorage("cloudKitStatus") private var cloudKitStatus = CloudKitStatus.none + + @State private var iCloudAvailable = false + + var body: some View { + @Bindable var userConfig = userConfig + List { + Section { + Toggle("Enable", isOn: $userConfig.enableCloudKitSync) + .disabled(!iCloudAvailable) + + HStack { + Text("Status") + + Spacer() + + Group { + if !iCloudAvailable { + if cloudKitStatus != .none { + Text(cloudKitStatus.title) + } else { + Text("Not Available") + } + } else { + if userConfig.enableCloudKitSync { + Text("Signed In") + } else { + Text("Off") + } + } + } + .foregroundStyle(.secondary) + } + } footer: { + if !iCloudAvailable { + if cloudKitStatus != .none { + Text(cloudKitStatus.message) + } else { + Text("Log in to an iCloud account to sync with iCloud.") + } + } else { + if userConfig.enableCloudKitSync { + Text("You have logged in to an iCloud account. You can upload local books to iCloud server") + } else { + Text("Switch on to sync with iCloud") + } + } + } + } + .task { + await iCloudStatusRefresh() + for await event in await CloudKitSyncManager.shared.events() { + guard case .account = event else { continue } + await iCloudStatusRefresh() + } + } + .navigationTitle("iCloud Syncing") + } + + private func iCloudStatusRefresh() async { + do { + let accountStatus = try await CloudKitSyncManager.container.accountStatus() + iCloudAvailable = accountStatus == .available + } catch { + iCloudAvailable = false + } + } +} diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift new file mode 100644 index 00000000..58cac81a --- /dev/null +++ b/Features/Sync/CloudKitSyncManager.swift @@ -0,0 +1,853 @@ +// +// CloudKitSyncManager.swift +// Hoshi Reader +// +// Copyright © 2026 Manhhao. +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import CloudKit +import OSLog + +actor CloudKitSyncManager { + + static let shared: CloudKitSyncManager = .init() + + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "CloudKitSync" + ) + + static var container: CKContainer { CKContainer(identifier: "iCloud.de.manhhao.hoshi") } + + private static let prioritizedZoneIDs = [CKRecordZone.ID(zoneName: CloudKitBookFile.zoneName)] + + nonisolated private var logger: Logger { Self.logger } + + private var eventContinuations: [UUID: AsyncStream.Continuation] = [:] + + private var cloudKitData: CloudKitData { + didSet { + do { + try persistCloudKitData() + } catch { + logger.error("Failed to persist CloudKit state: \(error, privacy: .public)") + } + } + } + + private var _syncEngine: CKSyncEngine? = nil + + private var syncEngine: CKSyncEngine { + if _syncEngine == nil { + initializeSyncEngineWithoutCheck() + } + return _syncEngine! + } + + private init() { + do { + let cloudKitData = try BookStorage.load(CloudKitData.self, from: Self.cloudKitDataURL) + self.cloudKitData = cloudKitData ?? CloudKitData() + } catch { + self.cloudKitData = CloudKitData() + logger.error("Failed to load CloudKit state from storage: \(error)") + } + } + + private func initializeSyncEngineWithoutCheck() { + let configuration = CKSyncEngine.Configuration( + database: Self.container.privateCloudDatabase, + stateSerialization: cloudKitData.stateSerialization, + delegate: self + ) + _syncEngine = CKSyncEngine(configuration) + logger.debug("CKSyncEngine initialized") + } + + func initialize() async { + do { + let allMetadata = try BookStorage.loadAllBooks() + try await resolveUUIDConflicts(books: allMetadata) + initializeSyncEngineWithoutCheck() + try await uploadLocalOnlyData() + } catch { + logger.error("Failed to initialize sync manager: \(error)") + } + } + + func disableSync() { + do { + try persistCloudKitData() + } catch { + logger.error("Failed to persist CloudKit state when disabling CKSyncEngine: \(error, privacy: .public)") + } + _syncEngine = nil + logger.debug("CKSyncEngine disabled") + } +} + +// MARK: - CKSyncEngineDelegate +extension CloudKitSyncManager: CKSyncEngineDelegate { + func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + switch event { + case .stateUpdate(let stateUpdate): + handleStateUpdate(stateUpdate) + case .fetchedRecordZoneChanges(let fetchedRecordZoneChanges): + handleFetchedRecordZoneChanges(fetchedRecordZoneChanges) + case .sentRecordZoneChanges(let sentRecordZoneChanges): + handleSentRecordZoneChanges(sentRecordZoneChanges) + case .accountChange(let accountChange): + handleAccountChange(accountChange) + case .fetchedDatabaseChanges, .sentDatabaseChanges: + break + case .willFetchChanges, .willFetchRecordZoneChanges, .willSendChanges, .didFetchChanges, .didFetchRecordZoneChanges, .didSendChanges: + break + @unknown default: + logger.info("Received unknown CKSyncEngine event: \(event, privacy: .public)") + } + } + + func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? { + let scope = context.options.scope + let pendingRecordZoneChanges = syncEngine.state.pendingRecordZoneChanges + let filteredChanges = pendingRecordZoneChanges.filter { scope.contains($0) } + + let books = self.cloudKitData.books + let shelves = self.cloudKitData.shelves + + return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: filteredChanges) { recordID in + let (uuid, fileType): (UUID, CloudKitFileType) + do { + (uuid, fileType) = try CKRecord.parseRecordName(recordID.recordName) + } catch { + logger.error("Failed to parse record name \(recordID.recordName, privacy: .public): \(error, privacy: .public)") + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil + } + do { + if fileType == .shelves { + return try shelves.makeRecord() + } + guard let cloudFile = books[uuid]?[fileType] else { + logger.log("CloudKit file of uuid \(uuid, privacy: .public) and type \(fileType, privacy: .public) had become stale before sending to iCloud server") + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil + } + let record = try cloudFile.makeRecord() + return record + } catch { + logger.error("Failed to generate CKRecord from file of uuid \(uuid, privacy: .public) and type \(fileType, privacy: .public): \(error, privacy: .public)") + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil + } + } + } + + func nextFetchChangesOptions(_ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.FetchChangesOptions { + var options = context.options + options.scope = .zoneIDs([CloudKitBookFile.zoneID]) + return options + } +} + +// MARK: - Event Handling +private extension CloudKitSyncManager { + private func handleStateUpdate(_ stateUpdate: CKSyncEngine.Event.StateUpdate) { + cloudKitData.stateSerialization = stateUpdate.stateSerialization + } + + private func handleFetchedRecordZoneChanges(_ fetchedRecordZoneChanges: CKSyncEngine.Event.FetchedRecordZoneChanges) { + for modification in fetchedRecordZoneChanges.modifications { + let modifiedRecord = modification.record + let (uuid, fileType): (UUID, CloudKitFileType) + do { + (uuid, fileType) = try CKRecord.parseRecordName(modifiedRecord.recordID.recordName) + } catch { + logger.error("Failed to parse record name \(modifiedRecord.recordID.recordName, privacy: .public): \(error, privacy: .public)") + continue + } + do { + try onFetchedRecord(modifiedRecord) + fire(event: .fetched(uuid: uuid)) + } catch { + logger.error("Failed to merge new record of uuid \(uuid, privacy: .public) and type \(fileType, privacy: .public) when fetching from server: \(error, privacy: .public)") + } + } + + for deletion in fetchedRecordZoneChanges.deletions { + let deletedRecordID = deletion.recordID + let (uuid, fileType): (UUID, CloudKitFileType) + do { + (uuid, fileType) = try CKRecord.parseRecordName(deletedRecordID.recordName) + } catch { + logger.error("Failed to parse record name: \(deletedRecordID.recordName, privacy: .public): \(error, privacy: .public)") + continue + } + do { + if fileType == .metadata { + if let metadata = try cloudKitData.books[uuid]?[fileType]?.decode(to: BookMetadata.self) { + try deleteLocal(books: [metadata]) + } + cloudKitData.books[uuid] = nil + } else { + cloudKitData.books[uuid]?[fileType] = nil + } + fire(event: .delete(.book(uuid: uuid))) + } catch { + logger.error("Failed to delete local file of uuid \(uuid, privacy: .public) and type \(fileType, privacy: .public) when fetching deletion: \(error, privacy: .public)") + } + } + } + + private func handleSentRecordZoneChanges(_ sentRecordZoneChanges: CKSyncEngine.Event.SentRecordZoneChanges) { + + var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] + var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] + + // save + let savedRecords = sentRecordZoneChanges.savedRecords + + for savedRecord in savedRecords { + let (uuid, fileType): (UUID, CloudKitFileType) + do { + (uuid, fileType) = try CKRecord.parseRecordName(savedRecord.recordID.recordName) + } catch { + logger.error("Failed to parse record name: \(savedRecord.recordID.recordName, privacy: .public): \(error, privacy: .public)") + continue + } + if fileType == .shelves { + self.cloudKitData.shelves.setLastKnownRecordIfNewer(savedRecord) + } else { + self.cloudKitData.books[uuid]?[fileType]?.setLastKnownRecordIfNewer(savedRecord) + } + fire(event: .sent(uuid: uuid, success: true)) + } + + for failedRecordSave in sentRecordZoneChanges.failedRecordSaves { + let failedRecord = failedRecordSave.record + let (uuid, fileType): (UUID, CloudKitFileType) + do { + (uuid, fileType) = try CKRecord.parseRecordName(failedRecord.recordID.recordName) + } catch { + logger.error("Failed to parse record name: \(failedRecord.recordID.recordName, privacy: .public): \(error, privacy: .public)") + continue + } + let error = failedRecordSave.error + + switch error.code { + case .serverRecordChanged: + let serverRecord = error.serverRecord + guard let serverRecord else { + logger.error("CloudKit reported a conflict without a server record for \(failedRecord.recordID.recordName, privacy: .public)") + continue + } + do { + try onFetchedRecord(serverRecord) + if fileType == .shelves { + self.cloudKitData.shelves.localModificationDate = .now + } else { + self.cloudKitData.books[uuid]?[fileType]?.localModificationDate = .now + } + pendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + } catch { + logger.error("Failed to merge new record of record name \(serverRecord.recordID.recordName, privacy: .public) when solving merge conflicts: \(error, privacy: .public)") + } + case .zoneNotFound: + let recordZoneID = failedRecord.recordID.zoneID + pendingDatabaseChanges.append(.saveZone(CKRecordZone(zoneID: recordZoneID))) + fallthrough + case .unknownItem: + pendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + if fileType == .shelves { + self.cloudKitData.shelves.lastKnownRecord = nil + } else { + self.cloudKitData.books[uuid]?[fileType]?.lastKnownRecord = nil + } + case .quotaExceeded: + fire(event: .error(.quotaExceeded)) + default: + logger.error("Saving record \(failedRecord.recordID.recordName, privacy: .public) failed with unhandled error: \(error, privacy: .public)") + } + + } + syncEngine.state.add(pendingRecordZoneChanges: pendingRecordZoneChanges) + syncEngine.state.add(pendingDatabaseChanges: pendingDatabaseChanges) + + // why do we need this??? Without it the `CKSyncEngine` will not send pending database changes. Probably bug + if !pendingDatabaseChanges.isEmpty { + Task.detached { + try? await self.syncEngine.sendChanges() + } + } + } + + private func handleAccountChange(_ accountChange: CKSyncEngine.Event.AccountChange) { + switch accountChange.changeType { + case .signIn: + syncEngine.state.add(pendingDatabaseChanges: [ + .saveZone(CKRecordZone(zoneName: CloudKitBookFile.zoneName)), + ]) + fire(event: .account(.signIn)) + case .signOut: + do { + try deleteLocalBooksWithoutEpub() + } catch { + logger.error("Failed to delete books without epub file when logging out") + } + disableSync() + self.cloudKitData = CloudKitData() + fire(event: .account(.signOut)) + case .switchAccounts(previousUser: let previousRecordID, currentUser: let currentRecordID): + guard previousRecordID.recordName != currentRecordID.recordName else { return } + do { + try deleteLocalBooksWithoutEpub() + } catch { + logger.error("Failed to delete books without epub file when switching accounts") + } + self.cloudKitData = CloudKitData() + disableSync() + fire(event: .account(.accountChanged)) + Task { + await initialize() + } + @unknown default: + break + } + } +} + +// MARK: - Fetching Data +extension CloudKitSyncManager { + + enum InternalError: LocalizedError { + case unmanagedFile(UUID, CloudKitFileType) + } + + private func onFetchedRecord(_ record: CKRecord) throws { + let (uuid, fileType) = try CKRecord.parseRecordName(record.recordID.recordName) + if fileType != .shelves, cloudKitData.books[uuid] == nil { + cloudKitData.books[uuid] = [:] + } + + if fileType != .shelves, cloudKitData.books[uuid]![fileType] == nil { + try replaceIfNewer(record: record) + return + } + + switch fileType { + case .shelves: + try mergeShelves(record: record) + case .statistics: + try mergeStats(record: record) + case .highlights: + try mergeHighlights(record: record) + default: + try replaceIfNewer(record: record) + } + } + + private func replaceIfNewer(record: CKRecord) throws { + let (uuid, fileType) = try CKRecord.parseRecordName(record.recordID.recordName) + let localFile = cloudKitData.books[uuid]![fileType] + + var shouldReplace = false + if let localFile { + shouldReplace = try record.localModificationDate > localFile.localModificationDate + } else { + shouldReplace = true + } + + if shouldReplace { + let fileURL = try record.fileURL + let directoryURL = fileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let data: Data + if fileType.isAssetType { + let assetURL = try record.assetURL + data = try Data(contentsOf: assetURL) + } else { + data = try record.contentData + } + try data.write(to: fileURL, options: .atomic) + cloudKitData.books[uuid]![fileType] = try CloudKitBookFile(record: record) + } else { + cloudKitData.books[uuid]![fileType]!.setLastKnownRecordIfNewer(record) + } + } + + private func mergeStats(record: CKRecord) throws { + let remoteStats = try record.mapData(to: [Statistics].self) + let (uuid, fileType) = try CKRecord.parseRecordName(record.recordID.recordName) + let fileURL = try record.fileURL + guard var localFile = cloudKitData.books[uuid]![fileType] else { + throw InternalError.unmanagedFile(uuid, fileType) + } + + let localStats = try localFile.decode(to: [Statistics].self) + let mergedStats = Merger.mergeStatistics(localStatistics: localStats, externalStatistics: remoteStats, syncMode: .merge) + + try BookStorage.saveLocal(mergedStats, url: fileURL) + localFile.setLastKnownRecordIfNewer(record) + cloudKitData.books[uuid]![fileType] = localFile + } + + private func mergeHighlights(record: CKRecord) throws { + let remoteHighlights = try record.mapData(to: [Highlight].self) + let (uuid, fileType) = try CKRecord.parseRecordName(record.recordID.recordName) + let fileURL = try record.fileURL + guard var localFile = cloudKitData.books[uuid]![fileType] else { + throw InternalError.unmanagedFile(uuid, fileType) + } + + let localHighlights = try localFile.decode(to: [Highlight].self) + let ancestorHighlights = try localFile.lastKnownRecord?.mapData(to: [Highlight].self) ?? [] + let mergedStats = Merger.mergeArray( + local: localHighlights, + remote: remoteHighlights, + ancestor: ancestorHighlights, + id: \.id, + isOnlyOrderChanged: { Set($0.values) == Set($1.values) }, + mergeTwoNew: { localHlt, _ in localHlt }, + threeWayMerge: { localHlt, _, _ in localHlt} + ) + + try BookStorage.saveLocal(mergedStats, url: fileURL) + localFile.setLastKnownRecordIfNewer(record) + cloudKitData.books[uuid]![fileType] = localFile + } + + private func mergeShelves(record: CKRecord) throws { + // We need to design the UI so that each operation by User corresponds to one atomic CKSyncEngine modification. But there is a problem: the jobs submitted to the serial executor may be re-ordered. + guard let localShelves = try self.cloudKitData.shelves.decode() else { + let remoteShelves = try record.mapData(to: [BookShelf].self) + let fileURL = try record.fileURL + try BookStorage.saveLocal(remoteShelves, url: fileURL) + self.cloudKitData.shelves.setLastKnownRecordIfNewer(record) + self.cloudKitData.shelves.localModificationDate = try record.localModificationDate + return + } + + let remoteShelves = try record.mapData(to: [BookShelf].self) + let ancestorShelves = try self.cloudKitData.shelves.lastKnownRecord?.mapData(to: [BookShelf].self) ?? [] + var mergedShelves = Merger.mergeArray( + local: localShelves, + remote: remoteShelves, + ancestor: ancestorShelves, + id: \.name, + isOnlyOrderChanged: Merger.shelvesOnlyOrderChanged(local:remote:), + mergeTwoNew: { localShelf, remoteShelf in + BookShelf( + name: localShelf.name, + bookIds: Array(Set(localShelf.bookIds).union(Set(remoteShelf.bookIds))) + ) + }, + threeWayMerge: { localShelf, remoteShelf, ancestorShelf in + BookShelf( + name: localShelf.name, + bookIds: Merger.mergeBookIds( + local: localShelf.bookIds, + remote: remoteShelf.bookIds, + ancestor: ancestorShelf.bookIds + ) + ) + } + ) + + // deduplicate: after three way merging, there may be cases where one book are placed under two shelves + let allBookIds = self.cloudKitData.books.keys + for bookId in allBookIds { + let localContainingShelf = localShelves.first(where: {$0.bookIds.contains(bookId)}) + let remoteContainingShelf = remoteShelves.first(where: {$0.bookIds.contains(bookId)}) + guard let localContainingShelf, + let remoteContainingShelf, + localContainingShelf.name != remoteContainingShelf.name else { + continue + } + let remoteModificationDate = try record.localModificationDate + let losingShelfName = self.cloudKitData.shelves.localModificationDate > remoteModificationDate + ? remoteContainingShelf.name + : localContainingShelf.name + if let losingShelfIndex = mergedShelves.firstIndex(where: { $0.name == losingShelfName }) { + mergedShelves[losingShelfIndex].bookIds.removeAll(where: { $0 == bookId }) + } + } + + let shouldSaveMergedShelves = !Merger.shelvesAreEquivalent(mergedShelves, remoteShelves) + let fileURL = try record.fileURL + try BookStorage.saveLocal(mergedShelves, url: fileURL) + + self.cloudKitData.shelves.setLastKnownRecordIfNewer(record) + if shouldSaveMergedShelves { + self.cloudKitData.shelves.localModificationDate = .now + syncEngine.state.add(pendingRecordZoneChanges: [ + .saveRecord(self.cloudKitData.shelves.recordID) + ]) + } else { + self.cloudKitData.shelves.localModificationDate = try record.localModificationDate + } + } +} + +// MARK: - Sending Data +extension CloudKitSyncManager { + func saveCloudFile(uuid: UUID, fileType: CloudKitFileType, fileName: String, folderName: String) { + if self.cloudKitData.books[uuid] == nil { + self.cloudKitData.books[uuid] = [:] + } + if let cloudFile = self.cloudKitData.books[uuid]?[fileType] { + self.cloudKitData.books[uuid]?[fileType]!.localModificationDate = .now + syncEngine.state.add(pendingRecordZoneChanges: [ + .saveRecord(cloudFile.recordID) + ]) + } else { + let newCloudKitBookFile = CloudKitBookFile(uuid: uuid, type: fileType, fileName: fileName, folderName: folderName, lastKnownRecordData: nil, localModificationDate: .now) + self.cloudKitData.books[uuid]![fileType] = newCloudKitBookFile + syncEngine.state.add(pendingRecordZoneChanges: [ + .saveRecord(newCloudKitBookFile.recordID) + ]) + } + } + + /// delete a book stored in iCloud server. If this book is not synced, no-op + func deleteCloudBook(_ book: BookMetadata) { + guard let files = self.cloudKitData.books[book.id] else { return } + self.cloudKitData.books[book.id] = nil + syncEngine.state.add(pendingRecordZoneChanges: files.map({ (_, cloudKitBookFile) in + .deleteRecord(cloudKitBookFile.recordID) + })) + if let epub = CloudKitBookEpub(from: book) { + Task { + await deleteCloudEpub(epub) + } + } + } + + func saveCloudShelves() { + self.cloudKitData.shelves.localModificationDate = .now + syncEngine.state.add(pendingRecordZoneChanges: [ + .saveRecord( + self.cloudKitData.shelves.recordID + ) + ]) + } +} + +// MARK: - Epub +// Epubs are managed manually instead of `CKSyncEngine` +// Be careful with reentrancy. There may be unexpected behaviors for unexpected user behaviors. +extension CloudKitSyncManager { + + private func fetchRecords(folderName: String, fileType: CloudKitFileType, fullDownload: Bool = false) async throws -> [CKRecord] { + let predicate = NSPredicate(format: "folderName == %@", argumentArray: [folderName]) + let query = CKQuery(recordType: fileType.rawValue, predicate: predicate) + let database = Self.container.privateCloudDatabase + let (matchResults, _) = try await database.records( + matching: query, + inZoneWith: fileType == .book ? CloudKitBookEpub.zoneID : CloudKitBookFile.zoneID, + desiredKeys: (fileType == .book && fullDownload) ? nil : [] + ) + var records = [CKRecord]() + for (_, recordResult) in matchResults { + switch recordResult { + case .success(let record): + records.append(record) + case .failure(let error): + Self.logger.error("Failed to fetch records of folder name \(folderName, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + return records + } + + private func checkCloudShelvesExist() async throws -> Bool { + let query = CKQuery(recordType: self.cloudKitData.shelves.recordType, predicate: NSPredicate(value: true)) + let database = Self.container.privateCloudDatabase + let (matchResults, _) = try await database.records( + matching: query, + inZoneWith: CloudKitBookShelves.zoneID, + desiredKeys: [] + ) + var records = [CKRecord]() + for (_, recordResult) in matchResults { + switch recordResult { + case .success(let record): + records.append(record) + case .failure(let error): + Self.logger.error("Failed to fetch records of bookshelves: \(error)") + } + } + return !records.isEmpty + } + + private func resolveUUIDConflicts(books: [BookMetadata]) async throws { + let collisions = try await withThrowingTaskGroup { group in + for book in books { + group.addTask { + let records = try await self.fetchRecords(folderName: book.folder, fileType: .metadata) + return (book, records) + } + } + + var collisions = [BookMetadata: UUID]() + for try await (book, records) in group { + if records.isEmpty { continue } + let firstRecord = records.first! + do { + let (remoteUUID, _) = try CKRecord.parseRecordName(firstRecord.recordID.recordName) + let localUUID = book.id + if localUUID == remoteUUID { continue } + collisions[book] = remoteUUID + } catch { + Self.logger.error("Failed to parse record name \(firstRecord.recordID.recordName, privacy: .public)") + continue + } + } + return collisions + } + + await withTaskGroup { group in + for (metadata, newUUID) in collisions { + group.addTask { + let newMetaData = BookMetadata( + id: newUUID, + title: metadata.title, + epub: metadata.epub, + cover: metadata.cover, + folder: metadata.folder, + lastAccess: metadata.lastAccess + ) + do { + let booksDir = try BookStorage.getBooksDirectory() + let metadataURL = booksDir.appending(path: metadata.folder).appending(path: FileNames.metadata) + try BookStorage.saveLocal(newMetaData, url: metadataURL) + } catch { + Self.logger.log("Failed to save book metadata of uuid: \(newUUID, privacy: .public)") + } + } + } + } + + if !collisions.isEmpty, var shelves = BookStorage.loadShelves() { + for (metadata, newUUID) in collisions { + for i in shelves.indices { + if let index = shelves[i].bookIds.firstIndex(of: metadata.id) { + shelves[i].bookIds[index] = newUUID + } + } + } + do { + let shelvesURL = try BookStorage.getBooksDirectory().appending(path: FileNames.shelves) + try BookStorage.saveLocal(shelves, url: shelvesURL) + } catch { + Self.logger.error("Failed to update uuids in shelves: \(error, privacy: .public)") + } + } + } + + func saveCloudEpub(_ epub: CloudKitBookEpub) async { + let database = Self.container.privateCloudDatabase + do { + let serverRecords = try await fetchRecords(folderName: epub.folderName, fileType: .book) + if !serverRecords.isEmpty { return } + let newRecord = try epub.makeNewRecord() + try await database.save(newRecord) + } catch let error as CKError where error.code == .zoneNotFound { + _ = try? await database.save(CKRecordZone(zoneName: CloudKitBookEpub.zoneName)) + await saveCloudEpub(epub) + } catch { + logger.error("Failed to save epub file of book \(epub.uuid, privacy: .public): \(error.localizedDescription)") + } + } + + func downloadCloudEpub(_ epub: CloudKitBookEpub) async { + do { + if FileManager.default.fileExists(atPath: try epub.fileURL.path(percentEncoded: false)) { return } + let records = try await fetchRecords(folderName: epub.folderName, fileType: .book, fullDownload: true) + guard let record = records.first else { return } + let assetURL = try record.assetURL + let data = try Data(contentsOf: assetURL) + let fileURL = try epub.fileURL + try data.write(to: fileURL, options: .atomic) + fire(event: .epubDownloaded(uuid: epub.uuid)) + logger.log("Saved epub file \(epub.uuid, privacy: .public) to \(epub.folderName, privacy: .public)/\(epub.fileName, privacy: .public)") + } catch { + logger.log("Failed to download epub file \(epub.uuid, privacy: .public) to \(epub.folderName, privacy: .public)/\(epub.fileName, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + private func deleteCloudEpub(_ epub: CloudKitBookEpub) async { + let records: [CKRecord] + do { + records = try await fetchRecords(folderName: epub.folderName, fileType: .book) + } catch { + Self.logger.error("Failed to fetch epub of book \(epub.uuid, privacy: .public): \(error)") + return + } + let database = Self.container.privateCloudDatabase + await withTaskGroup { group in + for record in records { + group.addTask { + do { + try await database.deleteRecord(withID: record.recordID) + } catch { + Self.logger.error("Failed to delete epub \(record.recordID.recordName, privacy: .public) on iCloud server: \(error)") + } + } + } + } + } + + private func uploadLocalOnlyData() async throws { + let localBooks = try BookStorage.loadAllBooks() + let localBooksMap = Dictionary(localBooks.map({ ($0.id, $0) })) { old, new in + new + } + let localOnlyBooks = try await withThrowingTaskGroup { group in + for (uuid, metadata) in localBooksMap { + group.addTask { + return (uuid, try await self.fetchRecords(folderName: metadata.folder, fileType: .metadata)) + } + } + + var localOnlyBooks = [BookMetadata]() + for try await (uuid, records) in group { + if records.isEmpty { + localOnlyBooks.append(localBooksMap[uuid]!) + } + } + + return localOnlyBooks + } + let booksRootDir = try BookStorage.getBooksDirectory() + for book in localOnlyBooks { + let bookDir = booksRootDir.appending(path: book.folder) + let bookFileURLs: [URL] + do { + bookFileURLs = try FileManager.default.contentsOfDirectory(at: bookDir, includingPropertiesForKeys: nil) + } catch { + logger.error("Failed to get directory contents of book \(book.id, privacy: .public): \(error)") + continue + } + for fileName in bookFileURLs.map({ $0.lastPathComponent }) { + guard let fileType = CloudKitFileType(fileName: fileName) else { continue } + saveCloudFile( + uuid: book.id, + fileType: fileType, + fileName: fileName, + folderName: book.folder, + ) + } + if let coverName = (book.cover as? NSString)?.lastPathComponent { + saveCloudFile( + uuid: book.id, + fileType: .cover, + fileName: coverName, + folderName: book.folder, + ) + } + if let epub = CloudKitBookEpub(from: book) { + Task { + await saveCloudEpub(epub) + } + } + } + if try await !checkCloudShelvesExist() && self.cloudKitData.shelves.data != nil { + saveCloudShelves() + } + } +} + +// MARK: - Local Data processing +extension CloudKitSyncManager { + + private static let cloudKitDataFileName = "cloudkit.json" + + private static var cloudKitDataURL: URL { + get throws { + let cloudKitDirURl = try BookStorage.getCloudKitSyncDirectory() + let cloudKitDataURL = cloudKitDirURl.appending(path: Self.cloudKitDataFileName) + return cloudKitDataURL + } + } + + private func persistCloudKitData() throws { + let cloudKitDirURl = try BookStorage.getCloudKitSyncDirectory() + try? FileManager.default.createDirectory(at: cloudKitDirURl, withIntermediateDirectories: true) + let fileURL = cloudKitDirURl.appending(path: Self.cloudKitDataFileName) + try BookStorage.saveLocal(cloudKitData, url: fileURL) + } + + func deleteLocal(books: [BookMetadata]) throws { + let bookRootDir = try BookStorage.getBooksDirectory() + for book in books { + let bookDir = bookRootDir.appending(path: book.folder) + try BookStorage.delete(at: bookDir) + } + guard var shelves = BookStorage.loadShelves() else { return } + for book in books { + for i in shelves.indices { + shelves[i].bookIds.removeAll { $0 == book.id } + } + } + try BookStorage.saveLocal(shelves, url: bookRootDir.appending(path: FileNames.shelves)) + } + + func deleteLocalBooksWithoutEpub() throws { + let books = try BookStorage.loadAllBooks() + let booksWithoutEpub = try books.filter { book in + guard let fileName = book.epub else { return true } + let booksDir = try BookStorage.getBooksDirectory() + let epubURL = booksDir.appending(path: book.folder).appending(path: fileName) + return !FileManager.default.fileExists(atPath: epubURL.path(percentEncoded: false)) + } + try deleteLocal(books: booksWithoutEpub) + } +} + +// MARK: - callbacks +extension CloudKitSyncManager { + nonisolated enum Event: Sendable { + enum AccountEvent { + case signIn + case signOut + case accountChanged + } + + enum DeleteEvent { + case book(uuid: UUID) + case zones + } + + enum SyncError: LocalizedError { + case quotaExceeded + } + + case fetched(uuid: UUID) + case sent(uuid: UUID, success: Bool) + case epubDownloaded(uuid: UUID) + case delete(DeleteEvent) + case account(AccountEvent) + case error(SyncError) + } + + func events() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Event.self) + let id = UUID() + eventContinuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.removeContinuation(id) } + } + return stream + } + + private func removeContinuation(_ id: UUID) { + eventContinuations[id] = nil + } + + private func fire(event: CloudKitSyncManager.Event) { + for continuation in eventContinuations.values { + continuation.yield(event) + } + } +} diff --git a/Features/Sync/SyncManager.swift b/Features/Sync/SyncManager.swift index 3849fd03..2c5d0a68 100644 --- a/Features/Sync/SyncManager.swift +++ b/Features/Sync/SyncManager.swift @@ -114,27 +114,28 @@ class SyncManager { switch syncDirection { case .importFromTtu: guard let ttuProgress else { return .skipped } - importProgress(ttuProgress: ttuProgress, to: url) + importProgress(ttuProgress: ttuProgress, to: url, bookId: book.id) if syncStats { - let mergedStats = mergeStatistics(localStatistics: localStats ?? [], externalStatistics: ttuStats ?? [], syncMode: statsSyncMode) + let mergedStats = Merger.mergeStatistics(localStatistics: localStats ?? [], externalStatistics: ttuStats ?? [], syncMode: statsSyncMode) if !mergedStats.isEmpty { - try? BookStorage.save(mergedStats, inside: url, as: FileNames.statistics) + try? BookStorage.save(mergedStats, inside: url, as: FileNames.statistics, sync: .book(book.id)) } } if syncAudioBook, let ttuAudioBook { - importAudioBook(ttuAudioBook: ttuAudioBook, to: url) + importAudioBook(ttuAudioBook: ttuAudioBook, to: url, bookId: book.id) } return .imported(title: book.displayTitle, characterCount: ttuProgress.exploredCharCount) case .exportToTtu: guard let localBookmark else { return .skipped } - let statsToExport: [Statistics]? = syncStats ? mergeStatistics(localStatistics: ttuStats ?? [], externalStatistics: localStats ?? [], syncMode: statsSyncMode) : nil + let statsToExport: [Statistics]? = syncStats ? Merger.mergeStatistics(localStatistics: ttuStats ?? [], externalStatistics: localStats ?? [], syncMode: statsSyncMode) : nil async let exportedProgress: Void = exportProgress( localBookmark: localBookmark, ttuProgress: ttuProgress, folderId: driveFolderId, fileId: progressFileId, - url: url + url: url, + bookId: book.id ) async let exportedStats: Void = exportStats( stats: statsToExport, @@ -181,14 +182,15 @@ class SyncManager { let booksDir = try BookStorage.getBooksDirectory() let bookFolder = try TtuConverter.convertFromTtu(bookData: tempURL, to: booksDir) + let bookId = BookStorage.loadMetadata(root: bookFolder)?.id if let progress = try await ttuProgress { - importProgress(ttuProgress: progress, to: bookFolder) + importProgress(ttuProgress: progress, to: bookFolder, bookId: bookId) } if let stats = try await ttuStats, !stats.isEmpty { - try BookStorage.save(stats, inside: bookFolder, as: FileNames.statistics) + try BookStorage.save(stats, inside: bookFolder, as: FileNames.statistics, sync: bookId.map { .book($0) }) } if let audioBook = try await ttuAudioBook { - importAudioBook(ttuAudioBook: audioBook, to: bookFolder) + importAudioBook(ttuAudioBook: audioBook, to: bookFolder, bookId: bookId) } return bookFolder } @@ -218,7 +220,7 @@ class SyncManager { return Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0) } - private func importProgress(ttuProgress: TtuProgress, to url: URL) { + private func importProgress(ttuProgress: TtuProgress, to url: URL, bookId: UUID?) { guard let bookInfo = BookStorage.loadBookInfo(root: url) else { return } let resolved = bookInfo.resolveCharacterPosition(ttuProgress.exploredCharCount) @@ -230,7 +232,7 @@ class SyncManager { lastModified: ttuProgress.lastBookmarkModified ) - try? BookStorage.save(bookmark, inside: url, as: FileNames.bookmark) + try? BookStorage.save(bookmark, inside: url, as: FileNames.bookmark, sync: bookId.map { .book($0) }) } private func fetchProgress(fileId: String?) async throws -> TtuProgress? { @@ -261,7 +263,7 @@ class SyncManager { ) } - private func exportProgress(localBookmark: Bookmark, ttuProgress: TtuProgress?, folderId: String, fileId: String?, url: URL) async throws { + private func exportProgress(localBookmark: Bookmark, ttuProgress: TtuProgress?, folderId: String, fileId: String?, url: URL, bookId: UUID?) async throws { guard let bookInfo = BookStorage.loadBookInfo(root: url), let lastModified = localBookmark.lastModified else { return } @@ -287,7 +289,7 @@ class SyncManager { characterCount: localBookmark.characterCount, lastModified: roundedDate ) - try? BookStorage.save(bookmark, inside: url, as: FileNames.bookmark) + try? BookStorage.save(bookmark, inside: url, as: FileNames.bookmark, sync: bookId.map { .book($0) }) } private func exportStats(stats: [Statistics]?, folderId: String, fileId: String?) async throws { @@ -295,34 +297,10 @@ class SyncManager { try await GoogleDriveHandler.shared.updateStatsFile(folderId: folderId, fileId: fileId, stats: stats) } - private func mergeStatistics(localStatistics: [Statistics], externalStatistics: [Statistics], syncMode: StatisticsSyncMode) -> [Statistics] { - if syncMode == .replace { - return externalStatistics - } - - var grouped: [String: Statistics] = [:] - - for stat in localStatistics { - grouped[stat.dateKey] = stat - } - - for stat in externalStatistics { - if let existing = grouped[stat.dateKey] { - if stat.lastStatisticModified > existing.lastStatisticModified { - grouped[stat.dateKey] = stat - } - } else { - grouped[stat.dateKey] = stat - } - } - - return Array(grouped.values) - } - - private func importAudioBook(ttuAudioBook: TtuAudioBook, to url: URL) { + private func importAudioBook(ttuAudioBook: TtuAudioBook, to url: URL, bookId: UUID?) { var playback = BookStorage.loadSasayakiPlayback(root: url) ?? SasayakiPlaybackData(lastPosition: 0) playback.lastPosition = ttuAudioBook.playbackPosition - try? BookStorage.save(playback, inside: url, as: FileNames.sasayakiPlayback) + try? BookStorage.save(playback, inside: url, as: FileNames.sasayakiPlayback, sync: bookId.map { .book($0) }) } private func exportAudioBook(title: String, playbackData: SasayakiPlaybackData?, folderId: String, fileId: String?) async throws { diff --git a/Hoshi Reader.entitlements b/Hoshi Reader.entitlements new file mode 100644 index 00000000..90f7103f --- /dev/null +++ b/Hoshi Reader.entitlements @@ -0,0 +1,16 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.de.manhhao.hoshi + + com.apple.developer.icloud-services + + CloudKit + + + diff --git a/Hoshi Reader.xcodeproj/project.pbxproj b/Hoshi Reader.xcodeproj/project.pbxproj index b558fb4b..2f7d7991 100644 --- a/Hoshi Reader.xcodeproj/project.pbxproj +++ b/Hoshi Reader.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 48568A472F1E7F5C00F9C126 /* HoshiIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = HoshiIcon.icon; sourceTree = ""; }; 48C8125E2F5F47C000127630 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 48D95BC52F1BF4D80074AD70 /* Hoshi Reader.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Hoshi Reader.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 48E033D82FE403D800E448A3 /* Hoshi Reader.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Hoshi Reader.entitlements"; sourceTree = ""; }; 48FE8D462F76AB88002C2C49 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 90F2F2642FBCA7C000FEABB6 /* Dictionaries.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Dictionaries.xcstrings; sourceTree = ""; }; 90F2F2652FBCA7C000FEABB6 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -79,6 +80,7 @@ membershipExceptions = ( Anki.swift, Book.swift, + CloudKitFile.swift, Dictionary.swift, Highlight.swift, Sasayaki.swift, @@ -93,6 +95,7 @@ CoverImage.swift, CSSSanitizer.swift, Extensions.swift, + Merger.swift, TtuConverter.swift, ); target = 48107AB62F114021009E12D1 /* Hoshi Reader */; @@ -179,6 +182,7 @@ 48107AAE2F114021009E12D1 = { isa = PBXGroup; children = ( + 48E033D82FE403D800E448A3 /* Hoshi Reader.entitlements */, 90F2F2642FBCA7C000FEABB6 /* Dictionaries.xcstrings */, 90F2F2652FBCA7C000FEABB6 /* Localizable.xcstrings */, 48568A622F1E926300F9C126 /* App */, @@ -487,6 +491,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = HoshiIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = "Hoshi Reader.entitlements"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = GPLBDQ2MBT; ENABLE_PREVIEWS = YES; @@ -528,6 +533,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = HoshiIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = "Hoshi Reader.entitlements"; CODE_SIGN_STYLE = Automatic; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/HoshiReader-Info.plist b/HoshiReader-Info.plist index 833dcae5..490b3f84 100644 --- a/HoshiReader-Info.plist +++ b/HoshiReader-Info.plist @@ -60,6 +60,7 @@ UIBackgroundModes audio + remote-notification UIFileSharingEnabled diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 4056831d..6a54f303 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -11,6 +11,9 @@ } } } + }, + " " : { + }, "**[More...](https://github.com/Manhhao/Hoshi-Reader/blob/develop/TTUSYNC.md)**" : { "localizations" : { @@ -625,6 +628,18 @@ } } } + }, + "Clear iCloud data" : { + + }, + "Clear iCloud data?" : { + + }, + "Clear local books?" : { + + }, + "Clear Unsynced Local Books" : { + }, "Client ID" : { "comment" : "Context: Settings > Syncing. Surrounding UI explains Google Drive setup, then shows sync direction, auto-sync, Client ID, status, sign-out, and connect controls. Preserve product names and Markdown.", @@ -1147,6 +1162,15 @@ } } } + }, + "iCloud Sync" : { + + }, + "iCloud Syncing" : { + + }, + "iCloud syncing has been disabled because you have run out of iCloud space. Please free up space or upgrade your storage." : { + }, "Import" : { "comment" : "Context: Shared Hoshi Reader label or action. Surrounding UI may be a settings row, alert button, toolbar item, or reader context menu.", @@ -1407,6 +1431,9 @@ } } } + }, + "Log in to an iCloud account to sync with iCloud." : { + }, "Long press a book and choose **Match**." : { "comment" : "Context: Sasayaki audiobook feature screens. Surrounding UI includes setup instructions, SRT/audio matching, playback controls, command center options, and Sasayaki theme settings. Preserve Markdown and product names.", @@ -1621,6 +1648,9 @@ } } } + }, + "Not Available" : { + }, "Not connected" : { "comment" : "Context: Shared Hoshi Reader label or action. Surrounding UI may be a settings row, alert button, toolbar item, or reader context menu.", @@ -1760,6 +1790,9 @@ } } } + }, + "Quota Exceeded" : { + }, "Reading" : { "localizations" : { @@ -2131,6 +2164,12 @@ } } } + }, + "Signed In" : { + + }, + "Signed Out" : { + }, "Signing out will clear authorization tokens, cached folder ids and book covers." : { "localizations" : { @@ -2282,6 +2321,9 @@ } } } + }, + "Switch on to sync with iCloud" : { + }, "Sync" : { "comment" : "Context: Settings > Syncing. Surrounding UI explains Google Drive setup, then shows sync direction, auto-sync, Client ID, status, sign-out, and connect controls. Preserve product names and Markdown.", @@ -2345,6 +2387,9 @@ } } } + }, + "Sync to iCloud" : { + }, "Syncing" : { "comment" : "Context: Settings > Syncing. Surrounding UI explains Google Drive setup, then shows sync direction, auto-sync, Client ID, status, sign-out, and connect controls. Preserve product names and Markdown.", @@ -2411,6 +2456,12 @@ } } } + }, + "This will clear all data of local only books" : { + + }, + "This will clear all data on iCloud server." : { + }, "This will clear cached folder ids and book covers." : { "localizations" : { @@ -2421,6 +2472,9 @@ } } } + }, + "This will upload local only books to iCloud server." : { + }, "Time to finish Book:" : { "comment" : "Context: Reader, chapters, highlights, and reading statistics screens. Surrounding UI includes jump-to-character alerts, progress rows, and reading metric labels.", @@ -2510,6 +2564,12 @@ } } } + }, + "Upload local books?" : { + + }, + "Upload Unsynced Local Books" : { + }, "Uploads books on first sync if no bookdata is stored on Google Drive." : { "localizations" : { @@ -2597,6 +2657,12 @@ } } } + }, + "You have logged in to an iCloud account. You can upload local books to iCloud server" : { + + }, + "You have logged out of iCloud account." : { + }, "ッツ Backup" : { "localizations" : { diff --git a/Models/Book.swift b/Models/Book.swift index 7537c1ec..a3c5eb55 100644 --- a/Models/Book.swift +++ b/Models/Book.swift @@ -41,14 +41,14 @@ nonisolated struct BookMetadata: Codable, Identifiable, Hashable { } } -struct Bookmark: Codable { +nonisolated struct Bookmark: Codable { let chapterIndex: Int let progress: Double let characterCount: Int var lastModified: Date? } -struct BookInfo: Codable { +nonisolated struct BookInfo: Codable { let characterCount: Int let chapterInfo: [String: ChapterInfo] @@ -75,7 +75,7 @@ struct BookInfo: Codable { } } -struct BookShelf: Codable { +nonisolated struct BookShelf: Codable, Equatable { let name: String var bookIds: [UUID] } diff --git a/Models/CloudKitFile.swift b/Models/CloudKitFile.swift new file mode 100644 index 00000000..c21ffad0 --- /dev/null +++ b/Models/CloudKitFile.swift @@ -0,0 +1,469 @@ +// +// CloudKitFile.swift +// Hoshi Reader +// +// Copyright © 2026 Manhhao. +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import CloudKit +import OSLog + +nonisolated struct CloudKitData: Codable { + var books: [UUID: [CloudKitFileType: CloudKitBookFile]] = [:] + var shelves: CloudKitBookShelves = .init(localModificationDate: .distantPast) + var stateSerialization: CKSyncEngine.State.Serialization? +} + +// MARK: - FileType +nonisolated enum CloudKitFileType: String, Codable, CustomStringConvertible { + case metadata = "metadata" + case bookmark = "bookmark" + case bookinfo = "bookinfo" + case shelves = "shelves" + case statistics = "statistics" + case sasayakiPlayback = "sasayaki_playback" + case highlights = "highlights" + case cover = "cover" + case book = "book" + + var description: String { self.rawValue } + + var isAssetType: Bool { self == .cover || self == .book } + + init?(fileName: String) { + switch fileName { + case FileNames.bookinfo: + self = .bookinfo + case FileNames.bookmark: + self = .bookmark + case FileNames.highlights: + self = .highlights + case FileNames.metadata: + self = .metadata + case FileNames.sasayakiPlayback: + self = .sasayakiPlayback + case FileNames.shelves: + self = .shelves + case FileNames.statistics: + self = .statistics + default: + return nil + } + } + + var fileName: String? { + switch self { + case .metadata: + FileNames.metadata + case .bookmark: + FileNames.bookmark + case .bookinfo: + FileNames.bookinfo + case .shelves: + FileNames.shelves + case .statistics: + FileNames.statistics + case .sasayakiPlayback: + FileNames.sasayakiPlayback + case .highlights: + FileNames.highlights + case .cover: + nil + case .book: + nil + } + } +} + +// MARK: - File Protocol +nonisolated protocol CloudKitFile { + + var localModificationDate: Date { get set } + var lastKnownRecordData: Data? { get set } + var lastKnownRecord: CKRecord? { get set } + + static var zoneName: String { get } + static var zoneID: CKRecordZone.ID { get } + var recordType: CKRecord.RecordType { get } + var recordName: String { get } + var recordID: CKRecord.ID { get } + + var type: CloudKitFileType { get } + func populateFields(record: CKRecord) throws +} + +nonisolated extension CloudKitFile { + + static var zoneName: String { "HoshiBooks" } + static var zoneID: CKRecordZone.ID { CKRecordZone.ID(zoneName: Self.zoneName) } + + var recordType: CKRecord.RecordType { type.rawValue } + var recordID: CKRecord.ID { .init(recordName: recordName, zoneID: Self.zoneID) } + + @discardableResult + mutating func setLastKnownRecordIfNewer(_ otherRecord: CKRecord) -> Bool { + if let localDate = self.lastKnownRecord?.modificationDate { + if let otherDate = otherRecord.modificationDate, + localDate < otherDate { + self.lastKnownRecord = otherRecord + return true + } + return false + } else { + self.lastKnownRecord = otherRecord + return true + } + } + + var lastKnownRecord: CKRecord? { + get { + if let data = self.lastKnownRecordData { + do { + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + return CKRecord(coder: unarchiver) + } catch { + CloudKitSyncManager.logger.error("Failed to decode last known CKRecord") + return nil + } + } else { + return nil + } + } + + set { + if let newValue { + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + newValue.encode(with: archiver) + self.lastKnownRecordData = archiver.encodedData + } else { + self.lastKnownRecordData = nil + } + } + } + + mutating func resetLastKnownRecord() { lastKnownRecord = nil} + + func makeRecord() throws -> CKRecord { + if let lastKnownRecordData { + let decoder = try NSKeyedUnarchiver(forReadingFrom: lastKnownRecordData) + if let record = CKRecord(coder: decoder) { + try populateFields(record: record) + return record + } + } + let record = CKRecord(recordType: recordType, recordID: recordID) + try populateFields(record: record) + return record + } +} + +// MARK: - Book files +nonisolated struct CloudKitBookFile: Codable { + let uuid: UUID + let type: CloudKitFileType + var fileName: String + var folderName: String + var lastKnownRecordData: Data? + var localModificationDate: Date + + init(uuid: UUID, type: CloudKitFileType, fileName: String, folderName: String, lastKnownRecordData: Data? = nil, localModificationDate: Date) { + self.uuid = uuid + self.type = type + self.fileName = fileName + self.folderName = folderName + self.lastKnownRecordData = lastKnownRecordData + self.localModificationDate = localModificationDate + } + + init(record: CKRecord) throws { + let (uuid, fileType) = try CKRecord.parseRecordName(record.recordID.recordName) + let fileName = try record.fileName + let folderName = try record.folderName + var newFile = CloudKitBookFile( + uuid: uuid, + type: fileType, + fileName: fileName, + folderName: folderName, + lastKnownRecordData: nil, + localModificationDate: .distantPast + ) + newFile.setLastKnownRecordIfNewer(record) + newFile.localModificationDate = try record.localModificationDate + self = newFile + } +} + +nonisolated extension CloudKitBookFile: CloudKitFile { + + var recordName: String { "\(uuid.uuidString)___\(type.rawValue)" } + + var fileURL: URL { + get throws { + let booksDir = try BookStorage.getBooksDirectory() + return booksDir.appending(path: folderName).appending(path: fileName) + } + } + + var data: Data { + get throws { + return try Data(contentsOf: fileURL) + } + } + + func decode(to type: T.Type) throws -> T { + try JSONDecoder().decode(type, from: data) + } + + func populateFields(record: CKRecord) throws { + record[.fileName] = fileName + record[.folderName] = folderName + if type.isAssetType { + record[.asset] = CKAsset(fileURL: try fileURL) + } else { + record[.contentData] = try data + } + record[.localModificationDate] = localModificationDate + } +} + +// MARK: - EPUB +nonisolated struct CloudKitBookEpub { + static let type: CloudKitFileType = .book + static let recordType: CKRecord.RecordType = type.rawValue + static let zoneName = "HoshiBookEpubs" + static let zoneID = CKRecordZone.ID(zoneName: zoneName) + + let uuid: UUID + let fileName: String + let folderName: String + + init(uuid: UUID, fileName: String, folderName: String) { + self.uuid = uuid + self.fileName = fileName + self.folderName = folderName + } + + init?(from metadata: BookMetadata) { + guard let fileName = metadata.epub else { + return nil + } + self.uuid = metadata.id + self.fileName = fileName + self.folderName = metadata.folder + } + + var recordName: String { "\(uuid.uuidString)___\(Self.type.rawValue)" } + var recordID: CKRecord.ID { .init(recordName: recordName, zoneID: Self.zoneID) } + var fileURL: URL { + get throws { + let booksDir = try BookStorage.getBooksDirectory() + return booksDir.appending(path: folderName).appending(path: fileName) + } + } + + func makeNewRecord() throws -> CKRecord { + let newRecord = CKRecord(recordType: CloudKitBookEpub.recordType, recordID: recordID) + newRecord[.asset] = CKAsset(fileURL: try fileURL) + newRecord[.fileName] = fileName + newRecord[.folderName] = folderName + return newRecord + } +} + +// MARK: - Bookshelves +nonisolated struct CloudKitBookShelves: Codable, CloudKitFile { + + /// This is necessary when the device is offline, in which case, the metadata in `CKRecord` cannot be updated + /// because it will be only updated after `sentRecordZoneChanges` + var localModificationDate: Date = .distantPast + var lastKnownRecordData: Data? = nil + + init(localModificationDate: Date, lastKnownRecordData: Data? = nil) { + self.localModificationDate = localModificationDate + self.lastKnownRecordData = lastKnownRecordData + } + + init(record: CKRecord) throws { + var newShelves = CloudKitBookShelves(localModificationDate: .distantPast, lastKnownRecordData: nil) + newShelves.setLastKnownRecordIfNewer(record) + newShelves.localModificationDate = try record.localModificationDate + self = newShelves + } + + func populateFields(record: CKRecord) throws { + if let data = try data { + record[.contentData] = data + } + record[.localModificationDate] = localModificationDate + } + + var fileURL: URL { + get throws { + let booksDir = try BookStorage.getBooksDirectory() + return booksDir.appending(path: FileNames.shelves) + } + } + + var data: Data? { + get throws { + let fileURL = try fileURL + if !FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) { + return nil + } + return try Data(contentsOf: fileURL) + } + } + + func decode() throws -> [BookShelf]? { + guard let data = try self.data else { + return nil + } + return try JSONDecoder().decode([BookShelf].self, from: data) + } + + static let type = CloudKitFileType.shelves + static let recordName: String = type.rawValue + + // conformance + var recordName: String { Self.recordName } + var type: CloudKitFileType { Self.type } +} + +// MARK: - CKRecord +nonisolated extension CKRecord.FieldKey { + static let fileName = "fileName" + static let folderName = "folderName" + static let contentData = "contentData" + static let asset = "asset" + static let localModificationDate = "localModificationDate" +} + +enum CloudKitFileError: LocalizedError { + case invalidRecordName + case invalidContentData + case invalidFolderName + case invalidFileName + case invalidAssetURL + case invalidLocalModificationDate +} + +nonisolated extension CKRecord { + + var fileType: CloudKitFileType { + get throws(CloudKitFileError) { + guard let (_, fileType) = try? Self.parseRecordName(recordID.recordName) else { + throw .invalidRecordName + } + return fileType + } + } + + var contentData: Data { + get throws(CloudKitFileError) { + guard try !fileType.isAssetType, + let data = self[.contentData] as? Data else { + throw .invalidContentData + } + return data + } + } + + func mapData(to type: T.Type) throws -> T { + try JSONDecoder().decode(type, from: contentData) + } + + var folderName: String { + get throws(CloudKitFileError) { + guard let folderName = self[.folderName] as? String else { + throw .invalidFolderName + } + return folderName + } + } + + var fileName: String { + get throws(CloudKitFileError) { + guard let fileName = self[.fileName] as? String else { + throw .invalidFileName + } + return fileName + } + } + + var fileURL: URL { + get throws { + let booksDir = try BookStorage.getBooksDirectory() + if try fileType == .shelves { + return booksDir.appending(path: FileNames.shelves) + } + return try booksDir.appending(path: folderName).appending(path: fileName) + } + } + + var assetURL: URL { + get throws(CloudKitFileError) { + guard try fileType.isAssetType, + let asset = self[.asset] as? CKAsset, + let assetURL = asset.fileURL else { + throw .invalidAssetURL + } + return assetURL + } + } + + var localModificationDate: Date { + get throws(CloudKitFileError) { + guard let date = self[.localModificationDate] as? Date else { + throw .invalidLocalModificationDate + } + return date + } + } + + static func parseRecordName(_ recordName: String) throws(CloudKitFileError) -> (UUID, CloudKitFileType) { + if recordName == CloudKitBookShelves.recordName { + return (UUID(), .shelves) + } + let recordNameSplit = recordName.split(separator: "___") + if recordNameSplit.count != 2 { + throw .invalidRecordName + } + let (uuid, fileTypeRaw) = (UUID(uuidString: String(recordNameSplit[0])), String(recordNameSplit[1])) + guard let fileType = CloudKitFileType(rawValue: fileTypeRaw), + let uuid else { + throw .invalidRecordName + } + return (uuid, fileType) + } +} + +// MARK: - UI +nonisolated enum CloudKitStatus: String, Codable { + case none + case signOut + case quotaExceeded + + var title: String { + switch self { + case .none: + "" + case .signOut: + String(localized:"Signed Out") + case .quotaExceeded: + String(localized:"Quota Exceeded") + } + } + + var message: String { + switch self { + case .none: + "" + case .signOut: + String(localized: "You have logged out of iCloud account.") + case .quotaExceeded: + String(localized: "iCloud syncing has been disabled because you have run out of iCloud space. Please free up space or upgrade your storage.") + } + } +} diff --git a/Models/Highlight.swift b/Models/Highlight.swift index bdb26664..ef6675de 100644 --- a/Models/Highlight.swift +++ b/Models/Highlight.swift @@ -45,7 +45,7 @@ enum HighlightColor: String, CaseIterable, Codable, Identifiable { } } -struct Highlight: Codable, Identifiable, Hashable { +nonisolated struct Highlight: Codable, Identifiable, Hashable, Equatable { let id: UUID let character: Int let offset: Int diff --git a/Models/Sasayaki.swift b/Models/Sasayaki.swift index 11d37c12..37d97bc1 100644 --- a/Models/Sasayaki.swift +++ b/Models/Sasayaki.swift @@ -36,7 +36,7 @@ struct SasayakiMatchData: Codable { let unmatched: Int } -struct SasayakiPlaybackData: Codable { +nonisolated struct SasayakiPlaybackData: Codable { var lastPosition: Double var delay: Double = 0 var rate: Float = 1 diff --git a/Models/Statistics.swift b/Models/Statistics.swift index 0efc0b98..5bbcc251 100644 --- a/Models/Statistics.swift +++ b/Models/Statistics.swift @@ -21,7 +21,7 @@ enum StatisticsSyncMode: String, CaseIterable, Codable { } // https://github.com/ttu-ttu/ebook-reader/blob/2703b50ec52b2e4f70afcab725c0f47dd8a66bf4/apps/web/src/lib/data/database/books-db/versions/v6/books-db-v6.ts#L68 -struct Statistics: Codable { +nonisolated struct Statistics: Codable, Hashable { let title: String let dateKey: String var charactersRead: Int diff --git a/Util/Merger.swift b/Util/Merger.swift new file mode 100644 index 00000000..0766019c --- /dev/null +++ b/Util/Merger.swift @@ -0,0 +1,160 @@ +// +// Merger.swift +// Hoshi Reader +// +// Copyright © 2026 Manhhao. +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +nonisolated struct Merger { + static func mergeStatistics(localStatistics: [Statistics], externalStatistics: [Statistics], syncMode: StatisticsSyncMode) -> [Statistics] { + if syncMode == .replace { + return externalStatistics + } + + var grouped: [String: Statistics] = [:] + + for stat in localStatistics { + grouped[stat.dateKey] = stat + } + + for stat in externalStatistics { + if let existing = grouped[stat.dateKey] { + if stat.lastStatisticModified > existing.lastStatisticModified { + grouped[stat.dateKey] = stat + } + } else { + grouped[stat.dateKey] = stat + } + } + + return Array(grouped.values) + } + + static func mergeArray( + local: [T], + remote: [T], + ancestor: [T], + id: KeyPath, + isOnlyOrderChanged: ([S: T], [S: T]) -> Bool, + mergeTwoNew: (T, T) -> T, + threeWayMerge: (T, T, T) -> T, + ) -> [T] { + + let localMap = Self.makeMap(array: local, id: id) + let remoteMap = Self.makeMap(array: remote, id: id) + let ancestorMap = Self.makeMap(array: ancestor, id: id) + + if isOnlyOrderChanged(localMap, remoteMap) { + let localOrder = local.map { $0[keyPath: id] } + let remoteOrder = remote.map { $0[keyPath: id] } + let ancestorOrder = ancestor.map { $0[keyPath: id] } + let localOrderChanged = localOrder != ancestorOrder + let remoteOrderChanged = remoteOrder != ancestorOrder + + if localOrderChanged && !remoteOrderChanged { + return local + } + return remote + } + + let localNames = localMap.keys + let remoteNames = remoteMap.keys + let ancestorNames = ancestorMap.keys + let allNames = Set(localNames).union(Set(remoteNames)).union(Set(ancestorNames)) + + var mergedArray = [T]() + + for name in allNames { + let nameInLocal = localMap[name] != nil + let nameInRemote = remoteMap[name] != nil + let nameInAncestor = ancestorMap[name] != nil + + switch (nameInAncestor, nameInLocal, nameInRemote) { + case (false, true, false): + mergedArray.append( + localMap[name]! + ) + case (false, false, true): + mergedArray.append( + remoteMap[name]! + ) + case (false, true, true): + mergedArray.append( + mergeTwoNew(localMap[name]!, remoteMap[name]!) + ) + case (true, true, true): + mergedArray.append( + threeWayMerge(localMap[name]!, remoteMap[name]!, ancestorMap[name]!) + ) + default: + break + } + } + + mergedArray.sort { (lhs: T, rhs: T) in + let lhsInLocal = localMap[lhs[keyPath: id]] != nil + let rhsInLocal = localMap[rhs[keyPath: id]] != nil + if lhsInLocal && !rhsInLocal { + return true + } else if !lhsInLocal && rhsInLocal { + return false + } else if lhsInLocal && rhsInLocal { + // we can not use `localNames` here since Swift dictionary does not ensure order + let orderedLocalNames = local.map({$0[keyPath: id]}) + return orderedLocalNames.firstIndex(of: lhs[keyPath: id])! < orderedLocalNames.firstIndex(of: rhs[keyPath: id])! + } else { + let orderedRemoteNames = remote.map({$0[keyPath: id]}) + return orderedRemoteNames.firstIndex(of: lhs[keyPath: id])! < orderedRemoteNames.firstIndex(of: rhs[keyPath: id])! + } + } + + return mergedArray + } + + static func shelvesOnlyOrderChanged(local: [String: BookShelf], remote: [String: BookShelf]) -> Bool { + if local.count != remote.count { + return false + } + for (name, lhsShelf) in local { + guard let rhsShelf = remote[name] else { return false } + if Set(lhsShelf.bookIds) != Set(rhsShelf.bookIds) { return false } + } + return true + } + + static func shelvesAreEquivalent(_ lhs: [BookShelf], _ rhs: [BookShelf]) -> Bool { + guard lhs.map(\.name) == rhs.map(\.name) else { return false } + return zip(lhs, rhs).allSatisfy { lhsShelf, rhsShelf in + Set(lhsShelf.bookIds) == Set(rhsShelf.bookIds) + } + } + + static func mergeBookIds( + local: [UUID], + remote: [UUID], + ancestor: [UUID], + ) -> [UUID] { + let allIds = Set(local).union(Set(remote)).union(Set(ancestor)) + let mergedIds = allIds.filter { bookID in + if ancestor.contains(bookID) { + return local.contains(bookID) && remote.contains(bookID) + } else { + return local.contains(bookID) || remote.contains(bookID) + } + } + return Array(mergedIds) + } + + private static func makeMap(array: [T], id: KeyPath) -> [S: T] { + var map: [S: T] = [:] + + for element in array { + map[element[keyPath: id]] = element + } + + return map + } +} diff --git a/Util/TtuConverter.swift b/Util/TtuConverter.swift index 17a4fb5d..18f0f09b 100644 --- a/Util/TtuConverter.swift +++ b/Util/TtuConverter.swift @@ -128,8 +128,8 @@ struct TtuConverter { lastAccess: Date() ) let bookInfo = BookProcessor.process(document: document) - try BookStorage.save(metadata, inside: destinationFolder, as: FileNames.metadata) - try BookStorage.save(bookInfo, inside: destinationFolder, as: FileNames.bookinfo) + try BookStorage.save(metadata, inside: destinationFolder, as: FileNames.metadata, sync: .book(metadata.id)) + try BookStorage.save(bookInfo, inside: destinationFolder, as: FileNames.bookinfo, sync: .book(metadata.id)) return destinationFolder }