From b25e118e9e5d281fb3800bd9523dcbbaaa9762f5 Mon Sep 17 00:00:00 2001 From: Haruka Date: Sat, 13 Jun 2026 04:28:52 +0800 Subject: [PATCH 01/16] fix --- App/HoshiReader.swift | 63 +- Core/BookStorage.swift | 99 ++- Core/UserConfig.swift | 20 + Features/Bookshelf/BookCell.swift | 23 +- Features/Bookshelf/BookView.swift | 12 +- Features/Bookshelf/BookshelfView.swift | 9 + Features/Bookshelf/BookshelfViewModel.swift | 35 +- Features/Reader/ReaderView/ReaderView.swift | 8 + .../Reader/ReaderView/ReaderViewModel.swift | 30 + Features/Settings/SyncView.swift | 75 ++ Features/Sync/CloudKitSyncManager.swift | 708 ++++++++++++++++++ Features/Sync/SyncManager.swift | 28 +- Hoshi Reader.xcodeproj/project.pbxproj | 2 + Models/Book.swift | 6 +- Models/CloudKitBook.swift | 393 ++++++++++ Models/Highlight.swift | 2 +- Models/Sasayaki.swift | 2 +- Models/Statistics.swift | 4 +- Util/Merger.swift | 145 ++++ 19 files changed, 1620 insertions(+), 44 deletions(-) create mode 100644 Features/Sync/CloudKitSyncManager.swift create mode 100644 Models/CloudKitBook.swift create mode 100644 Util/Merger.swift diff --git a/App/HoshiReader.swift b/App/HoshiReader.swift index bff068b6..2885a897 100644 --- a/App/HoshiReader.swift +++ b/App/HoshiReader.swift @@ -14,12 +14,16 @@ import WebKit struct HoshiReaderApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Environment(\.scenePhase) private var scenePhase - @State private var userConfig = UserConfig() + @State private var userConfig = UserConfig.shared @State private var pendingImportURL: URL? @State private var pendingRemoteImportURL: URL? @State private var pendingLookup: String? @State private var pendingTab: Int? @State private var didFinishLaunch = BookStorage.migrationsComplete + @State private var showSignOutConfirmation = false + @State private var cloudManagedBooks = [BookMetadata]() + @State private var showUploadLocalBooksConfirmation = false + @State private var showQuotaExceededConfirmation = false private var shortcutHandler = ShortcutHandler.shared init() { @@ -45,6 +49,11 @@ struct HoshiReaderApp: App { _ = DictionaryManager.shared _ = GoogleDriveHandler.shared WebViewPreloader.shared.warmup() + if userConfig.enableCloudKitSync { + Task { + await CloudKitSyncManager.shared.initializeSyncEngine() + } + } } var body: some Scene { @@ -99,6 +108,34 @@ struct HoshiReaderApp: App { } shortcutHandler.pendingType = nil } + .task { + await registerCloudKitErrorCallback() + } + .alert("Clear local books?", isPresented: $showSignOutConfirmation) { + Button("Confirm", role: .destructive) { + Task { + try await CloudKitSyncManager.shared.deleteLocal(books: cloudManagedBooks) + } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("You have logged out iCloud account. Do you want to clear local book data of the previous account?") + } + .alert("Upload local books?", isPresented: $showUploadLocalBooksConfirmation) { + Button("Upload") { + Task { + try? await CloudKitSyncManager.shared.uploadUnmanagedBooks() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You have logged in a new iCloud account. Do you want to upload local books to this iCloud server?") + } + .alert("iCloud Storage Full", isPresented: $showQuotaExceededConfirmation) { + Button("OK") {} + } message: { + Text("iCloud syncing has been disabled because you have run out of iCloud space. Please free up space or upgrade your storage.") + } } } @@ -124,6 +161,30 @@ struct HoshiReaderApp: App { pendingImportURL = url } } + + private func registerCloudKitErrorCallback() async { + let onError: @MainActor (CloudKitSyncManager.Event) -> Void = { event in + if case let .account(accountEvent) = event { + switch accountEvent { + case .signOut(managedBooks: let managedBooks): + self.cloudManagedBooks = managedBooks + showSignOutConfirmation = true + userConfig.enableCloudKitSync = false + case .signIn: + fallthrough + case .accountChanged: + showUploadLocalBooksConfirmation = true + } + } else if case let .error(syncError) = event { + switch syncError { + case .quotaExceeded: + showQuotaExceededConfirmation = true + userConfig.enableCloudKitSync = false + } + } + } + await CloudKitSyncManager.shared.addEventHandlers([onError]) + } } @Observable diff --git a/Core/BookStorage.swift b/Core/BookStorage.swift index 9438b7e5..aa330609 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" @@ -169,6 +170,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 +216,106 @@ 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, createCloudBook: Bool = false) throws { let targetURL = directory.appendingPathComponent(fileName) + try saveLocal(object, url: targetURL) + + saveCloudKitFile(object, url: targetURL, createCloudBook: createCloudBook) + } + + 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 saveCloudKitFile(_ object: T, url: URL, createCloudBook: Bool) { + guard UserConfig.shared.enableCloudKitSync else { return } + + if T.self == [BookShelf].self { + Task { + await CloudKitSyncManager.shared.saveCloudShelves() + } + return + } + + if T.self == BookMetadata.self { + guard let metadata = object as? BookMetadata else { fatalError() } + Task { + await CloudKitSyncManager.shared.saveCloudFile( + uuid: metadata.id, + fileType: .metadata, + fileName: FileNames.metadata, + folderName: url.deletingLastPathComponent().lastPathComponent + ) + } + return + } + + guard let metadata = Self.loadMetadata(root: url.deletingLastPathComponent()) else { + CloudKitSyncManager.logger.error("Failed to load BookMetadata in url \(url)") + return + } + let folderName = url.deletingLastPathComponent().lastPathComponent + if T.self == BookInfo.self { + Task { + await CloudKitSyncManager.shared.saveCloudFile( + uuid: metadata.id, + fileType: .bookinfo, + fileName: FileNames.bookinfo, + folderName: folderName + ) + } + } else if T.self == Bookmark.self { + Task { + await CloudKitSyncManager.shared.saveCloudFile( + uuid: metadata.id, + fileType: .bookmark, + fileName: FileNames.bookmark, + folderName: folderName + ) + } + } else if T.self == [Highlight].self { + Task { + await CloudKitSyncManager.shared.saveCloudFile( + uuid: metadata.id, + fileType: .highlights, + fileName: FileNames.highlights, + folderName: folderName + ) + } + } else if T.self == [Statistics].self { + Task { + await CloudKitSyncManager.shared.saveCloudFile( + uuid: metadata.id, + fileType: .statistics, + fileName: FileNames.statistics, + folderName: folderName + ) + } + } else if T.self == SasayakiPlaybackData.self { + Task { + await CloudKitSyncManager.shared.saveCloudFile( + uuid: metadata.id, + fileType: .sasayakiPlayback, + fileName: FileNames.sasayakiPlayback, + folderName: folderName + ) + } + } } - 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 +363,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/UserConfig.swift b/Core/UserConfig.swift index dbe629eb..fdddeeac 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.initializeSyncEngine() + } + } 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..3456895e 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 isCloudManaged = true let book: BookMetadata var viewModel: BookshelfViewModel var currentShelf: String? @@ -40,7 +41,16 @@ struct BookCell: View { onSelect() } } label: { - BookView(book: book, progress: viewModel.progress(for: book), isSelected: isSelecting && isSelected) + BookView(book: book, progress: viewModel.progress(for: book), isCloudManaged: isCloudManaged, isSelected: isSelecting && isSelected) + } + .task { + isCloudManaged = await CloudKitSyncManager.shared.isManaged(uuid: book.id) + let refreshManagedState: @MainActor (CloudKitSyncManager.Event) -> Void = { _ in + Task { + isCloudManaged = await CloudKitSyncManager.shared.isManaged(uuid: book.id) + } + } + await CloudKitSyncManager.shared.addEventHandlers([refreshManagedState]) } .buttonStyle(.plain) .contextMenu(isSelecting ? nil : ContextMenu { @@ -65,6 +75,17 @@ struct BookCell: View { } } + if userConfig.enableCloudKitSync && !isCloudManaged { + Button { + Task { + try? await CloudKitSyncManager.shared.uploadUnmanagedBook(book) + isCloudManaged = await CloudKitSyncManager.shared.isManaged(uuid: book.id) + } + } label: { + Label("Sync to iCloud", systemImage: "icloud") + } + } + if userConfig.enableSync { if userConfig.syncMode == .manual { Menu { diff --git a/Features/Bookshelf/BookView.swift b/Features/Bookshelf/BookView.swift index 770fe5c5..95f33e61 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 isCloudManaged: Bool var isSelected: Bool = false + + var titleText: Text { + if userConfig.enableCloudKitSync && !isCloudManaged { + return Text(Image(systemName: "icloud.slash")) + 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..b8badffd 100644 --- a/Features/Bookshelf/BookshelfView.swift +++ b/Features/Bookshelf/BookshelfView.swift @@ -85,6 +85,15 @@ struct BookshelfView: View { didLoadGDrive = true } } + if userConfig.enableCloudKitSync { + Task { + let refreshBooks: @MainActor (CloudKitSyncManager.Event) -> Void = { [weak viewModel] _ in + guard let viewModel else { return } + viewModel.loadBooks() + } + await CloudKitSyncManager.shared.addEventHandlers([refreshBooks]) + } + } } .fileImporter( isPresented: $viewModel.isImporting, diff --git a/Features/Bookshelf/BookshelfViewModel.swift b/Features/Bookshelf/BookshelfViewModel.swift index 8449690e..ec59b27b 100644 --- a/Features/Bookshelf/BookshelfViewModel.swift +++ b/Features/Bookshelf/BookshelfViewModel.swift @@ -169,12 +169,21 @@ 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: borrowing BookMetadata) { + guard UserConfig.shared.enableCloudKitSync else { return } + let uuid = book.id + Task { + await CloudKitSyncManager.shared.deleteCloudBook(uuid: uuid) + } + } + func renameBook(_ book: BookMetadata, title: String) { guard let index = books.firstIndex(where: { $0.id == book.id }) else { return @@ -524,8 +533,30 @@ 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, createCloudBook: true) + try BookStorage.save(bookinfo, inside: bookFolder, as: FileNames.bookinfo, createCloudBook: true) + + 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, + createCloudBook: true + ) + } + await CloudKitSyncManager.shared.saveCloudFile( + uuid: metadata.id, + fileType: .book, + fileName: localURL.lastPathComponent, + folderName: folderName, + createCloudBook: true + ) + } + } } 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..ae792ba4 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 { + let onSynced: @MainActor (CloudKitSyncManager.Event) -> Void = { [weak viewModel] direction in + guard let viewModel else { return } + viewModel.handleCloudKitSync(event: direction, dismiss: dismiss) + } + await CloudKitSyncManager.shared.addEventHandlers([onSynced]) + } .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..3d05fe30 100644 --- a/Features/Reader/ReaderView/ReaderViewModel.swift +++ b/Features/Reader/ReaderView/ReaderViewModel.swift @@ -326,6 +326,36 @@ class ReaderViewModel { flushStats() } + func handleCloudKitSync(event: CloudKitSyncManager.Event, dismiss: DismissAction) { + switch event { + case .sent(let uuid, let success): + guard uuid == book.id else { return } + if success { + isPaused = false + } else { + fallthrough + } + 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: + break + } + } + func jumpToCharacter(_ characterCount: Int) { guard let result = bookInfo.resolveCharacterPosition(characterCount) else { return } recordPosition() diff --git a/Features/Settings/SyncView.swift b/Features/Settings/SyncView.swift index 783aec06..7cd76025 100644 --- a/Features/Settings/SyncView.swift +++ b/Features/Settings/SyncView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import CloudKit struct SyncView: View { @Environment(UserConfig.self) var userConfig @@ -16,9 +17,37 @@ struct SyncView: View { @State private var showClearCacheConfirmation = false @State private var showSignOutConfirmation = false + @State private var iCloudAvailable = false + @State private var showUploadLocalBooksConfirmation = false + @State private var showClearLocalBooksConfirmation = false + @State private var showClearCloudKitConfirmation = false + var body: some View { @Bindable var userConfig = userConfig List { + Section { + Toggle("Enable iCloud Sync", isOn: $userConfig.enableCloudKitSync) + .disabled(!iCloudAvailable) + + if userConfig.enableCloudKitSync { + Button("Upload Unsynced Local Books") { + showUploadLocalBooksConfirmation.toggle() + } + + Button("Clear Unsynced Local Books", role: .destructive) { + showClearLocalBooksConfirmation.toggle() + } + + Button("Clear iCloud data", role: .destructive) { + showClearCloudKitConfirmation.toggle() + } + } + } footer: { + if !iCloudAvailable { + Text("Log in to an iCloud account to sync with iCloud.") + } + } + Section { Toggle("Enable", isOn: $userConfig.enableSync) } footer: { @@ -105,6 +134,15 @@ struct SyncView: View { } } } + .task { + await iCloudStatusRefresh() + let onChanged: @MainActor (CloudKitSyncManager.Event) -> Void = { _ in + Task { + await self.iCloudStatusRefresh() + } + } + await CloudKitSyncManager.shared.addEventHandlers([onChanged]) + } .navigationTitle("Syncing") .alert("Error", isPresented: $showError) { Button("OK") { } @@ -132,6 +170,36 @@ struct SyncView: View { } message: { Text("Signing out will clear authorization tokens, cached folder ids and book covers.") } + .alert("Upload local books?", isPresented: $showUploadLocalBooksConfirmation) { + Button("Confirm") { + Task { + try? await CloudKitSyncManager.shared.uploadUnmanagedBooks() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will upload local only books to iCloud server.") + } + .alert("Clear local books?", isPresented: $showClearLocalBooksConfirmation) { + Button("Confirm", role: .destructive) { + Task { + try await CloudKitSyncManager.shared.deleteLocalBooks(isManaged: false) + } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("This will clear all data of local only books") + } + .alert("Clear iCloud data?", isPresented: $showClearCloudKitConfirmation) { + Button("Confirm", role: .destructive) { + Task { + await CloudKitSyncManager.shared.deleteServerData() + } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("This will clear all data on iCloud server.") + } } private func textOfSyncMode(_ mode: SyncMode) -> some View { @@ -142,4 +210,11 @@ struct SyncView: View { Text("Manual") } } + + private func iCloudStatusRefresh() async { + guard let accountStatus = try? await CloudKitSyncManager.container.accountStatus() else { return } + if accountStatus == .available { + iCloudAvailable = true + } + } } diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift new file mode 100644 index 00000000..5205b616 --- /dev/null +++ b/Features/Sync/CloudKitSyncManager.swift @@ -0,0 +1,708 @@ +// +// CloudKitSyncHanlder.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.com.youwu.hoshi") } + + private static let prioritizedZoneIDs = [CKRecordZone.ID(zoneName: CloudKitBookFile.zoneName)] + + nonisolated private var logger: Logger { Self.logger } + + private var eventHandlers: [@MainActor (CloudKitSyncManager.Event) -> Void] = [] + + 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 { + initializeSyncEngine() + } + 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 stroage: \(error)") + } + } + + func initializeSyncEngine() { + let configuration = CKSyncEngine.Configuration( + database: Self.container.privateCloudDatabase, + stateSerialization: cloudKitData.stateSerialization, + delegate: self + ) + _syncEngine = CKSyncEngine(configuration) + logger.debug("CKSyncEngine initialized") + } + + 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 .fetchedDatabaseChanges(let fetchedDatabaseChanges): + handleFetchedDatabaseChanges(fetchedDatabaseChanges) + case .fetchedRecordZoneChanges(let fetchedRecordZoneChanges): + handleFetchedRecordZoneChanges(fetchedRecordZoneChanges) + case .sentDatabaseChanges(let sentDatabaseChanges): + handleSentDatabaseChanges(sentDatabaseChanges) + case .sentRecordZoneChanges(let sentRecordZoneChanges): + handleSentRecordZoneChanges(sentRecordZoneChanges) + case .accountChange(let accountChange): + handleAccountChange(accountChange) + 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)") + 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") + 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)") + return nil + } + } + } + + func nextFetchChangesOptions(_ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.FetchChangesOptions { + var options = context.options + options.prioritizedZoneIDs = Self.prioritizedZoneIDs + return options + } +} + +// MARK: - Event Handling +private extension CloudKitSyncManager { + private func handleStateUpdate(_ stateUpdate: CKSyncEngine.Event.StateUpdate) { + cloudKitData.stateSerialization = stateUpdate.stateSerialization + } + + private func handleFetchedDatabaseChanges(_ fetchedDatabaseChanges: CKSyncEngine.Event.FetchedDatabaseChanges) { + for deletion in fetchedDatabaseChanges.deletions { + let zoneName = deletion.zoneID.zoneName + if zoneName == CloudKitBookFile.zoneName || zoneName == CloudKitBookFile.assetZoneName { + self.cloudKitData.books = [:] + self.cloudKitData.shelves = .init(localModificationDate: .distantPast) + fire(event: .delete(.zones)) + } + } + } + + 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 { + try deleteLocal(recordID: deletedRecordID) + 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 handleSentDatabaseChanges(_ sentDatabaseChanges: CKSyncEngine.Event.SentDatabaseChanges) { + let deletedZoneIds = sentDatabaseChanges.deletedZoneIDs + var shouldDeleteCloudKitData = false + for deletedZoneId in deletedZoneIds { + if deletedZoneId.zoneName == CloudKitBookFile.zoneName || deletedZoneId.zoneName == CloudKitBookFile.assetZoneName { + shouldDeleteCloudKitData = true + } + } + if shouldDeleteCloudKitData { + self.cloudKitData.books = [:] + self.cloudKitData.shelves = .init(localModificationDate: .distantPast) + fire(event: .delete(.zones)) + } + } + + 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) + 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)), + .saveZone(CKRecordZone(zoneName: CloudKitBookFile.assetZoneName)), + ]) + fire(event: .account(.signIn)) + case .signOut: + let managedBooks: [BookMetadata] + do { + managedBooks = try getBooks(isManaged: true) + } catch { + managedBooks = [] + logger.error("Failed to get managed books of previous user when signing out") + } + disableSync() + self.cloudKitData = CloudKitData() + fire(event: .account(.signOut(managedBooks: managedBooks))) + case .switchAccounts(previousUser: let previousRecordID, currentUser: let currentRecordID): + guard previousRecordID.recordName != currentRecordID.recordName else { return } + self.cloudKitData = CloudKitData() + initializeSyncEngine() + fire(event: .account(.accountChanged)) + @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 .metadata: + try replaceIfNewer(record: record) + case .bookmark: + try replaceIfNewer(record: record) + case .bookinfo: + try replaceIfNewer(record: record) + case .shelves: + try mergeShelves(record: record) + case .statistics: + try mergeStats(record: record) + case .sasayakiPlayback: + try replaceIfNewer(record: record) + case .highlights: + try mergeHighlights(record: record) + case .cover: + try replaceIfNewer(record: record) + case .book: + 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 { + let localModified = localFile.localModificationDate + let remoteModified = try record.localModificationDate + if localModified > remoteModified { return false } + if remoteModified > localModified { return true } + return false + }() + } 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 + if self.cloudKitData.shelves.localModificationDate > remoteModificationDate { + let remoteIndex = mergedShelves.firstIndex(where: {$0.name == remoteContainingShelf.name})! + mergedShelves[remoteIndex].bookIds.removeAll(where: {$0 == bookId}) + } else { + let localIndex = mergedShelves.firstIndex(where: {$0.name == localContainingShelf.name})! + mergedShelves[localIndex].bookIds.removeAll(where: {$0 == bookId}) + } + } + + let fileURL = try record.fileURL + try BookStorage.saveLocal(mergedShelves, url: fileURL) + + self.cloudKitData.shelves.setLastKnownRecordIfNewer(record) + self.cloudKitData.shelves.localModificationDate = try record.localModificationDate + } +} + +// MARK: - Sending Data +extension CloudKitSyncManager { + // a tricky point: when should we modify `self.cloudKitData`? Before `state.add` or after `sentRecordZoneChanges`? Here we choose the way like example in Apple example repo + + /// - Parameters: + /// - createCloudBook: If this book is not managed by iCloud, should we continue saving this file? + func saveCloudFile(uuid: UUID, fileType: CloudKitFileType, fileName: String, folderName: String, createCloudBook: Bool = false) { + if self.cloudKitData.books[uuid] == nil { + if createCloudBook { + self.cloudKitData.books[uuid] = [:] + } else { + return + } + } + 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(uuid: UUID) { + guard let files = self.cloudKitData.books[uuid] else { return } + self.cloudKitData.books[uuid] = nil + syncEngine.state.add(pendingRecordZoneChanges: files.map({ (_, cloudKitBookFile) in + .deleteRecord(cloudKitBookFile.recordID) + })) + } + + func saveCloudShelves() { + self.cloudKitData.shelves.localModificationDate = .now + syncEngine.state.add(pendingRecordZoneChanges: [ + .saveRecord( + CloudKitBookShelves.recordID + ) + ]) + } + + func deleteServerData() { + syncEngine.state.add(pendingDatabaseChanges: [ + .deleteZone(CKRecordZone.ID(zoneName: CloudKitBookFile.zoneName)), + .deleteZone(CKRecordZone.ID(zoneName: CloudKitBookFile.assetZoneName)) + ]) + } + + func refresh() { + Task { + var fetchChangesOptions = CKSyncEngine.FetchChangesOptions() + fetchChangesOptions.prioritizedZoneIDs = Self.prioritizedZoneIDs + try? await syncEngine.fetchChanges(fetchChangesOptions) + try? await syncEngine.sendChanges() + } + } + + func uploadUnmanagedBooks() throws { + let unmanagedBooks = try getBooks(isManaged: false) + for book in unmanagedBooks { + try uploadUnmanagedBook(book) + } + } + + func uploadUnmanagedBook(_ book: BookMetadata) throws { + if self.cloudKitData.books[book.id] != nil { return } + let booksRootDir = try BookStorage.getBooksDirectory() + let bookDir = booksRootDir.appending(path: book.folder) + let bookFileURLs = try FileManager.default.contentsOfDirectory(at: bookDir, includingPropertiesForKeys: nil) + 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, + createCloudBook: true + ) + } + if let coverName = (book.cover as? NSString)?.lastPathComponent { + saveCloudFile( + uuid: book.id, + fileType: .cover, + fileName: coverName, + folderName: book.folder, + createCloudBook: true + ) + } + if let bookName = book.epub { + saveCloudFile( + uuid: book.id, + fileType: .book, + fileName: bookName, + folderName: book.folder, + createCloudBook: true + ) + } + } +} + +// MARK: - Local Data processing +extension CloudKitSyncManager { + + private static let cloudKitDataFileName = "cloudkit.json" + + private func getBooks(isManaged: Bool) throws -> [BookMetadata] { + let managedBookIds = Set(self.cloudKitData.books.keys) + let localBooks = try BookStorage.loadAllBooks() + let targetBooks = localBooks.filter({ managedBookIds.contains($0.id) == isManaged }) + return targetBooks + } + + 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) + } + + // Deleting the whole shelf file is not supported + private func deleteLocal(recordID: CKRecord.ID) throws { + let recordName = recordID.recordName + let (uuid, fileType) = try CKRecord.parseRecordName(recordName) + let fileURL = try cloudKitData.books[uuid]?[fileType]?.fileURL + if let fileURL, FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) { + try BookStorage.delete(at: 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 deleteLocalBooks(isManaged: Bool) throws { + let targetBooks = try getBooks(isManaged: isManaged) + try deleteLocal(books: targetBooks) + } +} + +// MARK: - other internal APIs +extension CloudKitSyncManager { + func isManaged(uuid: UUID) -> Bool { cloudKitData.books[uuid] != nil } +} + +// MARK: - callbacks +extension CloudKitSyncManager { + nonisolated enum Event { + enum AccountEvent { + case signIn + case signOut(managedBooks: [BookMetadata]) + 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 delete(DeleteEvent) + case account(AccountEvent) + case error(SyncError) + } + + func addEventHandlers(_ eventHandlers: [@MainActor (CloudKitSyncManager.Event) -> Void]) { + self.eventHandlers.append(contentsOf: eventHandlers) + } + + private func fire(event: CloudKitSyncManager.Event) { + Task { + let allCallbacks = self.eventHandlers + await MainActor.run { + for callBack in allCallbacks { + callBack(event) + } + } + } + } +} diff --git a/Features/Sync/SyncManager.swift b/Features/Sync/SyncManager.swift index 3849fd03..e1c4a687 100644 --- a/Features/Sync/SyncManager.swift +++ b/Features/Sync/SyncManager.swift @@ -116,7 +116,7 @@ class SyncManager { guard let ttuProgress else { return .skipped } importProgress(ttuProgress: ttuProgress, to: url) 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) } @@ -127,7 +127,7 @@ class SyncManager { 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, @@ -295,30 +295,6 @@ 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) { var playback = BookStorage.loadSasayakiPlayback(root: url) ?? SasayakiPlaybackData(lastPosition: 0) playback.lastPosition = ttuAudioBook.playbackPosition diff --git a/Hoshi Reader.xcodeproj/project.pbxproj b/Hoshi Reader.xcodeproj/project.pbxproj index b558fb4b..2a944bcf 100644 --- a/Hoshi Reader.xcodeproj/project.pbxproj +++ b/Hoshi Reader.xcodeproj/project.pbxproj @@ -79,6 +79,7 @@ membershipExceptions = ( Anki.swift, Book.swift, + CloudKitBook.swift, Dictionary.swift, Highlight.swift, Sasayaki.swift, @@ -93,6 +94,7 @@ CoverImage.swift, CSSSanitizer.swift, Extensions.swift, + Merger.swift, TtuConverter.swift, ); target = 48107AB62F114021009E12D1 /* Hoshi Reader */; 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/CloudKitBook.swift b/Models/CloudKitBook.swift new file mode 100644 index 00000000..92a97439 --- /dev/null +++ b/Models/CloudKitBook.swift @@ -0,0 +1,393 @@ +// +// CloudKitBook.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 } + + var recordType: CKRecord.RecordType { get } + var recordID: CKRecord.ID { get } + func populateFields(record: CKRecord) throws +} + +nonisolated extension CloudKitFile { + + @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 { + + static let zoneName: String = "HoshiBooks" + static let assetZoneName: String = "HoshiBookAssets" + + var recordName: String { "\(uuid.uuidString)___\(type.rawValue)" } + var recordType: CKRecord.RecordType { type.rawValue } + var zoneID: CKRecordZone.ID { .init(zoneName: type.isAssetType ? Self.assetZoneName : Self.zoneName) } + var recordID: CKRecord.ID { .init(recordName: recordName, zoneID: zoneID) } + + 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: - 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 recordType: CKRecord.RecordType = "Shelves" + static let recordName: String = "shelves" + static var recordID: CKRecord.ID { CKRecord.ID(recordName: Self.recordName, zoneID: Self.zoneID) } + static let zoneName: String = "HoshiBooks" + static var zoneID: CKRecordZone.ID { CKRecordZone.ID(zoneName: Self.zoneName) } + + // conformance + var recordType: CKRecord.RecordType { Self.recordType } + var recordID: CKRecord.ID { Self.recordID } +} + +// 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) + } +} 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..1ae31957 100644 --- a/Models/Statistics.swift +++ b/Models/Statistics.swift @@ -15,13 +15,13 @@ enum StatisticsAutostartMode: String, CaseIterable, Codable { case on = "On" } -enum StatisticsSyncMode: String, CaseIterable, Codable { +nonisolated enum StatisticsSyncMode: String, CaseIterable, Codable { case merge = "Merge" case replace = "Replace" } // 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..27600742 --- /dev/null +++ b/Util/Merger.swift @@ -0,0 +1,145 @@ +// +// 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) + + if isOnlyOrderChanged(localMap, remoteMap) { + return remote + } + + let ancestorMap = Self.makeMap(array: ancestor, id: id) + + 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 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 + } +} From f44b1a0b22dfd0ea4a56b7df4b599b6a6061f6b7 Mon Sep 17 00:00:00 2001 From: Haruka Date: Sat, 13 Jun 2026 21:29:37 +0800 Subject: [PATCH 02/16] fix memory leak after lifecycles of SwiftUI views have ended --- App/HoshiReader.swift | 6 +++--- Features/Bookshelf/BookCell.swift | 6 ++++-- Features/Bookshelf/BookshelfView.swift | 16 +++++++-------- Features/Reader/ReaderView/ReaderView.swift | 6 ++++-- Features/Settings/SyncView.swift | 6 ++++-- Features/Sync/CloudKitSyncManager.swift | 22 +++++++++++++++++---- 6 files changed, 41 insertions(+), 21 deletions(-) diff --git a/App/HoshiReader.swift b/App/HoshiReader.swift index 2885a897..4749212a 100644 --- a/App/HoshiReader.swift +++ b/App/HoshiReader.swift @@ -109,7 +109,7 @@ struct HoshiReaderApp: App { shortcutHandler.pendingType = nil } .task { - await registerCloudKitErrorCallback() + await observeCloudKitEvents() } .alert("Clear local books?", isPresented: $showSignOutConfirmation) { Button("Confirm", role: .destructive) { @@ -162,7 +162,7 @@ struct HoshiReaderApp: App { } } - private func registerCloudKitErrorCallback() async { + private func observeCloudKitEvents() async { let onError: @MainActor (CloudKitSyncManager.Event) -> Void = { event in if case let .account(accountEvent) = event { switch accountEvent { @@ -183,7 +183,7 @@ struct HoshiReaderApp: App { } } } - await CloudKitSyncManager.shared.addEventHandlers([onError]) + await CloudKitSyncManager.shared.observeEvents(onError) } } diff --git a/Features/Bookshelf/BookCell.swift b/Features/Bookshelf/BookCell.swift index 3456895e..a7a29a94 100644 --- a/Features/Bookshelf/BookCell.swift +++ b/Features/Bookshelf/BookCell.swift @@ -43,14 +43,16 @@ struct BookCell: View { } label: { BookView(book: book, progress: viewModel.progress(for: book), isCloudManaged: isCloudManaged, isSelected: isSelecting && isSelected) } - .task { + .task(id: userConfig.enableCloudKitSync) { isCloudManaged = await CloudKitSyncManager.shared.isManaged(uuid: book.id) + guard userConfig.enableCloudKitSync else { return } + let refreshManagedState: @MainActor (CloudKitSyncManager.Event) -> Void = { _ in Task { isCloudManaged = await CloudKitSyncManager.shared.isManaged(uuid: book.id) } } - await CloudKitSyncManager.shared.addEventHandlers([refreshManagedState]) + await CloudKitSyncManager.shared.observeEvents(refreshManagedState) } .buttonStyle(.plain) .contextMenu(isSelecting ? nil : ContextMenu { diff --git a/Features/Bookshelf/BookshelfView.swift b/Features/Bookshelf/BookshelfView.swift index b8badffd..361a3288 100644 --- a/Features/Bookshelf/BookshelfView.swift +++ b/Features/Bookshelf/BookshelfView.swift @@ -85,15 +85,15 @@ struct BookshelfView: View { didLoadGDrive = true } } - if userConfig.enableCloudKitSync { - Task { - let refreshBooks: @MainActor (CloudKitSyncManager.Event) -> Void = { [weak viewModel] _ in - guard let viewModel else { return } - viewModel.loadBooks() - } - await CloudKitSyncManager.shared.addEventHandlers([refreshBooks]) - } + } + .task(id: userConfig.enableCloudKitSync) { + guard userConfig.enableCloudKitSync else { return } + + let refreshBooks: @MainActor (CloudKitSyncManager.Event) -> Void = { [weak viewModel] _ in + guard let viewModel else { return } + viewModel.loadBooks() } + await CloudKitSyncManager.shared.observeEvents(refreshBooks) } .fileImporter( isPresented: $viewModel.isImporting, diff --git a/Features/Reader/ReaderView/ReaderView.swift b/Features/Reader/ReaderView/ReaderView.swift index ae792ba4..faea07ca 100644 --- a/Features/Reader/ReaderView/ReaderView.swift +++ b/Features/Reader/ReaderView/ReaderView.swift @@ -737,12 +737,14 @@ struct ReaderView: View { .task { await viewModel.syncOnOpen() } - .task { + .task(id: userConfig.enableCloudKitSync) { + guard userConfig.enableCloudKitSync else { return } + let onSynced: @MainActor (CloudKitSyncManager.Event) -> Void = { [weak viewModel] direction in guard let viewModel else { return } viewModel.handleCloudKitSync(event: direction, dismiss: dismiss) } - await CloudKitSyncManager.shared.addEventHandlers([onSynced]) + await CloudKitSyncManager.shared.observeEvents(onSynced) } .onChange(of: readerTextColor) { _, hex in viewModel.bridge.send(.updateTextColor(hex)) } .onChange(of: sasayakiTextColor) { _, _ in updateSasayakiColors() } diff --git a/Features/Settings/SyncView.swift b/Features/Settings/SyncView.swift index 7cd76025..b4f58024 100644 --- a/Features/Settings/SyncView.swift +++ b/Features/Settings/SyncView.swift @@ -136,12 +136,14 @@ struct SyncView: View { } .task { await iCloudStatusRefresh() - let onChanged: @MainActor (CloudKitSyncManager.Event) -> Void = { _ in + let onChanged: @MainActor (CloudKitSyncManager.Event) -> Void = { event in + guard case .account = event else { return } + Task { await self.iCloudStatusRefresh() } } - await CloudKitSyncManager.shared.addEventHandlers([onChanged]) + await CloudKitSyncManager.shared.observeEvents(onChanged) } .navigationTitle("Syncing") .alert("Error", isPresented: $showError) { diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift index 5205b616..587f9a4d 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -25,7 +25,7 @@ actor CloudKitSyncManager { nonisolated private var logger: Logger { Self.logger } - private var eventHandlers: [@MainActor (CloudKitSyncManager.Event) -> Void] = [] + private var eventHandlers: [UUID: @MainActor (CloudKitSyncManager.Event) -> Void] = [:] private var cloudKitData: CloudKitData { didSet { @@ -691,13 +691,27 @@ extension CloudKitSyncManager { case error(SyncError) } - func addEventHandlers(_ eventHandlers: [@MainActor (CloudKitSyncManager.Event) -> Void]) { - self.eventHandlers.append(contentsOf: eventHandlers) + func observeEvents(_ eventHandler: @escaping @MainActor (CloudKitSyncManager.Event) -> Void) async { + let id = UUID() + eventHandlers[id] = eventHandler + defer { + eventHandlers[id] = nil + } + + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(60)) + } catch is CancellationError { + break + } catch { + break + } + } } private func fire(event: CloudKitSyncManager.Event) { Task { - let allCallbacks = self.eventHandlers + let allCallbacks = Array(self.eventHandlers.values) await MainActor.run { for callBack in allCallbacks { callBack(event) From b6b993770a555bb1938879d3e5b1a422aef7806b Mon Sep 17 00:00:00 2001 From: Haruka Date: Sat, 13 Jun 2026 21:38:44 +0800 Subject: [PATCH 03/16] fix bug --- Core/BookStorage.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Core/BookStorage.swift b/Core/BookStorage.swift index aa330609..7641f8af 100644 --- a/Core/BookStorage.swift +++ b/Core/BookStorage.swift @@ -256,7 +256,8 @@ struct BookStorage { uuid: metadata.id, fileType: .metadata, fileName: FileNames.metadata, - folderName: url.deletingLastPathComponent().lastPathComponent + folderName: url.deletingLastPathComponent().lastPathComponent, + createCloudBook: createCloudBook ) } return @@ -273,7 +274,8 @@ struct BookStorage { uuid: metadata.id, fileType: .bookinfo, fileName: FileNames.bookinfo, - folderName: folderName + folderName: folderName, + createCloudBook: createCloudBook ) } } else if T.self == Bookmark.self { @@ -282,7 +284,8 @@ struct BookStorage { uuid: metadata.id, fileType: .bookmark, fileName: FileNames.bookmark, - folderName: folderName + folderName: folderName, + createCloudBook: createCloudBook ) } } else if T.self == [Highlight].self { @@ -291,7 +294,8 @@ struct BookStorage { uuid: metadata.id, fileType: .highlights, fileName: FileNames.highlights, - folderName: folderName + folderName: folderName, + createCloudBook: createCloudBook ) } } else if T.self == [Statistics].self { @@ -300,7 +304,8 @@ struct BookStorage { uuid: metadata.id, fileType: .statistics, fileName: FileNames.statistics, - folderName: folderName + folderName: folderName, + createCloudBook: createCloudBook ) } } else if T.self == SasayakiPlaybackData.self { @@ -309,7 +314,8 @@ struct BookStorage { uuid: metadata.id, fileType: .sasayakiPlayback, fileName: FileNames.sasayakiPlayback, - folderName: folderName + folderName: folderName, + createCloudBook: createCloudBook ) } } From 7beb455989794db6084529ca5714ce72d987ece9 Mon Sep 17 00:00:00 2001 From: Haruka Date: Sat, 13 Jun 2026 23:24:41 +0800 Subject: [PATCH 04/16] fix bugs --- Features/Settings/SyncView.swift | 8 +++++--- Features/Sync/CloudKitSyncManager.swift | 3 +++ Util/Merger.swift | 9 +++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Features/Settings/SyncView.swift b/Features/Settings/SyncView.swift index b4f58024..d9e6c6b8 100644 --- a/Features/Settings/SyncView.swift +++ b/Features/Settings/SyncView.swift @@ -214,9 +214,11 @@ struct SyncView: View { } private func iCloudStatusRefresh() async { - guard let accountStatus = try? await CloudKitSyncManager.container.accountStatus() else { return } - if accountStatus == .available { - iCloudAvailable = true + 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 index 587f9a4d..1ec8ae6c 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -114,6 +114,7 @@ extension CloudKitSyncManager: CKSyncEngineDelegate { (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 { @@ -122,12 +123,14 @@ extension CloudKitSyncManager: CKSyncEngineDelegate { } 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 } } diff --git a/Util/Merger.swift b/Util/Merger.swift index 27600742..b8d836f3 100644 --- a/Util/Merger.swift +++ b/Util/Merger.swift @@ -45,12 +45,17 @@ nonisolated struct Merger { 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 localOrderChanged = isOnlyOrderChanged(localMap, ancestorMap) + let remoteOrderChanged = isOnlyOrderChanged(remoteMap, ancestorMap) + + if localOrderChanged && !remoteOrderChanged { + return local + } return remote } - - let ancestorMap = Self.makeMap(array: ancestor, id: id) let localNames = localMap.keys let remoteNames = remoteMap.keys From 54b4a4d1c47009c60d01bc8bae9fd4ccc0ecf558 Mon Sep 17 00:00:00 2001 From: Haruka Date: Sun, 14 Jun 2026 01:11:15 +0800 Subject: [PATCH 05/16] fix shelves merging logic --- Features/Sync/CloudKitSyncManager.swift | 27 +++++++++++++++++-------- Util/Merger.swift | 14 +++++++++++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift index 1ec8ae6c..5532f12c 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -256,7 +256,11 @@ private extension CloudKitSyncManager { } do { try onFetchedRecord(serverRecord) - self.cloudKitData.books[uuid]?[fileType]?.localModificationDate = .now + 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)") @@ -484,20 +488,27 @@ extension CloudKitSyncManager { continue } let remoteModificationDate = try record.localModificationDate - if self.cloudKitData.shelves.localModificationDate > remoteModificationDate { - let remoteIndex = mergedShelves.firstIndex(where: {$0.name == remoteContainingShelf.name})! - mergedShelves[remoteIndex].bookIds.removeAll(where: {$0 == bookId}) - } else { - let localIndex = mergedShelves.firstIndex(where: {$0.name == localContainingShelf.name})! - mergedShelves[localIndex].bookIds.removeAll(where: {$0 == bookId}) + 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) - self.cloudKitData.shelves.localModificationDate = try record.localModificationDate + if shouldSaveMergedShelves { + self.cloudKitData.shelves.localModificationDate = .now + syncEngine.state.add(pendingRecordZoneChanges: [ + .saveRecord(CloudKitBookShelves.recordID) + ]) + } else { + self.cloudKitData.shelves.localModificationDate = try record.localModificationDate + } } } diff --git a/Util/Merger.swift b/Util/Merger.swift index b8d836f3..0766019c 100644 --- a/Util/Merger.swift +++ b/Util/Merger.swift @@ -48,8 +48,11 @@ nonisolated struct Merger { let ancestorMap = Self.makeMap(array: ancestor, id: id) if isOnlyOrderChanged(localMap, remoteMap) { - let localOrderChanged = isOnlyOrderChanged(localMap, ancestorMap) - let remoteOrderChanged = isOnlyOrderChanged(remoteMap, ancestorMap) + 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 @@ -122,6 +125,13 @@ nonisolated struct Merger { 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], From 285cc40993d20927da593cd8749ffb00518a2df2 Mon Sep 17 00:00:00 2001 From: Haruka Date: Sun, 14 Jun 2026 20:07:19 +0800 Subject: [PATCH 06/16] improve UI --- App/HoshiReader.swift | 39 +---- Features/Settings/AdvancedView.swift | 9 +- Features/Settings/CloudKitSyncView.swift | 133 ++++++++++++++++++ Features/Settings/SyncView.swift | 79 ----------- ...{CloudKitBook.swift => CloudKitFile.swift} | 29 ++++ Models/Statistics.swift | 2 +- 6 files changed, 176 insertions(+), 115 deletions(-) create mode 100644 Features/Settings/CloudKitSyncView.swift rename Models/{CloudKitBook.swift => CloudKitFile.swift} (94%) diff --git a/App/HoshiReader.swift b/App/HoshiReader.swift index 4749212a..9a480c59 100644 --- a/App/HoshiReader.swift +++ b/App/HoshiReader.swift @@ -14,16 +14,13 @@ import WebKit struct HoshiReaderApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Environment(\.scenePhase) private var scenePhase + @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? @State private var pendingTab: Int? @State private var didFinishLaunch = BookStorage.migrationsComplete - @State private var showSignOutConfirmation = false - @State private var cloudManagedBooks = [BookMetadata]() - @State private var showUploadLocalBooksConfirmation = false - @State private var showQuotaExceededConfirmation = false private var shortcutHandler = ShortcutHandler.shared init() { @@ -111,31 +108,6 @@ struct HoshiReaderApp: App { .task { await observeCloudKitEvents() } - .alert("Clear local books?", isPresented: $showSignOutConfirmation) { - Button("Confirm", role: .destructive) { - Task { - try await CloudKitSyncManager.shared.deleteLocal(books: cloudManagedBooks) - } - } - Button("Cancel", role: .cancel) { } - } message: { - Text("You have logged out iCloud account. Do you want to clear local book data of the previous account?") - } - .alert("Upload local books?", isPresented: $showUploadLocalBooksConfirmation) { - Button("Upload") { - Task { - try? await CloudKitSyncManager.shared.uploadUnmanagedBooks() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("You have logged in a new iCloud account. Do you want to upload local books to this iCloud server?") - } - .alert("iCloud Storage Full", isPresented: $showQuotaExceededConfirmation) { - Button("OK") {} - } message: { - Text("iCloud syncing has been disabled because you have run out of iCloud space. Please free up space or upgrade your storage.") - } } } @@ -166,20 +138,19 @@ struct HoshiReaderApp: App { let onError: @MainActor (CloudKitSyncManager.Event) -> Void = { event in if case let .account(accountEvent) = event { switch accountEvent { - case .signOut(managedBooks: let managedBooks): - self.cloudManagedBooks = managedBooks - showSignOutConfirmation = true + case .signOut: userConfig.enableCloudKitSync = false + cloudKitStatus = .signOut case .signIn: fallthrough case .accountChanged: - showUploadLocalBooksConfirmation = true + cloudKitStatus = .none } } else if case let .error(syncError) = event { switch syncError { case .quotaExceeded: - showQuotaExceededConfirmation = true userConfig.enableCloudKitSync = false + cloudKitStatus = .quotaExceeded } } } 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/CloudKitSyncView.swift b/Features/Settings/CloudKitSyncView.swift new file mode 100644 index 00000000..65a2d15d --- /dev/null +++ b/Features/Settings/CloudKitSyncView.swift @@ -0,0 +1,133 @@ +// +// 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 + @State private var showUploadLocalBooksConfirmation = false + @State private var showClearLocalBooksConfirmation = false + @State private var showClearCloudKitConfirmation = 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") + } + } + } + if userConfig.enableCloudKitSync { + Section { + Button("Upload Unsynced Local Books") { + showUploadLocalBooksConfirmation.toggle() + } + + Button("Clear Unsynced Local Books", role: .destructive) { + showClearLocalBooksConfirmation.toggle() + } + + Button("Clear iCloud data", role: .destructive) { + showClearCloudKitConfirmation.toggle() + } + } + } + } + .task { + await iCloudStatusRefresh() + let onChanged: @MainActor (CloudKitSyncManager.Event) -> Void = { event in + guard case .account = event else { return } + + Task { + await self.iCloudStatusRefresh() + } + } + await CloudKitSyncManager.shared.observeEvents(onChanged) + } + .navigationTitle("iCloud Syncing") + .alert("Upload local books?", isPresented: $showUploadLocalBooksConfirmation) { + Button("Confirm") { + Task { + try? await CloudKitSyncManager.shared.uploadUnmanagedBooks() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will upload local only books to iCloud server.") + } + .alert("Clear local books?", isPresented: $showClearLocalBooksConfirmation) { + Button("Confirm", role: .destructive) { + Task { + try await CloudKitSyncManager.shared.deleteLocalBooks(isManaged: false) + } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("This will clear all data of local only books") + } + .alert("Clear iCloud data?", isPresented: $showClearCloudKitConfirmation) { + Button("Confirm", role: .destructive) { + Task { + await CloudKitSyncManager.shared.deleteServerData() + } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("This will clear all data on iCloud server.") + } + } + + private func iCloudStatusRefresh() async { + do { + let accountStatus = try await CloudKitSyncManager.container.accountStatus() + iCloudAvailable = accountStatus == .available + } catch { + iCloudAvailable = false + } + } +} diff --git a/Features/Settings/SyncView.swift b/Features/Settings/SyncView.swift index d9e6c6b8..783aec06 100644 --- a/Features/Settings/SyncView.swift +++ b/Features/Settings/SyncView.swift @@ -7,7 +7,6 @@ // import SwiftUI -import CloudKit struct SyncView: View { @Environment(UserConfig.self) var userConfig @@ -17,37 +16,9 @@ struct SyncView: View { @State private var showClearCacheConfirmation = false @State private var showSignOutConfirmation = false - @State private var iCloudAvailable = false - @State private var showUploadLocalBooksConfirmation = false - @State private var showClearLocalBooksConfirmation = false - @State private var showClearCloudKitConfirmation = false - var body: some View { @Bindable var userConfig = userConfig List { - Section { - Toggle("Enable iCloud Sync", isOn: $userConfig.enableCloudKitSync) - .disabled(!iCloudAvailable) - - if userConfig.enableCloudKitSync { - Button("Upload Unsynced Local Books") { - showUploadLocalBooksConfirmation.toggle() - } - - Button("Clear Unsynced Local Books", role: .destructive) { - showClearLocalBooksConfirmation.toggle() - } - - Button("Clear iCloud data", role: .destructive) { - showClearCloudKitConfirmation.toggle() - } - } - } footer: { - if !iCloudAvailable { - Text("Log in to an iCloud account to sync with iCloud.") - } - } - Section { Toggle("Enable", isOn: $userConfig.enableSync) } footer: { @@ -134,17 +105,6 @@ struct SyncView: View { } } } - .task { - await iCloudStatusRefresh() - let onChanged: @MainActor (CloudKitSyncManager.Event) -> Void = { event in - guard case .account = event else { return } - - Task { - await self.iCloudStatusRefresh() - } - } - await CloudKitSyncManager.shared.observeEvents(onChanged) - } .navigationTitle("Syncing") .alert("Error", isPresented: $showError) { Button("OK") { } @@ -172,36 +132,6 @@ struct SyncView: View { } message: { Text("Signing out will clear authorization tokens, cached folder ids and book covers.") } - .alert("Upload local books?", isPresented: $showUploadLocalBooksConfirmation) { - Button("Confirm") { - Task { - try? await CloudKitSyncManager.shared.uploadUnmanagedBooks() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will upload local only books to iCloud server.") - } - .alert("Clear local books?", isPresented: $showClearLocalBooksConfirmation) { - Button("Confirm", role: .destructive) { - Task { - try await CloudKitSyncManager.shared.deleteLocalBooks(isManaged: false) - } - } - Button("Cancel", role: .cancel) { } - } message: { - Text("This will clear all data of local only books") - } - .alert("Clear iCloud data?", isPresented: $showClearCloudKitConfirmation) { - Button("Confirm", role: .destructive) { - Task { - await CloudKitSyncManager.shared.deleteServerData() - } - } - Button("Cancel", role: .cancel) { } - } message: { - Text("This will clear all data on iCloud server.") - } } private func textOfSyncMode(_ mode: SyncMode) -> some View { @@ -212,13 +142,4 @@ struct SyncView: View { Text("Manual") } } - - private func iCloudStatusRefresh() async { - do { - let accountStatus = try await CloudKitSyncManager.container.accountStatus() - iCloudAvailable = accountStatus == .available - } catch { - iCloudAvailable = false - } - } } diff --git a/Models/CloudKitBook.swift b/Models/CloudKitFile.swift similarity index 94% rename from Models/CloudKitBook.swift rename to Models/CloudKitFile.swift index 92a97439..aaadcac8 100644 --- a/Models/CloudKitBook.swift +++ b/Models/CloudKitFile.swift @@ -391,3 +391,32 @@ nonisolated extension CKRecord { 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/Statistics.swift b/Models/Statistics.swift index 1ae31957..5bbcc251 100644 --- a/Models/Statistics.swift +++ b/Models/Statistics.swift @@ -15,7 +15,7 @@ enum StatisticsAutostartMode: String, CaseIterable, Codable { case on = "On" } -nonisolated enum StatisticsSyncMode: String, CaseIterable, Codable { +enum StatisticsSyncMode: String, CaseIterable, Codable { case merge = "Merge" case replace = "Replace" } From 9a8925b5c5fab0e3e17866b8816383db448711c7 Mon Sep 17 00:00:00 2001 From: Haruka Date: Mon, 15 Jun 2026 16:43:32 +0800 Subject: [PATCH 07/16] streamline modification date comparison Co-authored-by: Manhhao <62149544+Manhhao@users.noreply.github.com> --- Features/Sync/CloudKitSyncManager.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift index 5532f12c..be0e03e1 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -370,13 +370,7 @@ extension CloudKitSyncManager { var shouldReplace = false if let localFile { - shouldReplace = try { - let localModified = localFile.localModificationDate - let remoteModified = try record.localModificationDate - if localModified > remoteModified { return false } - if remoteModified > localModified { return true } - return false - }() + shouldReplace = try record.localModificationDate > localFile.localModificationDate } else { shouldReplace = true } From a688d3f9f37d45eceb8ca499bd90913c9bbb731e Mon Sep 17 00:00:00 2001 From: Haruka Date: Mon, 15 Jun 2026 17:21:25 +0800 Subject: [PATCH 08/16] refactor --- Core/BookStorage.swift | 63 +++++---------------- Features/Bookshelf/BookshelfViewModel.swift | 2 +- Features/Sync/CloudKitSyncManager.swift | 38 ++----------- Models/CloudKitFile.swift | 2 +- 4 files changed, 19 insertions(+), 86 deletions(-) diff --git a/Core/BookStorage.swift b/Core/BookStorage.swift index 7641f8af..e599cee9 100644 --- a/Core/BookStorage.swift +++ b/Core/BookStorage.swift @@ -268,56 +268,19 @@ struct BookStorage { return } let folderName = url.deletingLastPathComponent().lastPathComponent - if T.self == BookInfo.self { - Task { - await CloudKitSyncManager.shared.saveCloudFile( - uuid: metadata.id, - fileType: .bookinfo, - fileName: FileNames.bookinfo, - folderName: folderName, - createCloudBook: createCloudBook - ) - } - } else if T.self == Bookmark.self { - Task { - await CloudKitSyncManager.shared.saveCloudFile( - uuid: metadata.id, - fileType: .bookmark, - fileName: FileNames.bookmark, - folderName: folderName, - createCloudBook: createCloudBook - ) - } - } else if T.self == [Highlight].self { - Task { - await CloudKitSyncManager.shared.saveCloudFile( - uuid: metadata.id, - fileType: .highlights, - fileName: FileNames.highlights, - folderName: folderName, - createCloudBook: createCloudBook - ) - } - } else if T.self == [Statistics].self { - Task { - await CloudKitSyncManager.shared.saveCloudFile( - uuid: metadata.id, - fileType: .statistics, - fileName: FileNames.statistics, - folderName: folderName, - createCloudBook: createCloudBook - ) - } - } else if T.self == SasayakiPlaybackData.self { - Task { - await CloudKitSyncManager.shared.saveCloudFile( - uuid: metadata.id, - fileType: .sasayakiPlayback, - fileName: FileNames.sasayakiPlayback, - folderName: folderName, - createCloudBook: createCloudBook - ) - } + let fileName = url.lastPathComponent + guard let fileType = CloudKitFileType(fileName: fileName) else { + CloudKitSyncManager.logger.error("Tried to upload an known file type in \(folderName, privacy: .public)/\(fileName, privacy: .public)") + return + } + Task { + await CloudKitSyncManager.shared.saveCloudFile( + uuid: metadata.id, + fileType: fileType, + fileName: fileName, + folderName: folderName, + createCloudBook: createCloudBook + ) } } diff --git a/Features/Bookshelf/BookshelfViewModel.swift b/Features/Bookshelf/BookshelfViewModel.swift index ec59b27b..a446d634 100644 --- a/Features/Bookshelf/BookshelfViewModel.swift +++ b/Features/Bookshelf/BookshelfViewModel.swift @@ -314,7 +314,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) } } diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift index be0e03e1..c4d7c06f 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -1,5 +1,5 @@ // -// CloudKitSyncHanlder.swift +// CloudKitSyncManager.swift // Hoshi Reader // // Copyright © 2026 Manhhao. @@ -343,23 +343,13 @@ extension CloudKitSyncManager { } switch fileType { - case .metadata: - try replaceIfNewer(record: record) - case .bookmark: - try replaceIfNewer(record: record) - case .bookinfo: - try replaceIfNewer(record: record) case .shelves: try mergeShelves(record: record) case .statistics: try mergeStats(record: record) - case .sasayakiPlayback: - try replaceIfNewer(record: record) case .highlights: try mergeHighlights(record: record) - case .cover: - try replaceIfNewer(record: record) - case .book: + default: try replaceIfNewer(record: record) } } @@ -559,15 +549,6 @@ extension CloudKitSyncManager { ]) } - func refresh() { - Task { - var fetchChangesOptions = CKSyncEngine.FetchChangesOptions() - fetchChangesOptions.prioritizedZoneIDs = Self.prioritizedZoneIDs - try? await syncEngine.fetchChanges(fetchChangesOptions) - try? await syncEngine.sendChanges() - } - } - func uploadUnmanagedBooks() throws { let unmanagedBooks = try getBooks(isManaged: false) for book in unmanagedBooks { @@ -702,19 +683,8 @@ extension CloudKitSyncManager { func observeEvents(_ eventHandler: @escaping @MainActor (CloudKitSyncManager.Event) -> Void) async { let id = UUID() eventHandlers[id] = eventHandler - defer { - eventHandlers[id] = nil - } - - while !Task.isCancelled { - do { - try await Task.sleep(for: .seconds(60)) - } catch is CancellationError { - break - } catch { - break - } - } + defer { eventHandlers[id] = nil } + try? await Task.sleep(for: .seconds(Int32.max)) } private func fire(event: CloudKitSyncManager.Event) { diff --git a/Models/CloudKitFile.swift b/Models/CloudKitFile.swift index aaadcac8..eb026c59 100644 --- a/Models/CloudKitFile.swift +++ b/Models/CloudKitFile.swift @@ -1,5 +1,5 @@ // -// CloudKitBook.swift +// CloudKitFile.swift // Hoshi Reader // // Copyright © 2026 Manhhao. From 1c35890214c030dc46af9dbfa9c2361a9c36c441 Mon Sep 17 00:00:00 2001 From: Haruka Date: Mon, 15 Jun 2026 20:24:29 +0800 Subject: [PATCH 09/16] skip loading BookMetadata if the book file has not arrived --- Core/BookStorage.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Core/BookStorage.swift b/Core/BookStorage.swift index e599cee9..f6d55228 100644 --- a/Core/BookStorage.swift +++ b/Core/BookStorage.swift @@ -363,7 +363,12 @@ struct BookStorage { if FileManager.default.fileExists(atPath: metadataURL.path(percentEncoded: false)) { let data = try Data(contentsOf: metadataURL) let book = try JSONDecoder().decode(BookMetadata.self, from: data) - books.append(book) + if let bookFileName = book.epub { + let bookFileURL = url.appending(path: bookFileName) + if FileManager.default.fileExists(atPath: bookFileURL.path(percentEncoded: false)) { + books.append(book) + } + } } } From 9e066f027a530e9a8990e18446c7508b195cf227 Mon Sep 17 00:00:00 2001 From: Manhhao Date: Thu, 18 Jun 2026 13:04:52 +0200 Subject: [PATCH 10/16] set up entitlements --- Features/Sync/CloudKitSyncManager.swift | 2 +- Hoshi Reader.entitlements | 16 ++++++ Hoshi Reader.xcodeproj/project.pbxproj | 6 ++- HoshiReader-Info.plist | 1 + Localizable.xcstrings | 66 +++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 Hoshi Reader.entitlements diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift index c4d7c06f..6dc7db12 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -19,7 +19,7 @@ actor CloudKitSyncManager { category: "CloudKitSync" ) - static var container: CKContainer { CKContainer(identifier: "iCloud.com.youwu.hoshi") } + static var container: CKContainer { CKContainer(identifier: "iCloud.de.manhhao.hoshi") } private static let prioritizedZoneIDs = [CKRecordZone.ID(zoneName: CloudKitBookFile.zoneName)] 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 2a944bcf..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,7 +80,7 @@ membershipExceptions = ( Anki.swift, Book.swift, - CloudKitBook.swift, + CloudKitFile.swift, Dictionary.swift, Highlight.swift, Sasayaki.swift, @@ -181,6 +182,7 @@ 48107AAE2F114021009E12D1 = { isa = PBXGroup; children = ( + 48E033D82FE403D800E448A3 /* Hoshi Reader.entitlements */, 90F2F2642FBCA7C000FEABB6 /* Dictionaries.xcstrings */, 90F2F2652FBCA7C000FEABB6 /* Localizable.xcstrings */, 48568A622F1E926300F9C126 /* App */, @@ -489,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; @@ -530,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" : { From c1e1a3563072826b19c5a7b884f08034a99570b5 Mon Sep 17 00:00:00 2001 From: Haruka Date: Sat, 20 Jun 2026 23:13:34 +0800 Subject: [PATCH 11/16] manage epub files manually --- App/HoshiReader.swift | 2 +- Core/BookStorage.swift | 15 +- Core/UserConfig.swift | 2 +- Features/Bookshelf/BookCell.swift | 37 +- Features/Bookshelf/BookView.swift | 6 +- Features/Bookshelf/BookshelfViewModel.swift | 20 +- .../Reader/ReaderView/ReaderViewModel.swift | 2 +- Features/Settings/CloudKitSyncView.swift | 48 --- Features/Sync/CloudKitSyncManager.swift | 374 ++++++++++++------ Models/CloudKitFile.swift | 75 +++- 10 files changed, 357 insertions(+), 224 deletions(-) diff --git a/App/HoshiReader.swift b/App/HoshiReader.swift index 9a480c59..cd6d9f20 100644 --- a/App/HoshiReader.swift +++ b/App/HoshiReader.swift @@ -48,7 +48,7 @@ struct HoshiReaderApp: App { WebViewPreloader.shared.warmup() if userConfig.enableCloudKitSync { Task { - await CloudKitSyncManager.shared.initializeSyncEngine() + await CloudKitSyncManager.shared.initialize() } } } diff --git a/Core/BookStorage.swift b/Core/BookStorage.swift index f6d55228..2d4ccad0 100644 --- a/Core/BookStorage.swift +++ b/Core/BookStorage.swift @@ -223,12 +223,12 @@ struct BookStorage { try FileManager.default.removeItem(at: url) } - static func save(_ object: T, inside directory: URL, as fileName: String, createCloudBook: Bool = false) throws { + static func save(_ object: T, inside directory: URL, as fileName: String) throws { let targetURL = directory.appendingPathComponent(fileName) try saveLocal(object, url: targetURL) - saveCloudKitFile(object, url: targetURL, createCloudBook: createCloudBook) + saveCloudKitFile(object, url: targetURL) } nonisolated static func saveLocal(_ object: T, url: URL) throws { @@ -239,7 +239,7 @@ struct BookStorage { try data.write(to: url, options: .atomic) } - static func saveCloudKitFile(_ object: T, url: URL, createCloudBook: Bool) { + static func saveCloudKitFile(_ object: T, url: URL) { guard UserConfig.shared.enableCloudKitSync else { return } if T.self == [BookShelf].self { @@ -257,7 +257,6 @@ struct BookStorage { fileType: .metadata, fileName: FileNames.metadata, folderName: url.deletingLastPathComponent().lastPathComponent, - createCloudBook: createCloudBook ) } return @@ -279,7 +278,6 @@ struct BookStorage { fileType: fileType, fileName: fileName, folderName: folderName, - createCloudBook: createCloudBook ) } } @@ -363,12 +361,7 @@ struct BookStorage { if FileManager.default.fileExists(atPath: metadataURL.path(percentEncoded: false)) { let data = try Data(contentsOf: metadataURL) let book = try JSONDecoder().decode(BookMetadata.self, from: data) - if let bookFileName = book.epub { - let bookFileURL = url.appending(path: bookFileName) - if FileManager.default.fileExists(atPath: bookFileURL.path(percentEncoded: false)) { - books.append(book) - } - } + books.append(book) } } diff --git a/Core/UserConfig.swift b/Core/UserConfig.swift index fdddeeac..047be0d9 100644 --- a/Core/UserConfig.swift +++ b/Core/UserConfig.swift @@ -164,7 +164,7 @@ class UserConfig { UserDefaults.standard.set(enableCloudKitSync, forKey: "enableCloudKitSync") if enableCloudKitSync { Task { - await CloudKitSyncManager.shared.initializeSyncEngine() + await CloudKitSyncManager.shared.initialize() } } else { Task { diff --git a/Features/Bookshelf/BookCell.swift b/Features/Bookshelf/BookCell.swift index a7a29a94..dae43d6c 100644 --- a/Features/Bookshelf/BookCell.swift +++ b/Features/Bookshelf/BookCell.swift @@ -13,7 +13,7 @@ struct BookCell: View { @State private var markReadConfirmation = false @State private var showRenameAlert = false @State private var renameText = "" - @State private var isCloudManaged = true + @State private var downloaded = true let book: BookMetadata var viewModel: BookshelfViewModel var currentShelf: String? @@ -41,18 +41,19 @@ struct BookCell: View { onSelect() } } label: { - BookView(book: book, progress: viewModel.progress(for: book), isCloudManaged: isCloudManaged, isSelected: isSelecting && isSelected) + BookView(book: book, progress: viewModel.progress(for: book), downloaded: downloaded, isSelected: isSelecting && isSelected) } .task(id: userConfig.enableCloudKitSync) { - isCloudManaged = await CloudKitSyncManager.shared.isManaged(uuid: book.id) + refreshEpubState(book: book) guard userConfig.enableCloudKitSync else { return } - let refreshManagedState: @MainActor (CloudKitSyncManager.Event) -> Void = { _ in - Task { - isCloudManaged = await CloudKitSyncManager.shared.isManaged(uuid: book.id) + let refresh: @MainActor (CloudKitSyncManager.Event) -> Void = { event in + if case let .epubDownloaded(uuid: uuid) = event, + uuid == book.id { + self.refreshEpubState(book: book) } } - await CloudKitSyncManager.shared.observeEvents(refreshManagedState) + await CloudKitSyncManager.shared.observeEvents(refresh) } .buttonStyle(.plain) .contextMenu(isSelecting ? nil : ContextMenu { @@ -77,14 +78,14 @@ struct BookCell: View { } } - if userConfig.enableCloudKitSync && !isCloudManaged { + if userConfig.enableCloudKitSync && !downloaded { Button { Task { - try? await CloudKitSyncManager.shared.uploadUnmanagedBook(book) - isCloudManaged = await CloudKitSyncManager.shared.isManaged(uuid: book.id) + guard let cloudEpub = CloudKitBookEpub(from: book) else { return } + await CloudKitSyncManager.shared.downloadCloudEpub(cloudEpub) } } label: { - Label("Sync to iCloud", systemImage: "icloud") + Label("Download from iCloud", systemImage: "icloud") } } @@ -194,4 +195,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 95f33e61..e38ba177 100644 --- a/Features/Bookshelf/BookView.swift +++ b/Features/Bookshelf/BookView.swift @@ -12,12 +12,12 @@ struct BookView: View { @Environment(UserConfig.self) var userConfig let book: BookMetadata let progress: Double - let isCloudManaged: Bool + let downloaded: Bool var isSelected: Bool = false var titleText: Text { - if userConfig.enableCloudKitSync && !isCloudManaged { - return Text(Image(systemName: "icloud.slash")) + Text(" ") + Text(book.displayTitle) + if userConfig.enableCloudKitSync && !downloaded { + return Text(Image(systemName: "icloud")) + Text(" ") + Text(book.displayTitle) } else { return Text(book.displayTitle) } diff --git a/Features/Bookshelf/BookshelfViewModel.swift b/Features/Bookshelf/BookshelfViewModel.swift index a446d634..887de653 100644 --- a/Features/Bookshelf/BookshelfViewModel.swift +++ b/Features/Bookshelf/BookshelfViewModel.swift @@ -176,11 +176,10 @@ class BookshelfViewModel { } } - func deleteCloudBook(_ book: borrowing BookMetadata) { + func deleteCloudBook(_ book: BookMetadata) { guard UserConfig.shared.enableCloudKitSync else { return } - let uuid = book.id Task { - await CloudKitSyncManager.shared.deleteCloudBook(uuid: uuid) + await CloudKitSyncManager.shared.deleteCloudBook(book) } } @@ -533,8 +532,8 @@ class BookshelfViewModel { let bookinfo = BookProcessor.process(document: document) - try BookStorage.save(metadata, inside: bookFolder, as: FileNames.metadata, createCloudBook: true) - try BookStorage.save(bookinfo, inside: bookFolder, as: FileNames.bookinfo, createCloudBook: true) + try BookStorage.save(metadata, inside: bookFolder, as: FileNames.metadata) + try BookStorage.save(bookinfo, inside: bookFolder, as: FileNames.bookinfo) if UserConfig.shared.enableCloudKitSync { Task.detached { @@ -545,16 +544,11 @@ class BookshelfViewModel { fileType: .cover, fileName: (coverURL as NSString).lastPathComponent, folderName: folderName, - createCloudBook: true ) } - await CloudKitSyncManager.shared.saveCloudFile( - uuid: metadata.id, - fileType: .book, - fileName: localURL.lastPathComponent, - folderName: folderName, - createCloudBook: true - ) + if let cloudKitEpub = CloudKitBookEpub(from: metadata) { + await CloudKitSyncManager.shared.saveCloudEpub(cloudKitEpub) + } } } } catch { diff --git a/Features/Reader/ReaderView/ReaderViewModel.swift b/Features/Reader/ReaderView/ReaderViewModel.swift index 3d05fe30..83520f4c 100644 --- a/Features/Reader/ReaderView/ReaderViewModel.swift +++ b/Features/Reader/ReaderView/ReaderViewModel.swift @@ -351,7 +351,7 @@ class ReaderViewModel { case .zones: break } - case .account, .error: + case .account, .error, .epubDownloaded: break } } diff --git a/Features/Settings/CloudKitSyncView.swift b/Features/Settings/CloudKitSyncView.swift index 65a2d15d..9d9d3020 100644 --- a/Features/Settings/CloudKitSyncView.swift +++ b/Features/Settings/CloudKitSyncView.swift @@ -14,9 +14,6 @@ struct CloudKitSyncView: View { @AppStorage("cloudKitStatus") private var cloudKitStatus = CloudKitStatus.none @State private var iCloudAvailable = false - @State private var showUploadLocalBooksConfirmation = false - @State private var showClearLocalBooksConfirmation = false - @State private var showClearCloudKitConfirmation = false var body: some View { @Bindable var userConfig = userConfig @@ -62,21 +59,6 @@ struct CloudKitSyncView: View { } } } - if userConfig.enableCloudKitSync { - Section { - Button("Upload Unsynced Local Books") { - showUploadLocalBooksConfirmation.toggle() - } - - Button("Clear Unsynced Local Books", role: .destructive) { - showClearLocalBooksConfirmation.toggle() - } - - Button("Clear iCloud data", role: .destructive) { - showClearCloudKitConfirmation.toggle() - } - } - } } .task { await iCloudStatusRefresh() @@ -90,36 +72,6 @@ struct CloudKitSyncView: View { await CloudKitSyncManager.shared.observeEvents(onChanged) } .navigationTitle("iCloud Syncing") - .alert("Upload local books?", isPresented: $showUploadLocalBooksConfirmation) { - Button("Confirm") { - Task { - try? await CloudKitSyncManager.shared.uploadUnmanagedBooks() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will upload local only books to iCloud server.") - } - .alert("Clear local books?", isPresented: $showClearLocalBooksConfirmation) { - Button("Confirm", role: .destructive) { - Task { - try await CloudKitSyncManager.shared.deleteLocalBooks(isManaged: false) - } - } - Button("Cancel", role: .cancel) { } - } message: { - Text("This will clear all data of local only books") - } - .alert("Clear iCloud data?", isPresented: $showClearCloudKitConfirmation) { - Button("Confirm", role: .destructive) { - Task { - await CloudKitSyncManager.shared.deleteServerData() - } - } - Button("Cancel", role: .cancel) { } - } message: { - Text("This will clear all data on iCloud server.") - } } private func iCloudStatusRefresh() async { diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift index 6dc7db12..2ea4e8f9 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -41,7 +41,7 @@ actor CloudKitSyncManager { private var syncEngine: CKSyncEngine { if _syncEngine == nil { - initializeSyncEngine() + initializeSyncEngineWithoutCheck() } return _syncEngine! } @@ -56,7 +56,7 @@ actor CloudKitSyncManager { } } - func initializeSyncEngine() { + private func initializeSyncEngineWithoutCheck() { let configuration = CKSyncEngine.Configuration( database: Self.container.privateCloudDatabase, stateSerialization: cloudKitData.stateSerialization, @@ -66,6 +66,17 @@ actor CloudKitSyncManager { 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() @@ -83,16 +94,14 @@ extension CloudKitSyncManager: CKSyncEngineDelegate { switch event { case .stateUpdate(let stateUpdate): handleStateUpdate(stateUpdate) - case .fetchedDatabaseChanges(let fetchedDatabaseChanges): - handleFetchedDatabaseChanges(fetchedDatabaseChanges) case .fetchedRecordZoneChanges(let fetchedRecordZoneChanges): handleFetchedRecordZoneChanges(fetchedRecordZoneChanges) - case .sentDatabaseChanges(let sentDatabaseChanges): - handleSentDatabaseChanges(sentDatabaseChanges) 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: @@ -138,7 +147,7 @@ extension CloudKitSyncManager: CKSyncEngineDelegate { func nextFetchChangesOptions(_ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.FetchChangesOptions { var options = context.options - options.prioritizedZoneIDs = Self.prioritizedZoneIDs + options.scope = .zoneIDs([CloudKitBookFile.zoneID]) return options } } @@ -149,17 +158,6 @@ private extension CloudKitSyncManager { cloudKitData.stateSerialization = stateUpdate.stateSerialization } - private func handleFetchedDatabaseChanges(_ fetchedDatabaseChanges: CKSyncEngine.Event.FetchedDatabaseChanges) { - for deletion in fetchedDatabaseChanges.deletions { - let zoneName = deletion.zoneID.zoneName - if zoneName == CloudKitBookFile.zoneName || zoneName == CloudKitBookFile.assetZoneName { - self.cloudKitData.books = [:] - self.cloudKitData.shelves = .init(localModificationDate: .distantPast) - fire(event: .delete(.zones)) - } - } - } - private func handleFetchedRecordZoneChanges(_ fetchedRecordZoneChanges: CKSyncEngine.Event.FetchedRecordZoneChanges) { for modification in fetchedRecordZoneChanges.modifications { let modifiedRecord = modification.record @@ -188,7 +186,10 @@ private extension CloudKitSyncManager { continue } do { - try deleteLocal(recordID: deletedRecordID) + if fileType == .metadata, + let metadata = try cloudKitData.books[uuid]?[fileType]?.decode(to: BookMetadata.self) { + try deleteLocal(books: [metadata]) + } cloudKitData.books[uuid]?[fileType] = nil fire(event: .delete(.book(uuid: uuid))) } catch { @@ -197,21 +198,6 @@ private extension CloudKitSyncManager { } } - private func handleSentDatabaseChanges(_ sentDatabaseChanges: CKSyncEngine.Event.SentDatabaseChanges) { - let deletedZoneIds = sentDatabaseChanges.deletedZoneIDs - var shouldDeleteCloudKitData = false - for deletedZoneId in deletedZoneIds { - if deletedZoneId.zoneName == CloudKitBookFile.zoneName || deletedZoneId.zoneName == CloudKitBookFile.assetZoneName { - shouldDeleteCloudKitData = true - } - } - if shouldDeleteCloudKitData { - self.cloudKitData.books = [:] - self.cloudKitData.shelves = .init(localModificationDate: .distantPast) - fire(event: .delete(.zones)) - } - } - private func handleSentRecordZoneChanges(_ sentRecordZoneChanges: CKSyncEngine.Event.SentRecordZoneChanges) { var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] @@ -299,25 +285,30 @@ private extension CloudKitSyncManager { case .signIn: syncEngine.state.add(pendingDatabaseChanges: [ .saveZone(CKRecordZone(zoneName: CloudKitBookFile.zoneName)), - .saveZone(CKRecordZone(zoneName: CloudKitBookFile.assetZoneName)), ]) fire(event: .account(.signIn)) case .signOut: - let managedBooks: [BookMetadata] do { - managedBooks = try getBooks(isManaged: true) + try deleteLocalBooksWithoutEpub() } catch { - managedBooks = [] - logger.error("Failed to get managed books of previous user when signing out") + logger.error("Failed to delete books without epub file when logging out") } disableSync() self.cloudKitData = CloudKitData() - fire(event: .account(.signOut(managedBooks: managedBooks))) + 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() - initializeSyncEngine() + disableSync() fire(event: .account(.accountChanged)) + Task { + await initialize() + } @unknown default: break } @@ -488,7 +479,7 @@ extension CloudKitSyncManager { if shouldSaveMergedShelves { self.cloudKitData.shelves.localModificationDate = .now syncEngine.state.add(pendingRecordZoneChanges: [ - .saveRecord(CloudKitBookShelves.recordID) + .saveRecord(self.cloudKitData.shelves.recordID) ]) } else { self.cloudKitData.shelves.localModificationDate = try record.localModificationDate @@ -498,17 +489,9 @@ extension CloudKitSyncManager { // MARK: - Sending Data extension CloudKitSyncManager { - // a tricky point: when should we modify `self.cloudKitData`? Before `state.add` or after `sentRecordZoneChanges`? Here we choose the way like example in Apple example repo - - /// - Parameters: - /// - createCloudBook: If this book is not managed by iCloud, should we continue saving this file? - func saveCloudFile(uuid: UUID, fileType: CloudKitFileType, fileName: String, folderName: String, createCloudBook: Bool = false) { + func saveCloudFile(uuid: UUID, fileType: CloudKitFileType, fileName: String, folderName: String) { if self.cloudKitData.books[uuid] == nil { - if createCloudBook { - self.cloudKitData.books[uuid] = [:] - } else { - return - } + self.cloudKitData.books[uuid] = [:] } if let cloudFile = self.cloudKitData.books[uuid]?[fileType] { self.cloudKitData.books[uuid]?[fileType]!.localModificationDate = .now @@ -525,69 +508,233 @@ extension CloudKitSyncManager { } /// delete a book stored in iCloud server. If this book is not synced, no-op - func deleteCloudBook(uuid: UUID) { - guard let files = self.cloudKitData.books[uuid] else { return } - self.cloudKitData.books[uuid] = nil + 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( - CloudKitBookShelves.recordID + 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 { - func deleteServerData() { - syncEngine.state.add(pendingDatabaseChanges: [ - .deleteZone(CKRecordZone.ID(zoneName: CloudKitBookFile.zoneName)), - .deleteZone(CKRecordZone.ID(zoneName: CloudKitBookFile.assetZoneName)) - ]) + 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 } - func uploadUnmanagedBooks() throws { - let unmanagedBooks = try getBooks(isManaged: false) - for book in unmanagedBooks { - try uploadUnmanagedBook(book) + 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 } - func uploadUnmanagedBook(_ book: BookMetadata) throws { - if self.cloudKitData.books[book.id] != nil { return } - let booksRootDir = try BookStorage.getBooksDirectory() - let bookDir = booksRootDir.appending(path: book.folder) - let bookFileURLs = try FileManager.default.contentsOfDirectory(at: bookDir, includingPropertiesForKeys: nil) - 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, - createCloudBook: true - ) + 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 } - if let coverName = (book.cover as? NSString)?.lastPathComponent { - saveCloudFile( - uuid: book.id, - fileType: .cover, - fileName: coverName, - folderName: book.folder, - createCloudBook: true - ) + + 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 let bookName = book.epub { - saveCloudFile( - uuid: book.id, - fileType: .book, - fileName: bookName, - folderName: book.folder, - createCloudBook: true - ) + } + + 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() } } } @@ -597,13 +744,6 @@ extension CloudKitSyncManager { private static let cloudKitDataFileName = "cloudkit.json" - private func getBooks(isManaged: Bool) throws -> [BookMetadata] { - let managedBookIds = Set(self.cloudKitData.books.keys) - let localBooks = try BookStorage.loadAllBooks() - let targetBooks = localBooks.filter({ managedBookIds.contains($0.id) == isManaged }) - return targetBooks - } - private static var cloudKitDataURL: URL { get throws { let cloudKitDirURl = try BookStorage.getCloudKitSyncDirectory() @@ -619,16 +759,6 @@ extension CloudKitSyncManager { try BookStorage.saveLocal(cloudKitData, url: fileURL) } - // Deleting the whole shelf file is not supported - private func deleteLocal(recordID: CKRecord.ID) throws { - let recordName = recordID.recordName - let (uuid, fileType) = try CKRecord.parseRecordName(recordName) - let fileURL = try cloudKitData.books[uuid]?[fileType]?.fileURL - if let fileURL, FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) { - try BookStorage.delete(at: fileURL) - } - } - func deleteLocal(books: [BookMetadata]) throws { let bookRootDir = try BookStorage.getBooksDirectory() for book in books { @@ -644,23 +774,24 @@ extension CloudKitSyncManager { try BookStorage.saveLocal(shelves, url: bookRootDir.appending(path: FileNames.shelves)) } - func deleteLocalBooks(isManaged: Bool) throws { - let targetBooks = try getBooks(isManaged: isManaged) - try deleteLocal(books: targetBooks) + 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: - other internal APIs -extension CloudKitSyncManager { - func isManaged(uuid: UUID) -> Bool { cloudKitData.books[uuid] != nil } -} - // MARK: - callbacks extension CloudKitSyncManager { nonisolated enum Event { enum AccountEvent { case signIn - case signOut(managedBooks: [BookMetadata]) + case signOut case accountChanged } @@ -675,6 +806,7 @@ extension CloudKitSyncManager { case fetched(uuid: UUID) case sent(uuid: UUID, success: Bool) + case epubDownloaded(uuid: UUID) case delete(DeleteEvent) case account(AccountEvent) case error(SyncError) diff --git a/Models/CloudKitFile.swift b/Models/CloudKitFile.swift index eb026c59..c21ffad0 100644 --- a/Models/CloudKitFile.swift +++ b/Models/CloudKitFile.swift @@ -30,7 +30,7 @@ nonisolated enum CloudKitFileType: String, Codable, CustomStringConvertible { var description: String { self.rawValue } - var isAssetType: Bool { self == .cover || self == .book} + var isAssetType: Bool { self == .cover || self == .book } init?(fileName: String) { switch fileName { @@ -79,17 +79,29 @@ nonisolated enum CloudKitFileType: String, Codable, CustomStringConvertible { // 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 { @@ -185,13 +197,7 @@ nonisolated struct CloudKitBookFile: Codable { nonisolated extension CloudKitBookFile: CloudKitFile { - static let zoneName: String = "HoshiBooks" - static let assetZoneName: String = "HoshiBookAssets" - var recordName: String { "\(uuid.uuidString)___\(type.rawValue)" } - var recordType: CKRecord.RecordType { type.rawValue } - var zoneID: CKRecordZone.ID { .init(zoneName: type.isAssetType ? Self.assetZoneName : Self.zoneName) } - var recordID: CKRecord.ID { .init(recordName: recordName, zoneID: zoneID) } var fileURL: URL { get throws { @@ -222,6 +228,50 @@ nonisolated extension CloudKitBookFile: CloudKitFile { } } +// 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 { @@ -273,15 +323,12 @@ nonisolated struct CloudKitBookShelves: Codable, CloudKitFile { return try JSONDecoder().decode([BookShelf].self, from: data) } - static let recordType: CKRecord.RecordType = "Shelves" - static let recordName: String = "shelves" - static var recordID: CKRecord.ID { CKRecord.ID(recordName: Self.recordName, zoneID: Self.zoneID) } - static let zoneName: String = "HoshiBooks" - static var zoneID: CKRecordZone.ID { CKRecordZone.ID(zoneName: Self.zoneName) } + static let type = CloudKitFileType.shelves + static let recordName: String = type.rawValue // conformance - var recordType: CKRecord.RecordType { Self.recordType } - var recordID: CKRecord.ID { Self.recordID } + var recordName: String { Self.recordName } + var type: CloudKitFileType { Self.type } } // MARK: - CKRecord From e33cfa1d8f86a90b181d19f397bbbc1fc03dbf05 Mon Sep 17 00:00:00 2001 From: Manhhao Date: Wed, 8 Jul 2026 11:14:00 +0200 Subject: [PATCH 12/16] replace event callbacks with asyncstream --- App/HoshiReader.swift | 3 +-- Features/Bookshelf/BookCell.swift | 5 ++-- Features/Bookshelf/BookshelfView.swift | 4 +-- Features/Reader/ReaderView/ReaderView.swift | 6 ++--- Features/Settings/CloudKitSyncView.swift | 10 +++----- Features/Sync/CloudKitSyncManager.swift | 28 +++++++++++---------- 6 files changed, 24 insertions(+), 32 deletions(-) diff --git a/App/HoshiReader.swift b/App/HoshiReader.swift index cd6d9f20..bbab1f51 100644 --- a/App/HoshiReader.swift +++ b/App/HoshiReader.swift @@ -135,7 +135,7 @@ struct HoshiReaderApp: App { } private func observeCloudKitEvents() async { - let onError: @MainActor (CloudKitSyncManager.Event) -> Void = { event in + for await event in await CloudKitSyncManager.shared.events() { if case let .account(accountEvent) = event { switch accountEvent { case .signOut: @@ -154,7 +154,6 @@ struct HoshiReaderApp: App { } } } - await CloudKitSyncManager.shared.observeEvents(onError) } } diff --git a/Features/Bookshelf/BookCell.swift b/Features/Bookshelf/BookCell.swift index dae43d6c..75ebd8dd 100644 --- a/Features/Bookshelf/BookCell.swift +++ b/Features/Bookshelf/BookCell.swift @@ -47,13 +47,12 @@ struct BookCell: View { refreshEpubState(book: book) guard userConfig.enableCloudKitSync else { return } - let refresh: @MainActor (CloudKitSyncManager.Event) -> Void = { event in + for await event in await CloudKitSyncManager.shared.events() { if case let .epubDownloaded(uuid: uuid) = event, uuid == book.id { - self.refreshEpubState(book: book) + refreshEpubState(book: book) } } - await CloudKitSyncManager.shared.observeEvents(refresh) } .buttonStyle(.plain) .contextMenu(isSelecting ? nil : ContextMenu { diff --git a/Features/Bookshelf/BookshelfView.swift b/Features/Bookshelf/BookshelfView.swift index 361a3288..06bb0fb4 100644 --- a/Features/Bookshelf/BookshelfView.swift +++ b/Features/Bookshelf/BookshelfView.swift @@ -89,11 +89,9 @@ struct BookshelfView: View { .task(id: userConfig.enableCloudKitSync) { guard userConfig.enableCloudKitSync else { return } - let refreshBooks: @MainActor (CloudKitSyncManager.Event) -> Void = { [weak viewModel] _ in - guard let viewModel else { return } + for await _ in await CloudKitSyncManager.shared.events() { viewModel.loadBooks() } - await CloudKitSyncManager.shared.observeEvents(refreshBooks) } .fileImporter( isPresented: $viewModel.isImporting, diff --git a/Features/Reader/ReaderView/ReaderView.swift b/Features/Reader/ReaderView/ReaderView.swift index faea07ca..a4e638d4 100644 --- a/Features/Reader/ReaderView/ReaderView.swift +++ b/Features/Reader/ReaderView/ReaderView.swift @@ -740,11 +740,9 @@ struct ReaderView: View { .task(id: userConfig.enableCloudKitSync) { guard userConfig.enableCloudKitSync else { return } - let onSynced: @MainActor (CloudKitSyncManager.Event) -> Void = { [weak viewModel] direction in - guard let viewModel else { return } - viewModel.handleCloudKitSync(event: direction, dismiss: dismiss) + for await event in await CloudKitSyncManager.shared.events() { + viewModel.handleCloudKitSync(event: event, dismiss: dismiss) } - await CloudKitSyncManager.shared.observeEvents(onSynced) } .onChange(of: readerTextColor) { _, hex in viewModel.bridge.send(.updateTextColor(hex)) } .onChange(of: sasayakiTextColor) { _, _ in updateSasayakiColors() } diff --git a/Features/Settings/CloudKitSyncView.swift b/Features/Settings/CloudKitSyncView.swift index 9d9d3020..806ee9db 100644 --- a/Features/Settings/CloudKitSyncView.swift +++ b/Features/Settings/CloudKitSyncView.swift @@ -62,14 +62,10 @@ struct CloudKitSyncView: View { } .task { await iCloudStatusRefresh() - let onChanged: @MainActor (CloudKitSyncManager.Event) -> Void = { event in - guard case .account = event else { return } - - Task { - await self.iCloudStatusRefresh() - } + for await event in await CloudKitSyncManager.shared.events() { + guard case .account = event else { continue } + await iCloudStatusRefresh() } - await CloudKitSyncManager.shared.observeEvents(onChanged) } .navigationTitle("iCloud Syncing") } diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift index 2ea4e8f9..33e89496 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -25,7 +25,7 @@ actor CloudKitSyncManager { nonisolated private var logger: Logger { Self.logger } - private var eventHandlers: [UUID: @MainActor (CloudKitSyncManager.Event) -> Void] = [:] + private var eventContinuations: [UUID: AsyncStream.Continuation] = [:] private var cloudKitData: CloudKitData { didSet { @@ -788,7 +788,7 @@ extension CloudKitSyncManager { // MARK: - callbacks extension CloudKitSyncManager { - nonisolated enum Event { + nonisolated enum Event: Sendable { enum AccountEvent { case signIn case signOut @@ -812,21 +812,23 @@ extension CloudKitSyncManager { case error(SyncError) } - func observeEvents(_ eventHandler: @escaping @MainActor (CloudKitSyncManager.Event) -> Void) async { + func events() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Event.self) let id = UUID() - eventHandlers[id] = eventHandler - defer { eventHandlers[id] = nil } - try? await Task.sleep(for: .seconds(Int32.max)) + 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) { - Task { - let allCallbacks = Array(self.eventHandlers.values) - await MainActor.run { - for callBack in allCallbacks { - callBack(event) - } - } + for continuation in eventContinuations.values { + continuation.yield(event) } } } From 04eb5f87e2a9b81bea45911174b5848e0cab1229 Mon Sep 17 00:00:00 2001 From: Manhhao Date: Wed, 8 Jul 2026 11:20:57 +0200 Subject: [PATCH 13/16] update shelves after rewriting uuids --- Features/Sync/CloudKitSyncManager.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift index 33e89496..9f5f9966 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -624,6 +624,22 @@ extension CloudKitSyncManager { } } } + + 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 { From c77353a61531375edcbedbc9cf5f4de6d449119a Mon Sep 17 00:00:00 2001 From: Manhhao Date: Wed, 8 Jul 2026 11:25:35 +0200 Subject: [PATCH 14/16] minor cleanup --- Core/BookStorage.swift | 2 +- Features/Reader/ReaderView/ReaderViewModel.swift | 8 ++------ Features/Sync/CloudKitSyncManager.swift | 13 ++++++++----- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/Core/BookStorage.swift b/Core/BookStorage.swift index 2d4ccad0..3e18aa46 100644 --- a/Core/BookStorage.swift +++ b/Core/BookStorage.swift @@ -269,7 +269,7 @@ struct BookStorage { let folderName = url.deletingLastPathComponent().lastPathComponent let fileName = url.lastPathComponent guard let fileType = CloudKitFileType(fileName: fileName) else { - CloudKitSyncManager.logger.error("Tried to upload an known file type in \(folderName, privacy: .public)/\(fileName, privacy: .public)") + CloudKitSyncManager.logger.error("Tried to upload an unknown file type in \(folderName, privacy: .public)/\(fileName, privacy: .public)") return } Task { diff --git a/Features/Reader/ReaderView/ReaderViewModel.swift b/Features/Reader/ReaderView/ReaderViewModel.swift index 83520f4c..d984e8c7 100644 --- a/Features/Reader/ReaderView/ReaderViewModel.swift +++ b/Features/Reader/ReaderView/ReaderViewModel.swift @@ -328,13 +328,9 @@ class ReaderViewModel { func handleCloudKitSync(event: CloudKitSyncManager.Event, dismiss: DismissAction) { switch event { - case .sent(let uuid, let success): + case .sent(let uuid, _): guard uuid == book.id else { return } - if success { - isPaused = false - } else { - fallthrough - } + isPaused = false case .fetched(let uuid): guard uuid == book.id else { return } highlights = BookStorage.loadHighlights(root: rootURL) ?? [] diff --git a/Features/Sync/CloudKitSyncManager.swift b/Features/Sync/CloudKitSyncManager.swift index 9f5f9966..58cac81a 100644 --- a/Features/Sync/CloudKitSyncManager.swift +++ b/Features/Sync/CloudKitSyncManager.swift @@ -52,7 +52,7 @@ actor CloudKitSyncManager { self.cloudKitData = cloudKitData ?? CloudKitData() } catch { self.cloudKitData = CloudKitData() - logger.error("Failed to load CloudKit state from stroage: \(error)") + logger.error("Failed to load CloudKit state from storage: \(error)") } } @@ -186,11 +186,14 @@ private extension CloudKitSyncManager { continue } do { - if fileType == .metadata, - let metadata = try cloudKitData.books[uuid]?[fileType]?.decode(to: BookMetadata.self) { - try deleteLocal(books: [metadata]) + 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 } - 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)") From 756c908f55722307e96efaca358568fe13fa32bc Mon Sep 17 00:00:00 2001 From: Manhhao Date: Wed, 8 Jul 2026 11:36:39 +0200 Subject: [PATCH 15/16] minor --- Core/DictionaryManager.swift | 4 +--- Features/Sasayaki/SasayakiPlayer.swift | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) 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/Features/Sasayaki/SasayakiPlayer.swift b/Features/Sasayaki/SasayakiPlayer.swift index 8947067e..3afcf928 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? @@ -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) } From 645d484c56a0b60be11e9c78e3ca60ce30086ae6 Mon Sep 17 00:00:00 2001 From: Manhhao Date: Fri, 10 Jul 2026 11:17:58 +0200 Subject: [PATCH 16/16] thread uuid through callsites, remove type guessing --- Core/BookStorage.swift | 68 +++++++------------ Features/Bookshelf/BookshelfViewModel.swift | 10 +-- .../Reader/ReaderView/ReaderViewModel.swift | 8 +-- Features/Sasayaki/SasayakiPlayer.swift | 2 +- Features/Settings/BackupView.swift | 5 +- Features/Sync/SyncManager.swift | 28 ++++---- Util/TtuConverter.swift | 4 +- 7 files changed, 54 insertions(+), 71 deletions(-) diff --git a/Core/BookStorage.swift b/Core/BookStorage.swift index 3e18aa46..ff22dbfe 100644 --- a/Core/BookStorage.swift +++ b/Core/BookStorage.swift @@ -22,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" @@ -223,63 +228,38 @@ struct BookStorage { 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) - saveCloudKitFile(object, url: targetURL) - } - - 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: url, options: .atomic) - } - - static func saveCloudKitFile(_ object: T, url: URL) { - guard UserConfig.shared.enableCloudKitSync else { return } - - if T.self == [BookShelf].self { + guard UserConfig.shared.enableCloudKitSync, let sync else { return } + switch sync { + case .shelves: Task { await CloudKitSyncManager.shared.saveCloudShelves() } - return - } - - if T.self == BookMetadata.self { - guard let metadata = object as? BookMetadata else { fatalError() } + case .book(let uuid): + guard let fileType = CloudKitFileType(fileName: fileName) else { + return + } Task { await CloudKitSyncManager.shared.saveCloudFile( - uuid: metadata.id, - fileType: .metadata, - fileName: FileNames.metadata, - folderName: url.deletingLastPathComponent().lastPathComponent, + uuid: uuid, + fileType: fileType, + fileName: fileName, + folderName: directory.lastPathComponent, ) } - return } + } + + nonisolated static func saveLocal(_ object: T, url: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(object) - guard let metadata = Self.loadMetadata(root: url.deletingLastPathComponent()) else { - CloudKitSyncManager.logger.error("Failed to load BookMetadata in url \(url)") - return - } - let folderName = url.deletingLastPathComponent().lastPathComponent - let fileName = url.lastPathComponent - guard let fileType = CloudKitFileType(fileName: fileName) else { - CloudKitSyncManager.logger.error("Tried to upload an unknown file type in \(folderName, privacy: .public)/\(fileName, privacy: .public)") - return - } - Task { - await CloudKitSyncManager.shared.saveCloudFile( - uuid: metadata.id, - fileType: fileType, - fileName: fileName, - folderName: folderName, - ) - } + try data.write(to: url, options: .atomic) } nonisolated static func load(_ type: T.Type, from url: URL) -> T? { diff --git a/Features/Bookshelf/BookshelfViewModel.swift b/Features/Bookshelf/BookshelfViewModel.swift index 887de653..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) { @@ -190,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) { @@ -413,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() } @@ -532,8 +532,8 @@ 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 { diff --git a/Features/Reader/ReaderView/ReaderViewModel.swift b/Features/Reader/ReaderView/ReaderViewModel.swift index d984e8c7..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 } @@ -601,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() } @@ -734,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() } @@ -764,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 3afcf928..b5ee1058 100644 --- a/Features/Sasayaki/SasayakiPlayer.swift +++ b/Features/Sasayaki/SasayakiPlayer.swift @@ -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) { 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/Sync/SyncManager.swift b/Features/Sync/SyncManager.swift index e1c4a687..2c5d0a68 100644 --- a/Features/Sync/SyncManager.swift +++ b/Features/Sync/SyncManager.swift @@ -114,15 +114,15 @@ 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 = 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: @@ -134,7 +134,8 @@ class SyncManager { 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,10 +297,10 @@ class SyncManager { try await GoogleDriveHandler.shared.updateStatsFile(folderId: folderId, fileId: fileId, stats: stats) } - 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/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 }