Files
ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift
T
shenandshen 54798ba578 refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
2026-05-25 10:19:14 +08:00

168 lines
6.5 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 {
/// 自定义协议名
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] = [:]
public init(parser: RDEPUBParser) {
self.parser = parser
super.init()
}
/// 处理 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")
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
}
clearTask(taskID)
return
}
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
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()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
} catch {
guard isTaskActive(taskID) else { return }
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
urlSchemeTask.didFailWithError(error)
}
clearTask(taskID)
}
/// 取消 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
}
}
/// 根据文件扩展名推断 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
}
}
}