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:
shenlei
2026-06-26 18:50:07 +09:00
co-authored by Claude
parent b8aa10c535
commit 22e7e44220
123 changed files with 3701 additions and 9118 deletions
@@ -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()
}