// RDEPUBResourceURLSchemeHandler.swift // ss-reader:// 自定义 URL 协议处理器 // 实现 WKURLSchemeHandler 协议,拦截 ss-reader://book/ 请求, // 将其映射到本地解压的 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 } } }