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

阅读器在多会话朗读、加密 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?
@@ -12,6 +12,11 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
public static let host = "book"
///
///
/// provider
public static var maximumInMemoryResourceBytes: UInt64 = 64 * 1024 * 1024
private weak var parser: RDEPUBParser?
private let fileManager = FileManager.default
@@ -87,9 +92,18 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
let fileSize = resourceSize(for: fileURL)
// provider
// provider
if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil {
// Provider nil IO
// provider
//
if parser.resourceDataProvider != nil {
respondWithProvidedDataOrFile(
fileURL: fileURL,
requestURL: requestURL,
taskID: taskID,
urlSchemeTask: urlSchemeTask,
fileSize: fileSize
)
} else if let fileSize, fileSize > 524_288 {
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else {
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
@@ -121,12 +135,75 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
return size
}
private func respondWithProvidedDataOrFile(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
fileSize: UInt64?
) {
ioQueue.async { [weak self] in
guard let self else { return }
guard self.isTaskActive(taskID) else { return }
if let provided = self.parser?.providedResourceData(at: fileURL) {
guard UInt64(provided.count) <= Self.maximumInMemoryResourceBytes else {
self.respondWithOversizeFailure(
fileURL: fileURL,
requestURL: requestURL,
taskID: taskID,
urlSchemeTask: urlSchemeTask,
byteCount: UInt64(provided.count)
)
return
}
self.respondWithData(provided, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
} else if let fileSize, fileSize > 524_288 {
self.respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else {
self.respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, skipsProvider: true)
}
}
}
private func respondWithOversizeFailure(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
byteCount: UInt64
) {
RDEPUBWebViewDebug.logSchemeTask(
"ResourceScheme",
requestURL: requestURL,
fileURL: fileURL,
event: "rejected-oversize-\(byteCount)"
)
Self.recordFailure()
DispatchQueue.main.async { [weak self] in
guard let self, self.isTaskActive(taskID) else { return }
urlSchemeTask.didFailWithError(
NSError(
domain: NSURLErrorDomain,
code: NSURLErrorDataLengthExceedsMaximum,
userInfo: [NSLocalizedDescriptionKey: "资源超过单资源内存上限(\(byteCount) 字节)"]
)
)
self.clearTask(taskID)
}
}
/// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue,
/// ensuring they run on the same serial queue that WebKit calls start() on.
/// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead
/// of silently returning, satisfying the protocol requirement that every started
/// task must receive a completion callback.
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
private func respondWithInMemoryData(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
skipsProvider: Bool = false
) {
ioQueue.async { [weak self] in
guard let self else {
DispatchQueue.main.async {
@@ -137,7 +214,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
do {
// provider provider
let data: Data
if let provided = self.parser?.providedResourceData(at: fileURL) {
if !skipsProvider, let provided = self.parser?.providedResourceData(at: fileURL) {
data = provided
} else {
data = try Data(contentsOf: fileURL)
@@ -148,22 +225,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
return
}
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
self.dispatchTaskSuccessCallback(
taskID: taskID,
urlSchemeTask: urlSchemeTask
) {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
self.respondWithData(data, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
return
} catch {
guard self.isTaskActive(taskID) else {
@@ -182,6 +244,31 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
private func respondWithData(
_ data: Data,
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask
) {
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
dispatchTaskSuccessCallback(
taskID: taskID,
urlSchemeTask: urlSchemeTask
) {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
}
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
ioQueue.async { [weak self] in
guard let self else {