342 lines
13 KiB
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
|
|
}
|
|
}
|
|
}
|