// ScrollbackCache.swift // Rolling on-disk cache of raw ANSI bytes per session. // // Design: // • Storage: /pi-remote/scrollback/.bin // • Cap: 5 MB. When an append would exceed the cap the oldest bytes are // dropped from the front (slice off the head) so the file stays below cap. // • Thread safety: a serial DispatchQueue guards all reads and writes. // No async/await — this is called from both main and background contexts. import Foundation /// Rolling on-disk ANSI scrollback cache for a single session. /// /// - `append(_:)` is O(n) on the file size only when the 5 MB cap is hit /// (the head-trim path). In the common case (data fits) it is a simple /// `FileHandle.seekToEnd` + `write`. /// - All public methods are safe to call from any thread or queue. public final class ScrollbackCache: @unchecked Sendable { // MARK: - Constants private static let maxBytes = 5 * 1024 * 1024 // 5 MB // MARK: - State private let fileURL: URL private let queue = DispatchQueue(label: "pi.scrollback", qos: .utility) // Cached file size tracked in memory to avoid repeated stat() calls. private var _sizeBytes: Int = 0 // MARK: - Init /// Creates (or reopens) a cache for `sessionId` stored at /// `/pi-remote/scrollback/.bin`. public init(sessionId: String) { let cachesDir = FileManager.default.urls( for: .cachesDirectory, in: .userDomainMask ).first ?? URL(fileURLWithPath: NSTemporaryDirectory()) let dir = cachesDir .appendingPathComponent("pi-remote", isDirectory: true) .appendingPathComponent("scrollback", isDirectory: true) // Best-effort directory creation — ignore errors, writes will surface // any real problem. try? FileManager.default.createDirectory( at: dir, withIntermediateDirectories: true ) fileURL = dir.appendingPathComponent("\(sessionId).bin") // Seed the in-memory size counter from the existing file (if any). _sizeBytes = (try? fileURL.resourceValues(forKeys: [.fileSizeKey])) .flatMap { $0.fileSize } ?? 0 } // MARK: - Public API /// Appends `data` to the cache, trimming the head when the 5 MB cap /// would be exceeded. public func append(_ data: Data) { guard !data.isEmpty else { return } queue.sync { _append(data) } } /// Returns the full current cache contents (may be empty). public func read() -> Data { queue.sync { (try? Data(contentsOf: fileURL)) ?? Data() } } /// Deletes the cache file and resets the in-memory size counter. public func clear() { queue.sync { try? FileManager.default.removeItem(at: fileURL) _sizeBytes = 0 } } /// Current cache size in bytes (in-memory approximation, always accurate /// after any `append` / `clear` call). public var sizeBytes: Int { queue.sync { _sizeBytes } } // MARK: - Private (always called on `queue`) private func _append(_ data: Data) { let newSize = _sizeBytes + data.count if newSize <= Self.maxBytes { // Fast path: just append. _write(data) } else { // Slow path: we need to drop bytes from the head. // Read the existing content, combine with new data, then keep // only the last `maxBytes` bytes so the result fits within cap. let existing = (try? Data(contentsOf: fileURL)) ?? Data() var combined = existing combined.append(data) let excess = combined.count - Self.maxBytes if excess > 0 { combined = combined.dropFirst(excess).withUnsafeBytes { Data($0) } } // Overwrite the file with the trimmed data. try? combined.write(to: fileURL, options: .atomic) _sizeBytes = combined.count } } private func _write(_ data: Data) { if FileManager.default.fileExists(atPath: fileURL.path) { // Append to existing file via FileHandle. if let handle = try? FileHandle(forWritingTo: fileURL) { defer { try? handle.close() } handle.seekToEndOfFile() handle.write(data) _sizeBytes += data.count } } else { // Create new file. try? data.write(to: fileURL, options: .atomic) _sizeBytes = data.count } } }