ReadViewSDK/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00

350 lines
13 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}
}