import CryptoKit import Foundation public protocol RDEPUBReaderPersistence: AnyObject { func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) func loadReaderSettings() -> RDEPUBReaderSettings? func saveReaderSettings(_ settings: RDEPUBReaderSettings) } public extension RDEPUBReaderPersistence { func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { #if DEBUG print("[RDEPUBReaderPersistence] ⚠️ loadBookmarks called on default no-op implementation for: \(bookIdentifier)") #endif return [] } func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) { #if DEBUG print("[RDEPUBReaderPersistence] ⚠️ saveBookmarks(\(bookmarks.count) items) called on default no-op implementation for: \(bookIdentifier)") #endif } func loadReaderSettings() -> RDEPUBReaderSettings? { #if DEBUG print("[RDEPUBReaderPersistence] ⚠️ loadReaderSettings called on default no-op implementation") #endif return nil } func saveReaderSettings(_ settings: RDEPUBReaderSettings) { #if DEBUG print("[RDEPUBReaderPersistence] ⚠️ saveReaderSettings called on default no-op implementation") #endif } } /// 受文件保护的内容存储:高亮正文、批注、书签摘录属于书籍内容,不应明文 /// 落在 UserDefaults(默认不加密、会进备份)。这里改写到 Application Support /// 下带 `NSFileProtection` 的文件,并排除 iCloud/iTunes 备份。 public final class RDEPUBProtectedContentStore { private let directoryURL: URL private let protection: FileProtectionType private let fileManager = FileManager.default private let namespace: String? private let allowsLegacyFallback: Bool /// 默认用 `.completeUntilFirstUserAuthentication`(首次解锁后可访问)而不是 /// `.complete`:两者都在磁盘上加密,但 `.complete` 会让锁屏期间的保存直接 /// 失败——阅读器在退到后台时可能正好赶上用户锁屏,那样会静默丢标注。 /// 对安全要求更高的宿主可以显式传入 `.complete`。 public init( directoryName: String = "ssreaderview-epub/protected", namespace: String? = nil, allowsLegacyFallback: Bool = true, protection: FileProtectionType = .completeUntilFirstUserAuthentication ) { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory self.directoryURL = base.appendingPathComponent(directoryName, isDirectory: true) self.namespace = namespace self.allowsLegacyFallback = allowsLegacyFallback self.protection = protection } /// bookIdentifier 可能含 `/`、空格等非法文件名字符,且长度不可控——十六进制 /// 编码会翻倍,长书名很容易撞上 255 字节的文件名上限。用 SHA256 定长摘要。 private func fileURL(forKey key: String) -> URL { let storageKey: String if let namespace { storageKey = namespace + "\u{0}" + key } else { storageKey = key } let digest = SHA256.hash(data: Data(storageKey.utf8)) let name = digest.map { String(format: "%02x", $0) }.joined() return directoryURL.appendingPathComponent("\(name).json", isDirectory: false) } private func legacyFileURL(forKey key: String) -> URL { let digest = SHA256.hash(data: Data(key.utf8)) let name = digest.map { String(format: "%02x", $0) }.joined() return directoryURL.appendingPathComponent("\(name).json", isDirectory: false) } private func ensureDirectory() throws { guard !fileManager.fileExists(atPath: directoryURL.path) else { return } try fileManager.createDirectory( at: directoryURL, withIntermediateDirectories: true, attributes: [.protectionKey: protection] ) var url = directoryURL var values = URLResourceValues() values.isExcludedFromBackup = true try? url.setResourceValues(values) } public func read(forKey key: String) -> Data? { if let data = try? Data(contentsOf: fileURL(forKey: key)) { return data } guard namespace != nil, allowsLegacyFallback else { return nil } return try? Data(contentsOf: legacyFileURL(forKey: key)) } public func write(_ data: Data, forKey key: String) { _ = writeIfPossible(data, forKey: key) } /// 返回写入是否完整成功。迁移旧数据时,只有成功落盘后才能删除原副本。 @discardableResult func writeIfPossible(_ data: Data, forKey key: String) -> Bool { do { try ensureDirectory() try data.write(to: fileURL(forKey: key), options: .atomic) // atomic 写入会替换文件,保护属性需要写完再设一次 try fileManager.setAttributes([.protectionKey: protection], ofItemAtPath: fileURL(forKey: key).path) return true } catch { #if DEBUG print("[RDEPUBProtectedContentStore] ⚠️ Failed to write '\(key)': \(error)") #endif return false } } public func removeValue(forKey key: String) { try? fileManager.removeItem(at: fileURL(forKey: key)) if namespace != nil, allowsLegacyFallback { try? fileManager.removeItem(at: legacyFileURL(forKey: key)) } } public func removeAll() { try? fileManager.removeItem(at: directoryURL) } } public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence { private let defaults: UserDefaults private let locationPrefix: String private let bookmarksPrefix: String private let highlightsPrefix: String private let settingsKey: String /// 高亮与书签(含书籍正文摘录)实际落盘的位置 private let contentStore: RDEPUBProtectedContentStore public init( defaults: UserDefaults = .standard, locationPrefix: String = "ssreader.epub.location.", bookmarksPrefix: String = "ssreader.epub.bookmarks.", highlightsPrefix: String = "ssreader.epub.highlights.", settingsKey: String = "ssreader.epub.settings", contentStore: RDEPUBProtectedContentStore? = nil ) { self.defaults = defaults self.locationPrefix = locationPrefix self.bookmarksPrefix = bookmarksPrefix self.highlightsPrefix = highlightsPrefix self.settingsKey = settingsKey if let contentStore { self.contentStore = contentStore } else { let namespaceKey = settingsKey + ".protectedContentNamespace" let isStandardDefaults = defaults === UserDefaults.standard let namespace: String if isStandardDefaults { namespace = "standard" } else if let storedNamespace = defaults.string(forKey: namespaceKey) { namespace = storedNamespace } else { namespace = UUID().uuidString defaults.set(namespace, forKey: namespaceKey) } self.contentStore = RDEPUBProtectedContentStore( namespace: namespace, // 旧版未分区文件无法区分来自哪个自定义 suite;只对标准容器保留迁移读取。 allowsLegacyFallback: isStandardDefaults ) } } /// 从受保护存储读取;首次访问时把历史明文数据迁移过去并删掉明文副本。 private func loadContentData(key: String) -> Data? { if let data = contentStore.read(forKey: key) { return data } guard let legacy = defaults.data(forKey: key) else { return nil } if contentStore.writeIfPossible(legacy, forKey: key) { defaults.removeObject(forKey: key) } return legacy } private func saveContentData(_ data: Data, key: String) { // 新副本确认落盘前保留历史数据,避免磁盘满或文件保护错误时丢失标注。 if contentStore.writeIfPossible(data, forKey: key), defaults.object(forKey: key) != nil { defaults.removeObject(forKey: key) } } /// 清除某本书的阅读内容数据(高亮、书签、进度)。 public func clearReadingData(for bookIdentifier: String) { contentStore.removeValue(forKey: highlightsPrefix + bookIdentifier) contentStore.removeValue(forKey: bookmarksPrefix + bookIdentifier) defaults.removeObject(forKey: highlightsPrefix + bookIdentifier) defaults.removeObject(forKey: bookmarksPrefix + bookIdentifier) defaults.removeObject(forKey: locationPrefix + bookIdentifier) } /// 清除全部书籍的高亮与书签内容数据,包括尚未迁移的历史 UserDefaults 数据。 public func clearAllReadingContent() { contentStore.removeAll() for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(highlightsPrefix) || key.hasPrefix(bookmarksPrefix) { defaults.removeObject(forKey: key) } } public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? { guard let data = defaults.data(forKey: locationPrefix + bookIdentifier) else { return nil } do { return try JSONDecoder().decode(RDEPUBLocation.self, from: data) } catch { #if DEBUG print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode location for '\(bookIdentifier)': \(error)") #endif return nil } } public func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) { do { let data = try JSONEncoder().encode(location) defaults.set(data, forKey: locationPrefix + bookIdentifier) } catch { #if DEBUG print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode location for '\(bookIdentifier)': \(error)") #endif } } public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { guard let data = loadContentData(key: bookmarksPrefix + bookIdentifier) else { return [] } do { return try JSONDecoder().decode([RDEPUBBookmark].self, from: data) } catch { #if DEBUG print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode bookmarks for '\(bookIdentifier)': \(error)") #endif return [] } } public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) { do { let data = try JSONEncoder().encode(bookmarks) saveContentData(data, key: bookmarksPrefix + bookIdentifier) } catch { #if DEBUG print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)") #endif } } public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] { guard let data = loadContentData(key: highlightsPrefix + bookIdentifier) else { return [] } do { return try JSONDecoder().decode([RDEPUBHighlight].self, from: data) } catch { #if DEBUG print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode highlights for '\(bookIdentifier)': \(error)") #endif return [] } } public func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) { do { let data = try JSONEncoder().encode(highlights) if data.count > 1_048_576 { #if DEBUG print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)") #endif } saveContentData(data, key: highlightsPrefix + bookIdentifier) } catch { #if DEBUG print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)") #endif } } public func loadReaderSettings() -> RDEPUBReaderSettings? { guard let data = defaults.data(forKey: settingsKey) else { return nil } do { return try JSONDecoder().decode(RDEPUBReaderSettings.self, from: data) } catch { #if DEBUG print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode reader settings: \(error)") #endif return nil } } public func saveReaderSettings(_ settings: RDEPUBReaderSettings) { do { let data = try JSONEncoder().encode(settings) defaults.set(data, forKey: settingsKey) } catch { #if DEBUG print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode reader settings: \(error)") #endif } } }