From 6e5401889fe68c35d905247849951bc8dc986cce Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 11:15:39 -0400 Subject: [PATCH 1/5] Keep saved transfer verdicts within recorded coverage --- .../TransferDetailsReportTests.swift | 3 ++ BitMatchTests/LocalTransferJournalTests.swift | 1 + BitMatchTests/ReportPDFLayoutTests.swift | 2 +- .../TransferDetailsReportTests.swift | 41 +++++++++++++++++ .../TransferLibraryPresentationTests.swift | 44 +++++++++++++++++-- CHANGELOG.md | 1 + .../BitMatchEngine/TransferJournal.swift | 14 +++--- .../QueueAdmissionTests.swift | 3 +- .../Models/TransferLibraryPresentation.swift | 5 ++- .../Services/TransferHistoryDocument.swift | 13 ++++-- Shared/Views/ReportView.swift | 2 +- .../Views/TransferPresentationAdapters.swift | 15 ++++++- docs/GUIDE.md | 4 +- 13 files changed, 129 insertions(+), 19 deletions(-) diff --git a/BitMatch-iPadTests/TransferDetailsReportTests.swift b/BitMatch-iPadTests/TransferDetailsReportTests.swift index 6cd951cf..7592840c 100644 --- a/BitMatch-iPadTests/TransferDetailsReportTests.swift +++ b/BitMatch-iPadTests/TransferDetailsReportTests.swift @@ -28,10 +28,13 @@ struct TransferDetailsReportTests { #expect(scope["recordedSelectedResults"] as? Int == 1) #expect(scope["configuredChecks"] == nil) #expect(object["selectedFilePlanRecorded"] as? Bool == false) + #expect(object["savedVerdict"] as? String == "Needs attention") + #expect(object["savedSafeToErase"] as? Bool == false) let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) #expect(csv.contains("\"operatorName\",\"Alex, \"\"DIT\"\"\"")) #expect(csv.contains(record.reportSettings.notes)) #expect(csv.contains("Original selected-file plan was not saved; export describes recorded results only")) + #expect(csv.contains("\"savedSafeToErase\",\"false\"")) let pdfData = try await ReportExporter.historyPDF(record: record, includeThumbnails: false) let pdf = try #require(PDFDocument(data: pdfData)) if let path = ProcessInfo.processInfo.environment["BITMATCH_WORKFLOW_SNAPSHOT_OUTPUT_DIR"] diff --git a/BitMatchTests/LocalTransferJournalTests.swift b/BitMatchTests/LocalTransferJournalTests.swift index 5b4c951d..1ecb7c54 100644 --- a/BitMatchTests/LocalTransferJournalTests.swift +++ b/BitMatchTests/LocalTransferJournalTests.swift @@ -16,6 +16,7 @@ struct LocalTransferJournalTests { cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs() ) try journal.markRunning(id: verifiedID) + try journal.recordPlan(id: verifiedID, fileCounts: [1]) try journal.finish( id: verifiedID, results: [ResultRow( diff --git a/BitMatchTests/ReportPDFLayoutTests.swift b/BitMatchTests/ReportPDFLayoutTests.swift index 9a796af8..15c64202 100644 --- a/BitMatchTests/ReportPDFLayoutTests.swift +++ b/BitMatchTests/ReportPDFLayoutTests.swift @@ -19,7 +19,7 @@ struct ReportPDFLayoutTests { #expect(ReportView.unsafeReportReason(safetyState: .failed, photographyJob: nil) == "The card is not safe to erase because the transfer failed.") #expect(ReportView.unsafeReportReason(safetyState: .needsAttention, photographyJob: nil) - == "The card is not safe to erase because one or more files need attention.") + == "These results do not establish a complete verified backup. Do not erase the card.") #expect(!ReportView.shouldShowSuccessBadge(safetyState: .failed, photographyJob: nil)) #expect(!ReportView.shouldShowSuccessBadge(safetyState: .needsAttention, photographyJob: nil)) } diff --git a/BitMatchTests/TransferDetailsReportTests.swift b/BitMatchTests/TransferDetailsReportTests.swift index f0213226..59f3b104 100644 --- a/BitMatchTests/TransferDetailsReportTests.swift +++ b/BitMatchTests/TransferDetailsReportTests.swift @@ -103,8 +103,49 @@ struct TransferDetailsReportTests { #expect(!text.contains("VERIFICATION SUCCESSFUL")) let json = try #require(JSONSerialization.jsonObject(with: TransferHistoryDocument(record: record, asCSV: false).data) as? [String: Any]) #expect(json["summary"] as? String == "Selected files verified") + #expect(json["savedVerdict"] as? String == "Needs attention") + #expect(json["savedSafeToErase"] as? Bool == false) let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) #expect(csv.contains("Selected files verified")) + #expect(csv.contains("\"savedSafeToErase\",\"false\"")) + } + + @Test func missingSavedPlanCannotTurnVerifiedRowsIntoWholeCardSafety() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("legacy-plan-report-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + 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 = .completed + record.summary = "Selected files verified" + record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, + size: 5, checksum: "fixture", destination: "Backup", + destinationPath: root.appendingPathComponent("clip.mov").path)] + + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) + let pdf = try #require(PDFDocument(data: try await ReportExporter.historyPDF(record: record, includeThumbnails: false))) + let text = try #require(pdf.string) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(text.contains("Original selected-file plan was not saved")) + #expect(!text.contains("Selected files verified")) + #expect(!text.contains("VERIFICATION SUCCESSFUL")) + + let json = try #require(JSONSerialization.jsonObject(with: TransferHistoryDocument(record: record, asCSV: false).data) as? [String: Any]) + #expect(json["summary"] as? String == "Selected files verified") + #expect(json["savedVerdict"] as? String == "Needs attention") + #expect(json["savedSafeToErase"] as? Bool == false) + let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) + #expect(csv.contains("\"Selected files verified\"")) + #expect(csv.contains("\"savedVerdict\",\"Needs attention\"")) + #expect(csv.contains("\"savedSafeToErase\",\"false\"")) + + record.plannedFileCounts = [1] + #expect(TransferLibraryPresentation.safetyState(for: record) == .safeToErase) + let coveredJSON = try #require(JSONSerialization.jsonObject(with: TransferHistoryDocument(record: record, asCSV: false).data) as? [String: Any]) + #expect(coveredJSON["savedSafeToErase"] as? Bool == true) + let coveredCSV = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) + #expect(coveredCSV.contains("\"savedSafeToErase\",\"true\"")) } @Test func quickHistoryRowLabelDoesNotRewriteExportedEvidence() throws { diff --git a/BitMatchTests/TransferLibraryPresentationTests.swift b/BitMatchTests/TransferLibraryPresentationTests.swift index e6947985..15b35f3d 100644 --- a/BitMatchTests/TransferLibraryPresentationTests.swift +++ b/BitMatchTests/TransferLibraryPresentationTests.swift @@ -62,6 +62,19 @@ struct TransferLibraryPresentationTests { #expect(TransferLibraryPresentation.stateLabel(for: record).tint != .green) } + @Test @MainActor func quickCopiedOnlyHistoryNeedsItsOriginalSelectedPlan() { + var record = PresentationTestSupport.record(state: .issues, verificationMode: .quick) + record.results = [PresentationTestSupport.row(.copiedUnverified, destination: record.destinations[0].url)] + record.quickOnlyUnverified = true + #expect(TransferLibraryPresentation.safetyState(for: record) == .copiedNotVerified) + + record.plannedFileCounts = nil + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) + #expect(TransferLibraryPresentation.stateLabel(for: record).tint != .green) + #expect(TransferOutcomePresentation.recordedCompletionReason(for: record).contains("selected-file plan was not saved")) + #expect(record.results.count == 1) + } + /// Only a completed, checksum-verified transfer is green. /// Plant: in `TransferLibraryPresentation.stateLabel`, change the /// `.issues` label's `tone: .warning` to `tone: .verified`. @@ -92,9 +105,10 @@ struct TransferLibraryPresentationTests { #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) #expect(TransferLibraryPresentation.stateLabel(for: record).tint != .green) - // Old journals have no recorded plan; preserve their existing rule. + // An older journal can show verified rows without proving that they + // covered the selected source set. record.plannedFileCounts = nil - #expect(TransferLibraryPresentation.safetyState(for: record) == .safeToErase) + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) } @Test func duplicateVerifiedPathCannotHideMissingPlannedFile() { @@ -139,6 +153,29 @@ struct TransferLibraryPresentationTests { #expect(TransferOutcomePresentation.recordedState(for: record) == .failed) } + @Test @MainActor func legacyCompletedFinishDoesNotRepeatAnUnprovableSavedSuccess() throws { + var record = PresentationTestSupport.record(state: .completed) + record.plannedFileCounts = nil + record.summary = "Selected files verified" + record.results = [PresentationTestSupport.row(.verified, destination: record.destinations[0].url)] + + let reason = TransferOutcomePresentation.recordedCompletionReason(for: record) + let state = try #require(TransferOutcomePresentation.recordedState(for: record)) + let finish = TransferOutcomePresentation.make( + state: state, rows: record.results, destinations: record.destinations.map(\.url), + hasErrors: false, hasCriticalErrors: false, errorCount: 0, warningCount: 0, + duration: nil, verificationMode: record.verificationMode, + canRetry: true, canExport: true, sourceName: record.title, + completionReason: reason + ) + + #expect(finish.safetyState == .needsAttention) + #expect(finish.visibleVerdictText.contains("Original selected-file plan was not saved")) + #expect(finish.visibleVerdictText.contains("Keep the source")) + #expect(!finish.visibleVerdictText.contains("Selected files verified")) + #expect(record.summary == "Selected files verified") + } + @Test func completedHistoryRequiresEveryPlannedSelectedFileOncePerDestination() { var record = PresentationTestSupport.record(state: .completed) let destination = record.destinations[0].url @@ -165,7 +202,7 @@ struct TransferLibraryPresentationTests { record.plannedFileCounts = nil record.results = [first] #expect(TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == nil) - #expect(TransferLibraryPresentation.safetyState(for: record) == .safeToErase) + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) } @Test func plannedSelectedCountDoesNotIncludeAppleDoubleExclusions() { @@ -476,6 +513,7 @@ private enum PresentationTestSupport { reportSettings: ReportPrefs(), generateASCMHL: true, projectID: nil ) record.state = state + record.plannedFileCounts = [1] return record } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 57af3647..46d4d75f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Older History entries without a saved selected-file plan now stay visible but do not claim the whole card is safe to erase, even if their saved rows say verified. They also no longer trigger the “Backed up before…” hint. Quick entries without that plan show Needs attention instead of a clean size-only copy. History exports keep the original message and add the saved safety verdict. - Mac: The menu bar shows the current transfer and its destinations, or the most recent result when idle. The idle icon is a small copy-and-check mark. Closing the main window during a transfer hides it while the work continues; Open BitMatch brings the same window back. Quit still asks before stopping active work. - Expanded transfer details show bytes written and read back for each destination, with separate live rates. These are measurements of active file I/O, not a drive-health or verification verdict. - Optional local Apple crash and hang summaries can be added to a diagnostics export. This is off by default, never uploaded by BitMatch, and Apple may not deliver a report. diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift index 3e9ee2f2..cf9c4091 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift @@ -638,7 +638,8 @@ public final class TransferJournal: Sendable { // A current transfer records the selected manifest size before // copying. A shorter or duplicated result set is incomplete even // if every retained row says it verified. Legacy nil-plan records - // keep their historical rule. + // keep their saved state for provenance; History and prior-backup + // hints cannot infer whole-card coverage from them. let incompletePlan = !Self.hasCompletePlannedResults(record) // A complete plan can still contain copied-but-unverified rows. // Only Quick mode intentionally accepts that outcome. @@ -695,12 +696,11 @@ public final class TransferJournal: Sendable { groups.reduce(0, { $0 + $1.count }) == record.results.count, let expectedPaths = groups.first.map({ Set($0.map(\.path)) }), !expectedPaths.isEmpty else { return false } - if let plan = record.plannedFileCounts { - guard plan.count == groups.count, - zip(plan, groups).allSatisfy({ expected, rows in - expected > 0 && Set(rows.map(\.path)).count == expected - }) else { return false } - } + guard let plan = record.plannedFileCounts, + plan.count == groups.count, + zip(plan, groups).allSatisfy({ expected, rows in + expected > 0 && Set(rows.map(\.path)).count == expected + }) else { return false } return groups.allSatisfy { Set($0.map(\.path)) == expectedPaths } } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift index 4b0f4a07..b62c6a80 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift @@ -122,6 +122,7 @@ struct QueueAdmissionTests { try complete(plan: [2], results: [row("a.mov"), row("b.mov")], fingerprint: "complete") #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "complete") != nil) try complete(plan: nil, results: [row("a.mov")], fingerprint: "legacy") - #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "legacy") != nil) + #expect(f.journal.records.first(where: { $0.sourceFingerprint == "legacy" })?.state == .completed) + #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "legacy") == nil) } } diff --git a/Shared/Core/Models/TransferLibraryPresentation.swift b/Shared/Core/Models/TransferLibraryPresentation.swift index ab9249a1..cbfe8f43 100644 --- a/Shared/Core/Models/TransferLibraryPresentation.swift +++ b/Shared/Core/Models/TransferLibraryPresentation.swift @@ -231,7 +231,10 @@ enum TransferLibraryPresentation { }), let expectedPaths = summaries.first.map({ Set($0.rows.map(\.path)) }), !expectedPaths.isEmpty else { return false } - if record.plannedFileCounts != nil, savedResultsCoverSelectedPlan(record) != true { return false } + // Without the original selected-file plan, matching saved rows cannot + // establish that every source file was represented. Keep older + // completed records visible, but do not infer whole-card safety. + guard savedResultsCoverSelectedPlan(record) == true else { return false } return summaries.allSatisfy { Set($0.rows.map(\.path)) == expectedPaths } } diff --git a/Shared/Core/Services/TransferHistoryDocument.swift b/Shared/Core/Services/TransferHistoryDocument.swift index bc57e83c..ba4194f9 100644 --- a/Shared/Core/Services/TransferHistoryDocument.swift +++ b/Shared/Core/Services/TransferHistoryDocument.swift @@ -16,16 +16,18 @@ struct TransferHistoryDocument: FileDocument { init(record: LocalTransferRecord, asCSV: Bool) throws { let scope = VerificationScopeEvidence(selectedMode: record.verificationMode, results: record.results, includeModePlan: record.plannedFileCounts != nil) + let savedVerdict = TransferLibraryPresentation.safetyState(for: record) if asCSV { func quote(_ value: String) -> String { "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" } - let header = "source,destination,status,bytes,checksum,verification,transfer_state,transfer_summary,project,asc_mhl,client,reused,recorded_status\n" + let header = "source,destination,status,bytes,checksum,verification,transfer_state,transfer_summary,project,asc_mhl,client,reused,recorded_status,saved_verdict,saved_safe_to_erase\n" let rows = record.results.map { row in [row.path, row.destinationPath ?? row.destination ?? "", TransferHistoryResultPresentation.statusText(for: row, mode: record.verificationMode), String(row.size), row.checksum ?? "", record.verificationMode.rawValue, record.state.rawValue, record.summary, record.reportSettings.projectName, record.generateASCMHL ? "requested" : "not requested", - record.reportSettings.clientName, row.wasReused ? "true" : "false", row.status].map(quote).joined(separator: ",") + record.reportSettings.clientName, row.wasReused ? "true" : "false", row.status, + savedVerdict.title, savedVerdict.isSafe ? "true" : "false"].map(quote).joined(separator: ",") } func pair(_ key: String, _ value: String) -> String { quote(key) + "," + quote(value) } func detail(_ value: String) -> String { @@ -39,13 +41,15 @@ struct TransferHistoryDocument: FileDocument { pair("retryOf", record.retryOf?.uuidString ?? ""), pair("plannedFileCounts", record.plannedFileCounts?.map(String.init).joined(separator: ";") ?? ""), pair("selectedMode", scope.selectedMode.rawValue), + pair("savedVerdict", savedVerdict.title), + pair("savedSafeToErase", savedVerdict.isSafe ? "true" : "false"), pair("selectedModeChecks", scope.configuredChecks ?? "Not recorded for this attempt"), pair("recordedSelectedSourceFiles", String(scope.recordedSelectedSourceFiles)), pair("recordedSelectedBackupResults", String(scope.recordedSelectedResults)), pair("recordedVerifiedBackupResults", String(scope.recordedVerifiedResults)), pair("recordedExcludedSourceFiles", String(scope.recordedExcludedSourceFiles)), pair("evidenceScope", record.plannedFileCounts == nil - ? "Original selected-file plan was not saved; export describes recorded results only" + ? "Original selected-file plan was not saved; export describes recorded results only and cannot establish whole-card safety" : "Saved results only; selected mode does not prove every check finished"), pair("operatorName", detail(record.reportSettings.operatorName)), pair("notes", detail(record.reportSettings.notes)), @@ -72,6 +76,8 @@ struct TransferHistoryDocument: FileDocument { let summary: String let createdAt: Date let verificationMode: VerificationMode + let savedVerdict: String + let savedSafeToErase: Bool let verificationScope: VerificationScopeEvidence let selectedFilePlanRecorded: Bool let projectName: String @@ -90,6 +96,7 @@ struct TransferHistoryDocument: FileDocument { data = try encoder.encode(Report(id: record.id, retryOf: record.retryOf, plannedFileCounts: record.plannedFileCounts, source: record.source.url.path, destinations: record.destinations.map { $0.url.path }, state: record.state, summary: record.summary, createdAt: record.createdAt, verificationMode: record.verificationMode, + savedVerdict: savedVerdict.title, savedSafeToErase: savedVerdict.isSafe, verificationScope: scope, selectedFilePlanRecorded: record.plannedFileCounts != nil, projectName: record.reportSettings.projectName, clientName: record.reportSettings.clientName, operatorName: EvidenceWriter.normalizedNotes(record.reportSettings.operatorName), diff --git a/Shared/Views/ReportView.swift b/Shared/Views/ReportView.swift index fcc5e87e..57fcc87d 100644 --- a/Shared/Views/ReportView.swift +++ b/Shared/Views/ReportView.swift @@ -940,7 +940,7 @@ struct ReportView: View { case .copiedNotVerified: return "The card is not safe to erase because Quick mode copied the files without verifying their contents." case .needsAttention: - return "The card is not safe to erase because one or more files need attention." + return "These results do not establish a complete verified backup. Do not erase the card." case .failed: return "The card is not safe to erase because the transfer failed." case .interrupted: diff --git a/Shared/Views/TransferPresentationAdapters.swift b/Shared/Views/TransferPresentationAdapters.swift index 56c66949..35139d65 100644 --- a/Shared/Views/TransferPresentationAdapters.swift +++ b/Shared/Views/TransferPresentationAdapters.swift @@ -79,7 +79,8 @@ extension TransferOutcomePresentation { canRetry: isFinished && record?.canRetry == true, canExport: isFinished, sourceName: record?.title ?? coordinator.sourceURL?.lastPathComponent ?? "", - completionReason: record?.summary ?? (coordinator.operationState == .failed ? coordinator.queueMessage : nil), + completionReason: record.map { recordedCompletionReason(for: $0) } + ?? (coordinator.operationState == .failed ? coordinator.queueMessage : nil), independentDestinationCount: record?.independentDestinationCount ) } @@ -105,4 +106,16 @@ extension TransferOutcomePresentation { return .inProgress } } + + /// Keep the journal's original message in History and exports, but do not + /// repeat a legacy success claim on Finish when its selected-file plan is + /// missing and the shared saved verdict has been downgraded. + @MainActor + static func recordedCompletionReason(for record: LocalTransferRecord) -> String { + if record.plannedFileCounts == nil, + (record.state == .completed || record.quickOnlyUnverified == true) { + return "Original selected-file plan was not saved; whole-card coverage cannot be confirmed. Keep the source." + } + return record.summary + } } diff --git a/docs/GUIDE.md b/docs/GUIDE.md index d3fbb7c3..e20ef8d5 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -25,6 +25,8 @@ For new copies, Standard verifies the backup against the bytes obtained during t 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. +Older History entries may not include the original selected-file plan. Their saved rows can still show which files were verified, but they cannot establish whole-card coverage or a safe-to-erase verdict. Keep the source and run a new verified transfer if you need that verdict. + “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. @@ -97,7 +99,7 @@ You can ignore SFTP entirely and keep everything local. No app analytics uploads BitMatch reads the source without writing to it. It copies through temporary files and doesn't overwrite conflicting destination files. Checksum modes reuse an existing file only after verifying that it matches. -Source scanning rejects unreadable metadata, unsafe paths, and portable filename collisions. Hidden files and empty folders are preserved, except for designated macOS metadata folders at the volume root; symlink entries are skipped. Verification rejects files that change size or identity while being read. Completion screens and reports count failures and sidecars too. +Source scanning rejects unreadable metadata, unsafe paths, and portable filename collisions. Apart from the AppleDouble setting and designated macOS metadata folders at a volume root, hidden files and empty folders are preserved; symlink entries are skipped. Verification rejects files that change size or identity while being read. Completion screens and reports count failures and sidecars too. **This is beta software from a one-person project.** I have not tested every camera, drive, filesystem, hub, or OS combination. Try it with disposable files before using it on a job. Keep the source card until every required backup finishes cleanly and you've checked the report. Keep another independent copy of anything you can't replace. From 8bb65fa0e036bdb39182291b96bde6b55f291ef7 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 11:18:42 -0400 Subject: [PATCH 2/5] Clarify incomplete saved transfer evidence on Finish --- BitMatch-iPadTests/BitMatch_iPadTests.swift | 2 +- BitMatchTests/ResultPresentationTests.swift | 20 +++++++++++++++++++ .../TransferLibraryPresentationTests.swift | 2 ++ .../CompletionVerdictPresentation.swift | 8 +++++--- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/BitMatch-iPadTests/BitMatch_iPadTests.swift b/BitMatch-iPadTests/BitMatch_iPadTests.swift index 17facd9b..e5e1fcfd 100644 --- a/BitMatch-iPadTests/BitMatch_iPadTests.swift +++ b/BitMatch-iPadTests/BitMatch_iPadTests.swift @@ -168,7 +168,7 @@ struct BitMatch_iPadTests { #expect(success.title == "The card is safe to erase") #expect(issues.title == "The card needs attention") - #expect(issues.sourceGuidance == "Do not erase the card until every file below is resolved.") + #expect(issues.sourceGuidance == "Do not erase the card. These results don't establish a complete verified backup.") } } diff --git a/BitMatchTests/ResultPresentationTests.swift b/BitMatchTests/ResultPresentationTests.swift index 7aed119a..7186408c 100644 --- a/BitMatchTests/ResultPresentationTests.swift +++ b/BitMatchTests/ResultPresentationTests.swift @@ -180,6 +180,26 @@ final class ResultPresentationTests: XCTestCase { XCTAssertEqual(presentation.sourceGuidance, "Do not erase the card. The transfer did not finish safely.") } + func testNeedsAttentionWithoutFailedRowsDoesNotClaimZeroFilesHadProblems() { + let presentation = CompletionVerdictPresentation.make(.issues, issueCount: 0) + + XCTAssertEqual(presentation.detail, "The saved results don't confirm the full transfer. Review them below.") + XCTAssertEqual( + presentation.sourceGuidance, + "Do not erase the card. These results don't establish a complete verified backup." + ) + } + + func testNeedsAttentionWithFailedRowsKeepsTheActualCount() { + let presentation = CompletionVerdictPresentation.make(.issues, issueCount: 2) + + XCTAssertEqual(presentation.detail, "2 files had problems. Review them below.") + XCTAssertEqual( + presentation.sourceGuidance, + "Do not erase the card. These results don't establish a complete verified backup." + ) + } + func testVisibleRowsCapsMoreThanOneThousandIssuesWithoutTrapping() { let rows = (0..<1_001).map { row("\($0).mov", status: "❌ Failed") } diff --git a/BitMatchTests/TransferLibraryPresentationTests.swift b/BitMatchTests/TransferLibraryPresentationTests.swift index 15b35f3d..e99ed798 100644 --- a/BitMatchTests/TransferLibraryPresentationTests.swift +++ b/BitMatchTests/TransferLibraryPresentationTests.swift @@ -173,6 +173,8 @@ struct TransferLibraryPresentationTests { #expect(finish.visibleVerdictText.contains("Original selected-file plan was not saved")) #expect(finish.visibleVerdictText.contains("Keep the source")) #expect(!finish.visibleVerdictText.contains("Selected files verified")) + #expect(!finish.visibleVerdictText.contains("0 files had problems")) + #expect(finish.guidance == "Do not erase the card. These results don't establish a complete verified backup.") #expect(record.summary == "Selected files verified") } diff --git a/Shared/Core/Models/CompletionVerdictPresentation.swift b/Shared/Core/Models/CompletionVerdictPresentation.swift index 18920f7d..a12996bf 100644 --- a/Shared/Core/Models/CompletionVerdictPresentation.swift +++ b/Shared/Core/Models/CompletionVerdictPresentation.swift @@ -46,9 +46,11 @@ struct CompletionVerdictPresentation: Equatable, Sendable { let files = issueCount == 1 ? "1 file" : "\(issueCount) files" return Self( title: safetyState.headline(cardName: cardName), - detail: "\(files) had problems. Review them below.", + detail: issueCount == 0 + ? "The saved results don't confirm the full transfer. Review them below." + : "\(files) had problems. Review them below.", symbol: safetyState.symbol, - sourceGuidance: "Do not erase \(card) until every file below is resolved." + sourceGuidance: "Do not erase \(card). These results don't establish a complete verified backup." ) case .failed: return Self( @@ -99,7 +101,7 @@ struct CompletionVerdictPresentation: Equatable, Sendable { title: presentation.title, detail: info.message, symbol: presentation.symbol, - sourceGuidance: "Do not erase \(card) until the problems are resolved." + sourceGuidance: presentation.sourceGuidance ) } return presentation From a429dd6a09dac2ff34a0947583772c222fc014a8 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 11:58:23 -0400 Subject: [PATCH 3/5] Keep transfer outcomes tied to saved evidence --- BitMatch/App/DockTile.swift | 39 +++++++- BitMatch/App/MacMenuBarCompanion.swift | 18 ++-- .../CopyVerifyExecutorIntegrityTests.swift | 25 +++++ BitMatchTests/DockTileStateTests.swift | 26 +++++ BitMatchTests/LiveResultsFeedTests.swift | 1 + BitMatchTests/LocalTransferJournalTests.swift | 8 +- .../LocalTransferQueueIntegrationTests.swift | 99 +++++++++++++++++++ BitMatchTests/MacMenuBarCompanionTests.swift | 33 +++++-- .../QueueSessionPresentationTests.swift | 25 +++++ BitMatchTests/SetupPresentationTests.swift | 1 + .../SharedCoordinatorTestSupport.swift | 28 +++++- BitMatchTests/TransferFinishNoticeTests.swift | 38 +++++-- .../TransferLibraryPresentationTests.swift | 50 ++++++++++ .../TransferOutcomePresentationTests.swift | 1 + CHANGELOG.md | 1 + .../BitMatchEngine/TransferJournal.swift | 12 ++- .../QueueAdmissionTests.swift | 62 +++++++++++- .../BitMatchEngineTests/StressTests.swift | 2 + .../Models/QueueSessionPresentation.swift | 12 ++- Shared/Core/Models/TransferFinishNotice.swift | 15 ++- Shared/Core/Services/CopyVerifyExecutor.swift | 5 +- .../Core/Services/LocalTransferJournal.swift | 10 +- .../Core/Services/SharedAppCoordinator.swift | 99 ++++++++++++++----- .../Views/TransferPresentationAdapters.swift | 24 +++-- 24 files changed, 549 insertions(+), 85 deletions(-) diff --git a/BitMatch/App/DockTile.swift b/BitMatch/App/DockTile.swift index 00fc4890..33c909e7 100644 --- a/BitMatch/App/DockTile.swift +++ b/BitMatch/App/DockTile.swift @@ -39,6 +39,26 @@ enum DockTileState: Equatable { return .needsReview } } + + /// A live executor can finish before its journal does. Use the saved + /// attempt before lighting the Dock green for a transfer. + static func makeTransfer( + state: OperationState, + record: LocalTransferRecord?, + fraction: Double? + ) -> DockTileState { + guard case .completed = state else { return make(state: state, fraction: fraction) } + guard let record else { return .needsReview } + switch TransferLibraryPresentation.safetyState(for: record) { + case .safeToErase: return .verified + case .failed: return .failed + case .waiting, .preparing, .copying, .verifying, .finishing: + let percent = Int((min(max(fraction ?? 0, 0), 0.99) * 100).rounded(.down)) + return .running(percent: percent) + case .copiedNotVerified, .needsAttention, .interrupted: + return .needsReview + } + } } /// Keeps `NSApp.dockTile` in step with the coordinator. @@ -53,18 +73,31 @@ final class DockTileController { coordinator.operationStatePublisher .combineLatest(coordinator.liveProgress.$progress) .throttle(for: .milliseconds(500), scheduler: RunLoop.main, latest: true) - .sink { [weak self] state, progress in - self?.show(DockTileState.make(state: state, fraction: progress?.overallProgress)) + .sink { [weak self] _, _ in + self?.refresh() } .store(in: &cancellables) coordinator.objectWillChange .receive(on: RunLoop.main) .sink { [weak self] _ in - DispatchQueue.main.async { self?.updateAttentionBadge() } + DispatchQueue.main.async { self?.refresh() } } .store(in: &cancellables) } + private func refresh() { + let state = coordinator.operationState + let fraction = coordinator.liveProgress.progress?.overallProgress + if coordinator.currentMode == .copyAndVerify { + let record = coordinator.activeRunContext?.journalRecordID.flatMap { id in + coordinator.transferJournal.records.first(where: { $0.id == id }) + } + show(DockTileState.makeTransfer(state: state, record: record, fraction: fraction)) + } else { + show(DockTileState.make(state: state, fraction: fraction)) + } + } + private func show(_ state: DockTileState) { updateAttentionBadge() guard state != shown else { return } diff --git a/BitMatch/App/MacMenuBarCompanion.swift b/BitMatch/App/MacMenuBarCompanion.swift index 4f2d5a13..df3907bf 100644 --- a/BitMatch/App/MacMenuBarCompanion.swift +++ b/BitMatch/App/MacMenuBarCompanion.swift @@ -210,18 +210,20 @@ struct MenuBarRecentTransfer: Equatable { .max(by: { ($0.endedAt ?? $0.createdAt) < ($1.endedAt ?? $1.createdAt) }) else { return nil } - guard let result = status(for: record.state) else { return nil } + guard let result = status(for: record) else { return nil } return Self(cardName: record.title, status: result.text, needsAttention: result.needsAttention) } - static func status(for state: LocalTransferState) -> (text: String, needsAttention: Bool)? { - switch state { - case .completed: ("Completed · review results in BitMatch", false) - case .issues: ("Needs review", true) + static func status(for record: LocalTransferRecord) -> (text: String, needsAttention: Bool)? { + guard record.state != .queued, record.state != .running else { return nil } + return switch TransferLibraryPresentation.safetyState(for: record) { + case .safeToErase: ("Completed · review results in BitMatch", false) + case .copiedNotVerified: ("Copied, not verified", true) + case .needsAttention: ("Needs review", true) case .failed: ("Transfer failed", true) - case .cancelled: ("Transfer cancelled", true) - case .interrupted: ("Transfer interrupted", true) - case .queued, .running: nil + case .interrupted: + (record.state == .cancelled ? "Transfer cancelled" : "Transfer interrupted", true) + case .waiting, .preparing, .copying, .verifying, .finishing: nil } } } diff --git a/BitMatchTests/CopyVerifyExecutorIntegrityTests.swift b/BitMatchTests/CopyVerifyExecutorIntegrityTests.swift index b329b28e..815b7b22 100644 --- a/BitMatchTests/CopyVerifyExecutorIntegrityTests.swift +++ b/BitMatchTests/CopyVerifyExecutorIntegrityTests.swift @@ -265,6 +265,31 @@ final class CopyVerifyExecutorIntegrityTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.appendingPathComponent("Reports").path)) } + func testMissingSavedPlanStopsAutomaticReportBeforeItCanClaimCompletion() async throws { + let fixture = try reportFixture(blockReportsFolder: false) + var projectFinalizations = 0 + let harness = ExecutorHarness(returnedResults: [fixture.result], emittedResults: [], + lifecycleCompletion: { _ in + projectFinalizations += 1 + return PhotographerFinalizationResult(context: nil, locallySafe: true) + }, + sourceURL: fixture.source, destinationURLs: [fixture.destination], + generateASCMHL: true, makeReport: true) + harness.onAuthoritativeResults = { throw TransferPlanPersistenceError.missingSavedPlan } + + do { + _ = try await harness.execute() + XCTFail("A missing saved plan must interrupt report publication") + } catch is TransferPlanPersistenceError { } + + XCTAssertEqual(harness.completedRows.count, 1) + XCTAssertNil(harness.terminalInfo) + XCTAssertEqual(harness.lastState, .failed) + XCTAssertEqual(projectFinalizations, 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.appendingPathComponent("ascmhl").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.appendingPathComponent("Reports").path)) + } + func testUnexpectedCancellationErrorIsNotAnIntentionalCancellation() async throws { let harness = ExecutorHarness(returnedResults: [], emittedResults: [], thrownError: CancellationError()) do { _ = try await harness.execute(); XCTFail("Must propagate interruption") } diff --git a/BitMatchTests/DockTileStateTests.swift b/BitMatchTests/DockTileStateTests.swift index 35488467..6c508a9e 100644 --- a/BitMatchTests/DockTileStateTests.swift +++ b/BitMatchTests/DockTileStateTests.swift @@ -1,4 +1,5 @@ // DockTileStateTests.swift +import Foundation import Testing @testable import BitMatch import BitMatchEngine @@ -19,4 +20,29 @@ struct DockTileStateTests { #expect(DockTileState.make(state: .notStarted, fraction: 0.5) == .appIcon) #expect(DockTileState.make(state: .cancelled, fraction: 0.5) == .needsReview) } + + @Test func executorSuccessWaitsForSavedCoverageBeforeDockTurnsGreen() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("dock-plan-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("Card") + let destination = root.appendingPathComponent("Backup") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), + source: try LocalTransferResource(url: source), + destinations: [try LocalTransferResource(url: destination)], + verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.results = [ResultRow(path: source.appendingPathComponent("clip.mov").path, + status: ResultOutcome.verified.statusText, size: 1, checksum: "abc", destination: "Backup", + destinationPath: destination.appendingPathComponent("clip.mov").path)] + let engineSuccess = OperationState.completed(.init(success: true, message: "Selected files verified")) + + record.state = .running + #expect(DockTileState.makeTransfer(state: engineSuccess, record: record, fraction: 1) != .verified) + record.state = .completed + #expect(DockTileState.makeTransfer(state: engineSuccess, record: record, fraction: 1) == .needsReview) + record.plannedFileCounts = [1] + #expect(DockTileState.makeTransfer(state: engineSuccess, record: record, fraction: 1) == .verified) + } } diff --git a/BitMatchTests/LiveResultsFeedTests.swift b/BitMatchTests/LiveResultsFeedTests.swift index 46324248..64ada5a8 100644 --- a/BitMatchTests/LiveResultsFeedTests.swift +++ b/BitMatchTests/LiveResultsFeedTests.swift @@ -215,6 +215,7 @@ private final class StreamingFileOperations: FileOperationsService, @unchecked S progressCallback: @escaping ProgressCallback, onFileResult: FileResultCallback? ) async throws -> FileOperation { + progressCallback(testPlannedProgress(filesPerDestination: files, destinationCount: destinationURLs.count)) func result(_ index: Int, _ destination: URL, success: Bool) -> FileOperationResult { FileOperationResult( sourceURL: sourceURL.appendingPathComponent("\(index).ARW"), diff --git a/BitMatchTests/LocalTransferJournalTests.swift b/BitMatchTests/LocalTransferJournalTests.swift index 1ecb7c54..27326534 100644 --- a/BitMatchTests/LocalTransferJournalTests.swift +++ b/BitMatchTests/LocalTransferJournalTests.swift @@ -550,7 +550,9 @@ struct LocalTransferJournalTests { let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination], verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(), generateASCMHL: true) try journal.markRunning(id: id) - let row = ResultRow(path: "clip.mov", status: "✅ Verified", size: 1, checksum: "abc", destination: "Backup") + try journal.recordPlan(id: id, fileCounts: [1]) + let row = ResultRow(path: "clip.mov", status: "✅ Verified", size: 1, checksum: "abc", + destination: "Backup", destinationPath: f.destination.appendingPathComponent("clip.mov").path) try journal.finish(id: id, results: [row], summary: "ASC MHL history already exists", hadIssues: true) let copyOnlyRetryID = try journal.requeue(id: id, generateASCMHL: false) @@ -694,7 +696,9 @@ struct LocalTransferJournalTests { let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination], verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: reportSettings, generateASCMHL: true) try journal.markRunning(id: id) - try journal.finish(id: id, results: [ResultRow(path: "clip.mov", status: "✅ Verified", size: 1, checksum: "abc", destination: "Backup")], + try journal.recordPlan(id: id, fileCounts: [1]) + try journal.finish(id: id, results: [ResultRow(path: "clip.mov", status: "✅ Verified", size: 1, checksum: "abc", + destination: "Backup", destinationPath: f.destination.appendingPathComponent("clip.mov").path)], summary: "Done", hadIssues: false) let record = try #require(journal.records.first) diff --git a/BitMatchTests/LocalTransferQueueIntegrationTests.swift b/BitMatchTests/LocalTransferQueueIntegrationTests.swift index 51d84732..a1a5e92f 100644 --- a/BitMatchTests/LocalTransferQueueIntegrationTests.swift +++ b/BitMatchTests/LocalTransferQueueIntegrationTests.swift @@ -2,12 +2,106 @@ import AppKit import Foundation import XCTest import CryptoKit +import Combine import Testing @testable import BitMatch import BitMatchEngine @MainActor final class LocalTransferQueueIntegrationTests: XCTestCase { + func testReviewLegacyCompletedRecordUsesDerivedCautionWithoutChangingSavedSummary() throws { + let f = try QueueFixture() + defer { f.cleanup() } + var oldRecord = LocalTransferRecord( + id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: f.source), + destinations: [try LocalTransferResource(url: f.destination)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(makeReport: false) + ) + oldRecord.state = .completed + oldRecord.summary = "Selected files verified" + oldRecord.results = [ResultRow( + path: f.source.appendingPathComponent("clip.mov").path, + status: ResultOutcome.verified.statusText, size: 4, checksum: "fixture", + destination: f.destination.lastPathComponent, + destinationPath: f.destination.appendingPathComponent("clip.mov").path + )] + try JSONEncoder().encode([oldRecord]).write(to: f.journalURL) + let journal = LocalTransferJournal(fileURL: f.journalURL) + let coordinator = SharedAppCoordinator( + platformManager: QueuePlatformManager(fileOperations: QueueRecordingOperations()), + transferJournal: journal, defaults: f.defaults + ) + + coordinator.reviewQueuedTransfer(oldRecord.id) + + guard case .completed(let info) = coordinator.operationState else { + return XCTFail("Expected a review outcome") + } + XCTAssertFalse(info.success) + XCTAssertTrue(info.message.contains("Original selected-file plan was not saved")) + XCTAssertFalse(info.message.contains("Selected files verified")) + XCTAssertEqual(coordinator.outcomeRecord?.summary, "Selected files verified") + } + + func testFailedPlanWriteBlocksAutomaticReportHandoff() async throws { + let f = try QueueFixture() + defer { f.cleanup() } + let journal = LocalTransferJournal(fileURL: f.journalURL, beforeRecordPlan: { _ in + throw NSError(domain: "BitMatchTestPlanWrite", code: 78) + }) + let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination], + verificationMode: .standard, cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(makeReport: true), generateASCMHL: false) + let operations = TransferPipeline(fileSystem: MacOSFileSystemService.shared, checksum: ChecksumEngine.shared) + let coordinator = SharedAppCoordinator(platformManager: QueuePlatformManager(fileOperations: operations), + transferJournal: journal, defaults: f.defaults) + + coordinator.startQueue() + let ended = await waitUntil(timeout: .seconds(15)) { @MainActor in + journal.records.first(where: { $0.id == id })?.state == .interrupted + && !coordinator.isOperationInProgress + } + XCTAssertTrue(ended) + let record = try XCTUnwrap(journal.records.first { $0.id == id }) + XCTAssertNil(record.plannedFileCounts) + XCTAssertEqual(record.results.count, 1) + XCTAssertEqual(coordinator.operationState, .failed) + XCTAssertFalse(FileManager.default.fileExists(atPath: f.destination.appendingPathComponent("Reports").path)) + XCTAssertNotEqual(TransferLibraryPresentation.safetyState(for: record), .safeToErase) + } + + func testLateJournalFinishFailureNeverEmitsASafeLiveVerdict() async throws { + let f = try QueueFixture() + defer { f.cleanup() } + let journal = LocalTransferJournal(fileURL: f.journalURL, beforeFinish: { _ in + throw NSError(domain: "BitMatchTestJournalFinish", code: 79) + }) + let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination], + verificationMode: .standard, cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(makeReport: false), generateASCMHL: false) + let operations = TransferPipeline(fileSystem: MacOSFileSystemService.shared, checksum: ChecksumEngine.shared) + let coordinator = SharedAppCoordinator(platformManager: QueuePlatformManager(fileOperations: operations), + transferJournal: journal, defaults: f.defaults) + var safeSignals = 0 + let subscription = coordinator.transferSignals.sink { signal in + if case .safeToErase = signal { safeSignals += 1 } + } + defer { subscription.cancel() } + + coordinator.startQueue() + let ended = await waitUntil(timeout: .seconds(15)) { @MainActor in + journal.records.first(where: { $0.id == id })?.state == .interrupted + && !coordinator.isOperationInProgress + } + XCTAssertTrue(ended) + let record = try XCTUnwrap(journal.records.first { $0.id == id }) + XCTAssertEqual(record.plannedFileCounts, [1]) + XCTAssertEqual(record.results.count, 1) + XCTAssertEqual(coordinator.operationState, .failed) + XCTAssertEqual(safeSignals, 0) + XCTAssertNotEqual(TransferLibraryPresentation.safetyState(for: record), .safeToErase) + } + func testUnexpectedCancellationErrorPersistsInterruptedRatherThanCancelled() async throws { let f = try QueueFixture() defer { f.cleanup() } @@ -1392,6 +1486,7 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { let unsafeID = try coordinator.enqueue(source: unsafe, destinations: [f.destination]) for id in [firstID, secondID, unsafeID] { try journal.markRunning(id: id) + try journal.recordPlan(id: id, fileCounts: [1]) let isSafe = id != unsafeID try journal.finish( id: id, @@ -1436,6 +1531,7 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs() ) try journal.markRunning(id: id) + try journal.recordPlan(id: id, fileCounts: [1]) try journal.finish( id: id, results: [ResultRow(path: "clip.mov", status: ResultOutcome.verified.statusText, @@ -1633,6 +1729,7 @@ private final class QuickQueueRecordingOperations: FileOperationsService, @unche progressCallback: @escaping ProgressCallback, onFileResult: FileResultCallback? ) async throws -> FileOperation { let sourceFile = sourceURL.appendingPathComponent("clip.mov") + progressCallback(testPlannedProgress(filesPerDestination: 1, destinationCount: destinationURLs.count)) let sourceBytes = try Data(contentsOf: sourceFile) let rows = try destinationURLs.map { destination in let outputRoot = try SafetyValidator.resolvedDestinationRootChecked( @@ -1698,6 +1795,7 @@ private final class SnapshotIsolationOperations: FileOperationsService, @uncheck label: settings.label, mode: verificationMode )) + progressCallback(testPlannedProgress(filesPerDestination: 1, destinationCount: destinationURLs.count)) let sourceFile = sourceURL.appendingPathComponent("clip.mov") let results = destinationURLs.map { destination -> FileOperationResult in let verification = verificationMode == .quick ? nil : VerificationResult( @@ -1806,6 +1904,7 @@ private final class QueueRecordingOperations: FileOperationsService, @unchecked settings: CameraLabelSettings, estimatedTotalBytes: Int64?, progressCallback: @escaping ProgressCallback, onFileResult: FileResultCallback?) async throws -> FileOperation { await gate.record(QueueStartSnapshot(source: sourceURL, destinations: destinationURLs, label: settings.label, mode: verificationMode)) + progressCallback(testPlannedProgress(filesPerDestination: 0, destinationCount: destinationURLs.count)) return FileOperation(sourceURL: sourceURL, destinationURLs: destinationURLs, startTime: Date(), endTime: Date(), results: [], verificationMode: verificationMode, settings: settings, estimatedTotalBytes: estimatedTotalBytes) } diff --git a/BitMatchTests/MacMenuBarCompanionTests.swift b/BitMatchTests/MacMenuBarCompanionTests.swift index 60dce3fb..a7a6a1f8 100644 --- a/BitMatchTests/MacMenuBarCompanionTests.swift +++ b/BitMatchTests/MacMenuBarCompanionTests.swift @@ -29,13 +29,30 @@ struct MacMenuBarCompanionTests { #expect(MenuBarDestinationLabel.text(for: .verifying) == "Verifying") } - @Test func recentStatusUsesSavedTerminalStateAndMakesNoSafetyClaim() { - #expect(MenuBarRecentTransfer.status(for: .queued)?.text == nil) - #expect(MenuBarRecentTransfer.status(for: .running)?.text == nil) - #expect(MenuBarRecentTransfer.status(for: .completed)?.text == "Completed · review results in BitMatch") - #expect(MenuBarRecentTransfer.status(for: .issues)?.needsAttention == true) - #expect(MenuBarRecentTransfer.status(for: .failed)?.text == "Transfer failed") - #expect(MenuBarRecentTransfer.status(for: .cancelled)?.text == "Transfer cancelled") - #expect(MenuBarRecentTransfer.status(for: .interrupted)?.text == "Transfer interrupted") + @Test func recentStatusUsesDerivedSavedVerdictForOldRecords() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("menu-plan-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("Card") + let destination = root.appendingPathComponent("Backup") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), + source: try LocalTransferResource(url: source), + destinations: [try LocalTransferResource(url: destination)], + verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + #expect(MenuBarRecentTransfer.status(for: record)?.text == nil) + record.state = .completed + record.results = [ResultRow(path: source.appendingPathComponent("clip.mov").path, + status: ResultOutcome.verified.statusText, size: 1, checksum: "abc", destination: "Backup", + destinationPath: destination.appendingPathComponent("clip.mov").path)] + #expect(MenuBarRecentTransfer.latest(in: [record])?.status == "Needs review") + #expect(MenuBarRecentTransfer.latest(in: [record])?.needsAttention == true) + record.plannedFileCounts = [1] + #expect(MenuBarRecentTransfer.latest(in: [record])?.status == "Completed · review results in BitMatch") + record.state = .cancelled + #expect(MenuBarRecentTransfer.status(for: record)?.text == "Transfer cancelled") + record.state = .interrupted + #expect(MenuBarRecentTransfer.status(for: record)?.text == "Transfer interrupted") } } diff --git a/BitMatchTests/QueueSessionPresentationTests.swift b/BitMatchTests/QueueSessionPresentationTests.swift index bbab76d7..a3786fc6 100644 --- a/BitMatchTests/QueueSessionPresentationTests.swift +++ b/BitMatchTests/QueueSessionPresentationTests.swift @@ -290,6 +290,30 @@ struct QueueSessionPresentationTests { #expect(!presentation.rows[2].showsSafeHero) } + @Test func legacyCompletedRowDoesNotRepeatUnprovenSuccessInQueue() throws { + let fixture = try QueuePresentationFixture() + defer { fixture.cleanup() } + var record = try fixture.record( + name: "A001", state: .completed, outcome: .verified, + summary: "Selected files verified", at: 1 + ) + record.plannedFileCounts = nil + + let row = try #require(QueueSessionPresentation.make( + records: [record], sessionIDs: [record.id], progress: nil, + mountedSourceIDs: [record.id] + ).rows.first) + + #expect(row.safetyState == .needsAttention) + #expect(row.action == .review) + #expect(row.cause?.contains("Original selected-file plan was not saved") == true) + #expect(row.copySummary.contains("Original selected-file plan was not saved")) + #expect(row.outcome?.visibleVerdictText.contains("Original selected-file plan was not saved") == true) + #expect(!row.copySummary.contains("Selected files verified")) + #expect(row.outcome?.visibleVerdictText.contains("Selected files verified") == false) + #expect(record.summary == "Selected files verified") + } + @Test func nonSafeRowNeverOffersOrAutomaticallyTriggersEject() throws { let fixture = try QueuePresentationFixture() defer { fixture.cleanup() } @@ -451,6 +475,7 @@ private final class QueuePresentationFixture { record.summary = summary record.quickOnlyUnverified = quickOnlyUnverified if let outcome { + record.plannedFileCounts = [1] record.results = [ResultRow( path: source.appendingPathComponent("clip.mov").path, status: outcome.statusText, size: size, checksum: outcome == .verified ? "abc" : nil, diff --git a/BitMatchTests/SetupPresentationTests.swift b/BitMatchTests/SetupPresentationTests.swift index 650abcd0..98bb4f65 100644 --- a/BitMatchTests/SetupPresentationTests.swift +++ b/BitMatchTests/SetupPresentationTests.swift @@ -195,6 +195,7 @@ struct SetupPresentationTests { generateASCMHL: false ) try journal.markRunning(id: recordID) + try journal.recordPlan(id: recordID, fileCounts: [1]) try journal.finish( id: recordID, results: [ResultRow( diff --git a/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift b/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift index 87e67ae0..7a6794d3 100644 --- a/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift +++ b/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift @@ -2,6 +2,23 @@ import Foundation @testable import BitMatch import BitMatchEngine +/// Fake file operations must publish the same selected-file plan that the +/// real engine saves before it can finalize a transfer. +func testPlannedProgress( + filesPerDestination: Int, + destinationCount: Int, + stage: ProgressStage = .preparing, + fraction: Double = 0 +) -> OperationProgress { + OperationProgress( + overallProgress: fraction, currentFile: nil, filesProcessed: 0, + totalFiles: filesPerDestination, currentStage: stage, speed: nil, + elapsedTime: nil, averageSpeed: nil, peakSpeed: nil, + bytesProcessed: nil, totalBytes: nil, + perDestinationTotals: Array(repeating: filesPerDestination, count: destinationCount) + ) +} + /// A source card with one file and two empty backups in a throwaway folder. struct CoordinatorFolders { let root: URL @@ -88,9 +105,14 @@ final class RecordingFileOperations: FileOperationsService, @unchecked Sendable onFileResult: FileResultCallback? ) async throws -> FileOperation { if let reportsStage { - progressCallback(OperationProgress( - overallProgress: 0.5, currentFile: "A.ARW", filesProcessed: 0, totalFiles: 1, - currentStage: reportsStage, speed: nil)) + progressCallback(testPlannedProgress( + filesPerDestination: 0, destinationCount: destinationURLs.count, + stage: reportsStage, fraction: 0.5 + )) + } else { + progressCallback(testPlannedProgress( + filesPerDestination: 0, destinationCount: destinationURLs.count + )) } await gate.record(RecordedStart( source: sourceURL, diff --git a/BitMatchTests/TransferFinishNoticeTests.swift b/BitMatchTests/TransferFinishNoticeTests.swift index 7e4af2b2..9c062fa9 100644 --- a/BitMatchTests/TransferFinishNoticeTests.swift +++ b/BitMatchTests/TransferFinishNoticeTests.swift @@ -100,9 +100,8 @@ struct TransferFinishNoticeTests { )) } - /// Plant: in `SharedAppCoordinator.notifyIfTransferEnded`, call the - /// source/destination overload with live selection instead of this - /// record overload; a later selection can then enter the notification. + /// The settled record supplies both the card identity and the verdict; + /// a later selection or the executor's early success cannot replace it. @Test func finishedRecordBuildsNotificationIdentity() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("bitmatch-notice-\(UUID())", isDirectory: true) @@ -111,18 +110,41 @@ struct TransferFinishNoticeTests { try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: backup, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - let record = LocalTransferRecord( + var record = LocalTransferRecord( id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: source), destinations: [try LocalTransferResource(url: backup)], verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs() ) + record.state = .completed + record.plannedFileCounts = [1] + record.results = [ResultRow( + path: source.appendingPathComponent("clip.mov").path, + status: ResultOutcome.verified.statusText, size: 4, checksum: "fixture", + destination: backup.lastPathComponent, + destinationPath: backup.appendingPathComponent("clip.mov").path + )] - let notice = TransferFinishNotice.make( - state: .completed(.init(success: true, message: "Verified")), - record: record, issueCount: 0, kind: .standaloneFinish - ) + let notice = TransferFinishNotice.make(record: record, kind: .standaloneFinish) #expect(notice?.title == "A001 is safe to erase") #expect(notice?.body == "Verified on \(TransferOutcomePresentation.destinationDriveName(backup)).") + + // No journal settlement means no notification, even if the executor + // has already reached its in-memory success state. + record.state = .running + #expect(TransferFinishNotice.make(record: record, kind: .standaloneFinish) == nil) + + // A finished record without its selected-file plan is an attention + // result, not a safe-to-erase notification. + record.state = .completed + record.plannedFileCounts = nil + let settledState = try #require(TransferOutcomePresentation.recordedState(for: record)) + let kind = TransferNotificationDecision.kind( + state: settledState, issueCount: 0, queueIsRunning: false, + isReplayingQueuedTransfer: false, notifyAttention: true, + notifyFinish: true, notifyEachQueuedCard: false + ) + #expect(kind == .attention) + #expect(TransferFinishNotice.make(record: record, kind: .standaloneFinish)?.title == "A001 needs attention") } } diff --git a/BitMatchTests/TransferLibraryPresentationTests.swift b/BitMatchTests/TransferLibraryPresentationTests.swift index e99ed798..97da2873 100644 --- a/BitMatchTests/TransferLibraryPresentationTests.swift +++ b/BitMatchTests/TransferLibraryPresentationTests.swift @@ -9,6 +9,50 @@ import BitMatchEngine struct TransferLibraryPresentationTests { private let allStates: [LocalTransferState] = [.queued, .running, .interrupted, .completed, .issues, .failed, .cancelled] + @Test func authoritativeHandoffRequiresTheSavedSelectedFilePlan() throws { + var record = PresentationTestSupport.record(state: .running) + record.plannedFileCounts = nil + let row = PresentationTestSupport.row(.verified, destination: record.destinations[0].url) + #expect(throws: TransferPlanPersistenceError.self) { + try SavedTransferPlanGate.requireSavedPlan(record, destinationCount: 1, + authoritativeRows: [row], priorWriteError: nil) + } + record.plannedFileCounts = [1] + try SavedTransferPlanGate.requireSavedPlan(record, destinationCount: 1, + authoritativeRows: [row], priorWriteError: nil) + let excluded = PresentationTestSupport.row(.excludedAppleDouble, destination: record.destinations[0].url) + try SavedTransferPlanGate.requireSavedPlan(record, destinationCount: 1, + authoritativeRows: [row, excluded], priorWriteError: nil) + record.plannedFileCounts = [2] + #expect(throws: TransferPlanPersistenceError.self) { + try SavedTransferPlanGate.requireSavedPlan(record, destinationCount: 1, + authoritativeRows: [row], priorWriteError: nil) + } + record.plannedFileCounts = [1] + #expect(throws: TransferPlanPersistenceError.self) { + try SavedTransferPlanGate.requireSavedPlan(record, destinationCount: 1, + authoritativeRows: [row], + priorWriteError: CocoaError(.fileWriteNoPermission)) + } + } + + @Test func liveExecutorSuccessIsReconciledToTheSavedTerminalVerdict() { + var record = PresentationTestSupport.record(state: .completed) + record.plannedFileCounts = nil + record.results = [PresentationTestSupport.row(.verified, destination: record.destinations[0].url)] + let settled = TransferOutcomePresentation.settledLiveState(for: record) + guard case .completed(let info) = settled else { + Issue.record("Expected a completed-but-unsafe live verdict") + return + } + #expect(info.success == false) + + record.state = .interrupted + #expect(TransferOutcomePresentation.settledLiveState(for: record) == .failed) + record.state = .cancelled + #expect(TransferOutcomePresentation.settledLiveState(for: record) == .cancelled) + } + // MARK: - State labels /// Plant: in `TransferLibraryPresentation.stateLabel`, change the @@ -161,6 +205,12 @@ struct TransferLibraryPresentationTests { let reason = TransferOutcomePresentation.recordedCompletionReason(for: record) let state = try #require(TransferOutcomePresentation.recordedState(for: record)) + if case .completed(let info) = state { + #expect(info.message == reason) + #expect(!info.message.contains("Selected files verified")) + } else { + Issue.record("Expected a completed review state for legacy History") + } let finish = TransferOutcomePresentation.make( state: state, rows: record.results, destinations: record.destinations.map(\.url), hasErrors: false, hasCriticalErrors: false, errorCount: 0, warningCount: 0, diff --git a/BitMatchTests/TransferOutcomePresentationTests.swift b/BitMatchTests/TransferOutcomePresentationTests.swift index e86238c5..b1082380 100644 --- a/BitMatchTests/TransferOutcomePresentationTests.swift +++ b/BitMatchTests/TransferOutcomePresentationTests.swift @@ -766,6 +766,7 @@ struct FinishedRunSnapshotTests { reportSettings: ReportPrefs(), generateASCMHL: false ) try journal.markRunning(id: id) + try journal.recordPlan(id: id, fileCounts: [1]) try journal.finish( id: id, results: [ResultRow( diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d4d75f..027ca964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Older History entries without a saved selected-file plan now stay visible but do not claim the whole card is safe to erase, even if their saved rows say verified. They also no longer trigger the “Backed up before…” hint. Quick entries without that plan show Needs attention instead of a clean size-only copy. History exports keep the original message and add the saved safety verdict. +- If a new transfer cannot save its selected-file plan, BitMatch stops before creating a completion report or marking a project card safe. The final record keeps the source unsafe. Finish, queue, menu bar, Dock and notifications follow the saved verdict. A later History-save failure also shows an interruption; any separately saved report or project evidence remains available for review. - Mac: The menu bar shows the current transfer and its destinations, or the most recent result when idle. The idle icon is a small copy-and-check mark. Closing the main window during a transfer hides it while the work continues; Open BitMatch brings the same window back. Quit still asks before stopping active work. - Expanded transfer details show bytes written and read back for each destination, with separate live rates. These are measurements of active file I/O, not a drive-health or verification verdict. - Optional local Apple crash and hang summaries can be added to a diagnostics export. This is off by default, never uploaded by BitMatch, and Apple may not deliver a report. diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift index cf9c4091..ff1282a6 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift @@ -466,6 +466,10 @@ public final class TransferJournal: Sendable { record.endedAt = nil record.summary = "Copying and verifying" record.independentDestinationCount = independentDestinationCount + // A direct retry may reuse this record. Its earlier selected + // plan is not evidence for the new attempt. + record.plannedFileCounts = nil + record.quickOnlyUnverified = nil } } } @@ -637,9 +641,9 @@ public final class TransferJournal: Sendable { record.quickOnlyUnverified = record.verificationMode == .quick ? quickOnlyUnverified : nil // A current transfer records the selected manifest size before // copying. A shorter or duplicated result set is incomplete even - // if every retained row says it verified. Legacy nil-plan records - // keep their saved state for provenance; History and prior-backup - // hints cannot infer whole-card coverage from them. + // if every retained row says it verified. A newly finishing attempt + // without a saved plan is incomplete too; older decoded records + // keep their historical state for provenance in History. let incompletePlan = !Self.hasCompletePlannedResults(record) // A complete plan can still contain copied-but-unverified rows. // Only Quick mode intentionally accepts that outcome. @@ -708,7 +712,7 @@ public final class TransferJournal: Sendable { /// AppleDouble exclusions are retained as evidence but are not selected /// files, so they do not make a complete selected-file plan look short. private static func hasCompletePlannedResults(_ record: LocalTransferRecord) -> Bool { - guard let plan = record.plannedFileCounts else { return true } + guard let plan = record.plannedFileCounts else { return false } guard plan.count == record.destinations.count else { return false } let selected = record.results.filter { ResultOutcome(statusText: $0.status) != .excludedAppleDouble diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift index b62c6a80..cd5a3789 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift @@ -122,7 +122,67 @@ struct QueueAdmissionTests { try complete(plan: [2], results: [row("a.mov"), row("b.mov")], fingerprint: "complete") #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "complete") != nil) try complete(plan: nil, results: [row("a.mov")], fingerprint: "legacy") - #expect(f.journal.records.first(where: { $0.sourceFingerprint == "legacy" })?.state == .completed) + #expect(f.journal.records.first(where: { $0.sourceFingerprint == "legacy" })?.state == .issues) #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "legacy") == nil) } + + @Test func failedPlanWriteCannotFinishAsACompleteVerifiedTransfer() throws { + let f = try fixture() + defer { try? FileManager.default.removeItem(at: f.root) } + let id = try f.journal.enqueue( + sourceURL: f.source, destinationURLs: [f.backup], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs() + ) + try f.journal.markRunning(id: id) + + // Put a directory at the journal file path for one write, then + // restore the original file. This reproduces a failed plan save + // followed by a successful terminal write without changing the card. + let journalURL = f.root.appendingPathComponent("journal.json") + let savedURL = f.root.appendingPathComponent("journal-saved.json") + try FileManager.default.moveItem(at: journalURL, to: savedURL) + try FileManager.default.createDirectory(at: journalURL, withIntermediateDirectories: true) + #expect(throws: (any Error).self) { + try f.journal.recordPlan(id: id, fileCounts: [1]) + } + #expect(f.journal.records.first?.plannedFileCounts == nil) + try FileManager.default.removeItem(at: journalURL) + try FileManager.default.moveItem(at: savedURL, to: journalURL) + + let row = ResultRow( + path: f.source.appendingPathComponent("clip.mov").path, + status: ResultOutcome.verified.statusText, size: 24, checksum: "fixture", + destination: f.backup.lastPathComponent, + destinationPath: f.backup.appendingPathComponent("clip.mov").path + ) + try f.journal.finish( + id: id, results: [row], summary: "Selected files verified", + hadIssues: false, sourceFingerprint: "card" + ) + let finished = try #require(f.journal.records.first) + #expect(finished.state == .issues) + #expect(finished.summary.contains("Transfer results are incomplete")) + #expect(finished.summary.contains("Do not erase")) + #expect(finished.results.count == 1) + #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "card") == nil) + #expect(try String(contentsOf: f.source.appendingPathComponent("clip.mov"), encoding: .utf8) == "source remains untouched") + } + + @Test func directRetryCannotReuseAnEarlierSelectedFilePlan() throws { + let f = try fixture() + defer { try? FileManager.default.removeItem(at: f.root) } + let id = try f.journal.enqueue( + sourceURL: f.source, destinationURLs: [f.backup], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs() + ) + try f.journal.markRunning(id: id) + try f.journal.recordPlan(id: id, fileCounts: [1]) + try f.journal.interrupt(id: id, summary: "Interrupted", results: []) + + try f.journal.markRunning(id: id) + #expect(f.journal.records.first?.plannedFileCounts == nil) + #expect(f.journal.records.first?.quickOnlyUnverified == nil) + try f.journal.recordPlan(id: id, fileCounts: [2]) + #expect(f.journal.records.first?.plannedFileCounts == [2]) + } } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/StressTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/StressTests.swift index 4459a213..3ce34956 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/StressTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/StressTests.swift @@ -431,6 +431,7 @@ final class StressTests: XCTestCase { generateASCMHL: false ) try journal.markRunning(id: id) + try journal.recordPlan(id: id, fileCounts: [12]) let service = makePipeline(pipelined: false) let cancellation = CancellationPoint(target: 1 + Int(rng.next() % 12)) let captured = StressResults() @@ -460,6 +461,7 @@ final class StressTests: XCTestCase { let retryID = try journal.requeue(id: id) try journal.markRunning(id: retryID) + try journal.recordPlan(id: retryID, fileCounts: [12]) let retry = try await stressRun(source: fixture.source, destinations: [destination], mode: .standard) let retryVerdict = verdict(for: retry) XCTAssertTrue(retryVerdict.success, "iteration \(iteration): \(describe(retry))") diff --git a/Shared/Core/Models/QueueSessionPresentation.swift b/Shared/Core/Models/QueueSessionPresentation.swift index 7e7ab2de..ad661c10 100644 --- a/Shared/Core/Models/QueueSessionPresentation.swift +++ b/Shared/Core/Models/QueueSessionPresentation.swift @@ -285,14 +285,15 @@ struct QueueSessionPresentation: Equatable, Sendable { state: CardSafetyState ) -> TransferOutcomePresentation? { guard record.state != .queued, record.state != .running else { return nil } + let displayedReason = TransferOutcomePresentation.recordedCompletionReason(for: record) let operationState: OperationState switch state { case .safeToErase: - operationState = .completed(.init(success: true, message: record.summary)) + operationState = .completed(.init(success: true, message: displayedReason)) case .copiedNotVerified: - operationState = .completed(.init(success: false, message: record.summary, copiedNotVerified: true)) + operationState = .completed(.init(success: false, message: displayedReason, copiedNotVerified: true)) case .needsAttention: - operationState = .completed(.init(success: false, message: record.summary)) + operationState = .completed(.init(success: false, message: displayedReason)) case .failed: operationState = .failed case .interrupted: @@ -323,7 +324,7 @@ struct QueueSessionPresentation: Equatable, Sendable { ), canExport: true, sourceName: record.title, - completionReason: record.summary, + completionReason: displayedReason, independentDestinationCount: record.independentDestinationCount ) } @@ -344,7 +345,8 @@ struct QueueSessionPresentation: Equatable, Sendable { return "\(files) on \(drive)" } } - let summary = record.summary.trimmingCharacters(in: .whitespacesAndNewlines) + let summary = TransferOutcomePresentation.recordedCompletionReason(for: record) + .trimmingCharacters(in: .whitespacesAndNewlines) return summary.isEmpty ? nil : summary } diff --git a/Shared/Core/Models/TransferFinishNotice.swift b/Shared/Core/Models/TransferFinishNotice.swift index 183cf615..0a4e085c 100644 --- a/Shared/Core/Models/TransferFinishNotice.swift +++ b/Shared/Core/Models/TransferFinishNotice.swift @@ -16,17 +16,16 @@ struct TransferFinishNotice: Equatable, Sendable { self.kind = kind } - static func make( - state: OperationState, - record: LocalTransferRecord, - issueCount: Int, - kind: TransferNotificationKind - ) -> TransferFinishNotice? { - make( + /// Use only a settled journal record. The executor may report success + /// before the journal has persisted its plan and final verdict. + static func make(record: LocalTransferRecord, kind: TransferNotificationKind) -> TransferFinishNotice? { + guard record.state != .queued, record.state != .running, + let state = TransferOutcomePresentation.recordedState(for: record) else { return nil } + return make( state: state, sourceName: record.title, destinations: record.destinations.map(\.url), - issueCount: issueCount, + issueCount: record.results.filter { !$0.isSuccessStatus }.count, kind: kind ) } diff --git a/Shared/Core/Services/CopyVerifyExecutor.swift b/Shared/Core/Services/CopyVerifyExecutor.swift index e935af0a..66f07ae8 100644 --- a/Shared/Core/Services/CopyVerifyExecutor.swift +++ b/Shared/Core/Services/CopyVerifyExecutor.swift @@ -353,9 +353,12 @@ final class CopyVerifyExecutor { let allResults = TransferCompletion.rows(from: operation) SharedLogger.info("Mapped \(allResults.count) authoritative operation results for report", category: .transfer) + // The journal's selected-file plan is authoritative for this run. + // Its failure must escape before MHL, project finalization or reports; + // only a project finalizer error is recoverable as an amber outcome. + try callbacks.onAuthoritativeResults(allResults) let photographerLifecycle = try Self.photographerLifecycleAfterAuthoritativeCompletion( completion: { - try callbacks.onAuthoritativeResults(allResults) guard let finalizer = config.photographerReportFinalizer else { return nil } return try finalizer(allResults) } diff --git a/Shared/Core/Services/LocalTransferJournal.swift b/Shared/Core/Services/LocalTransferJournal.swift index 494f22f3..454a23bd 100644 --- a/Shared/Core/Services/LocalTransferJournal.swift +++ b/Shared/Core/Services/LocalTransferJournal.swift @@ -23,11 +23,15 @@ final class LocalTransferJournal: ObservableObject { let queueSessionFileURL: URL private let beforeMarkRunning: ((UUID) throws -> Void)? private let beforeCancel: ((UUID) throws -> Void)? + private let beforeRecordPlan: ((UUID) throws -> Void)? + private let beforeFinish: ((UUID) throws -> Void)? init( fileURL: URL? = nil, beforeMarkRunning: ((UUID) throws -> Void)? = nil, - beforeCancel: ((UUID) throws -> Void)? = nil + beforeCancel: ((UUID) throws -> Void)? = nil, + beforeRecordPlan: ((UUID) throws -> Void)? = nil, + beforeFinish: ((UUID) throws -> Void)? = nil ) { let selectedURL = fileURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("BitMatch/transfer-history.json") @@ -36,6 +40,8 @@ final class LocalTransferJournal: ObservableObject { .appendingPathComponent("transfer-queue-session.json") self.beforeMarkRunning = beforeMarkRunning self.beforeCancel = beforeCancel + self.beforeRecordPlan = beforeRecordPlan + self.beforeFinish = beforeFinish store = TransferJournal(fileURL: selectedURL) refresh() } @@ -113,6 +119,7 @@ final class LocalTransferJournal: ObservableObject { func recordPlan(id: UUID, fileCounts: [Int]) throws { defer { refresh() } + try beforeRecordPlan?(id) try store.recordPlan(id: id, fileCounts: fileCounts) } @@ -169,6 +176,7 @@ final class LocalTransferJournal: ObservableObject { sourceFingerprint: String? = nil ) throws { defer { refresh() } + try beforeFinish?(id) try store.finish( id: id, results: results, diff --git a/Shared/Core/Services/SharedAppCoordinator.swift b/Shared/Core/Services/SharedAppCoordinator.swift index 996fc133..f8d4ef5b 100644 --- a/Shared/Core/Services/SharedAppCoordinator.swift +++ b/Shared/Core/Services/SharedAppCoordinator.swift @@ -75,6 +75,7 @@ struct TransferRunContext { private final class TransferRunResults { private(set) var rows: [ResultRow] = [] var attemptedPlanSave = false + var planSaveError: Error? func upsert(_ row: ResultRow) { if let index = rows.firstIndex(where: { @@ -91,6 +92,46 @@ private final class TransferRunResults { } } +/// A report or project finalization must not claim complete selected-file +/// coverage when the plan for this attempt was never saved. +enum TransferPlanPersistenceError: LocalizedError { + case missingSavedPlan + + var errorDescription: String? { + "BitMatch could not save the selected-file plan. Keep the source and review the partial results." + } +} + +enum SavedTransferPlanGate { + static func requireSavedPlan( + _ record: LocalTransferRecord?, + destinationCount: Int, + authoritativeRows: [ResultRow], + priorWriteError: Error? + ) throws { + guard priorWriteError == nil, + let record, + record.state == .running, + let plan = record.plannedFileCounts, + plan.count == destinationCount else { + throw TransferPlanPersistenceError.missingSavedPlan + } + let selectedRows = authoritativeRows.filter { + ResultOutcome(statusText: $0.status) != .excludedAppleDouble + } + let groups = DestinationResultSummary.make( + rows: selectedRows, destinations: record.destinations.map(\.url) + ) + guard groups.count == destinationCount, + zip(plan, groups).allSatisfy({ expected, group in + expected == group.rows.count + && Set(group.rows.map(\.path)).count == expected + }) else { + throw TransferPlanPersistenceError.missingSavedPlan + } + } +} + @MainActor class SharedAppCoordinator: ObservableObject { enum CancellationSettlementError: LocalizedError, Equatable { @@ -701,9 +742,10 @@ class SharedAppCoordinator: ObservableObject { // MARK: - Finish notification - private func notifyIfTransferEnded(_ state: OperationState) { + private func notifyIfTransferEnded(record: LocalTransferRecord) { guard currentMode == .copyAndVerify else { return } - let issueCount = results.filter { !$0.isSuccessStatus }.count + guard let state = TransferOutcomePresentation.recordedState(for: record) else { return } + let issueCount = record.results.filter { !$0.isSuccessStatus }.count guard let kind = TransferNotificationDecision.kind( state: state, issueCount: issueCount, @@ -713,14 +755,7 @@ class SharedAppCoordinator: ObservableObject { notifyFinish: generalSettings.notifyWhenTransferOrQueueFinishes, notifyEachQueuedCard: generalSettings.notifyForEachCardInQueue ) else { return } - let finishedRecord = outcomeRecord - guard let finishedRecord, - let notice = TransferFinishNotice.make( - state: state, - record: finishedRecord, - issueCount: issueCount, - kind: kind - ) else { return } + guard let notice = TransferFinishNotice.make(record: record, kind: kind) else { return } transferNotifier.post(notice) } @@ -751,10 +786,6 @@ class SharedAppCoordinator: ObservableObject { /// Feeds `progressPresentation`: engine progress at most every 120 ms, /// and the smoothing timer while an operation runs. private func setupProgressPresentation() { - operationStatePublisher - .removeDuplicates() - .sink { [weak self] state in self?.notifyIfTransferEnded(state) } - .store(in: &cancellables) IOSBackgroundTaskService.shared.secondsRemainingProvider = { [weak self] in self?.progressPresentation.measuredSecondsRemaining } @@ -1491,17 +1522,8 @@ class SharedAppCoordinator: ObservableObject { func reviewQueuedTransfer(_ id: UUID) { guard !isOperationInProgress, - let record = transferJournal.records.first(where: { $0.id == id }) else { return } - let safety = TransferLibraryPresentation.safetyState(for: record) - let state: OperationState - switch safety { - case .safeToErase: state = .completed(.init(success: true, message: record.summary)) - case .copiedNotVerified: state = .completed(.init(success: false, message: record.summary, copiedNotVerified: true)) - case .needsAttention: state = .completed(.init(success: false, message: record.summary)) - case .failed: state = .failed - case .interrupted: state = record.state == .cancelled ? .cancelled : .failed - default: return - } + let record = transferJournal.records.first(where: { $0.id == id }), + let state = TransferOutcomePresentation.settledLiveState(for: record) else { return } reviewedQueueAttentionIDs.insert(id) standaloneAttentionRecordIDsSinceLaunch.remove(id) reviewedQueueRecordID = id @@ -2011,7 +2033,10 @@ class SharedAppCoordinator: ObservableObject { if !runResults.attemptedPlanSave, let counts = progressUpdate.perDestinationTotals { runResults.attemptedPlanSave = true do { try self.transferJournal.recordPlan(id: recordID, fileCounts: counts) } - catch { SharedLogger.warning("Could not save transfer plan: \(error)", category: .transfer) } + catch { + runResults.planSaveError = error + SharedLogger.warning("Could not save transfer plan: \(error)", category: .transfer) + } } if let projectIdentity { self.photographerJobViewModel.updateProgressStage( @@ -2048,6 +2073,20 @@ class SharedAppCoordinator: ObservableObject { SharedLogger.transferEvent(.authoritativeRefused, run: startID, code: 3, explicit: true) throw CancellationError() } + // This callback runs before MHL, the project finalizer, and + // automatic reports. A failed plan write must stop all three + // from presenting incomplete selected-file evidence as safe. + do { + try SavedTransferPlanGate.requireSavedPlan( + self.transferJournal.records.first(where: { $0.id == recordID }), + destinationCount: context.destinationURLs.count, + authoritativeRows: allResults, + priorWriteError: runResults.planSaveError + ) + } catch { + SharedLogger.transferEvent(.authoritativeRefused, run: startID, code: 4) + throw error + } SharedLogger.transferEvent(.authoritativeAccepted, run: startID) runResults.replace(with: allResults) self.results = allResults @@ -2080,6 +2119,9 @@ class SharedAppCoordinator: ObservableObject { SharedLogger.transferEvent(.journalCancelled, run: startID, explicit: true) handleAttemptTerminal(recordID: recordID, belongsToQueueSession: belongsToQueueSession) } else if case .completed(let info) = operationState { + // The report and a project card's independent evidence may + // already have been saved. If this final journal write fails, + // preserve those artifacts but never emit a settled success. let durations = copyVerifyExecutor.completedPhaseDurations try transferJournal.finish( id: recordID, @@ -2192,6 +2234,11 @@ class SharedAppCoordinator: ObservableObject { private func handleAttemptTerminal(recordID: UUID, belongsToQueueSession: Bool? = nil) { guard let record = transferJournal.records.first(where: { $0.id == recordID }) else { return } + if let settled = TransferOutcomePresentation.settledLiveState(for: record), + operationState != settled { + operationState = settled + } + notifyIfTransferEnded(record: record) let safety = TransferLibraryPresentation.safetyState(for: record) let belongs = belongsToQueueSession ?? (isReplayingQueuedTransfer || queueSessionRecordIDs.contains(recordID)) diff --git a/Shared/Views/TransferPresentationAdapters.swift b/Shared/Views/TransferPresentationAdapters.swift index 35139d65..8a112ee2 100644 --- a/Shared/Views/TransferPresentationAdapters.swift +++ b/Shared/Views/TransferPresentationAdapters.swift @@ -79,23 +79,25 @@ extension TransferOutcomePresentation { canRetry: isFinished && record?.canRetry == true, canExport: isFinished, sourceName: record?.title ?? coordinator.sourceURL?.lastPathComponent ?? "", - completionReason: record.map { recordedCompletionReason(for: $0) } - ?? (coordinator.operationState == .failed ? coordinator.queueMessage : nil), + completionReason: journalWriteFailed + ? "Could not save transfer results. Keep the source and review History." + : record.map { recordedCompletionReason(for: $0) } + ?? (coordinator.operationState == .failed ? coordinator.queueMessage : nil), independentDestinationCount: record?.independentDestinationCount ) } /// Translate a saved attempt into presentation state without treating an /// interrupted process as a user-requested cancellation in the journal. - @MainActor static func recordedState(for record: LocalTransferRecord) -> OperationState? { + let reason = recordedCompletionReason(for: record) switch TransferLibraryPresentation.safetyState(for: record) { case .safeToErase: - return .completed(.init(success: true, message: record.summary)) + return .completed(.init(success: true, message: reason)) case .copiedNotVerified: - return .completed(.init(success: false, message: record.summary, copiedNotVerified: true)) + return .completed(.init(success: false, message: reason, copiedNotVerified: true)) case .needsAttention: - return .completed(.init(success: false, message: record.summary)) + return .completed(.init(success: false, message: reason)) case .failed: return .failed case .interrupted: @@ -107,10 +109,18 @@ extension TransferOutcomePresentation { } } + /// The running executor's optimistic completion is replaced only after + /// the journal settles. An unexpected interruption stays distinct from + /// a person pressing Cancel in the live operation state. + static func settledLiveState(for record: LocalTransferRecord) -> OperationState? { + guard record.state != .queued, record.state != .running else { return nil } + if record.state == .interrupted { return .failed } + return recordedState(for: record) + } + /// Keep the journal's original message in History and exports, but do not /// repeat a legacy success claim on Finish when its selected-file plan is /// missing and the shared saved verdict has been downgraded. - @MainActor static func recordedCompletionReason(for record: LocalTransferRecord) -> String { if record.plannedFileCounts == nil, (record.state == .completed || record.quickOnlyUnverified == true) { From 9dd447d36278ed5d13ba73fb9dace19c3c297dcf Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 12:02:59 -0400 Subject: [PATCH 4/5] Use reviewed transfer evidence for Dock verdict --- BitMatch/App/DockTile.swift | 14 ++++++++++---- .../LocalTransferQueueIntegrationTests.swift | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/BitMatch/App/DockTile.swift b/BitMatch/App/DockTile.swift index 33c909e7..9cfccbed 100644 --- a/BitMatch/App/DockTile.swift +++ b/BitMatch/App/DockTile.swift @@ -59,6 +59,15 @@ enum DockTileState: Equatable { return .needsReview } } + + @MainActor + static func makeTransfer(coordinator: SharedAppCoordinator) -> DockTileState { + makeTransfer( + state: coordinator.operationState, + record: coordinator.outcomeRecord, + fraction: coordinator.liveProgress.progress?.overallProgress + ) + } } /// Keeps `NSApp.dockTile` in step with the coordinator. @@ -89,10 +98,7 @@ final class DockTileController { let state = coordinator.operationState let fraction = coordinator.liveProgress.progress?.overallProgress if coordinator.currentMode == .copyAndVerify { - let record = coordinator.activeRunContext?.journalRecordID.flatMap { id in - coordinator.transferJournal.records.first(where: { $0.id == id }) - } - show(DockTileState.makeTransfer(state: state, record: record, fraction: fraction)) + show(DockTileState.makeTransfer(coordinator: coordinator)) } else { show(DockTileState.make(state: state, fraction: fraction)) } diff --git a/BitMatchTests/LocalTransferQueueIntegrationTests.swift b/BitMatchTests/LocalTransferQueueIntegrationTests.swift index a1a5e92f..d4db9805 100644 --- a/BitMatchTests/LocalTransferQueueIntegrationTests.swift +++ b/BitMatchTests/LocalTransferQueueIntegrationTests.swift @@ -25,7 +25,16 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { destination: f.destination.lastPathComponent, destinationPath: f.destination.appendingPathComponent("clip.mov").path )] - try JSONEncoder().encode([oldRecord]).write(to: f.journalURL) + var plannedRecord = LocalTransferRecord( + id: UUID(), createdAt: Date(), source: oldRecord.source, + destinations: oldRecord.destinations, verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(makeReport: false) + ) + plannedRecord.state = .completed + plannedRecord.summary = "Selected files verified" + plannedRecord.results = oldRecord.results + plannedRecord.plannedFileCounts = [1] + try JSONEncoder().encode([oldRecord, plannedRecord]).write(to: f.journalURL) let journal = LocalTransferJournal(fileURL: f.journalURL) let coordinator = SharedAppCoordinator( platformManager: QueuePlatformManager(fileOperations: QueueRecordingOperations()), @@ -41,6 +50,12 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { XCTAssertTrue(info.message.contains("Original selected-file plan was not saved")) XCTAssertFalse(info.message.contains("Selected files verified")) XCTAssertEqual(coordinator.outcomeRecord?.summary, "Selected files verified") + XCTAssertEqual(DockTileState.makeTransfer(coordinator: coordinator), .needsReview) + + coordinator.reviewQueuedTransfer(plannedRecord.id) + XCTAssertNil(coordinator.activeRunContext) + XCTAssertEqual(coordinator.outcomeRecord?.id, plannedRecord.id) + XCTAssertEqual(DockTileState.makeTransfer(coordinator: coordinator), .verified) } func testFailedPlanWriteBlocksAutomaticReportHandoff() async throws { From 1a7d6f68fbb07d0d64dc18087766a01fc8037c39 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 12:06:29 -0400 Subject: [PATCH 5/5] Qualify destination conflict guidance --- docs/GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index e20ef8d5..09297aae 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -97,7 +97,7 @@ You can ignore SFTP entirely and keep everything local. No app analytics uploads ## Before You Trust It With Your Footage -BitMatch reads the source without writing to it. It copies through temporary files and doesn't overwrite conflicting destination files. Checksum modes reuse an existing file only after verifying that it matches. +BitMatch reads the source without writing to it. It copies through temporary files and refuses destination files it finds already in place. Checksum modes reuse an existing file only after verifying that it matches. Source scanning rejects unreadable metadata, unsafe paths, and portable filename collisions. Apart from the AppleDouble setting and designated macOS metadata folders at a volume root, hidden files and empty folders are preserved; symlink entries are skipped. Verification rejects files that change size or identity while being read. Completion screens and reports count failures and sidecars too.