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 } } }