From 07ba5112db969e5f8112a0bb06e57bd75854217b Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 10 Oct 2026 11:42:59 -0400 Subject: [PATCH 1/2] Compact PDF reports and add optional backup previews and History metrics --- BitMatch-iPad/Views/ModularContentView.swift | 3 + BitMatch-iPadTests/ReportThumbnailTests.swift | 253 ++++++++++++++++++ .../WorkflowSnapshotTests.swift | 14 + BitMatch/Views/PreferencesWindow.swift | 4 +- BitMatchTests/ReportThumbnailTests.swift | 253 ++++++++++++++++++ BitMatchTests/SourceTreeUnchangedTests.swift | 103 ++++++- CHANGELOG.md | 5 + .../BitMatchEngine/TransferJournal.swift | 24 ++ Shared/Core/Models/ReportSummary.swift | 3 +- .../Core/Models/TransferHistoryMetrics.swift | 44 +++ .../Models/TransferLibraryPresentation.swift | 5 +- Shared/Core/Services/ReportExporter.swift | 54 +++- .../Services/ReportThumbnailService.swift | 145 ++++++++++ .../Services/TransferHistoryDocument.swift | 7 +- Shared/Views/ReportPDFRenderer.swift | 6 +- Shared/Views/ReportView.swift | 140 ++++++++-- Shared/Views/TransferLibraryView.swift | 27 ++ docs/GUIDE.md | 23 ++ docs/TODO.md | 10 +- docs/TODO_REPORTS_VALIDATION.md | 37 +++ 20 files changed, 1125 insertions(+), 35 deletions(-) create mode 100644 BitMatch-iPadTests/ReportThumbnailTests.swift create mode 100644 BitMatchTests/ReportThumbnailTests.swift create mode 100644 Shared/Core/Models/TransferHistoryMetrics.swift create mode 100644 Shared/Core/Services/ReportThumbnailService.swift create mode 100644 docs/TODO_REPORTS_VALIDATION.md diff --git a/BitMatch-iPad/Views/ModularContentView.swift b/BitMatch-iPad/Views/ModularContentView.swift index ecc30375..4fe9c6c9 100644 --- a/BitMatch-iPad/Views/ModularContentView.swift +++ b/BitMatch-iPad/Views/ModularContentView.swift @@ -358,6 +358,9 @@ struct SettingsSheetView: View { Section { Toggle(TransferOptionsPresentation.reportToggleTitle(), isOn: $coordinator.reportSettings.makeReport) + Toggle("Include clip thumbnails in PDFs", isOn: $coordinator.reportSettings.includeThumbnails) + .disabled(!coordinator.reportSettings.makeReport) + Text("Small frames from supported verified backup clips. Previews are private report content, not verification evidence.").font(.footnote).foregroundStyle(.secondary) Button(role: .destructive) { clearReportInfo() } label: { diff --git a/BitMatch-iPadTests/ReportThumbnailTests.swift b/BitMatch-iPadTests/ReportThumbnailTests.swift new file mode 100644 index 00000000..a2b4d0f3 --- /dev/null +++ b/BitMatch-iPadTests/ReportThumbnailTests.swift @@ -0,0 +1,253 @@ +import Foundation +import PDFKit +import Testing +@testable import BitMatch_iPad +import BitMatchEngine + +// Locally generated solid-colour H.264 fixture; no footage or third-party assets. +private let previewFixture = "AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAN2bW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAqF0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAQAAAACQAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAQAAABAAAAAAIZbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABxG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAYRzdGJsAAAAwHN0c2QAAAAAAAAAAQAAALBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAQAAkABIAAAASAAAAAAAAAABFExhdmM2My4xLjEwMSBsaWJ4MjY0AAAAAAAAAAAAAAAAGP//AAAANmF2Y0MBZAAL/+EAGWdkAAus2UEBOwEQAAADABAAAAMAoPFCmWABAAZo6+PLIsD9+PgAAAAAEHBhc3AAAAABAAAAAQAAABRidHJ0AAAAAAAAGTgAAAAAAAAAGHN0dHMAAAAAAAAAAQAAAAUAAAgAAAAAFHN0c3MAAAAAAAAAAQAAAAEAAAA4Y3R0cwAAAAAAAAAFAAAAAQAAEAAAAAABAAAoAAAAAAEAABAAAAAAAQAAAAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAABQAAAAEAAAAoc3RzegAAAAAAAAAAAAAABQAAAvAAAAAQAAAADQAAAA0AAAANAAAAFHN0Y28AAAAAAAAAAQAAA6YAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYzLjEuMTAxAAAACGZyZWUAAAMvbWRhdAAAAq0GBf//qdxF6b3m2Ui3lizYINkj7u94MjY0IC0gY29yZSAxNjUgcjMyMjIgYjM1NjA1YSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMgLSBDb3B5bGVmdCAyMDAzLTIwMjUgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwgLSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMgbWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz00IGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJheV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2FkYXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtleWludD0yNTAga2V5aW50X21pbj01IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAO2WIhAAS//7oyfzLJ6E/UMDDl9+ZWnHMI9HTDq9Ryj5DaMReURFNVE3hEI18BtiMaDgAAAfoCMQSrO8PAAAADEGaJGxD//6plgAYMAAAAAlBnkJ4gh8AA9MAAAAJAZ5hdEP/AAh4AAAACQGeY2pD/wAIeQ==" + +@MainActor +struct ReportThumbnailTests { + private func seed() throws -> (URL, URL, ResultRow) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("bitmatch-preview-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("preview.mp4") + let data = try #require(Data(base64Encoded: previewFixture)) + try data.write(to: file) + return (root, file, ResultRow(path: "/original-card/preview.mp4", status: ResultOutcome.verified.statusText, + size: Int64(data.count), checksum: "fixture", destination: root.path, destinationPath: file.path)) + } + + @Test func realVerifiedBackupProducesSmallJPEG() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let frames = try await ReportThumbnailService.collect(results: [row, row], enabled: true) + #expect(frames.requestedCount == 1) + let data = try #require(frames.images[row.path]) + #expect(data.starts(with: [0xff, 0xd8])) + #expect(data.count <= 64 * 1024) + } + + @Test func disabledAndQuickNeverStartExtraction() async throws { + let (_, _, row) = try seed() + defer { if let path = row.destinationPath { try? FileManager.default.removeItem(at: URL(fileURLWithPath: path).deletingLastPathComponent()) } } + let no = try await ReportThumbnailService.collect(results: [row], enabled: false) { _, _ in + Issue.record("Disabled preference read media"); return nil + } + #expect(no.images.isEmpty) + let quick = ResultRow(path: row.path, status: ResultOutcome.copiedUnverified.statusText, size: row.size, + checksum: nil, destination: row.destination, destinationPath: row.destinationPath) + let result = try await ReportThumbnailService.collect(results: [quick], enabled: true) { _, _ in + Issue.record("Quick copy used as verified preview"); return nil + } + #expect(result.requestedCount == 0) + } + + @Test func sourceIsNeverFallbackAndChangedSizeIsSkipped() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + for path in [nil, row.destinationPath] { + let unsafe = ResultRow(path: row.path, status: row.status, size: row.size + 1, checksum: row.checksum, + destination: row.destination, destinationPath: path) + let result = try await ReportThumbnailService.collect(results: [unsafe], enabled: true) { _, _ in + Issue.record("Missing or changed backup read"); return nil + } + #expect(result.images.isEmpty) + } + } + + @Test func limitAndZeroBudgetBoundWork() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let rows = (0..<50).map { index in ResultRow(path: "/card/\(index).mp4", status: row.status, + size: row.size, checksum: row.checksum, destination: row.destination, destinationPath: row.destinationPath) } + let capped = try await ReportThumbnailService.collect(results: rows, enabled: true, maximumPreviews: 2) { _, _ in Data([1]) } + #expect(capped.images.count == 2) + #expect(capped.requestedCount == 50) + let noTime = try await ReportThumbnailService.collect(results: rows, enabled: true, budget: 0) { _, _ in + Issue.record("Zero budget started extraction"); return nil + } + #expect(noTime.images.isEmpty) + } + + @Test func corruptClipAndCancellationDoNotBecomeSuccess() async throws { + let (root, file, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let bytes = Data(repeating: 0, count: Int(row.size)); try bytes.write(to: file) + let failed = try await ReportThumbnailService.collect(results: [row], enabled: true) + #expect(failed.images.isEmpty) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await ReportThumbnailService.collect(results: [row], enabled: true) + } + do { _ = try await task.value; Issue.record("Cancellation swallowed") } + catch is CancellationError { } + } + + @Test func regeneratedHistoryNeedsOnlyBackupAndRetainsUnsafeVerdict() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("original-card") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: source), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .issues; record.summary = "Keep the source"; record.results = [row] + try FileManager.default.removeItem(at: source) + let data = try await ReportExporter.historyPDF(record: record, includeThumbnails: true) + let pdf = try #require(PDFDocument(data: data)) + let text = try #require(pdf.string) + #expect(text.contains("Clip preview")) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(text.contains("No new verification was performed")) + #expect(!text.contains("VERIFICATION SUCCESSFUL")) + if let directory = (ProcessInfo.processInfo.environment["BITMATCH_PREVIEW_PDF_OUTPUT"] ?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_PREVIEW_PDF_OUTPUT"]) { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("clip-preview.pdf")) + } + } + @Test func symlinkBackupIsSkippedAndAnotherOnlineBackupCanSupplyPreview() async throws { + let (root, file, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let link = root.appendingPathComponent("redirect.mp4") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: file) + let redirected = ResultRow(path: row.path, status: row.status, size: row.size, checksum: row.checksum, + destination: row.destination, destinationPath: link.path) + let result = try await ReportThumbnailService.collect(results: [redirected, row], enabled: true) + #expect(result.images.count == 1) + let rejected = try await ReportThumbnailService.collect(results: [redirected], enabled: true) { _, _ in + Issue.record("Symlinked backup was read"); return nil + } + #expect(rejected.images.isEmpty) + } + + @Test func unsupportedClipsRemainInRegeneratedPDFAndOptOutHasNoPreview() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .issues + record.results = [row, ResultRow(path: "/original-card/proprietary.r3d", status: row.status, size: 20, + checksum: "saved", destination: row.destination, destinationPath: nil)] + let data = try await ReportExporter.historyPDF(record: record, includeThumbnails: false) + let text = try #require(PDFDocument(data: data)?.string) + #expect(text.contains("proprietary.r3d")) + #expect(text.contains("preview.mp4")) + #expect(!text.contains("Clip preview")) + #expect(text.contains("NOT SAFE TO ERASE")) + } + + @Test func historyMetricsUseMeasuredWorkAndDoNotInventLegacyTiming() throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.results = [row, row] + func values(_ record: LocalTransferRecord) -> [String: String] { + Dictionary(uniqueKeysWithValues: TransferHistoryMetrics.make(record).map { ($0.title, $0.value) }) + } + let legacy = values(record) + #expect(legacy["Recorded source files"] == "1") + for field in ["Started", "Finished", "Copy", "Verify", "Average copy speed (all backups)"] { + #expect(legacy[field] == "—") + } + record.copyDurationSeconds = 2 + record.performanceTelemetry = TransferPerformanceTelemetry(copyBytes: 4 * 1_048_576) + #expect(values(record)["Average copy speed (all backups)"] == "2.0 MB/s") + record.copyDurationSeconds = .nan + #expect(values(record)["Average copy speed (all backups)"] == "—") + let quick = LocalTransferRecord(id: UUID(), createdAt: Date(), source: record.source, + destinations: record.destinations, verificationMode: .quick, cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs()) + #expect(values(quick)["Verify"] == "Not performed (Quick)") + } + + @Test func historyReadLeaseRejectsReplacementAndCanBeReleasedTwice() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("bitmatch-lease-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let original = root.appendingPathComponent("backup") + try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true) + let resource = try LocalTransferResource(url: original) + let access = try resource.beginReadAccess() + access.release(); access.release() + try FileManager.default.removeItem(at: original) + try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true) + #expect(throws: (any Error).self) { _ = try resource.beginReadAccess() } + } + + @Test func automaticExporterHonorsPreferenceWithoutPuttingImagesInJSON() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + var prefs = ReportPrefs(makeReport: true) + prefs.includeThumbnails = true + prefs.verificationMode = .standard + try await ReportExporter.export(mode: .copyAndVerify, jobID: UUID(), started: Date(), finished: Date(), + sourceURL: nil, destinationURLs: [root], results: [row], fileCount: 1, matchCount: 1, prefs: prefs, + workers: 1, totalBytesProcessed: row.size, safetyState: .safeToErase) + let files = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + .compactMap { $0 as? URL } + let pdf = try #require(files.first { $0.pathExtension == "pdf" }) + let text = try #require(PDFDocument(url: pdf)?.string) + #expect(text.contains("Clip previews: 1/1")) + #expect(text.contains("VERIFICATION SUCCESSFUL")) + let json = files.filter { $0.pathExtension == "json" } + #expect(!json.isEmpty) + for url in json { + let value = try String(contentsOf: url, encoding: .utf8) + #expect(!value.contains("images")) + #expect(!value.contains("base64")) + } + #expect(!text.contains("Performance Metrics")) + #expect(!text.contains("Top File Types")) + #expect(!text.contains("Workers:")) + } + + @Test func cancellationDuringCollectionDoesNotStartAnotherClip() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let (started, signal) = AsyncStream.makeStream() + let task = Task { + try await ReportThumbnailService.collect(results: [row], enabled: true) { _, _ in + signal.yield(()) + try await Task.sleep(for: .seconds(30)) + Issue.record("Cancelled extraction continued"); return nil + } + } + for await _ in started { break } + task.cancel() + do { _ = try await task.value; Issue.record("Cancelled collection returned success") } + catch is CancellationError { } + signal.finish() + } + + @Test func previewRowsPaginateWithoutLosingFileResults() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let frame = try #require(try await ReportThumbnailService.collect(results: [row], enabled: true).images[row.path]) + let rows = (0..<75).map { index in + ResultRow(path: "/card/clip-\(index).mp4", status: row.status, size: row.size, checksum: row.checksum, + destination: "Backup", destinationPath: row.destinationPath) + } + let previews = ReportThumbnails(images: Dictionary(uniqueKeysWithValues: rows.map { ($0.path, frame) }), requestedCount: rows.count) + let summary = ReportSummary(jobID: UUID(), started: Date(), finished: Date(), mode: .copyAndVerify, + source: "/card", destinations: [root.path], totalFiles: rows.count, matched: rows.count, issues: 0, + workers: 1, appVersion: "test", osVersion: "test", client: "", production: "", company: "", + verificationMethod: "Standard", totalBytesProcessed: row.size * Int64(rows.count), averageSpeed: 0, + clientLogoData: nil, companyLogoData: nil, photographyJob: nil, safetyState: .safeToErase) + let document = try #require(PDFDocument(data: ReportPDFRenderer.renderPDF(summary: summary, results: rows, thumbnails: previews))) + #expect(document.pageCount > 1) + let text = try #require(document.string) + for index in 0..<75 { #expect(text.components(separatedBy: "clip-\(index).mp4").count == 2) } + if let directory = (ProcessInfo.processInfo.environment["BITMATCH_PREVIEW_PDF_OUTPUT"] ?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_PREVIEW_PDF_OUTPUT"]), + let data = document.dataRepresentation() { + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("preview-pagination.pdf")) + } + } + + @Test func historySearchDoesNotCallIntentionalExclusionsFailed() throws { + let (root, _, _) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.results = [ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 10, checksum: nil, destination: "Backup")] + let index = TransferLibraryPresentation.SearchIndex(records: [record]) + let line = try #require(index.match(for: record, search: "._clip.mov")?.clipLine) + #expect(line.contains("intentionally excluded")) + #expect(!line.contains("failed")) + } + +} diff --git a/BitMatch-iPadTests/WorkflowSnapshotTests.swift b/BitMatch-iPadTests/WorkflowSnapshotTests.swift index 7040e7a1..7d9ecb94 100644 --- a/BitMatch-iPadTests/WorkflowSnapshotTests.swift +++ b/BitMatch-iPadTests/WorkflowSnapshotTests.swift @@ -128,6 +128,20 @@ final class WorkflowSnapshotTests: XCTestCase { try capture(AppleDoubleSelectionView(review: review).padding(), size: CGSize(width: 820, height: 650), name: "ipad-appledouble-selection") } + func testCaptureExpandedHistoryMetrics() async throws { + try requireCaptureConfiguration() + let fixture = try SnapshotFixture() + defer { fixture.restoreGlobalPreferences() } + try fixture.seedInterruptedTransfer() + let record = try XCTUnwrap(fixture.journal.records.first) + for width in [320.0, 500.0, 820.0] { + await settle() + try capture(TransferLibraryView(coordinator: fixture.coordinator, journal: fixture.journal, + initialRecordID: record.id), + size: CGSize(width: width, height: 1400), name: "history-metrics-\(Int(width))") + } + } + private func requireCaptureConfiguration() throws { let environment = ProcessInfo.processInfo.environment let enabled = environment["BITMATCH_CAPTURE_WORKFLOW_SNAPSHOTS"] == "1" diff --git a/BitMatch/Views/PreferencesWindow.swift b/BitMatch/Views/PreferencesWindow.swift index d73e93e1..41095cd2 100644 --- a/BitMatch/Views/PreferencesWindow.swift +++ b/BitMatch/Views/PreferencesWindow.swift @@ -258,11 +258,11 @@ struct PreferencesWindow: View { } Section { - Toggle("Include thumbnails", isOn: $coordinator.reportSettings.includeThumbnails) + Toggle("Include clip thumbnails in PDFs", isOn: $coordinator.reportSettings.includeThumbnails) } header: { Text("Contents") } footer: { - Text("Adds one small preview for each file.") + Text("Adds a small frame from supported verified backup clips. Previews are private report content, not verification evidence. Unavailable clips stay listed.") } } diff --git a/BitMatchTests/ReportThumbnailTests.swift b/BitMatchTests/ReportThumbnailTests.swift new file mode 100644 index 00000000..7c59800c --- /dev/null +++ b/BitMatchTests/ReportThumbnailTests.swift @@ -0,0 +1,253 @@ +import Foundation +import PDFKit +import Testing +@testable import BitMatch +import BitMatchEngine + +// Locally generated solid-colour H.264 fixture; no footage or third-party assets. +private let previewFixture = "AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAN2bW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAqF0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAQAAAACQAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAQAAABAAAAAAIZbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABxG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAYRzdGJsAAAAwHN0c2QAAAAAAAAAAQAAALBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAQAAkABIAAAASAAAAAAAAAABFExhdmM2My4xLjEwMSBsaWJ4MjY0AAAAAAAAAAAAAAAAGP//AAAANmF2Y0MBZAAL/+EAGWdkAAus2UEBOwEQAAADABAAAAMAoPFCmWABAAZo6+PLIsD9+PgAAAAAEHBhc3AAAAABAAAAAQAAABRidHJ0AAAAAAAAGTgAAAAAAAAAGHN0dHMAAAAAAAAAAQAAAAUAAAgAAAAAFHN0c3MAAAAAAAAAAQAAAAEAAAA4Y3R0cwAAAAAAAAAFAAAAAQAAEAAAAAABAAAoAAAAAAEAABAAAAAAAQAAAAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAABQAAAAEAAAAoc3RzegAAAAAAAAAAAAAABQAAAvAAAAAQAAAADQAAAA0AAAANAAAAFHN0Y28AAAAAAAAAAQAAA6YAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYzLjEuMTAxAAAACGZyZWUAAAMvbWRhdAAAAq0GBf//qdxF6b3m2Ui3lizYINkj7u94MjY0IC0gY29yZSAxNjUgcjMyMjIgYjM1NjA1YSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMgLSBDb3B5bGVmdCAyMDAzLTIwMjUgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwgLSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMgbWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz00IGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJheV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2FkYXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtleWludD0yNTAga2V5aW50X21pbj01IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAO2WIhAAS//7oyfzLJ6E/UMDDl9+ZWnHMI9HTDq9Ryj5DaMReURFNVE3hEI18BtiMaDgAAAfoCMQSrO8PAAAADEGaJGxD//6plgAYMAAAAAlBnkJ4gh8AA9MAAAAJAZ5hdEP/AAh4AAAACQGeY2pD/wAIeQ==" + +@MainActor +struct ReportThumbnailTests { + private func seed() throws -> (URL, URL, ResultRow) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("bitmatch-preview-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("preview.mp4") + let data = try #require(Data(base64Encoded: previewFixture)) + try data.write(to: file) + return (root, file, ResultRow(path: "/original-card/preview.mp4", status: ResultOutcome.verified.statusText, + size: Int64(data.count), checksum: "fixture", destination: root.path, destinationPath: file.path)) + } + + @Test func realVerifiedBackupProducesSmallJPEG() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let frames = try await ReportThumbnailService.collect(results: [row, row], enabled: true) + #expect(frames.requestedCount == 1) + let data = try #require(frames.images[row.path]) + #expect(data.starts(with: [0xff, 0xd8])) + #expect(data.count <= 64 * 1024) + } + + @Test func disabledAndQuickNeverStartExtraction() async throws { + let (_, _, row) = try seed() + defer { if let path = row.destinationPath { try? FileManager.default.removeItem(at: URL(fileURLWithPath: path).deletingLastPathComponent()) } } + let no = try await ReportThumbnailService.collect(results: [row], enabled: false) { _, _ in + Issue.record("Disabled preference read media"); return nil + } + #expect(no.images.isEmpty) + let quick = ResultRow(path: row.path, status: ResultOutcome.copiedUnverified.statusText, size: row.size, + checksum: nil, destination: row.destination, destinationPath: row.destinationPath) + let result = try await ReportThumbnailService.collect(results: [quick], enabled: true) { _, _ in + Issue.record("Quick copy used as verified preview"); return nil + } + #expect(result.requestedCount == 0) + } + + @Test func sourceIsNeverFallbackAndChangedSizeIsSkipped() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + for path in [nil, row.destinationPath] { + let unsafe = ResultRow(path: row.path, status: row.status, size: row.size + 1, checksum: row.checksum, + destination: row.destination, destinationPath: path) + let result = try await ReportThumbnailService.collect(results: [unsafe], enabled: true) { _, _ in + Issue.record("Missing or changed backup read"); return nil + } + #expect(result.images.isEmpty) + } + } + + @Test func limitAndZeroBudgetBoundWork() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let rows = (0..<50).map { index in ResultRow(path: "/card/\(index).mp4", status: row.status, + size: row.size, checksum: row.checksum, destination: row.destination, destinationPath: row.destinationPath) } + let capped = try await ReportThumbnailService.collect(results: rows, enabled: true, maximumPreviews: 2) { _, _ in Data([1]) } + #expect(capped.images.count == 2) + #expect(capped.requestedCount == 50) + let noTime = try await ReportThumbnailService.collect(results: rows, enabled: true, budget: 0) { _, _ in + Issue.record("Zero budget started extraction"); return nil + } + #expect(noTime.images.isEmpty) + } + + @Test func corruptClipAndCancellationDoNotBecomeSuccess() async throws { + let (root, file, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let bytes = Data(repeating: 0, count: Int(row.size)); try bytes.write(to: file) + let failed = try await ReportThumbnailService.collect(results: [row], enabled: true) + #expect(failed.images.isEmpty) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await ReportThumbnailService.collect(results: [row], enabled: true) + } + do { _ = try await task.value; Issue.record("Cancellation swallowed") } + catch is CancellationError { } + } + + @Test func regeneratedHistoryNeedsOnlyBackupAndRetainsUnsafeVerdict() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("original-card") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: source), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .issues; record.summary = "Keep the source"; record.results = [row] + try FileManager.default.removeItem(at: source) + let data = try await ReportExporter.historyPDF(record: record, includeThumbnails: true) + let pdf = try #require(PDFDocument(data: data)) + let text = try #require(pdf.string) + #expect(text.contains("Clip preview")) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(text.contains("No new verification was performed")) + #expect(!text.contains("VERIFICATION SUCCESSFUL")) + if let directory = (ProcessInfo.processInfo.environment["BITMATCH_PREVIEW_PDF_OUTPUT"] ?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_PREVIEW_PDF_OUTPUT"]) { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("clip-preview.pdf")) + } + } + @Test func symlinkBackupIsSkippedAndAnotherOnlineBackupCanSupplyPreview() async throws { + let (root, file, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let link = root.appendingPathComponent("redirect.mp4") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: file) + let redirected = ResultRow(path: row.path, status: row.status, size: row.size, checksum: row.checksum, + destination: row.destination, destinationPath: link.path) + let result = try await ReportThumbnailService.collect(results: [redirected, row], enabled: true) + #expect(result.images.count == 1) + let rejected = try await ReportThumbnailService.collect(results: [redirected], enabled: true) { _, _ in + Issue.record("Symlinked backup was read"); return nil + } + #expect(rejected.images.isEmpty) + } + + @Test func unsupportedClipsRemainInRegeneratedPDFAndOptOutHasNoPreview() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .issues + record.results = [row, ResultRow(path: "/original-card/proprietary.r3d", status: row.status, size: 20, + checksum: "saved", destination: row.destination, destinationPath: nil)] + let data = try await ReportExporter.historyPDF(record: record, includeThumbnails: false) + let text = try #require(PDFDocument(data: data)?.string) + #expect(text.contains("proprietary.r3d")) + #expect(text.contains("preview.mp4")) + #expect(!text.contains("Clip preview")) + #expect(text.contains("NOT SAFE TO ERASE")) + } + + @Test func historyMetricsUseMeasuredWorkAndDoNotInventLegacyTiming() throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.results = [row, row] + func values(_ record: LocalTransferRecord) -> [String: String] { + Dictionary(uniqueKeysWithValues: TransferHistoryMetrics.make(record).map { ($0.title, $0.value) }) + } + let legacy = values(record) + #expect(legacy["Recorded source files"] == "1") + for field in ["Started", "Finished", "Copy", "Verify", "Average copy speed (all backups)"] { + #expect(legacy[field] == "—") + } + record.copyDurationSeconds = 2 + record.performanceTelemetry = TransferPerformanceTelemetry(copyBytes: 4 * 1_048_576) + #expect(values(record)["Average copy speed (all backups)"] == "2.0 MB/s") + record.copyDurationSeconds = .nan + #expect(values(record)["Average copy speed (all backups)"] == "—") + let quick = LocalTransferRecord(id: UUID(), createdAt: Date(), source: record.source, + destinations: record.destinations, verificationMode: .quick, cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs()) + #expect(values(quick)["Verify"] == "Not performed (Quick)") + } + + @Test func historyReadLeaseRejectsReplacementAndCanBeReleasedTwice() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("bitmatch-lease-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let original = root.appendingPathComponent("backup") + try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true) + let resource = try LocalTransferResource(url: original) + let access = try resource.beginReadAccess() + access.release(); access.release() + try FileManager.default.removeItem(at: original) + try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true) + #expect(throws: (any Error).self) { _ = try resource.beginReadAccess() } + } + + @Test func automaticExporterHonorsPreferenceWithoutPuttingImagesInJSON() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + var prefs = ReportPrefs(makeReport: true) + prefs.includeThumbnails = true + prefs.verificationMode = .standard + try await ReportExporter.export(mode: .copyAndVerify, jobID: UUID(), started: Date(), finished: Date(), + sourceURL: nil, destinationURLs: [root], results: [row], fileCount: 1, matchCount: 1, prefs: prefs, + workers: 1, totalBytesProcessed: row.size, safetyState: .safeToErase) + let files = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + .compactMap { $0 as? URL } + let pdf = try #require(files.first { $0.pathExtension == "pdf" }) + let text = try #require(PDFDocument(url: pdf)?.string) + #expect(text.contains("Clip previews: 1/1")) + #expect(text.contains("VERIFICATION SUCCESSFUL")) + let json = files.filter { $0.pathExtension == "json" } + #expect(!json.isEmpty) + for url in json { + let value = try String(contentsOf: url, encoding: .utf8) + #expect(!value.contains("images")) + #expect(!value.contains("base64")) + } + #expect(!text.contains("Performance Metrics")) + #expect(!text.contains("Top File Types")) + #expect(!text.contains("Workers:")) + } + + @Test func cancellationDuringCollectionDoesNotStartAnotherClip() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let (started, signal) = AsyncStream.makeStream() + let task = Task { + try await ReportThumbnailService.collect(results: [row], enabled: true) { _, _ in + signal.yield(()) + try await Task.sleep(for: .seconds(30)) + Issue.record("Cancelled extraction continued"); return nil + } + } + for await _ in started { break } + task.cancel() + do { _ = try await task.value; Issue.record("Cancelled collection returned success") } + catch is CancellationError { } + signal.finish() + } + + @Test func previewRowsPaginateWithoutLosingFileResults() async throws { + let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + let frame = try #require(try await ReportThumbnailService.collect(results: [row], enabled: true).images[row.path]) + let rows = (0..<75).map { index in + ResultRow(path: "/card/clip-\(index).mp4", status: row.status, size: row.size, checksum: row.checksum, + destination: "Backup", destinationPath: row.destinationPath) + } + let previews = ReportThumbnails(images: Dictionary(uniqueKeysWithValues: rows.map { ($0.path, frame) }), requestedCount: rows.count) + let summary = ReportSummary(jobID: UUID(), started: Date(), finished: Date(), mode: .copyAndVerify, + source: "/card", destinations: [root.path], totalFiles: rows.count, matched: rows.count, issues: 0, + workers: 1, appVersion: "test", osVersion: "test", client: "", production: "", company: "", + verificationMethod: "Standard", totalBytesProcessed: row.size * Int64(rows.count), averageSpeed: 0, + clientLogoData: nil, companyLogoData: nil, photographyJob: nil, safetyState: .safeToErase) + let document = try #require(PDFDocument(data: ReportPDFRenderer.renderPDF(summary: summary, results: rows, thumbnails: previews))) + #expect(document.pageCount > 1) + let text = try #require(document.string) + for index in 0..<75 { #expect(text.components(separatedBy: "clip-\(index).mp4").count == 2) } + if let directory = (ProcessInfo.processInfo.environment["BITMATCH_PREVIEW_PDF_OUTPUT"] ?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_PREVIEW_PDF_OUTPUT"]), + let data = document.dataRepresentation() { + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("preview-pagination.pdf")) + } + } + + @Test func historySearchDoesNotCallIntentionalExclusionsFailed() throws { + let (root, _, _) = try seed(); defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.results = [ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 10, checksum: nil, destination: "Backup")] + let index = TransferLibraryPresentation.SearchIndex(records: [record]) + let line = try #require(index.match(for: record, search: "._clip.mov")?.clipLine) + #expect(line.contains("intentionally excluded")) + #expect(!line.contains("failed")) + } + +} diff --git a/BitMatchTests/SourceTreeUnchangedTests.swift b/BitMatchTests/SourceTreeUnchangedTests.swift index 7f7474ef..d891057a 100644 --- a/BitMatchTests/SourceTreeUnchangedTests.swift +++ b/BitMatchTests/SourceTreeUnchangedTests.swift @@ -22,6 +22,7 @@ final class SourceTreeUnchangedTests: XCTestCase { ) defer { fixture.cleanup() } let source = sourceTreeCanonicalDirectoryURL(fixture.source) + try seedSourceMetadata(source) let before = try SourceTreeSnapshot(root: source) // Drive the executor the app uses, with every writer on @@ -94,6 +95,98 @@ final class SourceTreeUnchangedTests: XCTestCase { } } } + func testCancellationAfterReadbackLeavesSourceMetadataUnchanged() async throws { + try await exerciseUnsuccessfulHandoff(cancel: true) + } + + func testReportPublicationFailureLeavesSourceMetadataUnchanged() async throws { + try await exerciseUnsuccessfulHandoff(cancel: false) + } + + private func exerciseUnsuccessfulHandoff(cancel: Bool) async throws { + try await FileOperationsTestLock.shared.run { + let fixture = try DisposableTransferFixture(seed: 20261010, fileCount: 3, bytesPerFile: 64 * 1024) + defer { fixture.cleanup() } + let source = sourceTreeCanonicalDirectoryURL(fixture.source) + try seedSourceMetadata(source) + let before = try SourceTreeSnapshot(root: source) + XCTAssertFalse(before.entries["."]?.extendedAttributes.isEmpty ?? true) + if !cancel { + // A real destination conflict during report publication, after media verification. + for destination in fixture.destinations { + try Data("existing file must not be overwritten".utf8).write(to: destination.appendingPathComponent("Reports")) + } + } + let executor = CopyVerifyExecutor(platformManager: MacOSPlatformManager.shared, + timingService: OperationTimingService(), errorService: ErrorReportingService(), + stateService: OperationStateService(), backgroundTaskService: IOSBackgroundTaskService.shared) + let config = CopyVerifyConfig(operationId: UUID(), sourceURL: source, destinationURLs: fixture.destinations, + verificationMode: .standard, cameraLabelSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(makeReport: true), estimatedFiles: fixture.manifest.count, + estimatedBytes: 0, currentMode: .copyAndVerify, generateASCMHL: true) + let finalState = StateBox() + var authoritativeCount = 0 + do { + _ = try await executor.execute(config: config, callbacks: CopyVerifyCallbacks( + onProgress: { _ in }, onResult: { _ in }, onStateChange: { finalState.value = $0 }, + onAuthoritativeResults: { rows in + authoritativeCount = rows.count + if cancel { executor.cancel() } + })) + if cancel { XCTFail("Cancellation must throw") } + } catch is CancellationError { + XCTAssertTrue(cancel) + } catch { + XCTAssertFalse(cancel, "Explicit cancellation should propagate CancellationError") + } + XCTAssertEqual(authoritativeCount, fixture.manifest.count * fixture.destinations.count) + if cancel { + XCTAssertEqual(finalState.value, .cancelled) + } else { + guard case .completed(let info) = finalState.value else { + XCTFail("Expected a retained unsafe report-failure outcome"); return + } + XCTAssertFalse(info.success) + XCTAssertTrue(info.message.contains("report could not be saved")) + } + let after = try SourceTreeSnapshot(root: source) + XCTAssertEqual(after.entries, before.entries) + } + } + +} + +private func seedSourceMetadata(_ source: URL) throws { + let targets = [source] + (try FileManager.default.contentsOfDirectory(at: source, includingPropertiesForKeys: [.isRegularFileKey])) + .filter { (try? $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true }.prefix(1) + for url in targets { + let value = Data("source metadata must stay intact".utf8) + let result = value.withUnsafeBytes { setxattr(url.path, "com.bitmatch.source-test", $0.baseAddress, value.count, 0, XATTR_NOFOLLOW) } + if result != 0 { + if errno == ENOTSUP { throw XCTSkip("Fixture filesystem does not support extended attributes") } + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } +} + +private func sourceExtendedAttributes(_ url: URL) throws -> [String: Data] { + let length = listxattr(url.path, nil, 0, XATTR_NOFOLLOW) + guard length >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + if length == 0 { return [:] } + var buffer = [CChar](repeating: 0, count: length) + let read = listxattr(url.path, &buffer, length, XATTR_NOFOLLOW) + guard read == length else { throw POSIXError(.EIO) } + let names = buffer.split(separator: 0).map { String(decoding: $0.map { UInt8(bitPattern: $0) }, as: UTF8.self) } + var output: [String: Data] = [:] + for name in names { + let size = getxattr(url.path, name, nil, 0, 0, XATTR_NOFOLLOW) + guard size >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + var value = Data(count: size) + let count = value.withUnsafeMutableBytes { getxattr(url.path, name, $0.baseAddress, size, 0, XATTR_NOFOLLOW) } + guard count == size else { throw POSIXError(.EIO) } + output[name] = value + } + return output } @MainActor @@ -111,6 +204,10 @@ private struct SourceTreeSnapshot { let modificationDate: Date? let posixPermissions: Int? let sha256: String? + let creationDate: Date? + let ownerID: Int? + let groupID: Int? + let extendedAttributes: [String: Data] } let entries: [String: Entry] @@ -136,7 +233,11 @@ private struct SourceTreeSnapshot { size: (attributes[.size] as? NSNumber)?.int64Value ?? 0, modificationDate: attributes[.modificationDate] as? Date, posixPermissions: (attributes[.posixPermissions] as? NSNumber)?.intValue, - sha256: sha256 + sha256: sha256, + creationDate: attributes[.creationDate] as? Date, + ownerID: (attributes[.ownerAccountID] as? NSNumber)?.intValue, + groupID: (attributes[.groupOwnerAccountID] as? NSNumber)?.intValue, + extendedAttributes: try sourceExtendedAttributes(url) ) } self.entries = entries diff --git a/CHANGELOG.md b/CHANGELOG.md index 7894b926..ec05bc0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Added optional clip previews to PDFs, off by default. Supported videos use a small frame from a verified backup; unavailable clips stay listed. A preview is not a new verification. +- Simplified PDF reports: a compact verdict, transfer summary and file list, with previews beside filenames. Removed repeated statistics and empty technical fields. +- History can export a PDF with or without previews, without reconnecting the original card. Expanded rows show recorded dates, sizes, copy/verify times and measured average speed; missing measurements stay blank. +- Added a plain-language verification guide and expanded source-preservation tests to include filesystem metadata and extended attributes. + ## [0.2.6] - 2026-10-10 An optional way to leave recognized Mac metadata companions out of a transfer. diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift index c45a9762..3f6dbb03 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift @@ -34,6 +34,19 @@ public struct LocalTransferResource: Codable, Sendable { #endif } + /// Read access for optional history previews; requires the original destination identity. + public func beginReadAccess() throws -> LocalTransferReadAccess { + let resolved = try resolve() + let scoped = resolved.startAccessingSecurityScopedResource() + do { + try validate(resolved) + return LocalTransferReadAccess(url: resolved, scoped: scoped) + } catch { + if scoped { resolved.stopAccessingSecurityScopedResource() } + throw error + } + } + fileprivate func resolve() throws -> URL { var stale = false #if os(macOS) @@ -68,6 +81,17 @@ public struct LocalTransferResource: Codable, Sendable { } } +public final class LocalTransferReadAccess: Sendable { + public let url: URL + private let scoped: Mutex + fileprivate init(url: URL, scoped: Bool) { self.url = url; self.scoped = Mutex(scoped) } + public func release() { + let wasScoped = scoped.withLock { scoped in defer { scoped = false }; return scoped } + if wasScoped { url.stopAccessingSecurityScopedResource() } + } + deinit { release() } +} + public struct LocalTransferRecord: Identifiable, Codable, Sendable { public let id: UUID public let createdAt: Date diff --git a/Shared/Core/Models/ReportSummary.swift b/Shared/Core/Models/ReportSummary.swift index 4f69ac09..6f956ef0 100644 --- a/Shared/Core/Models/ReportSummary.swift +++ b/Shared/Core/Models/ReportSummary.swift @@ -14,7 +14,7 @@ struct ReportSummary { let totalFiles: Int let matched: Int let issues: Int - let workers: Int + let workers: Int? let appVersion: String let osVersion: String let client: String @@ -33,4 +33,5 @@ struct ReportSummary { /// The same fail-safe card verdict shown by the outcome screen. Report /// rendering must never infer safety from an empty issue list alone. var safetyState: CardSafetyState = .needsAttention + var hasRecordedTiming: Bool = true } diff --git a/Shared/Core/Models/TransferHistoryMetrics.swift b/Shared/Core/Models/TransferHistoryMetrics.swift new file mode 100644 index 00000000..cd730ea9 --- /dev/null +++ b/Shared/Core/Models/TransferHistoryMetrics.swift @@ -0,0 +1,44 @@ +import Foundation +import BitMatchEngine + +struct TransferHistoryMetric: Equatable, Identifiable { + let title: String + let value: String + var id: String { title } +} + +/// Recorded rows describe retained evidence, not an inferred complete source inventory. +enum TransferHistoryMetrics { + static func make(_ record: LocalTransferRecord) -> [TransferHistoryMetric] { + func date(_ date: Date?) -> String { date?.formatted(date: .abbreviated, time: .shortened) ?? "—" } + func duration(_ seconds: TimeInterval?) -> String { + guard let seconds, seconds.isFinite, seconds >= 0 else { return "—" } + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .abbreviated + return formatter.string(from: seconds) ?? "—" + } + let files = Dictionary(grouping: record.results, by: \.path).values.compactMap(\.first) + var bytes: Int64 = 0 + var validSize = true + for row in files { + let sum = bytes.addingReportingOverflow(max(0, row.size)) + if sum.overflow { validSize = false; break } + bytes = sum.partialValue + } + let speed: String + if let copyBytes = record.performanceTelemetry?.copyBytes, copyBytes >= 0, + let seconds = record.copyDurationSeconds, seconds.isFinite, seconds > 0 { + speed = String(format: "%.1f MB/s", Double(copyBytes) / seconds / 1_048_576) + } else { speed = "—" } + return [ + .init(title: "Started", value: date(record.startedAt)), + .init(title: "Finished", value: date(record.endedAt)), + .init(title: "Recorded source files", value: files.count.formatted()), + .init(title: "Recorded source size", value: validSize ? ByteCountPresentation.fileSize(bytes) : "—"), + .init(title: "Copy", value: duration(record.copyDurationSeconds)), + .init(title: "Verify", value: record.verificationMode == .quick ? "Not performed (Quick)" : duration(record.verifyDurationSeconds)), + .init(title: "Average copy speed (all backups)", value: speed) + ] + } +} diff --git a/Shared/Core/Models/TransferLibraryPresentation.swift b/Shared/Core/Models/TransferLibraryPresentation.swift index 41dc0ac2..30409160 100644 --- a/Shared/Core/Models/TransferLibraryPresentation.swift +++ b/Shared/Core/Models/TransferLibraryPresentation.swift @@ -19,10 +19,12 @@ enum TransferLibraryPresentation { private enum DestinationState: Int { case verified case notVerified + case excluded case failed init(_ row: ResultRow) { - if !row.isSuccessStatus { self = .failed } + if ResultOutcome(statusText: row.status) == .excludedAppleDouble { self = .excluded } + else if !row.isSuccessStatus { self = .failed } else if row.isVerifiedStatus { self = .verified } else { self = .notVerified } } @@ -96,6 +98,7 @@ enum TransferLibraryPresentation { case .verified: destination case .notVerified: "\(destination) (not verified)" case .failed: "\(destination) (failed)" + case .excluded: "\(destination) (intentionally excluded)" } } let destinationText = Self.naturalList(destinations) diff --git a/Shared/Core/Services/ReportExporter.swift b/Shared/Core/Services/ReportExporter.swift index ef17015b..89aeff75 100644 --- a/Shared/Core/Services/ReportExporter.swift +++ b/Shared/Core/Services/ReportExporter.swift @@ -84,8 +84,9 @@ enum ReportExporter { // Generate PDF on main thread (required for SwiftUI views). Every // platform renders the same `ReportView` through `ReportPDFRenderer` // (Promise 5, "one app everywhere"). + let thumbnails = try await ReportThumbnailService.collect(results: results, enabled: shouldGenerateFullReport && prefs.includeThumbnails) let pdfData: Data? = shouldGenerateFullReport ? await MainActor.run { - ReportPDFRenderer.renderPDF(summary: summary, results: results) + ReportPDFRenderer.renderPDF(summary: summary, results: results, thumbnails: thumbnails) } : nil let projectCSV = try photographerContext.map { try projectCSVEvidence(context: $0, results: results) } @@ -120,6 +121,57 @@ enum ReportExporter { safeToErase: safetyState == .safeToErase) } + /// Re-renders saved evidence without touching the source or changing the journal verdict. + static func historyPDF(record: LocalTransferRecord, includeThumbnails: Bool) async throws -> Data { + try Task.checkCancellation() + var accesses: [(LocalTransferResource, LocalTransferReadAccess)] = [] + if includeThumbnails { + for resource in record.destinations { + if let access = try? resource.beginReadAccess() { accesses.append((resource, access)) } + } + } + defer { accesses.forEach { $0.1.release() } } + let previewRows = record.results.map { row -> ResultRow in + var path: String? + if let oldPath = row.destinationPath { + let comparable = ResultPathMatch.comparablePath(oldPath) + for (resource, access) in accesses { + let original = ResultPathMatch.comparablePath(resource.url.path) + if comparable.hasPrefix(original + "/") { + path = access.url.appendingPathComponent(String(comparable.dropFirst(original.count + 1))).path + break + } + } + } + return ResultRow(id: row.id, path: row.path, status: row.status, size: row.size, checksum: row.checksum, + destination: row.destination, destinationPath: path, clipIntegrity: row.clipIntegrity, wasReused: row.wasReused) + } + let thumbnails = try await ReportThumbnailService.collect(results: previewRows, enabled: includeThumbnails) + let recordedBytes = record.results.filter(\.isSuccessStatus).reduce(into: Int64(0)) { bytes, row in + let sum = bytes.addingReportingOverflow(max(0, row.size)); bytes = sum.overflow ? Int64.max : sum.partialValue + } + let files = Set(record.results.map(\.path)).count + let start = record.startedAt ?? record.createdAt + let finish = record.endedAt ?? start + let notes = [EvidenceWriter.normalizedNotes(record.reportSettings.notes), record.summary, + "Regenerated from saved history. No new verification was performed. Recorded results only.", + record.startedAt == nil || record.endedAt == nil ? "Some original transfer timing is unavailable." : nil].compactMap { $0 }.joined(separator: "\n") + let summary = ReportSummary(jobID: record.id, started: start, finished: finish, mode: .copyAndVerify, + source: record.source.url.path, destinations: record.destinations.map { $0.url.path }, totalFiles: files, + matched: record.results.filter(\.isVerifiedStatus).count, + issues: record.results.filter { !$0.isSuccessStatus && ResultOutcome(statusText: $0.status) != .excludedAppleDouble }.count, + workers: nil, appVersion: "", osVersion: "", + client: record.reportSettings.clientName, production: record.reportSettings.production, company: record.reportSettings.company, + verificationMethod: record.verificationMode.rawValue, verificationMode: record.verificationMode, + totalBytesProcessed: recordedBytes, averageSpeed: 0, + copyDurationSeconds: record.copyDurationSeconds, verifyDurationSeconds: record.verifyDurationSeconds, + clientLogoData: nil, companyLogoData: nil, photographyJob: nil, notes: notes, + safetyState: TransferLibraryPresentation.safetyState(for: record), + hasRecordedTiming: record.startedAt != nil && record.endedAt != nil) + try Task.checkCancellation() + return await MainActor.run { ReportPDFRenderer.renderPDF(summary: summary, results: record.results, thumbnails: thumbnails) } + } + /// Converts the engine's authoritative verdict into the one safety state /// used by the outcome and PDF. The exporter never infers completeness /// from the subset of result rows it happens to receive. diff --git a/Shared/Core/Services/ReportThumbnailService.swift b/Shared/Core/Services/ReportThumbnailService.swift new file mode 100644 index 00000000..d75ce38a --- /dev/null +++ b/Shared/Core/Services/ReportThumbnailService.swift @@ -0,0 +1,145 @@ +import Foundation +@preconcurrency import AVFoundation +import CoreGraphics +import ImageIO +import UniformTypeIdentifiers +import BitMatchEngine + +struct ReportThumbnails: Sendable { + var images: [String: Data] = [:] + var requestedCount = 0 + var notice: String { + "Clip previews: \(images.count)/\(requestedCount) · backup at report time, not verification evidence." + } +} + +/// Optional report decoration only. No source fallback, disk cache or diagnostic payload. +enum ReportThumbnailService { + static let maximumPreviews = 200 + static let maximumSeconds: TimeInterval = 30 + static let perClipSeconds: TimeInterval = 2 + static let videoExtensions: Set = ["mov", "mp4", "m4v"] + + static func collect(results: [ResultRow], enabled: Bool, + maximumPreviews: Int = maximumPreviews, + budget: TimeInterval = maximumSeconds, + extract: @Sendable (URL, TimeInterval) async throws -> Data? = frame) async throws -> ReportThumbnails { + guard enabled else { return ReportThumbnails() } + var output = ReportThumbnails() + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(max(0, budget))) + let eligible = results.filter { $0.isVerifiedStatus && videoExtensions.contains(URL(fileURLWithPath: $0.path).pathExtension.lowercased()) } + output.requestedCount = Set(eligible.map(\.path)).count + var seen: Set = [] + var attempts = 0 + for row in eligible { + try Task.checkCancellation() + guard !seen.contains(row.path) else { continue } + guard attempts < max(0, maximumPreviews), clock.now < deadline, + let path = row.destinationPath else { continue } + let url = URL(fileURLWithPath: path) + // Never follow a companion or a redirected backup path into unrelated data. + guard SafetyValidator.firstSymlinkComponent(in: url) == nil, + let attributes = try? FileManager.default.attributesOfItem(atPath: path), + attributes[.type] as? FileAttributeType == .typeRegular, + (attributes[.size] as? NSNumber)?.int64Value == row.size else { continue } + seen.insert(row.path) + attempts += 1 + let remaining = clock.now.duration(to: deadline).components + let seconds = Double(remaining.seconds) + Double(remaining.attoseconds) / 1e18 + do { + if let image = try await extract(url, min(perClipSeconds, max(0, seconds))), image.count <= 64 * 1024 { + output.images[row.path] = image + } + } catch is CancellationError { throw CancellationError() } + catch { /* A preview failure never changes transfer evidence or logs footage details. */ } + } + try Task.checkCancellation() + return output + } + + static func frame(_ url: URL, timeout: TimeInterval) async throws -> Data? { + try Task.checkCancellation() + let request = ThumbnailRequest(url: url, timeout: timeout) + let image = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { request.start($0) } + } onCancel: { request.cancel() } + try Task.checkCancellation() + return image + } +} + +/// The lock owns completion so a blocked decoder queue cannot delay the caller's +/// timeout or cancellation. The serial queue alone owns the AVFoundation generator. +private final class ThumbnailRequest: @unchecked Sendable { + private let queue = DispatchQueue(label: "BitMatch.report-preview") + private let lock = NSLock() + private var cancelled = false + private var finished = false + private var continuation: CheckedContinuation? + private var timer: DispatchSourceTimer? + private var generator: AVAssetImageGenerator? + private let url: URL + private let timeout: TimeInterval + + init(url: URL, timeout: TimeInterval) { self.url = url; self.timeout = timeout } + private var isFinished: Bool { lock.lock(); defer { lock.unlock() }; return finished } + + func start(_ continuation: CheckedContinuation) { + lock.lock() + if cancelled { + finished = true + lock.unlock() + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + timer.schedule(deadline: .now() + max(0, timeout)) + timer.setEventHandler { [weak self] in self?.finish(.success(nil)) } + self.timer = timer + timer.resume() + lock.unlock() + queue.async { [self] in + guard !isFinished else { return } + let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url)) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 256, height: 144) + self.generator = generator + guard !isFinished else { generator.cancelAllCGImageGeneration(); self.generator = nil; return } + generator.generateCGImageAsynchronously(for: .zero) { [weak self] image, _, _ in + self?.queue.async { [weak self] in + guard let self, !self.isFinished else { return } + let data: Data? = autoreleasepool { + guard let image else { return nil } + let bytes = NSMutableData() + guard let output = CGImageDestinationCreateWithData(bytes, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(output, image, [kCGImageDestinationLossyCompressionQuality: 0.65] as CFDictionary) + return CGImageDestinationFinalize(output) ? bytes as Data : nil + } + self.finish(.success(data)) + } + } + } + } + + func cancel() { + lock.lock(); cancelled = true; lock.unlock() + finish(.failure(CancellationError())) + } + + private func finish(_ result: Result) { + lock.lock() + guard !finished, let continuation else { lock.unlock(); return } + finished = true + self.continuation = nil + let timer = self.timer + self.timer = nil + let outcome: Result = cancelled ? .failure(CancellationError()) : result + lock.unlock() + timer?.setEventHandler {}; timer?.cancel() + continuation.resume(with: outcome) + // This requests native cancellation; it does not promise decoder shutdown. + queue.async { [self] in generator?.cancelAllCGImageGeneration(); generator = nil } + } +} diff --git a/Shared/Core/Services/TransferHistoryDocument.swift b/Shared/Core/Services/TransferHistoryDocument.swift index 42e3873d..04fd81d3 100644 --- a/Shared/Core/Services/TransferHistoryDocument.swift +++ b/Shared/Core/Services/TransferHistoryDocument.swift @@ -1,14 +1,15 @@ -// TransferHistoryDocument.swift - One transfer's history, exported as JSON or CSV +// TransferHistoryDocument.swift - One transfer's history, exported as PDF, JSON or CSV import SwiftUI import UniformTypeIdentifiers import BitMatchEngine /// The file `TransferLibraryView` exports for one journal record. It holds -/// the paths, results and verdict only: no security-scoped bookmarks or +/// report data (and optional PDF previews): no security-scoped bookmarks or /// credentials leave the journal. struct TransferHistoryDocument: FileDocument { - static var readableContentTypes: [UTType] { [.json, .commaSeparatedText] } + static var readableContentTypes: [UTType] { [.json, .commaSeparatedText, .pdf] } let data: Data + init(data: Data) { self.data = data } init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) } diff --git a/Shared/Views/ReportPDFRenderer.swift b/Shared/Views/ReportPDFRenderer.swift index e8f8910c..a7ad1c5d 100644 --- a/Shared/Views/ReportPDFRenderer.swift +++ b/Shared/Views/ReportPDFRenderer.swift @@ -34,8 +34,8 @@ enum ReportPDFRenderer { } @MainActor - static func renderPDF(summary: ReportSummary, results: [ResultRow]) -> Data { - let report = ReportView(s: summary, rows: results) + static func renderPDF(summary: ReportSummary, results: [ResultRow], thumbnails: ReportThumbnails = ReportThumbnails()) -> Data { + let report = ReportView(s: summary, rows: results, thumbnails: thumbnails) let blocks = report.pdfBlocks let heights = blockHeights(blocks) let pages = ReportPDFLayout.pages(blockHeights: heights, contentHeight: contentHeight) @@ -63,6 +63,8 @@ enum ReportPDFRenderer { VStack(alignment: .leading, spacing: 0) { ForEach(page.blocks, id: \.self) { index in blocks[index].view + .frame(width: pageWidth - 2 * horizontalMargin, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) .frame(height: heights[index], alignment: .topLeading) } } diff --git a/Shared/Views/ReportView.swift b/Shared/Views/ReportView.swift index 880ccca1..afe6ac65 100644 --- a/Shared/Views/ReportView.swift +++ b/Shared/Views/ReportView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ImageIO import BitMatchEngine #if os(macOS) @@ -12,8 +13,10 @@ struct ReportView: View { let s: Summary let rows: [ResultRow] + var thumbnails = ReportThumbnails() private var duration: String { + guard s.hasRecordedTiming else { return "—" } let interval = s.finished.timeIntervalSince(s.started) let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] @@ -35,7 +38,7 @@ struct ReportView: View { } private var totalDurationSeconds: TimeInterval { - max(0, s.finished.timeIntervalSince(s.started)) + s.hasRecordedTiming ? max(0, s.finished.timeIntervalSince(s.started)) : 0 } private var reportStatistics: ReportResultStatistics { @@ -185,33 +188,40 @@ struct ReportView: View { } append(headerSection, separated: false) - append(summarySection) - if let notes = s.notes, !notes.isEmpty { append(notesSection(notes)) } + append(pdfOutcomeSection) + append(pdfSummarySection) + if let notes = s.notes, !notes.isEmpty { + append(VStack(alignment: .leading, spacing: 5) { + Text("Notes").font(.system(size: 12, weight: .semibold)) + Text(notes).font(.system(size: 10)).fixedSize(horizontal: false, vertical: true) + }) + } if s.photographyJob != nil { append(photographyJobSection) } - append(metricsSection) - append(environmentSection) - append(technicalSection) - append(statisticsSection) - if !extensionBreakdown.isEmpty { append(extensionSection) } + append(pdfLocationsSection) + if thumbnails.requestedCount > 0 { + append(Text(thumbnails.notice).font(.system(size: 9)).foregroundColor(.secondary)) + } if rows.isEmpty { append(VStack(alignment: .leading, spacing: 10) { - Text("Complete Result Manifest").font(.system(size: 14, weight: .semibold)) + Text("File results").font(.system(size: 14, weight: .semibold)) Text("No result rows were recorded in this run.") .font(.system(size: 10)).foregroundColor(.secondary) }) } else { // The first row carries its header so the header cannot be stranded. + var previewed: Set = [] for (index, row) in rows.enumerated() { + let showPreview = previewed.insert(row.path).inserted append(VStack(alignment: .leading, spacing: 0) { if index == 0 { - Text("Complete Result Manifest") + Text("File results") .font(.system(size: 14, weight: .semibold)) .padding(.bottom, 10) } VStack(spacing: 0) { - if index == 0 { manifestHeaderRow(compact: true) } + if index == 0 { pdfManifestHeader } Divider() - manifestDataRow(for: row, compact: true) + pdfManifestRow(row, showPreview: showPreview) }.padding(.horizontal, 8) }, separated: index == 0, manifest: index > 0) } @@ -230,17 +240,88 @@ struct ReportView: View { } } } - append(outcomeSection.padding(.top, 20), separated: false) append(footerSection) return blocks } + private var pdfLocationsSection: some View { + VStack(alignment: .leading, spacing: 5) { + Text("Locations").font(.system(size: 12, weight: .semibold)) + Text("Source: \(s.source)") + .font(.system(size: 9, design: .monospaced)).fixedSize(horizontal: false, vertical: true) + ForEach(Array(s.destinations.enumerated()), id: \.offset) { index, path in + Text("Backup \(index + 1): \(path)") + .font(.system(size: 9, design: .monospaced)).fixedSize(horizontal: false, vertical: true) + } + } + } + + private func pdfDestination(for row: ResultRow) -> String { + if let path = row.destinationPath { + let comparable = ResultPathMatch.comparablePath(path) + let matches = s.destinations.enumerated().filter { _, root in + let root = ResultPathMatch.comparablePath(root) + return comparable == root || comparable.hasPrefix(root + "/") + } + if let match = matches.max(by: { $0.element.count < $1.element.count }) { + return "Backup \(match.offset + 1)" + } + } + return row.destination ?? "—" + } + + private var pdfManifestHeader: some View { + HStack(spacing: 8) { + Text("").frame(width: 18) + Text("File").frame(width: 250, alignment: .leading) + Text("Destination").frame(width: 150, alignment: .leading) + Text("Size").frame(width: 70, alignment: .trailing) + }.font(.system(size: 9, weight: .semibold)).foregroundColor(.secondary).padding(.vertical, 6) + } + + private func pdfManifestRow(_ row: ResultRow, showPreview: Bool) -> some View { + let status = ResultStatusPresentation.make(status: row.status) + let source = ResultPathMatch.comparablePath(s.source) + let path = ResultPathMatch.comparablePath(row.path) + let display = path.hasPrefix(source + "/") ? String(path.dropFirst(source.count + 1)) : row.fileName + return HStack(spacing: 8) { + Image(systemName: status.symbol).foregroundColor(status.color) + .font(.system(size: 10)).frame(width: 18, alignment: .leading) + HStack(spacing: 8) { + if showPreview, let data = thumbnails.images[row.path], + let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) { + Image(decorative: image, scale: 1).resizable().scaledToFit().frame(width: 56, height: 32) + } + Text(display).font(.system(size: 10, weight: .medium)).lineLimit(2).truncationMode(.middle) + }.frame(width: 250, alignment: .leading) + Text(pdfDestination(for: row)).font(.system(size: 9)).foregroundColor(.secondary) + .lineLimit(2).truncationMode(.middle).frame(width: 150, alignment: .leading) + Text(ByteCountPresentation.fileSize(row.size)).font(.system(size: 10)).frame(width: 70, alignment: .trailing) + }.padding(.vertical, 5) + } + + private var pdfOutcomeSection: some View { + let safe = Self.shouldShowSuccessBadge(safetyState: s.safetyState, photographyJob: s.photographyJob) + return HStack(alignment: .top, spacing: 8) { + Image(systemName: safe ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + VStack(alignment: .leading, spacing: 3) { + Text(safe ? "VERIFICATION SUCCESSFUL" : "NOT SAFE TO ERASE").font(.system(size: 12, weight: .semibold)) + Text(safe ? "All required copies passed verification." : Self.unsafeReportReason(safetyState: s.safetyState, photographyJob: s.photographyJob)) + .font(.system(size: 10)) + } + Spacer(minLength: 0) + }.foregroundColor(safe ? .green : .orange).padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background((safe ? Color.green : Color.orange).opacity(0.05), in: RoundedRectangle(cornerRadius: 6)) + } + func pdfContinuationHeader(manifest: Bool) -> some View { VStack(alignment: .leading, spacing: 0) { Text("BitMatch Verification Report - continued") .font(.system(size: 9, weight: .medium)) .foregroundColor(.secondary) - if manifest { manifestHeaderRow(compact: true).padding(.horizontal, 8) } + if manifest { pdfManifestHeader.padding(.horizontal, 8) } } } @@ -316,6 +397,25 @@ struct ReportView: View { } } + private var pdfSummarySection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("\(s.mode == .copyAndVerify ? "Copy & Verify" : "Check") · \(s.verificationMethod)") + .font(.system(size: 12, weight: .semibold)) + Text("\(s.totalFiles) \(s.totalFiles == 1 ? "file" : "files") · \(verifiedFileCount) verified \(verifiedFileCount == 1 ? "copy" : "copies") · \(issueCount) file \(issueCount == 1 ? "issue" : "issues") · \(totalSizeFormatted)") + .font(.system(size: 10)) + if rows.contains(where: { ResultOutcome(statusText: $0.status) == .excludedAppleDouble }) { + Text("\(rows.filter { ResultOutcome(statusText: $0.status) == .excludedAppleDouble }.count) intentionally excluded results").font(.system(size: 10)) + } + if s.hasRecordedTiming { + Text("Elapsed: \(duration)" + (s.averageSpeed > 0 ? " · \(throughput)" : "")) + .font(.system(size: 10)).foregroundColor(.secondary) + } + if let phaseDuration { + Text(phaseDuration).font(.system(size: 10)).foregroundColor(.secondary) + } + }.frame(maxWidth: .infinity, alignment: .leading) + } + @ViewBuilder private var summarySection: some View { VStack(alignment: .leading, spacing: 10) { @@ -349,7 +449,7 @@ struct ReportView: View { .font(.system(size: 11, weight: .medium)) Text("Workers:").foregroundColor(.secondary).font(.system(size: 11)) - Text("\(s.workers) parallel") + Text(s.workers.map { "\($0) parallel" } ?? "—") .font(.system(size: 11, weight: .medium)) } @@ -498,7 +598,7 @@ struct ReportView: View { } GridRow { environmentLabel("Verification", value: s.verificationMethod) - environmentLabel("Workers", value: "\(s.workers) parallel") + environmentLabel("Workers", value: s.workers.map { "\($0) parallel" } ?? "—") } } } @@ -742,7 +842,7 @@ struct ReportView: View { .font(.system(size: 16, weight: .bold)) .foregroundColor(.green) - Text("All files verified with 100% accuracy") + Text("All required copies passed verification.") .font(.system(size: 11)) .foregroundColor(.secondary) } @@ -874,8 +974,10 @@ struct ReportView: View { Text("Job ID: \(s.jobID.uuidString)") .font(.system(size: 8, design: .monospaced)) - Text("BitMatch v\(s.appVersion) • \(s.osVersion)") - .font(.system(size: 8)) + if !s.appVersion.isEmpty { + Text("BitMatch v\(s.appVersion) • \(s.osVersion)") + .font(.system(size: 8)) + } } .foregroundColor(.secondary) diff --git a/Shared/Views/TransferLibraryView.swift b/Shared/Views/TransferLibraryView.swift index 90d6ed7e..9442e11c 100644 --- a/Shared/Views/TransferLibraryView.swift +++ b/Shared/Views/TransferLibraryView.swift @@ -7,6 +7,8 @@ struct TransferLibraryView: View { @ObservedObject var coordinator: SharedAppCoordinator @ObservedObject var journal: LocalTransferJournal @State private var search = "" + @State private var pdfTask: Task? + @State private var preparingPDF = false @State private var errorMessage: String? @State private var exportDocument: TransferHistoryDocument? @State private var showExport = false @@ -69,6 +71,7 @@ struct TransferLibraryView: View { .onReceive(journal.$records) { records in searchIndex = TransferLibraryPresentation.SearchIndex(records: records) } + .onDisappear { pdfTask?.cancel() } .sheet(item: $reauthorizeRecord) { record in ReauthorizeLocationsView(coordinator: coordinator, journal: journal, recordID: record.id) } @@ -226,6 +229,8 @@ struct TransferLibraryView: View { } if actions.export { Menu("Export report") { + Button(preparingPDF ? "Preparing PDF…" : "PDF report") { exportPDF(record, previews: false) }.disabled(preparingPDF) + Button("PDF with clip previews") { exportPDF(record, previews: true) }.disabled(preparingPDF) Button("JSON report") { export(record, asCSV: false) } Button("CSV results") { export(record, asCSV: true) } } @@ -257,6 +262,11 @@ struct TransferLibraryView: View { Text("Review this card in its project before preparing another ingest.") .foregroundStyle(.secondary) } + ForEach(TransferHistoryMetrics.make(record)) { metric in + Text("\(metric.title): \(metric.value)").textSelection(.enabled) + } + Text("Counts describe saved result rows, including exclusions. Missing measurements are shown as —.") + .foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) Text("Source: \(record.source.url.path)").textSelection(.enabled) ForEach(record.destinations.indices, id: \.self) { index in Text("Destination: \(record.destinations[index].url.path)").textSelection(.enabled) @@ -284,6 +294,23 @@ struct TransferLibraryView: View { .padding(.top, 4) } + private func exportPDF(_ record: LocalTransferRecord, previews: Bool) { + pdfTask?.cancel() + preparingPDF = true + pdfTask = Task { + defer { preparingPDF = false } + do { + let work = Task.detached { try await ReportExporter.historyPDF(record: record, includeThumbnails: previews) } + let data = try await withTaskCancellationHandler { try await work.value } onCancel: { work.cancel() } + try Task.checkCancellation() + exportDocument = TransferHistoryDocument(data: data) + exportType = .pdf + showExport = true + } catch is CancellationError { } + catch { errorMessage = "PDF could not be exported. \(error.localizedDescription)" } + } + } + private func export(_ record: LocalTransferRecord, asCSV: Bool) { do { exportDocument = try TransferHistoryDocument(record: record, asCSV: asCSV) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 5b2002ee..817f2557 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -8,6 +8,21 @@ On iPhone and iPad, keep BitMatch open while it works. iOS can interrupt a trans Notifications say when a card is safe to erase, when something needs you, and when the queue is done. Change them in Settings. +## How Verification Works + +Copying a file and checking it are separate jobs. BitMatch reads the card, writes each backup, then reads the destination bytes back. A matching checksum is evidence that those bytes matched when they were checked; a copy finishing or a filename appearing isn't enough. + +- **Quick** copies and checks sizes. It doesn't verify contents and never marks the card safe to erase. +- **Standard** hashes the source while copying and reads every backup back with SHA-256. +- **Thorough** also reads the source again, using SHA-256 and MD5 to check for changes during the transfer. +- **Paranoid** adds a byte-by-byte comparison with the source as well as SHA-256. + +An existing file is reused only after the selected verification checks establish that it matches. Conflicting files are never overwritten. A failed, cancelled or interrupted attempt keeps its partial results and stays unsafe. A retry is a new attempt, not permission to trust the old one. + +“Safe to erase” requires complete verified coverage on every required backup, plus any requested handoff/report steps. Two folders on the same physical drive aren't independent backups. Quick, missing files and intentional AppleDouble exclusions cannot earn a whole-card safe verdict. Keep the source until you've reviewed the results; a successful check doesn't promise that a drive can never fail later. + +PDF, CSV, JSON and ASC MHL describe the recorded work. They don't perform another verification just because you export them. Master Report summarizes saved records; **Check** reads the media again. Clip previews are optional report decoration and never proof of integrity. + ## AppleDouble Companions BitMatch preserves files by default, including hidden `._` companions that macOS uses for metadata on drives such as exFAT. Those can contain resource forks and Finder metadata; they're not always disposable. @@ -123,3 +138,11 @@ Run the relevant tests locally before submitting changes. Shared-code changes ne **Why open source?** So you can trust it. The code is here. Read it before you trust your footage to it. See the [MIT license](../LICENSE). + +## PDF clip previews and History + +Turn on **Settings → Reports → Include clip thumbnails in PDFs** to add small previews to new PDF reports. It starts off. Previews come from verified backup files after transfer verification; Quick copies do not supply verified previews. MOV, MP4 and M4V are supported when Apple's decoder can read them. Proprietary or unreadable clips remain in the file list without an image. + +In **History**, expand a transfer and choose **Export report → PDF report** or **PDF with clip previews**. The original card does not need to be connected. Previews need an accessible original backup location whose saved folder identity still matches. Missing or changed-size backup files are skipped. The report retains the saved outcome and says that no new verification was performed; an image shows backup contents at report time, not proof that the backup is still unchanged. + +Preview generation is sequential, limited to 200 clips and a 30-second collection budget, with a two-second wait per clip. Images are at most 256 × 144 pixels and 64 KB each. There is no persistent preview cache, and images are never added to diagnostic exports. Cancellation stops the report request; unavailable previews do not change verification or card safety. diff --git a/docs/TODO.md b/docs/TODO.md index dc1d5c91..8b881d39 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,6 +1,6 @@ # To do -The working list for BitMatch, reviewed against `main` at `964d094b` on October 10, 2026. These are roadmap candidates, not instructions to implement every feature or a promised release scope. When something ships, record it in the [changelog](../CHANGELOG.md) and remove the finished task here. +The working list for BitMatch, reviewed against `main` at `964d094b` on October 10, 2026. These are roadmap candidates, not instructions to implement every feature or a promised release scope. The first quick-win batch is implemented on `feature/todo-reports` for review; see [validation](TODO_REPORTS_VALIDATION.md). When something ships, record it in the [changelog](../CHANGELOG.md) and remove the finished task here. **Source media → trustworthy copies → independent verification → accurate evidence → safe-to-erase decision.** @@ -30,7 +30,7 @@ Investigate #16 first. Then follow evidence and actual user reports. Preserve so - [ ] **Check everything again at Start.** Existing engine preflight checks output roots, overlaps and access; journal preparation checks stored identities. Audit remaining gaps between selection, queueing and execution. Cover card/drive disconnect or replacement and two queued cards resolving to the same output folder (for example two “NO NAME” cards without a card number in the naming pattern). **Acceptance:** refuse unsafe starts before copying, explain why nothing was copied, and retain regressions for each gap found. -- [ ] **Finish the card-untouched regression.** `SourceTreeUnchangedTests` already compares paths, contents, sizes, modification dates and permissions through the app executor in every verification mode, with reports and MHL enabled. Extend it to capture and compare extended attributes and relevant filesystem metadata; coordinate with #16's real companion fixtures. **Acceptance:** the full source snapshot is unchanged after success, cancellation and failure; unsupported metadata checks are reported as such, not counted as passes. +- [x] **Finish the card-untouched regression.** `SourceTreeUnchangedTests` already compares paths, contents, sizes, modification dates and permissions through the app executor in every verification mode, with reports and MHL enabled. The quick-win batch adds creation dates, ownership and extended attributes, plus cancellation-after-readback and report-publication-failure tests; coordinate with #16's real companion fixtures. **Acceptance:** the full source snapshot is unchanged after success, cancellation and failure; unsupported metadata checks are reported as such, not counted as passes. - [ ] **Re-copy a newly written file that fails verification.** Investigate one automatic re-copy/re-verify, coordinated with recovery and sibling repair. **Acceptance:** replacement is limited to an output whose identity and ownership by this attempt are proven; never replace a file predating the run or one changed by another writer. Preserve the initial failure and retry outcome, and keep the verdict unsafe if retry fails. @@ -52,12 +52,12 @@ Investigate #16 first. Then follow evidence and actual user reports. Preserve so ## P3 — Useful reports and history -- [ ] **Optional clip thumbnails in PDFs.** The Mac already exposes an `includeThumbnails` preference, default off, but no thumbnail extraction/rendering path was found in the current report code. Resolve that misleading control when taking up this work; do not count the setting as an implemented feature. Keep generation optional and off by default. Start with one representative frame per supported video clip, preferably through AVFoundation, after critical copy/verification work. Support report generation/regeneration from previously verified media, with bounded memory, concurrency and time. Keep large reports compact; skip unsupported, unreadable and proprietary formats gracefully. Cache only where repeat generation benefits justify the storage and privacy cost. **Acceptance:** opted-in PDFs show supported clips' names, images and verification information; unsupported clips remain listed. Thumbnail failure never changes copy success, verification or safe-to-erase decisions, and footage/thumbnails never enter diagnostic exports. No proxies, transcoding, media browser or contact-sheet management. +- [x] **Optional clip thumbnails in PDFs.** Implemented in the quick-win batch, pending merge/release. The default-off setting now generates previews on Mac, iPhone and iPad; History can regenerate a PDF from accessible verified backups. Keep generation optional and off by default. Start with one representative frame per supported video clip, preferably through AVFoundation, after critical copy/verification work. Support report generation/regeneration from previously verified media, with bounded memory, concurrency and time. Keep large reports compact; skip unsupported, unreadable and proprietary formats gracefully. Cache only where repeat generation benefits justify the storage and privacy cost. **Acceptance:** opted-in PDFs show supported clips' names, images and verification information; unsupported clips remain listed. Thumbnail failure never changes copy success, verification or safe-to-erase decisions, and footage/thumbnails never enter diagnostic exports. No proxies, transcoding, media browser or contact-sheet management. -- [ ] **More detail in History.** Add date/time, file count, total size, measured copy/verify durations and average speed to expanded rows where missing. Keep collapsed rows compact; show a dash for measurements absent from older records rather than inventing them. Coordinate with the reused/recopied/pending counts above. +- [x] **More detail in History.** Add date/time, file count, total size, measured copy/verify durations and average speed to expanded rows where missing. Keep collapsed rows compact; show a dash for measurements absent from older records rather than inventing them. Coordinate with the reused/recopied/pending counts above. - [ ] **History from the drives themselves.** Read past offloads from saved reports on connected destinations, so “Backed up before…” works for a shuttle drive filled on another Mac. Keep discovered evidence distinct from a fresh verification. - [ ] **Per-card/per-drive notes and operator.** Carry these into reports and ASC MHL and from one card to the next; coordinate with organize-and-rename work. -- [ ] **“How verification works” guide.** Explain readback, modes, selected-file scope, evidence and the limits of a safe-to-erase decision. +- [x] **“How verification works” guide.** Explain readback, modes, selected-file scope, evidence and the limits of a safe-to-erase decision. ## P4 — Convenience after reliability diff --git a/docs/TODO_REPORTS_VALIDATION.md b/docs/TODO_REPORTS_VALIDATION.md new file mode 100644 index 00000000..ef88551d --- /dev/null +++ b/docs/TODO_REPORTS_VALIDATION.md @@ -0,0 +1,37 @@ +# Reports and History quick-win batch + +Implemented on `feature/todo-reports`, based on released 0.2.6 (`a78ca98d`). This is review work, not another published release. + +## Changes + +- PDF reports lead with the recorded verdict and a compact summary. Repeated performance statistics, worker counts, file-type breakdowns and empty environment fields are removed. Notes, source/backup locations, per-file results, project evidence and job ID remain. Preview and filename share a row; pagination uses the same sizing for measurement and rendering. +- **Settings → Reports → Include clip thumbnails in PDFs** is optional and off by default on Mac, iPad and iPhone. **History → Export report** offers PDF with or without previews. History export uses saved evidence, not a new verification, and does not require the original card. +- Expanded History shows dates, deduplicated recorded source counts/sizes, measured phase durations and measured aggregate copy speed. Missing old measurements show a dash. Intentional exclusions are not described as failed clips in History search. +- [The guide](GUIDE.md) explains verification modes, destination readback, scope and card-safety limits. +- Mac source-preservation snapshots now include creation dates, ownership and all extended attributes, alongside paths, contents, size, modification dates and permissions. + +## Preview limits and safety + +Extraction is sequential: at most 200 attempted clips, with a 30-second collection budget and a two-second wait per clip. AVFoundation frames are limited to 256 × 144; retained JPEGs are capped at 64 KB each. Timeout and cancellation settle the caller independently of the decoder queue and request native cancellation. This bounds caller waiting; it does not guarantee that a wedged OS decoder has exited. + +Only verified MOV/MP4/M4V backup rows are eligible. Missing, changed-size, symlinked or unsupported media is skipped, with no source fallback. History additionally resolves saved destination bookmarks and validates original folder/volume identity. A preview reflects backup contents at report time and is explicitly not verification evidence. It never updates the saved result, checksum or safety verdict. There is no persistent cache and images are not serialized into JSON or diagnostic exports. + +## Validation scope + +Synthetic solid-colour H.264 media exercises real AVFoundation extraction on Mac and the iPad simulator. Shared regression tests cover default-off/Quick exclusions, unavailable and changed-size backups, symlink rejection, another available backup, corrupt media, cancellation before and during collection, attempt/time budgets, PDF regeneration with the original source deleted, unchanged unsafe verdicts, unsupported files retained in reports, automatic exporter wiring, JSON without images, History measurements, destination identity rejection and 75-preview pagination with every filename present exactly once. + +Mac source snapshots pass through the real app executor in all four verification modes, with requested reports and MHL. Separate Standard cases cancel after readback and cause a real report-publication conflict; both compare the complete source snapshot afterwards. These do not establish metadata preservation on every filesystem or during every possible disconnect. Unsupported xattr fixtures explicitly skip instead of passing. + +The generated PDF and six-page preview report were rendered with Poppler and visually inspected. Seeded History layouts were rendered at 320, 500 and 820 points. This is synthetic and simulator coverage; physical iPhone/iPad picker/export walkthroughs and proprietary-camera footage checks remain release gates. + +One concurrent validation run failed existing timing-based waits: five Mac tests and two engine tests (three assertions). Failures and logs are retained under `dist/validation/todo-reports`; no production transfer or verification code was changed to accommodate them. Separate reruns are recorded below. The first new report-conflict test also incorrectly expected an exception; it was corrected to assert the existing retained, unsafe completion outcome. + +## Final local results + +- Mac app suite: **1,084 passed, 9 skipped, 0 failed** (1,093 tests in the Xcode summary). +- iPad Air (M4), iOS 26.5 simulator: **60 passed, 4 skipped, 0 failed** (64 tests). The four optional capture tests were also run with capture enabled earlier in this batch; final skips do not represent missing assertions in the feature tests. +- Engine: **121 XCTest cases, 15 skipped, 0 failures**, plus **132 Swift Testing tests passed**. The two timing-based engine tests that failed under concurrent validation passed in the separate full rerun. +- Mac and iPhone/iPad Release builds: **BUILD SUCCEEDED** for both targets (unsigned local validation). +- `git diff --check`: clean. + +All 14 new shared report/History tests pass on both Mac and the iPad simulator. Native extraction uses real synthetic video; deliberately wedged OS decoders and physical mobile share-sheet/picker workflows were not simulated. Preview failures and caller cancellation do not constitute evidence of successful media verification. From 6be85337bd34e2e2f4bfcef96277bf612b73b4bf Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 10 Oct 2026 12:10:45 -0400 Subject: [PATCH 2/2] Record signed Mac report and History walkthrough --- docs/TODO_REPORTS_VALIDATION.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/TODO_REPORTS_VALIDATION.md b/docs/TODO_REPORTS_VALIDATION.md index ef88551d..d52f613e 100644 --- a/docs/TODO_REPORTS_VALIDATION.md +++ b/docs/TODO_REPORTS_VALIDATION.md @@ -35,3 +35,13 @@ One concurrent validation run failed existing timing-based waits: five Mac tests - `git diff --check`: clean. All 14 new shared report/History tests pass on both Mac and the iPad simulator. Native extraction uses real synthetic video; deliberately wedged OS decoders and physical mobile share-sheet/picker workflows were not simulated. Preview failures and caller cancellation do not constitute evidence of successful media verification. + +## Signed Mac walkthrough — October 10 + +Built this branch with the existing `release_mac.sh` process, `SKIP_NOTARIZE=1`, using the existing Developer ID identity. Archive and signature verification succeeded; this local build was not notarized or published. + +Through the actual app and native folder pickers, copied two synthetic H.264 clips and a text file from a disposable mounted APFS image to a second APFS image using Standard, reports, clip previews and ASC MHL. The 200 MB destination correctly blocked the transfer below the space reserve; after expanding it to 2 GB, all three copies verified, MHL and PDF/CSV/JSON/checksum reports were saved, and History showed the saved count, size and timings. Source hashes, sizes and modification timestamps were unchanged; every backup hash matched. + +Ejected the source image, then used History's **PDF with clip previews** action and native save panel. The one-page PDF contained both 256 × 144 previews and all three results. Poppler rendering was visually inspected. Existing user transfers were preserved; only this walkthrough's queue row and destination selection were removed. Report, preview and MHL preferences were restored. Both disposable disk images were ejected and deleted; generated reports and fixture receipts remain under `dist/validation/todo-reports/walkthrough`. + +The OS refused creation of the attempted exFAT sparse image (`Operation not permitted`), so this UI walkthrough establishes APFS coverage only. It does not replace real-exFAT engine tests, physical iPhone/iPad walkthroughs or proprietary-camera footage validation.