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

阅读器在多会话朗读、加密 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
@@ -25,12 +25,22 @@ extension RDEPUBParser {
}
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
try Self.extractionQueue.sync {
try extractArchiveIfNeededLocked(epubURL: epubURL)
}
}
/// parser
/// `container.xml` parser
private func extractArchiveIfNeededLocked(epubURL: URL) throws -> URL {
let fileManager = FileManager.default
let extractionURL = temporaryExtractionDirectory(for: epubURL)
if fileManager.fileExists(atPath: extractionURL.path) {
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
if fileManager.fileExists(atPath: containerURL.path) {
// 访 LRU FIFO
Self.touchExtractionDirectory(extractionURL)
return extractionURL
}
try? fileManager.removeItem(at: extractionURL)
@@ -67,6 +77,8 @@ extension RDEPUBParser {
throw error
}
Self.touchExtractionDirectory(extractionURL)
Self.pruneExtractionCache(keeping: extractionURL)
return extractionURL
}
@@ -107,9 +119,7 @@ extension RDEPUBParser {
}
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
let baseURL = Self.extractionCacheRootURL
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
@@ -133,6 +143,97 @@ extension RDEPUBParser {
}
}
// MARK: -
extension RDEPUBParser {
private static let extractionQueue = DispatchQueue(label: "com.ssreaderview.epub.extraction-cache")
/// 使
public static var extractionCacheMaximumTotalBytes: UInt64 = 512 * 1024 * 1024
/// 30
public static var extractionCacheMaximumAge: TimeInterval = 30 * 24 * 60 * 60
public static var extractionCacheRootURL: URL {
let fileManager = FileManager.default
return fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? fileManager.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
}
/// 宿
public static func clearExtractionCache() {
try? FileManager.default.removeItem(at: extractionCacheRootURL)
}
///
/// - Parameter keeping: 使
public static func pruneExtractionCache(keeping survivor: URL? = nil) {
let fileManager = FileManager.default
let root = extractionCacheRootURL
guard let children = try? fileManager.contentsOfDirectory(
at: root,
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return
}
let survivorPath = survivor?.standardizedFileURL.path
var entries: [(url: URL, size: UInt64, modifiedAt: Date)] = []
for child in children {
let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey])
guard values?.isDirectory == true else { continue }
let modifiedAt = values?.contentModificationDate ?? .distantPast
if child.standardizedFileURL.path == survivorPath {
continue
}
if Date().timeIntervalSince(modifiedAt) > extractionCacheMaximumAge {
try? fileManager.removeItem(at: child)
continue
}
entries.append((child, directorySize(at: child), modifiedAt))
}
// survivor
var total = entries.reduce(UInt64(0)) { $0 + $1.size }
if let survivor {
total += directorySize(at: survivor)
}
guard total > extractionCacheMaximumTotalBytes else { return }
for entry in entries.sorted(by: { $0.modifiedAt < $1.modifiedAt }) {
try? fileManager.removeItem(at: entry.url)
total = total > entry.size ? total - entry.size : 0
if total <= extractionCacheMaximumTotalBytes { return }
}
}
fileprivate static func touchExtractionDirectory(_ url: URL) {
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
}
private static func directorySize(at url: URL) -> UInt64 {
guard let enumerator = FileManager.default.enumerator(
at: url,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey],
options: []
) else {
return 0
}
var total: UInt64 = 0
for case let fileURL as URL in enumerator {
let values = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
let size = values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0
total += UInt64(size)
}
return total
}
}
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
private(set) var rootFilePath: String?