feat: chapter runtime refactoring and related updates
- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator - Update EPUB core: parser, reading session, JS bridge, navigator layout - Update reader controller: data source, location resolution, persistence - Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator - Remove deprecated navigation state machine and pagination state - Update text rendering: book cache, HTML normalizer - Update UI: text content view, dark image adjuster, text selection controller - Update settings and reader configuration - Add CODE_REVIEW.md and AUDIT_FINAL.md documentation - Update pod dependencies (remove SSAlertSwift, SnapKit) - Update podspec and pod configuration files Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
@@ -19,6 +18,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
|
||||
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
|
||||
@@ -69,6 +70,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
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()
|
||||
@@ -116,61 +119,153 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
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) {
|
||||
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)
|
||||
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")
|
||||
} 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)
|
||||
}
|
||||
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)
|
||||
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 isTaskActive(taskID) else { return }
|
||||
Self.recordFailure()
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
clearTask(taskID)
|
||||
return
|
||||
}
|
||||
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()
|
||||
clearTask(taskID)
|
||||
let chunkSize = 65_536
|
||||
var streamingCompleted = false
|
||||
defer {
|
||||
fileHandle.closeFile()
|
||||
if !streamingCompleted {
|
||||
// Task was cancelled during streaming; didFailWithError already sent above
|
||||
// or will be sent by the guard check below. No need to send again.
|
||||
}
|
||||
self.clearTask(taskID)
|
||||
}
|
||||
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))
|
||||
}
|
||||
return
|
||||
}
|
||||
let data = fileHandle.readData(ofLength: chunkSize)
|
||||
if data.isEmpty { break }
|
||||
self.dispatchTaskCallbackIfActive(taskID: taskID) {
|
||||
urlSchemeTask.didReceive(data)
|
||||
}
|
||||
}
|
||||
streamingCompleted = true
|
||||
self.dispatchTaskSuccessCallback(
|
||||
taskID: taskID,
|
||||
urlSchemeTask: urlSchemeTask
|
||||
) {
|
||||
urlSchemeTask.didFinish()
|
||||
}
|
||||
Self.recordStreamedResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
||||
}
|
||||
while true {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
let data = fileHandle.readData(ofLength: chunkSize)
|
||||
if data.isEmpty { break }
|
||||
urlSchemeTask.didReceive(data)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
urlSchemeTask.didFinish()
|
||||
Self.recordStreamedResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
||||
}
|
||||
|
||||
private static func recordStreamedResponse() {
|
||||
|
||||
Reference in New Issue
Block a user