修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
@@ -47,6 +48,113 @@ public extension RDEPUBReaderPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
/// 受文件保护的内容存储:高亮正文、批注、书签摘录属于书籍内容,不应明文
|
||||
/// 落在 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
|
||||
@@ -59,18 +167,80 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
|
||||
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"
|
||||
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? {
|
||||
@@ -99,7 +269,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
}
|
||||
|
||||
public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else {
|
||||
guard let data = loadContentData(key: bookmarksPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
do {
|
||||
@@ -115,7 +285,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(bookmarks)
|
||||
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
|
||||
saveContentData(data, key: bookmarksPrefix + bookIdentifier)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)")
|
||||
@@ -124,7 +294,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
}
|
||||
|
||||
public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
|
||||
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else {
|
||||
guard let data = loadContentData(key: highlightsPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
do {
|
||||
@@ -145,7 +315,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
|
||||
#endif
|
||||
}
|
||||
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
|
||||
saveContentData(data, key: highlightsPrefix + bookIdentifier)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)")
|
||||
|
||||
Reference in New Issue
Block a user