Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions BitMatch-iPad/Views/ModularContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
253 changes: 253 additions & 0 deletions BitMatch-iPadTests/ReportThumbnailTests.swift

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions BitMatch-iPadTests/WorkflowSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions BitMatch/Views/PreferencesWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}

}
Expand Down
253 changes: 253 additions & 0 deletions BitMatchTests/ReportThumbnailTests.swift

Large diffs are not rendered by default.

103 changes: 102 additions & 1 deletion BitMatchTests/SourceTreeUnchangedTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -68,6 +81,17 @@ public struct LocalTransferResource: Codable, Sendable {
}
}

public final class LocalTransferReadAccess: Sendable {
public let url: URL
private let scoped: Mutex<Bool>
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
Expand Down
3 changes: 2 additions & 1 deletion Shared/Core/Models/ReportSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
44 changes: 44 additions & 0 deletions Shared/Core/Models/TransferHistoryMetrics.swift
Original file line number Diff line number Diff line change
@@ -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)
]
}
}
5 changes: 4 additions & 1 deletion Shared/Core/Models/TransferLibraryPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 53 additions & 1 deletion Shared/Core/Services/ReportExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading