- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
266 lines
9.9 KiB
Swift
266 lines
9.9 KiB
Swift
// RDEPUBResourceURLSchemeHandler.swift
|
||
// ss-reader:// 自定义 URL 协议处理器
|
||
// 实现 WKURLSchemeHandler 协议,拦截 ss-reader://book/<path> 请求,
|
||
// 将其映射到本地解压的 EPUB 资源文件。支持 MIME 类型推断和可选资源的空响应处理。
|
||
|
||
import Foundation
|
||
import WebKit
|
||
|
||
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
|
||
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"
|
||
|
||
/// 底层解析器引用(弱引用,避免循环持有)
|
||
private weak var parser: RDEPUBParser?
|
||
/// 文件管理器实例
|
||
private let fileManager = FileManager.default
|
||
/// 串行队列,用于同步访问活跃任务字典
|
||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
||
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
|
||
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
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
|
||
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
|
||
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)
|
||
)
|
||
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)
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 取消 URL Scheme 任务
|
||
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 respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
|
||
do {
|
||
let data = try Data(contentsOf: fileURL)
|
||
guard isTaskActive(taskID) else { return }
|
||
let response = URLResponse(
|
||
url: requestURL,
|
||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||
expectedContentLength: data.count,
|
||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||
)
|
||
urlSchemeTask.didReceive(response)
|
||
urlSchemeTask.didReceive(data)
|
||
urlSchemeTask.didFinish()
|
||
Self.recordInMemoryResponse()
|
||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
||
} catch {
|
||
guard isTaskActive(taskID) else { return }
|
||
Self.recordFailure()
|
||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
||
urlSchemeTask.didFailWithError(error)
|
||
}
|
||
clearTask(taskID)
|
||
}
|
||
|
||
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
|
||
let response = URLResponse(
|
||
url: requestURL,
|
||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||
expectedContentLength: Int(fileSize),
|
||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||
)
|
||
urlSchemeTask.didReceive(response)
|
||
|
||
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
|
||
guard isTaskActive(taskID) else { return }
|
||
Self.recordFailure()
|
||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||
clearTask(taskID)
|
||
return
|
||
}
|
||
|
||
let chunkSize = 65_536
|
||
defer {
|
||
fileHandle.closeFile()
|
||
clearTask(taskID)
|
||
}
|
||
while true {
|
||
guard isTaskActive(taskID) else { return }
|
||
let data = fileHandle.readData(ofLength: chunkSize)
|
||
if data.isEmpty { break }
|
||
urlSchemeTask.didReceive(data)
|
||
}
|
||
urlSchemeTask.didFinish()
|
||
Self.recordStreamedResponse()
|
||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
||
}
|
||
|
||
private static func recordStreamedResponse() {
|
||
debugMetricsQueue.sync {
|
||
streamedResponseCount += 1
|
||
}
|
||
}
|
||
|
||
private static func recordInMemoryResponse() {
|
||
debugMetricsQueue.sync {
|
||
inMemoryResponseCount += 1
|
||
}
|
||
}
|
||
|
||
private static func recordFailure() {
|
||
debugMetricsQueue.sync {
|
||
failureCount += 1
|
||
}
|
||
}
|
||
|
||
/// 根据文件扩展名推断 MIME 类型
|
||
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"
|
||
}
|
||
}
|
||
|
||
/// 根据文件扩展名推断文本编码(文本类文件返回 UTF-8)
|
||
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
|
||
}
|
||
}
|
||
|
||
/// 判断是否为可选资源(字体/图片/CSS/JS 等缺失时不报错,返回空响应)
|
||
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
|
||
}
|
||
}
|
||
}
|