134 lines
4.5 KiB
Swift
134 lines
4.5 KiB
Swift
// ScrollbackCache.swift
|
|
// Rolling on-disk cache of raw ANSI bytes per session.
|
|
//
|
|
// Design:
|
|
// • Storage: <caches>/pi-remote/scrollback/<sessionId>.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
|
|
/// `<caches>/pi-remote/scrollback/<sessionId>.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
|
|
}
|
|
}
|
|
}
|