修复阅读器资源、朗读与持久化稳定性

阅读器在多会话朗读、加密 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:
shenlei
2026-07-30 20:52:21 +09:00
parent e01cbc169d
commit 3e60bf1869
40 changed files with 3210 additions and 2691 deletions
@@ -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)")