阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
300 lines
11 KiB
Swift
300 lines
11 KiB
Swift
|
|
import Foundation
|
|
import ZIPFoundation
|
|
|
|
extension RDEPUBParser {
|
|
|
|
func parseContainerRootFile(at containerURL: URL) throws -> String {
|
|
guard let parser = XMLParser(contentsOf: containerURL) else {
|
|
throw RDEPUBParserError.invalidXML(containerURL)
|
|
}
|
|
|
|
let delegate = ContainerXMLParserDelegate()
|
|
parser.shouldProcessNamespaces = false
|
|
parser.delegate = delegate
|
|
|
|
guard parser.parse() else {
|
|
throw parser.parserError ?? RDEPUBParserError.invalidXML(containerURL)
|
|
}
|
|
|
|
guard let rootFilePath = delegate.rootFilePath, !rootFilePath.isEmpty else {
|
|
throw RDEPUBParserError.missingRootFile
|
|
}
|
|
|
|
return rootFilePath
|
|
}
|
|
|
|
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
|
|
try Self.extractionQueue.sync {
|
|
try extractArchiveIfNeededLocked(epubURL: epubURL)
|
|
}
|
|
}
|
|
|
|
/// 同一缓存目录在解压完成前不能被其他 parser 复用。串行化解压阶段后,复用
|
|
/// 分支看到的 `container.xml` 才能代表完整书籍,而不是另一个 parser 的半成品。
|
|
private func extractArchiveIfNeededLocked(epubURL: URL) throws -> URL {
|
|
let fileManager = FileManager.default
|
|
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
|
|
|
if fileManager.fileExists(atPath: extractionURL.path) {
|
|
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
|
|
if fileManager.fileExists(atPath: containerURL.path) {
|
|
// 复用即视为一次访问,刷新时间戳,淘汰时才是真正的 LRU 而非 FIFO
|
|
Self.touchExtractionDirectory(extractionURL)
|
|
return extractionURL
|
|
}
|
|
try? fileManager.removeItem(at: extractionURL)
|
|
}
|
|
|
|
guard let archive = Archive(url: epubURL, accessMode: .read) else {
|
|
throw RDEPUBParserError.archiveOpenFailed(epubURL)
|
|
}
|
|
|
|
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
|
|
|
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
|
|
}
|
|
|
|
Self.touchExtractionDirectory(extractionURL)
|
|
Self.pruneExtractionCache(keeping: extractionURL)
|
|
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("/") {
|
|
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
|
|
}
|
|
|
|
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
|
let baseURL = Self.extractionCacheRootURL
|
|
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
|
|
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
|
|
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
|
let slug = epubURL.deletingPathExtension().lastPathComponent
|
|
.replacingOccurrences(of: " ", with: "-")
|
|
let signature = String(format: "%.0f", modifiedAt)
|
|
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
|
|
}
|
|
|
|
func reset() {
|
|
extractionRootURL = nil
|
|
opfURL = nil
|
|
resetPublicationState()
|
|
}
|
|
|
|
func resetPublicationState() {
|
|
metadata = RDEPUBMetadata()
|
|
manifest = [:]
|
|
spine = []
|
|
tableOfContents = []
|
|
}
|
|
}
|
|
|
|
// MARK: - 解压缓存淘汰
|
|
|
|
extension RDEPUBParser {
|
|
|
|
private static let extractionQueue = DispatchQueue(label: "com.ssreaderview.epub.extraction-cache")
|
|
|
|
/// 解压缓存总量上限;每次成功解压后超出即按最近使用时间淘汰最旧的书。
|
|
public static var extractionCacheMaximumTotalBytes: UInt64 = 512 * 1024 * 1024
|
|
|
|
/// 解压目录的最长保留时长,超过即淘汰(默认 30 天)。
|
|
public static var extractionCacheMaximumAge: TimeInterval = 30 * 24 * 60 * 60
|
|
|
|
public static var extractionCacheRootURL: URL {
|
|
let fileManager = FileManager.default
|
|
return fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
|
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
|
?? fileManager.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
|
}
|
|
|
|
/// 清空全部解压缓存。宿主收到低存储信号或需要「清理阅读数据」时调用。
|
|
public static func clearExtractionCache() {
|
|
try? FileManager.default.removeItem(at: extractionCacheRootURL)
|
|
}
|
|
|
|
/// 按「先过期、再总量」两级策略淘汰解压目录。
|
|
/// - Parameter keeping: 本次正在使用的目录,任何情况下都不淘汰。
|
|
public static func pruneExtractionCache(keeping survivor: URL? = nil) {
|
|
let fileManager = FileManager.default
|
|
let root = extractionCacheRootURL
|
|
guard let children = try? fileManager.contentsOfDirectory(
|
|
at: root,
|
|
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return
|
|
}
|
|
|
|
let survivorPath = survivor?.standardizedFileURL.path
|
|
var entries: [(url: URL, size: UInt64, modifiedAt: Date)] = []
|
|
|
|
for child in children {
|
|
let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey])
|
|
guard values?.isDirectory == true else { continue }
|
|
let modifiedAt = values?.contentModificationDate ?? .distantPast
|
|
|
|
if child.standardizedFileURL.path == survivorPath {
|
|
continue
|
|
}
|
|
if Date().timeIntervalSince(modifiedAt) > extractionCacheMaximumAge {
|
|
try? fileManager.removeItem(at: child)
|
|
continue
|
|
}
|
|
entries.append((child, directorySize(at: child), modifiedAt))
|
|
}
|
|
|
|
// survivor 也占额度,否则刚解压的大书会把其他书全部挤掉却仍然超限
|
|
var total = entries.reduce(UInt64(0)) { $0 + $1.size }
|
|
if let survivor {
|
|
total += directorySize(at: survivor)
|
|
}
|
|
guard total > extractionCacheMaximumTotalBytes else { return }
|
|
|
|
for entry in entries.sorted(by: { $0.modifiedAt < $1.modifiedAt }) {
|
|
try? fileManager.removeItem(at: entry.url)
|
|
total = total > entry.size ? total - entry.size : 0
|
|
if total <= extractionCacheMaximumTotalBytes { return }
|
|
}
|
|
}
|
|
|
|
fileprivate static func touchExtractionDirectory(_ url: URL) {
|
|
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
|
|
}
|
|
|
|
private static func directorySize(at url: URL) -> UInt64 {
|
|
guard let enumerator = FileManager.default.enumerator(
|
|
at: url,
|
|
includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey],
|
|
options: []
|
|
) else {
|
|
return 0
|
|
}
|
|
var total: UInt64 = 0
|
|
for case let fileURL as URL in enumerator {
|
|
let values = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
|
|
let size = values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0
|
|
total += UInt64(size)
|
|
}
|
|
return total
|
|
}
|
|
}
|
|
|
|
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
|
|
|
private(set) var rootFilePath: String?
|
|
|
|
func parser(
|
|
_ parser: XMLParser,
|
|
didStartElement elementName: String,
|
|
namespaceURI: String?,
|
|
qualifiedName qName: String?,
|
|
attributes attributeDict: [String: String] = [:]
|
|
) {
|
|
let name = XMLName.localName(from: qName ?? elementName)
|
|
guard name == "rootfile", rootFilePath == nil else {
|
|
return
|
|
}
|
|
rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|
|
|
|
/// 解析 com.apple.ibooks.display-options.xml,提取 orientation-lock 选项值。
|
|
final class IBooksDisplayOptionsParserDelegate: NSObject, XMLParserDelegate {
|
|
|
|
private(set) var orientationLock: String?
|
|
|
|
private var isCapturingOrientationOption = false
|
|
private var buffer = ""
|
|
|
|
func parser(
|
|
_ parser: XMLParser,
|
|
didStartElement elementName: String,
|
|
namespaceURI: String?,
|
|
qualifiedName qName: String?,
|
|
attributes attributeDict: [String: String] = [:]
|
|
) {
|
|
let name = XMLName.localName(from: qName ?? elementName)
|
|
guard name == "option" else { return }
|
|
let optionName = (attributeDict["name"] ?? "").lowercased()
|
|
if optionName == "orientation-lock" {
|
|
isCapturingOrientationOption = true
|
|
buffer = ""
|
|
}
|
|
}
|
|
|
|
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
|
guard isCapturingOrientationOption else { return }
|
|
buffer += string
|
|
}
|
|
|
|
func parser(
|
|
_ parser: XMLParser,
|
|
didEndElement elementName: String,
|
|
namespaceURI: String?,
|
|
qualifiedName qName: String?
|
|
) {
|
|
let name = XMLName.localName(from: qName ?? elementName)
|
|
guard name == "option", isCapturingOrientationOption else { return }
|
|
isCapturingOrientationOption = false
|
|
let value = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty, orientationLock == nil {
|
|
orientationLock = value
|
|
}
|
|
}
|
|
}
|