阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
437 lines
16 KiB
Swift
437 lines
16 KiB
Swift
import Foundation
|
|
import WebKit
|
|
|
|
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
struct DebugMetrics {
|
|
let streamedResponses: Int
|
|
let inMemoryResponses: Int
|
|
let failures: Int
|
|
}
|
|
|
|
public static let scheme = "ss-reader"
|
|
|
|
public static let host = "book"
|
|
|
|
/// 单个资源走「整体读入内存」分支时的上限。超过则优雅失败而不是冒着
|
|
/// 内存峰值被系统终止的风险。只影响无法流式的分支(配置了加密资源
|
|
/// provider 时密文必须整体解密),流式分支不受此限制。
|
|
public static var maximumInMemoryResourceBytes: UInt64 = 64 * 1024 * 1024
|
|
|
|
private weak var parser: RDEPUBParser?
|
|
|
|
private let fileManager = FileManager.default
|
|
|
|
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
|
|
|
private let ioQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.io", qos: .utility)
|
|
|
|
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
|
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
|
|
private static var streamedResponseCount = 0
|
|
private static var inMemoryResponseCount = 0
|
|
private static var failureCount = 0
|
|
|
|
public init(parser: RDEPUBParser) {
|
|
self.parser = parser
|
|
super.init()
|
|
}
|
|
|
|
static func resetDebugMetrics() {
|
|
debugMetricsQueue.sync {
|
|
streamedResponseCount = 0
|
|
inMemoryResponseCount = 0
|
|
failureCount = 0
|
|
}
|
|
}
|
|
|
|
static func debugMetricsSnapshot() -> DebugMetrics {
|
|
debugMetricsQueue.sync {
|
|
DebugMetrics(
|
|
streamedResponses: streamedResponseCount,
|
|
inMemoryResponses: inMemoryResponseCount,
|
|
failures: failureCount
|
|
)
|
|
}
|
|
}
|
|
|
|
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
|
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
|
|
syncQueue.sync {
|
|
activeTasks[taskID] = true
|
|
}
|
|
|
|
let requestURL = urlSchemeTask.request.url
|
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "start")
|
|
|
|
guard let parser,
|
|
let requestURL,
|
|
let fileURL = parser.fileURL(forResourceURL: requestURL),
|
|
fileManager.fileExists(atPath: fileURL.path) else {
|
|
if let requestURL, Self.isOptionalResource(requestURL) {
|
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-optional-resource")
|
|
let response = URLResponse(
|
|
url: requestURL,
|
|
mimeType: Self.mimeType(for: requestURL.pathExtension),
|
|
expectedContentLength: 0,
|
|
textEncodingName: Self.textEncodingName(for: requestURL.pathExtension)
|
|
)
|
|
// H-08 fix: These calls happen synchronously in webView(_:start:) which
|
|
// runs on the same queue WebKit calls start on, so no thread violation here.
|
|
urlSchemeTask.didReceive(response)
|
|
urlSchemeTask.didReceive(Data())
|
|
urlSchemeTask.didFinish()
|
|
} else {
|
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
|
|
Self.recordFailure()
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
|
}
|
|
clearTask(taskID)
|
|
return
|
|
}
|
|
|
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
|
|
|
let fileSize = resourceSize(for: fileURL)
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
|
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
|
|
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
|
|
}
|
|
|
|
private func clearTask(_ id: ObjectIdentifier) {
|
|
syncQueue.sync {
|
|
activeTasks[id] = nil
|
|
}
|
|
}
|
|
|
|
private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
|
|
syncQueue.sync {
|
|
activeTasks[id] == true
|
|
}
|
|
}
|
|
|
|
private func resourceSize(for url: URL) -> UInt64? {
|
|
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
|
|
let size = attrs[.size] as? UInt64 else {
|
|
return nil
|
|
}
|
|
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,
|
|
skipsProvider: Bool = false
|
|
) {
|
|
ioQueue.async { [weak self] in
|
|
guard let self else {
|
|
DispatchQueue.main.async {
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
}
|
|
return
|
|
}
|
|
do {
|
|
// 优先向加密资源 provider 索取解密数据,provider 放行的明文文件仍直读磁盘
|
|
let data: Data
|
|
if !skipsProvider, let provided = self.parser?.providedResourceData(at: fileURL) {
|
|
data = provided
|
|
} else {
|
|
data = try Data(contentsOf: fileURL)
|
|
}
|
|
guard self.isTaskActive(taskID) else {
|
|
DispatchQueue.main.async {
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
}
|
|
return
|
|
}
|
|
self.respondWithData(data, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
|
|
return
|
|
} catch {
|
|
guard self.isTaskActive(taskID) else {
|
|
DispatchQueue.main.async {
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
}
|
|
return
|
|
}
|
|
Self.recordFailure()
|
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
|
DispatchQueue.main.async {
|
|
urlSchemeTask.didFailWithError(error)
|
|
}
|
|
}
|
|
self.clearTask(taskID)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
DispatchQueue.main.async {
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
}
|
|
return
|
|
}
|
|
let response = URLResponse(
|
|
url: requestURL,
|
|
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
|
expectedContentLength: Int(fileSize),
|
|
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
|
)
|
|
self.dispatchTaskCallbackIfActive(taskID: taskID) {
|
|
urlSchemeTask.didReceive(response)
|
|
}
|
|
|
|
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
|
|
guard self.isTaskActive(taskID) else {
|
|
DispatchQueue.main.async {
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
}
|
|
return
|
|
}
|
|
Self.recordFailure()
|
|
DispatchQueue.main.async {
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
|
}
|
|
self.clearTask(taskID)
|
|
return
|
|
}
|
|
|
|
let chunkSize = 65_536
|
|
defer {
|
|
fileHandle.closeFile()
|
|
}
|
|
while true {
|
|
guard self.isTaskActive(taskID) else {
|
|
// H-08 fix: Task was cancelled. Send didFailWithError on main queue.
|
|
DispatchQueue.main.async {
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
}
|
|
self.clearTask(taskID)
|
|
return
|
|
}
|
|
let data = fileHandle.readData(ofLength: chunkSize)
|
|
if data.isEmpty { break }
|
|
self.dispatchTaskCallbackIfActive(taskID: taskID) {
|
|
urlSchemeTask.didReceive(data)
|
|
}
|
|
}
|
|
self.dispatchTaskSuccessCallback(
|
|
taskID: taskID,
|
|
urlSchemeTask: urlSchemeTask
|
|
) {
|
|
urlSchemeTask.didFinish()
|
|
}
|
|
Self.recordStreamedResponse()
|
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
|
}
|
|
}
|
|
|
|
private func dispatchTaskCallbackIfActive(taskID: ObjectIdentifier, _ callback: @escaping () -> Void) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, self.isTaskActive(taskID) else { return }
|
|
callback()
|
|
}
|
|
}
|
|
|
|
private func dispatchTaskSuccessCallback(
|
|
taskID: ObjectIdentifier,
|
|
urlSchemeTask: any WKURLSchemeTask,
|
|
_ callback: @escaping () -> Void
|
|
) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else {
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
return
|
|
}
|
|
guard self.isTaskActive(taskID) else {
|
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
self.clearTask(taskID)
|
|
return
|
|
}
|
|
callback()
|
|
self.clearTask(taskID)
|
|
}
|
|
}
|
|
|
|
private static func recordStreamedResponse() {
|
|
debugMetricsQueue.sync {
|
|
streamedResponseCount += 1
|
|
}
|
|
}
|
|
|
|
private static func recordInMemoryResponse() {
|
|
debugMetricsQueue.sync {
|
|
inMemoryResponseCount += 1
|
|
}
|
|
}
|
|
|
|
private static func recordFailure() {
|
|
debugMetricsQueue.sync {
|
|
failureCount += 1
|
|
}
|
|
}
|
|
|
|
private static func mimeType(for pathExtension: String) -> String {
|
|
switch pathExtension.lowercased() {
|
|
case "html", "htm":
|
|
return "text/html"
|
|
case "xhtml":
|
|
return "application/xhtml+xml"
|
|
case "css":
|
|
return "text/css"
|
|
case "js":
|
|
return "application/javascript"
|
|
case "xml", "opf", "ncx":
|
|
return "application/xml"
|
|
case "svg":
|
|
return "image/svg+xml"
|
|
case "jpg", "jpeg":
|
|
return "image/jpeg"
|
|
case "png":
|
|
return "image/png"
|
|
case "gif":
|
|
return "image/gif"
|
|
case "webp":
|
|
return "image/webp"
|
|
case "ttf":
|
|
return "font/ttf"
|
|
case "otf":
|
|
return "font/otf"
|
|
case "woff":
|
|
return "font/woff"
|
|
case "woff2":
|
|
return "font/woff2"
|
|
case "mp3":
|
|
return "audio/mpeg"
|
|
case "mp4":
|
|
return "video/mp4"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
private static func textEncodingName(for pathExtension: String) -> String? {
|
|
switch pathExtension.lowercased() {
|
|
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
|
|
return "utf-8"
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func isOptionalResource(_ url: URL) -> Bool {
|
|
switch url.pathExtension.lowercased() {
|
|
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|