Files
ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift
T
shenlei 3e60bf1869 修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
2026-07-30 20:52:21 +09:00

351 lines
13 KiB
Swift

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
}
}
}