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:
@@ -166,10 +166,23 @@ enum RDEPUBJavaScriptBridge {
|
||||
return string
|
||||
}
|
||||
|
||||
/// Encodes a string value as a safe JavaScript string literal.
|
||||
///
|
||||
/// Uses JSON serialization to handle escaping of quotes, backslashes, control
|
||||
/// characters, and Unicode, then strips the array wrapper to produce a standalone
|
||||
/// JS string literal (including surrounding quotes).
|
||||
///
|
||||
/// - Parameter value: The string to encode, or `nil` which produces the JS keyword `null`.
|
||||
/// - Returns: A JavaScript string literal safe for inline embedding in JS source.
|
||||
private static func javaScriptStringLiteral(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
return jsonString(from: [value], fallback: "[null]")
|
||||
.replacingOccurrences(of: "[", with: "")
|
||||
.replacingOccurrences(of: "]", with: "")
|
||||
guard JSONSerialization.isValidJSONObject([value]),
|
||||
let data = try? JSONSerialization.data(withJSONObject: [value], options: []),
|
||||
let arrayString = String(data: data, encoding: .utf8) else {
|
||||
return "null"
|
||||
}
|
||||
// JSON encodes ["value"] as `["value"]`. Drop the leading `[` and trailing `]`
|
||||
// to yield `"value"` — a valid JS string literal with all special characters escaped.
|
||||
return String(arrayString.dropFirst().dropLast())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,20 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
return CGSize(width: width, height: containerSize.height)
|
||||
}
|
||||
|
||||
/// Content insets that account for safe area (Dynamic Island / notch) on iPhone.
|
||||
/// Uses the larger of safeAreaInsets and reflowableContentInsets for top/bottom
|
||||
/// to ensure content is never hidden under the Dynamic Island or home indicator.
|
||||
public var safeReflowableContentInsets: UIEdgeInsets {
|
||||
let top = max(safeAreaInsets.top, reflowableContentInsets.top)
|
||||
let bottom = max(safeAreaInsets.bottom, reflowableContentInsets.bottom)
|
||||
let left = max(safeAreaInsets.left, reflowableContentInsets.left)
|
||||
let right = max(safeAreaInsets.right, reflowableContentInsets.right)
|
||||
return UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)
|
||||
}
|
||||
|
||||
/// Fixed layout content insets that respect safe areas on all devices including iPhone.
|
||||
public var fixedContentInset: UIEdgeInsets {
|
||||
var insets = safeAreaInsets
|
||||
if userInterfaceIdiom != .phone {
|
||||
insets = .zero
|
||||
}
|
||||
|
||||
let horizontalInsets = max(insets.left, insets.right)
|
||||
insets.left = horizontalInsets
|
||||
insets.right = horizontalInsets
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBNavigatorState: String, Codable {
|
||||
|
||||
case initializing
|
||||
|
||||
case loading
|
||||
|
||||
case idle
|
||||
|
||||
case jumping
|
||||
|
||||
case moving
|
||||
|
||||
case repaginating
|
||||
|
||||
public var isStableForSnapshotApplication: Bool {
|
||||
self == .idle
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,11 @@ extension RDEPUBParser {
|
||||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
||||
|
||||
if fileManager.fileExists(atPath: extractionURL.path) {
|
||||
return extractionURL
|
||||
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
|
||||
if fileManager.fileExists(atPath: containerURL.path) {
|
||||
return extractionURL
|
||||
}
|
||||
try? fileManager.removeItem(at: extractionURL)
|
||||
}
|
||||
|
||||
guard let archive = Archive(url: epubURL, accessMode: .read) else {
|
||||
@@ -38,24 +42,50 @@ extension RDEPUBParser {
|
||||
|
||||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
||||
|
||||
for entry in archive {
|
||||
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
|
||||
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
|
||||
}
|
||||
switch entry.type {
|
||||
case .directory:
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
case .file:
|
||||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
_ = try archive.extract(entry, to: destinationURL)
|
||||
case .symlink:
|
||||
continue
|
||||
do {
|
||||
for entry in archive {
|
||||
if shouldSkipEntry(entry.path) { continue }
|
||||
|
||||
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
|
||||
if isCriticalEntry(entry.path) {
|
||||
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch entry.type {
|
||||
case .directory:
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
case .file:
|
||||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
_ = try archive.extract(entry, to: destinationURL)
|
||||
case .symlink:
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: extractionURL)
|
||||
throw error
|
||||
}
|
||||
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
private func isCriticalEntry(_ entryPath: String) -> Bool {
|
||||
let lowercased = entryPath.lowercased()
|
||||
return lowercased == "mimetype"
|
||||
|| lowercased.hasPrefix("meta-inf/")
|
||||
|| lowercased.hasSuffix(".opf")
|
||||
}
|
||||
|
||||
private func shouldSkipEntry(_ entryPath: String) -> Bool {
|
||||
let lowercased = entryPath.lowercased()
|
||||
if lowercased.hasPrefix("__macosx/") { return true }
|
||||
if lowercased.hasPrefix(".ds_store") { return true }
|
||||
if lowercased.contains("/.ds_store") { return true }
|
||||
if lowercased.hasSuffix("/thumbs.db") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
|
||||
|
||||
if entryPath.hasPrefix("/") {
|
||||
|
||||
@@ -21,6 +21,64 @@ public final class RDEPUBParser {
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Cache Management
|
||||
|
||||
/// The base directory used for EPUB extraction caches.
|
||||
public static var extractionCacheBaseDirectory: URL {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
}
|
||||
|
||||
/// Removes all EPUB extraction caches from disk.
|
||||
/// Call this when the host app wants to free disk space, e.g. on didReceiveMemoryWarning
|
||||
/// or during cleanup when the reader is no longer needed.
|
||||
public static func cleanExtractionCache() throws {
|
||||
let fileManager = FileManager.default
|
||||
let baseURL = extractionCacheBaseDirectory
|
||||
guard fileManager.fileExists(atPath: baseURL.path) else { return }
|
||||
try fileManager.removeItem(at: baseURL)
|
||||
}
|
||||
|
||||
/// Evicts extraction caches until total disk usage is below the given threshold,
|
||||
/// removing least-recently-accessed directories first.
|
||||
/// - Parameter maxBytes: Maximum total bytes for all extraction caches. Defaults to 500 MB.
|
||||
public static func evictExtractionCache(maxBytes: UInt64 = 500 * 1024 * 1024) throws {
|
||||
let fileManager = FileManager.default
|
||||
let baseURL = extractionCacheBaseDirectory
|
||||
guard fileManager.fileExists(atPath: baseURL.path) else { return }
|
||||
|
||||
let contents = try fileManager.contentsOfDirectory(
|
||||
at: baseURL,
|
||||
includingPropertiesForKeys: [.contentAccessDateKey, .isDirectoryKey],
|
||||
options: .skipsHiddenFiles
|
||||
)
|
||||
|
||||
var entries: [(url: URL, accessDate: Date, size: UInt64)] = []
|
||||
var totalSize: UInt64 = 0
|
||||
|
||||
for directoryURL in contents {
|
||||
guard let resourceValues = try? directoryURL.resourceValues(forKeys: [.isDirectoryKey, .contentAccessDateKey]),
|
||||
resourceValues.isDirectory == true else {
|
||||
continue
|
||||
}
|
||||
let directorySize = directoryURL.directorySize
|
||||
let accessDate = resourceValues.contentAccessDate ?? Date.distantPast
|
||||
entries.append((url: directoryURL, accessDate: accessDate, size: directorySize))
|
||||
totalSize += directorySize
|
||||
}
|
||||
|
||||
guard totalSize > maxBytes else { return }
|
||||
|
||||
entries.sort { $0.accessDate < $1.accessDate }
|
||||
|
||||
for entry in entries {
|
||||
guard totalSize > maxBytes else { break }
|
||||
try? fileManager.removeItem(at: entry.url)
|
||||
totalSize -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
public func makePublication() -> RDEPUBPublication {
|
||||
RDEPUBPublication(parser: self)
|
||||
}
|
||||
@@ -75,4 +133,24 @@ public final class RDEPUBParser {
|
||||
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
|
||||
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Directory Size Extension
|
||||
|
||||
private extension URL {
|
||||
var directorySize: UInt64 {
|
||||
let fileManager = FileManager.default
|
||||
guard let enumerator = fileManager.enumerator(at: self, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles], errorHandler: nil) else {
|
||||
return 0
|
||||
}
|
||||
var totalSize: UInt64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let fileSize = resourceValues.fileSize else {
|
||||
continue
|
||||
}
|
||||
totalSize += UInt64(fileSize)
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,10 @@ public final class RDEPUBReadingSession {
|
||||
|
||||
public let publication: RDEPUBPublication
|
||||
|
||||
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
|
||||
|
||||
public private(set) var activePages: [EPUBPage] = []
|
||||
|
||||
public private(set) var activeChapters: [EPUBChapterInfo] = []
|
||||
|
||||
public private(set) var stagedPages: [EPUBPage]?
|
||||
|
||||
public private(set) var stagedChapters: [EPUBChapterInfo]?
|
||||
|
||||
public private(set) var stagedRestoreLocation: RDEPUBLocation?
|
||||
|
||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||
|
||||
public private(set) var pendingNavigationPageNum: Int?
|
||||
@@ -37,16 +29,10 @@ public final class RDEPUBReadingSession {
|
||||
publication.resourceResolver
|
||||
}
|
||||
|
||||
public func transition(to state: RDEPUBNavigatorState) {
|
||||
navigatorState = state
|
||||
}
|
||||
|
||||
public func resetRuntimeState() {
|
||||
navigatorState = .initializing
|
||||
activePages = []
|
||||
activeChapters = []
|
||||
clearPendingNavigation()
|
||||
clearStagedSnapshot()
|
||||
currentViewport = nil
|
||||
currentReadingContext = nil
|
||||
}
|
||||
@@ -56,36 +42,6 @@ public final class RDEPUBReadingSession {
|
||||
activeChapters = snapshot.chapters
|
||||
}
|
||||
|
||||
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
|
||||
stagedPages = snapshot.pages
|
||||
stagedChapters = snapshot.chapters
|
||||
stagedRestoreLocation = restoreLocation
|
||||
}
|
||||
|
||||
public func stagedSnapshot() -> PaginationSnapshot? {
|
||||
guard let stagedPages, let stagedChapters else {
|
||||
return nil
|
||||
}
|
||||
return (stagedPages, stagedChapters)
|
||||
}
|
||||
|
||||
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
|
||||
guard navigatorState.isStableForSnapshotApplication,
|
||||
let stagedPages,
|
||||
let stagedChapters else {
|
||||
return nil
|
||||
}
|
||||
let restoreLocation = stagedRestoreLocation
|
||||
clearStagedSnapshot()
|
||||
return ((stagedPages, stagedChapters), restoreLocation)
|
||||
}
|
||||
|
||||
public func clearStagedSnapshot() {
|
||||
stagedPages = nil
|
||||
stagedChapters = nil
|
||||
stagedRestoreLocation = nil
|
||||
}
|
||||
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
@@ -241,7 +197,6 @@ public final class RDEPUBReadingSession {
|
||||
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
|
||||
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
|
||||
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
|
||||
transition(to: .jumping)
|
||||
return pageIndex + 1
|
||||
}
|
||||
|
||||
@@ -292,9 +247,6 @@ public final class RDEPUBReadingSession {
|
||||
if pendingNavigationPageNum == pageNumber {
|
||||
clearPendingNavigation()
|
||||
}
|
||||
if navigatorState == .jumping || navigatorState == .moving {
|
||||
transition(to: .idle)
|
||||
}
|
||||
}
|
||||
|
||||
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -36,11 +36,18 @@ extension RDEPUBWebView {
|
||||
guard let self else { return }
|
||||
self.handleSelectionMenuAction(action)
|
||||
}
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||
]
|
||||
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
|
||||
if #available(iOS 16.0, *) {
|
||||
let interaction = UIEditMenuInteraction(delegate: webView)
|
||||
webView.addInteraction(interaction)
|
||||
webView._editMenuInteraction = interaction
|
||||
} else {
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||
]
|
||||
}
|
||||
webView.navigationDelegate = self
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
webView.scrollView.showsHorizontalScrollIndicator = false
|
||||
@@ -82,6 +89,10 @@ extension RDEPUBWebView {
|
||||
if let webView {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
}
|
||||
// M-15: Clean up UIEditMenuInteraction on iOS 16+
|
||||
if #available(iOS 16.0, *), let interaction = (webView as? RDEPUBAnnotationWebView)?._editMenuInteraction as? UIEditMenuInteraction {
|
||||
webView?.removeInteraction(interaction)
|
||||
}
|
||||
webView?.navigationDelegate = nil
|
||||
if let userContentController = webView?.configuration.userContentController {
|
||||
RDEPUBJavaScriptBridge.messageNames.forEach {
|
||||
|
||||
@@ -33,6 +33,10 @@ final class RDEPUBAnnotationWebView: WKWebView {
|
||||
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
||||
// Stored as Any? to avoid @available on stored property restriction.
|
||||
var _editMenuInteraction: Any?
|
||||
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
true
|
||||
}
|
||||
@@ -61,6 +65,29 @@ final class RDEPUBAnnotationWebView: WKWebView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate {
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
menuFor configuration: UIEditMenuConfiguration
|
||||
) -> UIMenu {
|
||||
UIMenu(children: [
|
||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
])
|
||||
}
|
||||
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
targetRectFor configuration: UIEditMenuConfiguration
|
||||
) -> CGRect {
|
||||
bounds
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBWebView: UIView {
|
||||
|
||||
public weak var delegate: RDEPUBWebViewDelegate?
|
||||
@@ -126,6 +153,11 @@ public final class RDEPUBWebView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// M-08: Retain cycle note — webView → configuration.userContentController → self (as WKScriptMessageHandler)
|
||||
// forms a retain cycle. The cycle is broken by calling teardownWebView(), which removes
|
||||
// the message handlers. teardownWebView() is called in reset() and deinit. IMPORTANT:
|
||||
// deinit may never trigger if the cycle is not broken first. Every code path that disposes
|
||||
// of this view MUST call reset() before releasing the last reference.
|
||||
deinit {
|
||||
teardownWebView()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user