import UIKit final class RDEPUBChapterRuntimeStore { private let chapterDataCache = RDEPUBChapterDataCache() private let pageCountCache = RDEPUBPageCountCache() let imageCache = NSCache() let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated) private let chapterLoadQueueKey = DispatchSpecificKey() // M-02: currentSpineIndex and windowSpineIndices are accessed only from the main thread // (verified by audit of all 7 access points). They are not protected by locks unlike // navigationLock/prefetchLock/buildingLock/cfiMapLock, but this is safe as long as // access remains main-thread-only. Do NOT access from chapterLoadQueue. private(set) var currentSpineIndex: Int? private(set) var windowSpineIndices: [Int] = [] private var pendingNavigationTarget: Int? private let navigationLock = NSLock() private var pendingPrefetchTargets: Set = [] private let prefetchLock = NSLock() private(set) var isBuilding: Bool = false private let buildingLock = NSLock() private var buildingCFIMapSpineIndices: Set = [] private let cfiMapLock = NSLock() private var pendingChapterLoadSpineIndices: Set = [] private let pendingChapterLoadLock = NSLock() init() { imageCache.countLimit = 50 imageCache.totalCostLimit = 104_857_600 // 100 MB chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ()) } func assertNotOnChapterLoadQueue() { dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue)) } func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? { return chapterDataCache[spineIndex] } func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? { return pageCountCache[key] } func insertChapter(_ chapter: RDEPUBRuntimeChapter) { chapterDataCache[chapter.spineIndex] = chapter RDEPUBMemoryProbe.log("chapterLoaded spine=\(chapter.spineIndex) pages=\(chapter.pages.count)") } func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) { pageCountCache[key] = pc } func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) { currentSpineIndex = spineIndex let radius = max(0, windowRadius) let lowerBound = max(0, spineIndex - radius) let upperBound = min(totalSpineCount - 1, spineIndex + radius) guard lowerBound <= upperBound else { windowSpineIndices = [spineIndex] return } windowSpineIndices = Array(lowerBound...upperBound) } func evictableSpineIndices() -> [Int] { let windowSet = Set(windowSpineIndices) return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) } } func evict(spineIndex: Int) { chapterDataCache.remove(spineIndex: spineIndex) pageCountCache.remove(forSpineIndex: spineIndex) } func evictAllExceptCurrent() { guard let current = currentSpineIndex else { chapterDataCache.removeAll() pageCountCache.removeAll() return } let currentChapter = chapterDataCache[current] chapterDataCache.removeAll() if let ch = currentChapter { chapterDataCache[current] = ch } pageCountCache.removeAll() } func handleMemoryWarning() { evictAllExceptCurrent() imageCache.removeAllObjects() } func setNavigationTarget(spineIndex: Int) { navigationLock.lock() pendingNavigationTarget = spineIndex navigationLock.unlock() } func consumeNavigationTarget() -> Int? { navigationLock.lock() let target = pendingNavigationTarget pendingNavigationTarget = nil navigationLock.unlock() return target } func addPrefetchTarget(_ spineIndex: Int) { prefetchLock.lock() pendingPrefetchTargets.insert(spineIndex) prefetchLock.unlock() } func removePrefetchTarget(_ spineIndex: Int) { prefetchLock.lock() pendingPrefetchTargets.remove(spineIndex) prefetchLock.unlock() } func clearPrefetchTargets() { prefetchLock.lock() pendingPrefetchTargets.removeAll() prefetchLock.unlock() } func hasPrefetchTarget(_ spineIndex: Int) -> Bool { prefetchLock.lock() let has = pendingPrefetchTargets.contains(spineIndex) prefetchLock.unlock() return has } func markBuilding(_ building: Bool) { buildingLock.lock() isBuilding = building buildingLock.unlock() } func beginPendingChapterLoad(for spineIndex: Int) -> Bool { pendingChapterLoadLock.lock() defer { pendingChapterLoadLock.unlock() } return pendingChapterLoadSpineIndices.insert(spineIndex).inserted } func endPendingChapterLoad(for spineIndex: Int) { pendingChapterLoadLock.lock() pendingChapterLoadSpineIndices.remove(spineIndex) pendingChapterLoadLock.unlock() } func hasPendingChapterLoad(for spineIndex: Int) -> Bool { pendingChapterLoadLock.lock() let hasPendingLoad = pendingChapterLoadSpineIndices.contains(spineIndex) pendingChapterLoadLock.unlock() return hasPendingLoad } func beginBuildingCFIMap(for spineIndex: Int) -> Bool { cfiMapLock.lock() defer { cfiMapLock.unlock() } let inserted = buildingCFIMapSpineIndices.insert(spineIndex).inserted return inserted } func endBuildingCFIMap(for spineIndex: Int) { cfiMapLock.lock() buildingCFIMapSpineIndices.remove(spineIndex) cfiMapLock.unlock() } func invalidateAllForSettingsChange() { RDEPUBMemoryProbe.log("settingsChangeInvalidateAll") chapterDataCache.removeAll() pageCountCache.removeAll() imageCache.removeAllObjects() cfiMapLock.lock() buildingCFIMapSpineIndices.removeAll() cfiMapLock.unlock() pendingChapterLoadLock.lock() pendingChapterLoadSpineIndices.removeAll() pendingChapterLoadLock.unlock() } }