ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift

342 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)
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
}
/// 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 {
let 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
}
}
}