- 新增 RDEPUBResourceDataProvider 解密钩子协议 + 访问登记表,收敛章节 HTML/ 图片/内联 CSS/脚注/封面/图片查看器/正文取图等读取点,scheme handler 对加密书 禁用流式分支;新增 RDEPUBDecryptingImageAttachment 解密 DTCoreText 图片附件; RDEPUBReaderDependencies.live(resourceDataProvider:) 便捷注入。明文书零影响。 - 试读墙:configuration.trialPolicy + delegate epubReaderTrialWallView/ DidReachTrialWall,UI 由宿主提供(RDEPUBReaderController+Trial)。 - 工具栏定制:RDEPUBReaderTop/BottomToolViewProtocol 协议 + dependencies 工厂注入, 内置栏已 conform,configureTopToolView 改协议类型。 - 朝向锁定:RDEPUBMetadata.orientation(OPF rendition:orientation 主, iBooks display-options 兜底)+ RDEPUBReaderController+Orientation 重写支持朝向、 打开后主动转向。 注:RDEPUBReaderController+LocationResolution / PaginationCoordinator 为本次改动前 即存在的工作区修改,一并纳入。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
350 lines
13 KiB
Swift
350 lines
13 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"
|
||
|
||
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 时不能走流式分支:密文必须整体解密后再响应,
|
||
// 统一改走内存分支(内部会先询问 provider,明文文件仍按磁盘直读)
|
||
if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil {
|
||
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
|
||
}
|
||
|
||
/// 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) {
|
||
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 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
|
||
}
|
||
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")
|
||
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 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
|
||
}
|
||
}
|
||
}
|