feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
This commit is contained in:
@@ -72,7 +72,11 @@ enum RDEPUBJavaScriptBridge {
|
||||
window.WeReadApi.clearHighlights();
|
||||
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
|
||||
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
|
||||
window.WeReadApi.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex));
|
||||
window.WeReadApi.scrollToLocation(
|
||||
\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")),
|
||||
\(request.pageIndex),
|
||||
\(javaScriptStringLiteral(request.targetHighlightRangeInfo))
|
||||
);
|
||||
} else {
|
||||
window.WeReadApi.scrollToPage(\(request.pageIndex));
|
||||
}
|
||||
|
||||
@@ -212,6 +212,8 @@ public enum RDEPUBParserError: LocalizedError {
|
||||
case missingManifestItem(idref: String)
|
||||
/// 构建完成的 spine 为空(无可阅读的内容)
|
||||
case emptySpine
|
||||
/// 归档条目路径包含路径穿越或非法字符
|
||||
case invalidArchiveEntryPath(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -229,6 +231,8 @@ public enum RDEPUBParserError: LocalizedError {
|
||||
return "spine itemref 找不到对应 manifest 项: \(idref)"
|
||||
case .emptySpine:
|
||||
return "OPF spine 为空"
|
||||
case .invalidArchiveEntryPath(let path):
|
||||
return "归档条目路径非法: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.scrollView.showsVerticalScrollIndicator = false
|
||||
webView.isUserInteractionEnabled = false
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = true
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
||||
}
|
||||
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
return webView
|
||||
@@ -297,6 +295,9 @@ public final class RDEPUBPaginator: NSObject {
|
||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
||||
}
|
||||
|
||||
/// 各轮测量记录值(用于稳定性日志)
|
||||
private var measurementPassValues: [Int] = []
|
||||
|
||||
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
|
||||
private func scheduleMeasurementPass() {
|
||||
let sessionID = activeSessionID
|
||||
@@ -306,8 +307,18 @@ public final class RDEPUBPaginator: NSObject {
|
||||
let currentSpineIndex = currentSpineIndexForMeasurement() else {
|
||||
return
|
||||
}
|
||||
pageCounts[currentSpineIndex] = max(1, pendingMeasurementValue)
|
||||
let finalValue = max(1, pendingMeasurementValue)
|
||||
// 测量稳定性日志:三轮差异过大时告警
|
||||
if measurementPassValues.count >= 2 {
|
||||
let minVal = measurementPassValues.min() ?? 1
|
||||
let maxVal = measurementPassValues.max() ?? 1
|
||||
if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)")
|
||||
}
|
||||
}
|
||||
pageCounts[currentSpineIndex] = finalValue
|
||||
currentMeasurementOffset += 1
|
||||
measurementPassValues = []
|
||||
measureNextSpineItem()
|
||||
return
|
||||
}
|
||||
@@ -333,8 +344,10 @@ public final class RDEPUBPaginator: NSObject {
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
|
||||
self.measurementPassValues.append(number.intValue)
|
||||
} else if let intValue = value as? Int {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
|
||||
self.measurementPassValues.append(intValue)
|
||||
}
|
||||
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
|
||||
self.scheduleMeasurementPass()
|
||||
@@ -344,6 +357,20 @@ public final class RDEPUBPaginator: NSObject {
|
||||
}
|
||||
|
||||
extension RDEPUBPaginator: WKNavigationDelegate {
|
||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme {
|
||||
decisionHandler(.allow)
|
||||
} else {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)")
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ extension RDEPUBParser {
|
||||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
||||
|
||||
for entry in archive {
|
||||
let destinationURL = extractionURL.appendingPathComponent(entry.path)
|
||||
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)
|
||||
@@ -64,6 +66,31 @@ extension RDEPUBParser {
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
/// 校验归档条目路径,防止路径穿越攻击
|
||||
/// - Parameters:
|
||||
/// - entryPath: 归档中的原始条目路径
|
||||
/// - extractionRoot: 解压根目录
|
||||
/// - Returns: 校验通过的目标 URL,非法路径返回 nil
|
||||
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
|
||||
// 拒绝绝对路径
|
||||
if entryPath.hasPrefix("/") {
|
||||
return nil
|
||||
}
|
||||
// 拒绝包含 .. 的路径段
|
||||
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
|
||||
if components.contains(where: { $0 == ".." }) {
|
||||
return nil
|
||||
}
|
||||
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
|
||||
let standardizedDest = destinationURL.standardizedFileURL.path
|
||||
let standardizedRoot = extractionRoot.standardizedFileURL.path
|
||||
// 确保最终路径位于解压根目录下
|
||||
guard standardizedDest.hasPrefix(standardizedRoot) else {
|
||||
return nil
|
||||
}
|
||||
return destinationURL
|
||||
}
|
||||
|
||||
/// 计算 EPUB 的临时解压目录路径
|
||||
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
|
||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||
|
||||
@@ -76,6 +76,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
publication: RDEPUBPublication,
|
||||
viewportSize: CGSize,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
targetHighlightRangeInfo: String? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) -> RDEPUBRenderRequest? {
|
||||
@@ -104,6 +105,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
totalPagesInChapter: page.totalPagesInChapter,
|
||||
presentation: presentationStyle(viewportSize: viewportSize),
|
||||
targetLocation: targetLocation,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo,
|
||||
highlights: highlights,
|
||||
searchPresentation: searchPresentation
|
||||
)
|
||||
|
||||
@@ -31,6 +31,8 @@ public final class RDEPUBReadingSession {
|
||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||
/// 挂起的导航目标页码
|
||||
public private(set) var pendingNavigationPageNum: Int?
|
||||
/// 挂起的高亮 Range 信息(仅 Web 渲染路径使用)
|
||||
public private(set) var pendingNavigationHighlightRangeInfo: String?
|
||||
/// 当前视口信息
|
||||
public private(set) var currentViewport: RDEPUBViewport?
|
||||
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
|
||||
@@ -105,6 +107,7 @@ public final class RDEPUBReadingSession {
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
pendingNavigationHighlightRangeInfo = nil
|
||||
}
|
||||
|
||||
/// 检查指定页码和 spine 索引是否有挂起的导航位置
|
||||
@@ -123,6 +126,26 @@ public final class RDEPUBReadingSession {
|
||||
return pendingNavigationLocation
|
||||
}
|
||||
|
||||
/// 查询指定页是否存在待消费的高亮 Range 信息。
|
||||
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
|
||||
guard pendingNavigationPageNum == pageNumber,
|
||||
let pendingNavigationHighlightRangeInfo,
|
||||
!pendingNavigationHighlightRangeInfo.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard let pendingNavigationLocation else {
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
guard let spineIndex else {
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
||||
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
|
||||
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
|
||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
@@ -225,7 +248,8 @@ public final class RDEPUBReadingSession {
|
||||
public func queueNavigation(
|
||||
to location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
bookIdentifier: String?
|
||||
bookIdentifier: String?,
|
||||
targetHighlightRangeInfo: String? = nil
|
||||
) -> Int? {
|
||||
guard let normalizedLocation = resourceResolver.normalizedLocation(
|
||||
location,
|
||||
@@ -235,8 +259,14 @@ public final class RDEPUBReadingSession {
|
||||
return nil
|
||||
}
|
||||
|
||||
pendingNavigationLocation = normalizedLocation.fragment == nil ? nil : normalizedLocation
|
||||
pendingNavigationPageNum = normalizedLocation.fragment == nil ? nil : pageIndex + 1
|
||||
let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
|
||||
let shouldKeepPendingNavigation =
|
||||
normalizedLocation.fragment != nil ||
|
||||
normalizedLocation.rangeAnchor != nil ||
|
||||
hasTargetHighlightRangeInfo
|
||||
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
|
||||
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
|
||||
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
|
||||
transition(to: .jumping)
|
||||
return pageIndex + 1
|
||||
}
|
||||
@@ -363,4 +393,4 @@ public final class RDEPUBReadingSession {
|
||||
|
||||
return (pages, chapters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
public var presentation: RDEPUBPresentationStyle
|
||||
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
|
||||
public var targetLocation: RDEPUBLocation?
|
||||
/// 目标高亮的 DOM Range 信息,用于 Web 渲染路径下的精确跳转
|
||||
public var targetHighlightRangeInfo: String?
|
||||
/// 高亮列表
|
||||
public var highlights: [RDEPUBHighlight]
|
||||
/// 搜索结果展示信息
|
||||
@@ -97,6 +99,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
totalPagesInChapter: Int,
|
||||
presentation: RDEPUBPresentationStyle,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
targetHighlightRangeInfo: String? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) {
|
||||
@@ -106,6 +109,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
self.totalPagesInChapter = totalPagesInChapter
|
||||
self.presentation = presentation
|
||||
self.targetLocation = targetLocation
|
||||
self.targetHighlightRangeInfo = targetHighlightRangeInfo
|
||||
self.highlights = highlights
|
||||
self.searchPresentation = searchPresentation
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ import WebKit
|
||||
|
||||
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
|
||||
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
struct DebugMetrics {
|
||||
let streamedResponses: Int
|
||||
let inMemoryResponses: Int
|
||||
let failures: Int
|
||||
}
|
||||
|
||||
/// 自定义协议名
|
||||
public static let scheme = "ss-reader"
|
||||
/// 自定义主机名
|
||||
@@ -21,12 +27,34 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
||||
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
|
||||
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||
@@ -55,6 +83,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
urlSchemeTask.didFinish()
|
||||
} else {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
|
||||
Self.recordFailure()
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
}
|
||||
clearTask(taskID)
|
||||
@@ -63,27 +92,12 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
||||
|
||||
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()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
||||
} catch {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
||||
urlSchemeTask.didFailWithError(error)
|
||||
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)
|
||||
}
|
||||
|
||||
clearTask(taskID)
|
||||
}
|
||||
|
||||
/// 取消 URL Scheme 任务
|
||||
@@ -106,6 +120,89 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let chunkSize = 65_536
|
||||
defer {
|
||||
fileHandle.closeFile()
|
||||
clearTask(taskID)
|
||||
}
|
||||
while true {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
let data = fileHandle.readData(ofLength: chunkSize)
|
||||
if data.isEmpty { break }
|
||||
urlSchemeTask.didReceive(data)
|
||||
}
|
||||
urlSchemeTask.didFinish()
|
||||
Self.recordStreamedResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
||||
}
|
||||
|
||||
private static func recordStreamedResponse() {
|
||||
debugMetricsQueue.sync {
|
||||
streamedResponseCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func recordInMemoryResponse() {
|
||||
debugMetricsQueue.sync {
|
||||
inMemoryResponseCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func recordFailure() {
|
||||
debugMetricsQueue.sync {
|
||||
failureCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断 MIME 类型
|
||||
private static func mimeType(for pathExtension: String) -> String {
|
||||
switch pathExtension.lowercased() {
|
||||
@@ -165,4 +262,4 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ extension RDEPUBWebView {
|
||||
webView.backgroundColor = .clear
|
||||
webView.clipsToBounds = true
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = true
|
||||
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
||||
}
|
||||
|
||||
addSubview(webView)
|
||||
|
||||
@@ -97,7 +97,8 @@ extension RDEPUBWebView {
|
||||
request.targetLocation?.href ?? "",
|
||||
String(request.targetLocation?.progression ?? 0),
|
||||
String(request.targetLocation?.lastProgression ?? 0),
|
||||
request.targetLocation?.fragment ?? ""
|
||||
request.targetLocation?.fragment ?? "",
|
||||
request.targetHighlightRangeInfo ?? ""
|
||||
].joined(separator: "|")
|
||||
let highlightSignature = request.highlights
|
||||
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
|
||||
|
||||
@@ -6,6 +6,14 @@ import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
private var normalSearchOverlayColor: UIColor {
|
||||
UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
|
||||
}
|
||||
|
||||
private var activeSearchOverlayColor: UIColor {
|
||||
UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
|
||||
}
|
||||
|
||||
/// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView
|
||||
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||||
guard let webView else {
|
||||
@@ -70,7 +78,7 @@ extension RDEPUBWebView {
|
||||
)
|
||||
let searchDecorations = decorationList(
|
||||
from: payload["search"] as? [[String: Any]],
|
||||
defaultColor: UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
defaultColor: normalSearchOverlayColor
|
||||
)
|
||||
return highlightDecorations + searchDecorations
|
||||
}
|
||||
@@ -105,9 +113,9 @@ extension RDEPUBWebView {
|
||||
let hex = item["color"] as? String ?? "#F8E16C"
|
||||
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
|
||||
case .activeSearch:
|
||||
color = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
color = activeSearchOverlayColor
|
||||
case .search:
|
||||
color = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
color = normalSearchOverlayColor
|
||||
case .highlight:
|
||||
let hex = item["color"] as? String ?? "#F8E16C"
|
||||
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
|
||||
|
||||
@@ -21,6 +21,30 @@ enum RDEPUBWebViewDebug {
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// 详细日志模式是否启用(默认关闭,可通过 UserDefaults "RDEPUBWebViewVerboseEnabled" 开启)
|
||||
/// 开启后 logMessage 将输出完整消息体,关闭时仅输出消息名、字段名和文本长度
|
||||
static var isVerboseEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool {
|
||||
return configured
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
/// 是否允许开启 inspectable。
|
||||
/// 默认关闭,仅在阅读器配置显式允许或 UserDefaults 覆盖时开启。
|
||||
static var isInspectableEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool {
|
||||
return configured
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
/// 将阅读器配置同步到调试策略,保证默认安全策略由配置显式控制。
|
||||
static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) {
|
||||
isInspectableEnabled = inspectableEnabled
|
||||
isVerboseEnabled = verboseLoggingEnabled
|
||||
}
|
||||
|
||||
/// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例)
|
||||
static func webViewID(_ webView: WKWebView?) -> String {
|
||||
guard let webView else { return "nil-webview" }
|
||||
@@ -51,10 +75,26 @@ enum RDEPUBWebViewDebug {
|
||||
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
|
||||
}
|
||||
|
||||
/// 输出 JS 消息接收日志(含消息名称和消息体)
|
||||
/// 输出 JS 消息接收日志(默认仅输出字段名和文本长度,verbose 模式输出完整消息体)
|
||||
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
|
||||
guard isEnabled else { return }
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
|
||||
if isVerboseEnabled {
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
|
||||
return
|
||||
}
|
||||
if let dict = body as? [String: Any] {
|
||||
let keys = dict.keys.sorted().joined(separator: ",")
|
||||
var meta = "keys=[\(keys)]"
|
||||
if let text = dict["text"] as? String {
|
||||
meta += " textLen=\(text.count)"
|
||||
}
|
||||
if let urlString = dict["url"] as? String ?? dict["href"] as? String {
|
||||
meta += " url=\(summarizedURL(URL(string: urlString)))"
|
||||
}
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) \(meta)")
|
||||
} else {
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) type=\(Swift.type(of: body))")
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息)
|
||||
@@ -81,4 +121,4 @@ enum RDEPUBWebViewDebug {
|
||||
}
|
||||
return url.lastPathComponent.isEmpty ? path : url.lastPathComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(function() {
|
||||
(function() {
|
||||
if (window.RDReaderBridge) { return; }
|
||||
window.addEventListener('error', function(event) {
|
||||
try {
|
||||
@@ -470,7 +470,18 @@
|
||||
setVisiblePageIndex(pageIndex);
|
||||
reportProgression(null);
|
||||
},
|
||||
scrollToLocation: function(location, fallbackPageIndex) {
|
||||
scrollToLocation: function(location, fallbackPageIndex, targetRangeInfo) {
|
||||
if (targetRangeInfo) {
|
||||
var highlightRange = rangeFromInfo(targetRangeInfo);
|
||||
var highlightRects = rectPayloadForRange(highlightRange, 0, 0);
|
||||
if (highlightRects.length) {
|
||||
var highlightRect = highlightRects[0];
|
||||
var highlightAbsoluteLeft = Math.max(0, highlightRect.x + currentPageOffset());
|
||||
setVisiblePageIndex(Math.max(0, Math.floor(highlightAbsoluteLeft / effectivePageStride())));
|
||||
reportProgression(location && location.fragment ? location.fragment : null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (location && location.fragment) {
|
||||
var resolvedTarget = fragmentTarget(location.fragment);
|
||||
if (resolvedTarget) {
|
||||
|
||||
Reference in New Issue
Block a user