From 15adeec2c1400c1725d9ae9679e94685545d81f8 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Wed, 2 Sep 2026 10:27:11 +0400 Subject: [PATCH] feature: sent-PDF history (10) + image retention (30) with pruning per Ben's 2026-09-02 ruling --- Sources/Shotdeck/AppModel.swift | 5 +- Sources/Shotdeck/PanelSnapshot.swift | 2 +- Sources/Shotdeck/PickerSelfTest.swift | 185 +++++++- Sources/Shotdeck/SendController.swift | 16 + Sources/Shotdeck/main.swift | 4 +- .../ShotdeckCore/History/HistoryStore.swift | 446 ++++++++++++++++++ .../ShotdeckCoreTests/HistoryStoreTests.swift | 319 +++++++++++++ 7 files changed, 966 insertions(+), 11 deletions(-) create mode 100644 Sources/ShotdeckCore/History/HistoryStore.swift create mode 100644 Tests/ShotdeckCoreTests/HistoryStoreTests.swift diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 47897ad..605d605 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -52,6 +52,7 @@ public final class AppModel { let picker: RegionPickerController let ledger: ReturnLedger let watcher: ReturnWatcher + let historyStore: HistoryStore let updateChecker: UpdateChecker public init( @@ -63,7 +64,7 @@ public final class AppModel { picker: RegionPickerController, ledger: ReturnLedger, watcher: ReturnWatcher - ) { + ) throws { self.paths = paths self.spool = spool self.composer = composer @@ -72,6 +73,7 @@ public final class AppModel { self.picker = picker self.ledger = ledger self.watcher = watcher + self.historyStore = try HistoryStore(paths: paths) self.session = CaptureSession( id: UUID(), createdAt: Date(), @@ -208,6 +210,7 @@ public final class AppModel { ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil + || ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"] != nil if !skipSchedule { updateChecker.startSchedule() } diff --git a/Sources/Shotdeck/PanelSnapshot.swift b/Sources/Shotdeck/PanelSnapshot.swift index e6580e3..f1c9f5d 100644 --- a/Sources/Shotdeck/PanelSnapshot.swift +++ b/Sources/Shotdeck/PanelSnapshot.swift @@ -163,7 +163,7 @@ enum PanelSnapshot { watchFolder: root.appendingPathComponent("watch", isDirectory: true) ) let ledger = try ReturnLedger(paths: paths) - let model = AppModel( + let model = try AppModel( paths: paths, spool: try SpoolStore(paths: paths), composer: PDFComposer(), diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift index b30c703..3d2cca1 100644 --- a/Sources/Shotdeck/PickerSelfTest.swift +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -30,6 +30,20 @@ enum PickerSelfTest { } } + /// Standalone HISTORY phase when `SHOTDECK_HISTORY_SELFTEST` is set without + /// the picker chain. Waits for NSApp like the other phases, then exits. + static func runHistoryIfRequested() { + guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"], + !raw.isEmpty + else { return } + _ = raw + DispatchQueue.main.async { + MainActor.assumeIsolated { + runHistoryPhase() + } + } + } + private static func execute(outputDirectory: URL) { do { try FileManager.default.createDirectory( @@ -117,7 +131,8 @@ enum PickerSelfTest { runRegionPersistPhase() // Hop off this MainActor job so the SEND-TRUTH Task can run; do not // exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then - // chains to UPDATE-SELFTEST (or exits if that phase is not requested). + // chains to HISTORY, then UPDATE-SELFTEST (or exits if that phase is + // not requested). runSendTruthPhase() } @@ -166,21 +181,166 @@ enum PickerSelfTest { /// Fail path must leave the session open in the temp spool; success path archives /// and mints a fresh empty session. Scheduled as a new MainActor job because this /// function is called from inside `execute()` — a nested run-loop wait would never - /// let the Task start. On success, chains to UPDATE-SELFTEST instead of exiting. + /// let the Task start. On success, chains to HISTORY instead of exiting. private static func runSendTruthPhase() { Task { @MainActor in do { try await executeSendTruth() print("SEND-TRUTH PASS") fflush(stdout) - if !startUpdateSelfTestIfRequested() { - exit(0) - } } catch { print("SEND-TRUTH FAIL \(error)") fflush(stdout) exit(1) } + runHistoryPhase() + } + } + + /// Phase 4: drive HistoryStore record/prune in a temp root. Env-gated by + /// `SHOTDECK_HISTORY_SELFTEST` the same way UPDATE is gated, and also runs + /// whenever the picker self-test chain is already in flight so a full + /// self-test prints HISTORY PASS|FAIL. Chains to UPDATE-SELFTEST (or exits). + private static func runHistoryPhase() { + let historyRequested = ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"] + let pickerRequested = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] + let shouldRun = (historyRequested.map { !$0.isEmpty } ?? false) + || (pickerRequested.map { !$0.isEmpty } ?? false) + guard shouldRun else { + if !startUpdateSelfTestIfRequested() { + exit(0) + } + return + } + + Task { @MainActor in + do { + try await executeHistorySelfTest() + print("HISTORY PASS") + fflush(stdout) + if !startUpdateSelfTestIfRequested() { + exit(0) + } + } catch { + print("HISTORY FAIL \(error)") + fflush(stdout) + exit(1) + } + } + } + + private static func executeHistorySelfTest() async throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("shotdeck-history-selftest-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let paths = try AppSupportPaths( + root: root.appendingPathComponent("root", isDirectory: true), + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + let store = try HistoryStore(paths: paths) + let sources = root.appendingPathComponent("user-sources", isDirectory: true) + try fm.createDirectory(at: sources, withIntermediateDirectories: true) + + var recorded: [HistoryEntry] = [] + for i in 0..<12 { + let source = sources.appendingPathComponent("source-\(i).pdf") + try Data("%PDF-1.4\n%hist-\(i)\n%%EOF\n".utf8).write(to: source) + let entry = try await store.recordSentPDF( + sourceURL: source, + sessionID: UUID(), + pageCount: 1, + sentAt: Date(timeIntervalSince1970: 1_800_000_000 + TimeInterval(i)) + ) + recorded.append(entry) + let onDisk = try Data(contentsOf: source) + guard onDisk == Data("%PDF-1.4\n%hist-\(i)\n%%EOF\n".utf8) else { + throw HistorySelfTestError.detail("user source PDF was modified: \(source.path)") + } + } + + let listed = await store.listPDFs() + guard listed.count == 10 else { + throw HistorySelfTestError.detail("listPDFs count \(listed.count) want 10") + } + let wantNewest = Array(recorded.suffix(10).reversed()) + guard listed.map(\.id) == wantNewest.map(\.id) else { + throw HistorySelfTestError.detail("listPDFs did not return the 10 newest") + } + let pdfsDir = paths.root.appendingPathComponent("history/pdfs", isDirectory: true) + for entry in recorded.prefix(2) { + let gone = pdfsDir.appendingPathComponent(entry.fileName) + guard !fm.fileExists(atPath: gone.path) else { + throw HistorySelfTestError.detail("oldest PDF still on disk: \(gone.path)") + } + } + + let same = sources.appendingPathComponent("same.pdf") + try Data("%PDF-1.4\n%same\n%%EOF\n".utf8).write(to: same) + let first = try await store.recordSentPDF( + sourceURL: same, sessionID: nil, pageCount: 1, + sentAt: Date(timeIntervalSince1970: 1_800_000_100) + ) + let second = try await store.recordSentPDF( + sourceURL: same, sessionID: nil, pageCount: 1, + sentAt: Date(timeIntervalSince1970: 1_800_000_101) + ) + guard first.id != second.id, first.fileURL.path != second.fileURL.path, + fm.fileExists(atPath: first.fileURL.path), + fm.fileExists(atPath: second.fileURL.path), + fm.fileExists(atPath: same.path) + else { + throw HistorySelfTestError.detail("re-record same source did not produce two distinct copies") + } + + let spool = try SpoolStore(paths: paths) + let png = try makeTinyPNGData() + let base = Date(timeIntervalSince1970: 1_800_100_000) + var n = 0 + var oldestSessionID: UUID? + for count in [5, 15, 15] { + if n == 0 { + oldestSessionID = try await spool.currentSession().id + } + for _ in 0.. AppModel { let ledger = try ReturnLedger(paths: paths) - return AppModel( + return try AppModel( paths: paths, spool: try SpoolStore(paths: paths), composer: PDFComposer(), diff --git a/Sources/ShotdeckCore/History/HistoryStore.swift b/Sources/ShotdeckCore/History/HistoryStore.swift new file mode 100644 index 0000000..ef1a40e --- /dev/null +++ b/Sources/ShotdeckCore/History/HistoryStore.swift @@ -0,0 +1,446 @@ +import Foundation + +/// Retention ruling (2026-09-02). Ben's instruction is the authority that +/// supersedes the earlier D-11 never-delete rule FOR THIS STORE only: +/// +/// "the last 10 files are saved, and the past 30 images, and cleared up +/// afterwards. user should be able to select from those and send again." +/// +/// App-managed copies live under `AppSupportPaths.root/history/`. Pruning +/// unlinks files under `history/pdfs/` and archived capture PNGs beyond the +/// newest 30. It never deletes a user file, never touches the outbox PDF, and +/// never touches the open spool session (D-11 still applies there). + +/// One sent-PDF copy retained for re-send. The file at `fileURL` is the +/// app-managed copy under `history/pdfs/`, not the user's original. +public struct HistoryEntry: Codable, Sendable, Equatable, Identifiable { + public let id: UUID + public let fileName: String + public let fileURL: URL + public let originalFileName: String + public let sessionID: UUID? + public let pageCount: Int + public let sentAt: Date + + public init( + id: UUID, + fileName: String, + fileURL: URL, + originalFileName: String, + sessionID: UUID?, + pageCount: Int, + sentAt: Date + ) { + self.id = id + self.fileName = fileName + self.fileURL = fileURL + self.originalFileName = originalFileName + self.sessionID = sessionID + self.pageCount = pageCount + self.sentAt = sentAt + } +} + +/// A capture PNG on disk under an archived session, listed for re-send. +/// `path` is the real file; HistoryStore never copies images. +public struct ImageRef: Sendable, Equatable { + public let path: URL + public let capturedAt: Date + public let sessionID: UUID + + public init(path: URL, capturedAt: Date, sessionID: UUID) { + self.path = path + self.capturedAt = capturedAt + self.sessionID = sessionID + } +} + +public actor HistoryStore { + public static let pdfRetentionCount = 10 + public static let imageRetentionCount = 30 + + private let paths: AppSupportPaths + private let historyRoot: URL + private let pdfsDirectory: URL + private let manifestURL: URL + private var entries: [HistoryEntry] + + public init(paths: AppSupportPaths) throws { + self.paths = paths + self.historyRoot = paths.root.appendingPathComponent("history", isDirectory: true) + self.pdfsDirectory = historyRoot.appendingPathComponent("pdfs", isDirectory: true) + self.manifestURL = historyRoot.appendingPathComponent("history.json") + do { + try FileManager.default.createDirectory(at: historyRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: pdfsDirectory, withIntermediateDirectories: true) + } catch { + throw ShotdeckError.spoolWriteFailed( + path: historyRoot.path, + underlying: error.localizedDescription + ) + } + self.entries = try Self.loadEntries(from: manifestURL, pdfsDirectory: pdfsDirectory) + } + + /// Copies `sourceURL` into `history/pdfs/` (never moves or writes the + /// user's file), appends an entry, then keeps the 10 newest PDF copies. + public func recordSentPDF( + sourceURL: URL, + sessionID: UUID?, + pageCount: Int, + sentAt: Date + ) throws -> HistoryEntry { + let fm = FileManager.default + guard fm.fileExists(atPath: sourceURL.path) else { + throw ShotdeckError.spoolWriteFailed( + path: sourceURL.path, + underlying: "source PDF does not exist" + ) + } + let data: Data + do { + data = try Data(contentsOf: sourceURL) + } catch { + throw ShotdeckError.spoolWriteFailed( + path: sourceURL.path, + underlying: error.localizedDescription + ) + } + + let id = UUID() + let fileName = "\(DubaiTime.fileStamp(sentAt))-\(id.uuidString.lowercased()).pdf" + let destURL = pdfsDirectory.appendingPathComponent(fileName) + try AtomicFile.write(data, to: destURL) + + let entry = HistoryEntry( + id: id, + fileName: fileName, + fileURL: destURL, + originalFileName: sourceURL.lastPathComponent, + sessionID: sessionID, + pageCount: pageCount, + sentAt: sentAt + ) + entries.append(entry) + try prunePDFEntries() + return entry + } + + /// Newest first, at most `pdfRetentionCount`. + public func listPDFs() -> [HistoryEntry] { + Array(sortedPDFs().prefix(Self.pdfRetentionCount)) + } + + /// The newest capture PNGs across `archive/` sessions (including + /// `removed/`). Does not copy files and does not look at the open spool. + public func listImages(limit: Int = 30) throws -> [ImageRef] { + let images = try collectArchivedImages() + return images.prefix(max(limit, 0)).map(\.ref) + } + + /// Across archived sessions only: keep the 30 newest PNGs total (including + /// `removed/`); delete older PNGs, drop them from `session.json`, and + /// remove a session directory left with zero PNGs. Never touches spool/. + public func pruneImages() throws { + let fm = FileManager.default + let images = try collectArchivedImages() + let keepCount = Self.imageRetentionCount + let doomed = Array(images.dropFirst(keepCount)) + guard !doomed.isEmpty else { return } + + var remainingBySession: [UUID: CaptureSession] = [:] + var dirBySession: [UUID: URL] = [:] + for item in images { + remainingBySession[item.session.id] = item.session + dirBySession[item.session.id] = item.sessionDir + } + + var droppedIDs: [UUID: Set] = [:] + for item in doomed { + try deleteIfPrunableImage(item.ref.path) + if let captureID = item.captureID { + droppedIDs[item.session.id, default: []].insert(captureID) + } + } + + for (sessionID, ids) in droppedIDs { + guard var session = remainingBySession[sessionID] else { continue } + for id in ids { + session = session.removing(captureID: id) + } + remainingBySession[sessionID] = session + } + + let touchedIDs = Set(doomed.map(\.session.id)) + for sessionID in touchedIDs { + guard let sessionDir = dirBySession[sessionID] else { continue } + guard isUnderArchive(sessionDir), !isUnderSpool(sessionDir) else { continue } + + if pngsRemaining(in: sessionDir).isEmpty { + if fm.fileExists(atPath: sessionDir.path) { + try fm.removeItem(at: sessionDir) + Log.spool.warning("Pruned \(sessionDir.path, privacy: .public)") + } + try AtomicFile.fsyncDirectory(at: paths.archive) + continue + } + + if let session = remainingBySession[sessionID], droppedIDs[sessionID] != nil { + try AtomicFile.writeJSON( + session, + to: sessionDir.appendingPathComponent("session.json") + ) + } + } + } + + // MARK: - PDF retention + + private func prunePDFEntries() throws { + let sorted = sortedPDFs() + let kept = Array(sorted.prefix(Self.pdfRetentionCount)) + let discarded = sorted.dropFirst(Self.pdfRetentionCount) + for entry in discarded { + let url = pdfsDirectory.appendingPathComponent(entry.fileName) + try deleteIfAppManagedPDF(url) + } + entries = kept + try persistEntries() + } + + private func sortedPDFs() -> [HistoryEntry] { + entries.sorted { lhs, rhs in + if lhs.sentAt != rhs.sentAt { return lhs.sentAt > rhs.sentAt } + return lhs.id.uuidString > rhs.id.uuidString + } + } + + private func persistEntries() throws { + try AtomicFile.writeJSON(entries, to: manifestURL) + } + + private func deleteIfAppManagedPDF(_ url: URL) throws { + guard isUnderPDFs(url) else { return } + let fm = FileManager.default + guard fm.fileExists(atPath: url.path) else { return } + do { + try fm.removeItem(at: url) + } catch { + throw ShotdeckError.spoolWriteFailed( + path: url.path, + underlying: error.localizedDescription + ) + } + Log.spool.warning("Pruned \(url.path, privacy: .public)") + try AtomicFile.fsyncDirectory(at: pdfsDirectory) + } + + // MARK: - Archived images + + private struct ArchivedImage { + let ref: ImageRef + let session: CaptureSession + let sessionDir: URL + let captureID: UUID? + } + + private func collectArchivedImages() throws -> [ArchivedImage] { + let dirs = try archivedSessionDirectories() + var collected: [ArchivedImage] = [] + for dir in dirs { + let sessionID = UUID(uuidString: dir.lastPathComponent) ?? UUID() + let session = (try? loadSession(at: dir, id: sessionID)) + ?? CaptureSession( + id: sessionID, + createdAt: fileDate(dir) ?? Date(), + state: .archived, + captures: [], + pdfFileName: nil + ) + var referenced = Set() + for capture in session.captures { + let url = dir.appendingPathComponent(capture.fileName) + guard FileManager.default.fileExists(atPath: url.path) else { continue } + referenced.insert(capture.fileName) + collected.append( + ArchivedImage( + ref: ImageRef(path: url, capturedAt: capture.capturedAt, sessionID: session.id), + session: session, + sessionDir: dir, + captureID: capture.id + ) + ) + } + let removedDir = dir.appendingPathComponent("removed", isDirectory: true) + for url in pngFiles(in: dir) where !referenced.contains(url.lastPathComponent) { + collected.append( + ArchivedImage( + ref: ImageRef( + path: url, + capturedAt: fileDate(url) ?? .distantPast, + sessionID: session.id + ), + session: session, + sessionDir: dir, + captureID: nil + ) + ) + } + for url in pngFiles(in: removedDir) { + collected.append( + ArchivedImage( + ref: ImageRef( + path: url, + capturedAt: fileDate(url) ?? .distantPast, + sessionID: session.id + ), + session: session, + sessionDir: dir, + captureID: nil + ) + ) + } + } + return collected.sorted { lhs, rhs in + if lhs.ref.capturedAt != rhs.ref.capturedAt { + return lhs.ref.capturedAt > rhs.ref.capturedAt + } + return lhs.ref.path.path > rhs.ref.path.path + } + } + + private func archivedSessionDirectories() throws -> [URL] { + let fm = FileManager.default + let entries: [URL] + do { + entries = try fm.contentsOfDirectory( + at: paths.archive, + includingPropertiesForKeys: [.isDirectoryKey], + options: [] + ) + } catch { + throw ShotdeckError.spoolWriteFailed( + path: paths.archive.path, + underlying: error.localizedDescription + ) + } + return entries.filter { url in + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + return isDirectory && UUID(uuidString: url.lastPathComponent) != nil + } + } + + private func loadSession(at dir: URL, id: UUID) throws -> CaptureSession { + let url = dir.appendingPathComponent("session.json") + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let session = try decoder.decode(CaptureSession.self, from: data) + guard session.id == id else { + throw ShotdeckError.manifestCorrupt(path: url.path) + } + return session + } + + private func pngFiles(in dir: URL) -> [URL] { + let fm = FileManager.default + guard fm.fileExists(atPath: dir.path) else { return [] } + let entries = (try? fm.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [] + )) ?? [] + return entries.filter { url in + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + return !isDirectory && url.pathExtension.lowercased() == "png" + } + } + + private func pngsRemaining(in sessionDir: URL) -> [URL] { + pngFiles(in: sessionDir) + + pngFiles(in: sessionDir.appendingPathComponent("removed", isDirectory: true)) + } + + private func deleteIfPrunableImage(_ url: URL) throws { + guard isUnderArchive(url), !isUnderSpool(url) else { return } + let fm = FileManager.default + guard fm.fileExists(atPath: url.path) else { return } + do { + try fm.removeItem(at: url) + } catch { + throw ShotdeckError.spoolWriteFailed( + path: url.path, + underlying: error.localizedDescription + ) + } + Log.spool.warning("Pruned \(url.path, privacy: .public)") + try AtomicFile.fsyncDirectory(at: url.deletingLastPathComponent()) + } + + private func fileDate(_ url: URL) -> Date? { + let values = try? url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey]) + return values?.creationDate ?? values?.contentModificationDate + } + + // MARK: - Path guards + + private func isUnderPDFs(_ url: URL) -> Bool { + isUnderDirectory(url, parent: pdfsDirectory) + } + + private func isUnderArchive(_ url: URL) -> Bool { + isUnderDirectory(url, parent: paths.archive) + } + + private func isUnderSpool(_ url: URL) -> Bool { + isUnderDirectory(url, parent: paths.spool) + } + + private func isUnderDirectory(_ url: URL, parent: URL) -> Bool { + let parentPath = parent.standardizedFileURL.path + let path = url.standardizedFileURL.path + if path == parentPath { return true } + let prefix = parentPath.hasSuffix("/") ? parentPath : parentPath + "/" + return path.hasPrefix(prefix) + } + + // MARK: - Manifest load + + private static func loadEntries(from url: URL, pdfsDirectory: URL) throws -> [HistoryEntry] { + let fm = FileManager.default + guard fm.fileExists(atPath: url.path) else { return [] } + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw ShotdeckError.spoolWriteFailed( + path: url.path, + underlying: error.localizedDescription + ) + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + do { + let decoded = try decoder.decode([HistoryEntry].self, from: data) + return decoded.map { entry in + HistoryEntry( + id: entry.id, + fileName: entry.fileName, + fileURL: pdfsDirectory.appendingPathComponent(entry.fileName), + originalFileName: entry.originalFileName, + sessionID: entry.sessionID, + pageCount: entry.pageCount, + sentAt: entry.sentAt + ) + } + } catch { + let corruptURL = url.deletingLastPathComponent() + .appendingPathComponent("history.json.corrupt-\(DubaiTime.fileStamp(Date()))") + try? fm.moveItem(at: url, to: corruptURL) + Log.spool.error( + "history.json could not be decoded; moved to \(corruptURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + return [] + } + } +} diff --git a/Tests/ShotdeckCoreTests/HistoryStoreTests.swift b/Tests/ShotdeckCoreTests/HistoryStoreTests.swift new file mode 100644 index 0000000..9e74ab5 --- /dev/null +++ b/Tests/ShotdeckCoreTests/HistoryStoreTests.swift @@ -0,0 +1,319 @@ +import CoreGraphics +import Foundation +import ImageIO +import Testing +import ShotdeckCore + +@Test +func recordTwelvePDFsKeepsTenNewestAndDeletesOldestCopies() async throws { + let (root, paths) = try makeHistoryPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let store = try HistoryStore(paths: paths) + let sources = root.appendingPathComponent("user-sources", isDirectory: true) + try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) + + var recorded: [HistoryEntry] = [] + for i in 0..<12 { + let source = sources.appendingPathComponent("source-\(i).pdf") + try writeDummyPDF(to: source, marker: "pdf-\(i)") + let sentAt = Date(timeIntervalSince1970: 1_800_000_000 + TimeInterval(i)) + let entry = try await store.recordSentPDF( + sourceURL: source, + sessionID: UUID(), + pageCount: i + 1, + sentAt: sentAt + ) + recorded.append(entry) + } + + let listed = await store.listPDFs() + #expect(listed.count == 10) + #expect(listed.map(\.id) == recorded.suffix(10).reversed().map(\.id)) + #expect(listed.map(\.sentAt) == recorded.suffix(10).reversed().map(\.sentAt)) + + let pdfsDir = paths.root.appendingPathComponent("history/pdfs", isDirectory: true) + for entry in recorded.prefix(2) { + #expect(!FileManager.default.fileExists(atPath: pdfsDir.appendingPathComponent(entry.fileName).path)) + } + for entry in recorded.suffix(10) { + #expect(FileManager.default.fileExists(atPath: pdfsDir.appendingPathComponent(entry.fileName).path)) + } +} + +@Test +func pruneImagesKeepsThirtyNewestAcrossThreeArchivedSessionsAndLeavesOpenSessionAlone() async throws { + let (root, paths) = try makeHistoryPaths() + defer { try? FileManager.default.removeItem(at: root) } + + let spool = try SpoolStore(paths: paths) + let base = Date(timeIntervalSince1970: 1_800_100_000) + var captures: [(id: UUID, capturedAt: Date, sessionID: UUID)] = [] + + // 5 oldest + 15 + 15 = 35 archived PNGs. The oldest session is emptied by prune. + let perSession = [5, 15, 15] + var index = 0 + for count in perSession { + let sessionID = try await spool.currentSession().id + for _ in 0.. (root: URL, paths: AppSupportPaths) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-history-\(UUID().uuidString)", isDirectory: true) + let paths = try AppSupportPaths( + root: root.appendingPathComponent("root", isDirectory: true), + outbox: root.appendingPathComponent("outbox", isDirectory: true), + watchFolder: root.appendingPathComponent("watch", isDirectory: true) + ) + return (root, paths) +} + +private func writeDummyPDF(to url: URL, marker: String) throws { + try Data("%PDF-1.4\n%\(marker)\n%%EOF\n".utf8).write(to: url) +} + +private func makeHistoryPNGData( + width: Int, + height: Int, + red: CGFloat, + green: CGFloat, + blue: CGFloat +) throws -> Data { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw HistoryFixtureError.pngGenerationFailed + } + context.setFillColor(red: red, green: green, blue: blue, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { + throw HistoryFixtureError.pngGenerationFailed + } + let buffer = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(buffer, "public.png" as CFString, 1, nil) else { + throw HistoryFixtureError.pngGenerationFailed + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + throw HistoryFixtureError.pngGenerationFailed + } + return buffer as Data +} + +private func pngFiles(under root: URL) -> [URL] { + let fm = FileManager.default + guard let enumerator = fm.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [] + ) else { return [] } + var urls: [URL] = [] + for case let url as URL in enumerator { + let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) ?? false + if isFile, url.pathExtension.lowercased() == "png" { + urls.append(url) + } + } + return urls +} + +private func assertManifestsMatchDisk(archiveRoot: URL) throws { + let fm = FileManager.default + let sessions = (try fm.contentsOfDirectory( + at: archiveRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [] + )).filter { + ((try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false) + && UUID(uuidString: $0.lastPathComponent) != nil + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + for dir in sessions { + let manifestURL = dir.appendingPathComponent("session.json") + #expect(fm.fileExists(atPath: manifestURL.path)) + let session = try decoder.decode(CaptureSession.self, from: Data(contentsOf: manifestURL)) + for capture in session.captures { + let url = dir.appendingPathComponent(capture.fileName) + #expect(fm.fileExists(atPath: url.path)) + } + let topLevelPNGs = (try fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil, options: [])) + .filter { $0.pathExtension.lowercased() == "png" } + let manifestNames = Set(session.captures.map(\.fileName)) + #expect(Set(topLevelPNGs.map(\.lastPathComponent)) == manifestNames) + } +} + +private enum HistoryFixtureError: Error { + case pngGenerationFailed +}