diff --git a/BitMatch-iPadTests/WorkflowSnapshotTests.swift b/BitMatch-iPadTests/WorkflowSnapshotTests.swift index 76a637aa..38167ee9 100644 --- a/BitMatch-iPadTests/WorkflowSnapshotTests.swift +++ b/BitMatch-iPadTests/WorkflowSnapshotTests.swift @@ -32,6 +32,12 @@ final class WorkflowSnapshotTests: XCTestCase { name: "ipad-comparison-differences" ) + try capture( + ContentView(coordinator: fixture.coordinator), + size: CGSize(width: 393, height: 852), + name: "iphone-comparison-differences" + ) + try fixture.seedCompletion() await settle() try capture( diff --git a/BitMatch.xcodeproj/project.pbxproj b/BitMatch.xcodeproj/project.pbxproj index 9adeb56b..33456fd7 100644 --- a/BitMatch.xcodeproj/project.pbxproj +++ b/BitMatch.xcodeproj/project.pbxproj @@ -643,7 +643,7 @@ CODE_SIGN_ENTITLEMENTS = BitMatch/BitMatch.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -654,7 +654,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatch; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -672,7 +672,7 @@ CODE_SIGN_ENTITLEMENTS = BitMatch/BitMatch.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -683,7 +683,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatch; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -698,11 +698,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -716,11 +716,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -733,10 +733,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -749,10 +749,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -767,7 +767,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -784,7 +784,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPad"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -802,7 +802,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -819,7 +819,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPad"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -837,11 +837,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadTests"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -858,11 +858,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadTests"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -879,11 +879,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -899,11 +899,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 0.2.4; + MARKETING_VERSION = 0.2.5; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/BitMatch/Core/Services/CardEjectService.swift b/BitMatch/Core/Services/CardEjectService.swift index 4409f496..08a516d7 100644 --- a/BitMatch/Core/Services/CardEjectService.swift +++ b/BitMatch/Core/Services/CardEjectService.swift @@ -22,7 +22,8 @@ enum CardEjectService { /// Nil on success; a plain, displayable message on failure. static func eject(_ url: URL) async -> String? { - await Task.detached(priority: .userInitiated) { + guard isEjectable(url) else { return "This folder is not on an ejectable drive. There is nothing to eject." } + return await Task.detached(priority: .userInitiated) { do { // The source may be a folder on the card (DCIM); eject // needs the volume itself. diff --git a/BitMatchTests/CardEjectionPresentationTests.swift b/BitMatchTests/CardEjectionPresentationTests.swift index 40c95d20..8acd7782 100644 --- a/BitMatchTests/CardEjectionPresentationTests.swift +++ b/BitMatchTests/CardEjectionPresentationTests.swift @@ -38,6 +38,12 @@ struct CardEjectionPresentationTests { ) } + @Test func internalStorageCannotBeEjected() async { + let folder = FileManager.default.temporaryDirectory + #expect(!CardEjectService.isEjectable(folder)) + #expect(await CardEjectService.eject(folder) == "This folder is not on an ejectable drive. There is nothing to eject.") + } + // MARK: shouldAutoEject: verified only @Test func onlyAFullyVerifiedResultAutoEjects() { diff --git a/BitMatchTests/LocalTransferQueueIntegrationTests.swift b/BitMatchTests/LocalTransferQueueIntegrationTests.swift index 3759d6ff..546bfa75 100644 --- a/BitMatchTests/LocalTransferQueueIntegrationTests.swift +++ b/BitMatchTests/LocalTransferQueueIntegrationTests.swift @@ -1267,6 +1267,31 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { XCTAssertThrowsError(try coordinator.moveQueuedTransfer(id: firstID, to: 0)) } + /// Plant: remove the ejectability guard from queuePresentation. + func testInternalSourceDoesNotOfferOrAttemptBulkEjection() async throws { + let f = try QueueFixture() + defer { f.cleanup() } + let journal = LocalTransferJournal(fileURL: f.journalURL) + let coordinator = SharedAppCoordinator( + platformManager: QueuePlatformManager(fileOperations: QueueRecordingOperations()), + transferJournal: journal, defaults: f.defaults) + let id = try coordinator.enqueue(source: f.source, destinations: [f.destination]) + try journal.markRunning(id: id) + try journal.finish(id: id, results: [ResultRow( + path: "clip.mov", status: ResultOutcome.verified.statusText, + size: 4, checksum: "abc", destination: "backup", + destinationPath: f.destination.appendingPathComponent("clip.mov").path + )], summary: "Verified", hadIssues: false) + XCTAssertFalse(CardEjectService.isEjectable(f.source)) + XCTAssertTrue(coordinator.queuePresentation.ejectableCardIDs.isEmpty) + XCTAssertNotEqual(coordinator.queuePresentation.rows.first?.action, .eject) + let recorder = EjectRecorder() + let error = await coordinator.ejectAllSafeQueueSources { await recorder.record($0) } + XCTAssertNil(error) + let attempted = await recorder.urls + XCTAssertTrue(attempted.isEmpty) + } + func testEjectAllSafeCardsSkipsUnsafeRowsContinuesAndReportsFailures() async throws { let f = try QueueFixture() defer { f.cleanup() } @@ -1280,7 +1305,9 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { let coordinator = SharedAppCoordinator( platformManager: QueuePlatformManager(fileOperations: QueueRecordingOperations()), transferJournal: journal, - defaults: f.defaults + defaults: f.defaults, + // Simulate removable cards; the fixture folders themselves are internal. + sourceEjectability: { _ in true } ) let firstID = try coordinator.enqueue(source: f.source, destinations: [f.destination]) let secondID = try coordinator.enqueue(source: second, destinations: [f.destination]) diff --git a/BitMatchTests/SharedCompareFlowTests.swift b/BitMatchTests/SharedCompareFlowTests.swift index 24d69832..9e2dee81 100644 --- a/BitMatchTests/SharedCompareFlowTests.swift +++ b/BitMatchTests/SharedCompareFlowTests.swift @@ -522,7 +522,25 @@ struct SharedCompareFlowTests { let coordinator = await MainActor.run { SharedAppCoordinator(platformManager: platform, defaults: .isolatedWorkflowDefaults()) } - checksum.onVerify = { await coordinator.cancelOperation() } + checksum.onVerify = { + await MainActor.run { + let run = coordinator.stateService.currentOperationId + coordinator.cancelOperation() + // Plant: log only activeStartID in cancelOperation. Compare has no + // copy start ID, so its user-cancel event would lose this run. + do { + let data = try TransferDiagnosticStore.shared.exportData() + let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let events = try #require(root["events"] as? [[String: Any]]) + let id = try #require(run) + #expect(events.contains { + $0["run"] as? String == id.uuidString + && $0["event"] as? String == "cancelOrigin" + && $0["origin"] as? String == "user" + }) + } catch { Issue.record(error) } + } + } await MainActor.run { coordinator.currentMode = .compareFolders coordinator.verificationMode = .standard diff --git a/CHANGELOG.md b/CHANGELOG.md index b8945c60..40fe1ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.2.5] - 2026-10-05 + +A reliability update for checking backups and investigating transfer problems. + +- Fixed Check closing the app when a saved MHL or BitMatch report contains more than one file. +- Check now explains which folder has missing or extra files. An extra file alone does not mean the copied files are damaged. +- Diagnostics include comparison progress, outcomes and cancellation, without footage names or paths. +- Existing hidden macOS metadata conflicts now give a clearer explanation. Source files and existing backups remain protected from overwrite. +- Eject is no longer offered for folders on internal storage. + +Tested locally with signed-app transfers, existing backups, cancellation, MHL and reports across HFS+, exFAT and APFS. The saved-checksum crash is confirmed and fixed; the exact cause of the Sony transfer error in issue #8 remains unconfirmed. If a transfer or check still fails, use **History → Export diagnostics** and include the exact error. Diagnostics exclude footage names and paths. + ## [0.2.4] - 2026-10-04 A reliability update for large-file verification. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1239387f..3caf9268 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -53,6 +53,8 @@ bash test.sh release-builds # macOS and iOS simulator Release builds `ipad-test` refuses to guess a simulator; set `IOS_SIMULATOR_DESTINATION` to one installed on your machine. Set `DERIVED_DATA_ROOT` to reuse a build folder (default `.derived-data/`). +If the Mac test host stalls in `dyld` while opening Sparkle from a checkout under Desktop, try a build folder outside Desktop: `DERIVED_DATA_ROOT=/private/tmp/bitmatch-local-tests bash test.sh mac-test`. On Xcode 27.0 this allowed tests to execute without changing signing, sandbox or privacy settings. A loader stall is not a test pass; keep its log separate from the successful rerun. + `.github/workflows/ci.yml` defines Mac tests, the iPad/iPhone build and engine tests on push and pull request. Run the required checks locally before submitting changes; record any host or toolchain failures separately from passing tests. ### Change workflow diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/File/DestinationWriter.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/File/DestinationWriter.swift index e1986661..9c6a1965 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/File/DestinationWriter.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/File/DestinationWriter.swift @@ -1062,6 +1062,15 @@ public final class DestinationWriter { #if canImport(Darwin) + private static func reuseConflict(_ reason: String, relativePath: String) -> NSError { + guard (relativePath as NSString).lastPathComponent.hasPrefix("._") else { + return existingDestinationConflictError(reason) + } + return existingDestinationConflictError( + "A hidden companion file on the backup differs from the source. macOS can create or update these files on exFAT drives. BitMatch is refusing to overwrite it. Nothing was overwritten. Keep the source intact and export the results before retrying." + ) + } + private static func validateReusableExistingDestinationFile( source: URL, destination: PinnedDestinationFile, @@ -1074,7 +1083,7 @@ public final class DestinationWriter { ) async throws { let destinationInfo = try destination.snapshot() guard Int64(destinationInfo.st_size) == sourceSize else { - throw existingDestinationConflictError("Existing destination file differs in size") + throw reuseConflict("Existing destination file differs in size", relativePath: relativePath) } if verificationMode == .quick { @@ -1093,7 +1102,7 @@ public final class DestinationWriter { relativePath: relativePath, hooks: hooks ) else { - throw existingDestinationConflictError("Existing destination file bytes differ; refusing to overwrite it") + throw reuseConflict("Existing destination file bytes differ; refusing to overwrite it", relativePath: relativePath) } } @@ -1106,7 +1115,7 @@ public final class DestinationWriter { relativePath: relativePath, hooks: hooks ) else { - throw existingDestinationConflictError("Existing destination file checksum differs; refusing to overwrite it") + throw reuseConflict("Existing destination file checksum differs; refusing to overwrite it", relativePath: relativePath) } } diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/FolderComparer.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/FolderComparer.swift index 44f99048..2c796b56 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/FolderComparer.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/FolderComparer.swift @@ -21,6 +21,24 @@ public struct FolderComparer: Sendable { verificationMode: VerificationMode, progress: @Sendable (OperationProgress) async -> Void ) async throws -> CompareStats { + let run = TransferDiagnostics.runID ?? UUID() + SharedLogger.comparisonEvent(.compareStarted, run: run, mode: verificationMode) + do { + let stats = try await compareContents(left: left, right: right, + verificationMode: verificationMode, run: run, progress: progress) + SharedLogger.comparisonEvent(.compareFinished, run: run, outcome: .completed, stats: stats) + return stats + } catch { + SharedLogger.transferError(error, run: run) + SharedLogger.comparisonEvent(.compareFinished, run: run, + outcome: error is CancellationError ? .cancelled : .failed) + throw error + } + } + + private func compareContents(left: URL, right: URL, verificationMode: VerificationMode, + run: UUID, progress: @Sendable (OperationProgress) async -> Void) async throws -> CompareStats { + try Task.checkCancellation() let didStartLeftScope = fileAccess.startAccessing(url: left) let didStartRightScope = fileAccess.startAccessing(url: right) defer { @@ -32,7 +50,10 @@ public struct FolderComparer: Sendable { } } + SharedLogger.comparisonEvent(.comparePhase, run: run, phase: .listingSource) let sourceFiles = try await fileAccess.getFileList(from: left) + try Task.checkCancellation() + SharedLogger.comparisonEvent(.comparePhase, run: run, phase: .listingDestination) let destFiles = try await fileAccess.getFileList(from: right) let sourceMap = try buildFileMap(files: sourceFiles, base: left) @@ -60,6 +81,7 @@ public struct FolderComparer: Sendable { let plan = CompareCheckPlan.make(for: verificationMode) let totalCommon = common.count var processedCommon = 0 + SharedLogger.comparisonEvent(.comparePhase, run: run, phase: .checkingContents, checked: 0, total: totalCommon) await progress(OperationProgress( overallProgress: totalCommon == 0 ? 1.0 : 0.0, @@ -81,6 +103,9 @@ public struct FolderComparer: Sendable { try Task.checkCancellation() processedCommon += 1 + if processedCommon == 1 || processedCommon % 1000 == 0 || processedCommon == totalCommon { + SharedLogger.comparisonEvent(.compareProgress, run: run, checked: processedCommon, total: totalCommon) + } let overall = totalCommon == 0 ? 1.0 : Double(processedCommon) / Double(totalCommon) await progress(OperationProgress( overallProgress: overall, diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift index 901ab5c6..ab23631f 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/SavedChecksumCheck.swift @@ -405,10 +405,14 @@ public struct SavedChecksumCheck: Sendable { } var expected: [String: ExpectedFile] = [:] for entry in sorted { expected[entry.relativePath] = entry } - let usedURLs = Set(expected.values.map(\.record.url)) - let records = Array(Dictionary(uniqueKeysWithValues: sorted.map { ($0.record.url, $0.record) }).values) - .filter { usedURLs.contains($0.url) } - .sorted { $0.date > $1.date } + // Every file in a report/history shares its record URL. Collect each + // contributing record once, after choosing the newest evidence per file. + var recordsByURL: [URL: Record] = [:] + for entry in expected.values { recordsByURL[entry.record.url] = entry.record } + let records = recordsByURL.values.sorted { + if $0.date != $1.date { return $0.date > $1.date } + return $0.url.path < $1.url.path + } return Discovery(root: root, expectedFiles: expected, records: records) } diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/SharedLogger.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/SharedLogger.swift index 60418c15..c8027e53 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/SharedLogger.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/SharedLogger.swift @@ -46,6 +46,7 @@ extension SharedLogger { case terminalError, journalCancelled, journalInterrupted, journalFinished, phaseChanged case verifyStarted, verifyOpenedDestination, verifyRead, verifyDigestFinished, verifyFinished case verifyConfiguration, clipInspectionStarted, clipInspectionFinished + case compareStarted, comparePhase, compareProgress, compareFinished } public static func transferEvent(_ event: TransferEvent, run: UUID?, code: Int = 0, taskCancelled: Bool = false, explicit: Bool = false) { @@ -149,3 +150,27 @@ extension SharedLogger { } } } + + +extension SharedLogger { + public enum ComparisonPhase: String, Codable, Sendable { + case listingSource, listingDestination, checkingContents + } + public enum ComparisonOutcome: String, Codable, Sendable { case completed, failed, cancelled } + + /// Comparison diagnostics carry counts and closed vocabulary only. Paths stay in + /// the explicit differences report, never in the diagnostics export. + public static func comparisonEvent(_ event: TransferEvent, run: UUID, + mode: VerificationMode? = nil, phase: ComparisonPhase? = nil, + outcome: ComparisonOutcome? = nil, stats: CompareStats? = nil, + checked: Int? = nil, total: Int? = nil) { + TransferDiagnosticStore.shared.record(event, run: run) { + $0.verificationMode = mode; $0.comparisonPhase = phase; $0.comparisonOutcome = outcome + $0.filesProcessed = checked; $0.totalFiles = total; $0.taskCancelled = Task.isCancelled + $0.onlyInSourceCount = stats?.onlyInLeftCount + $0.onlyInDestinationCount = stats?.onlyInRightCount + $0.mismatchedCount = stats?.mismatchedCount; $0.matchingCount = stats?.commonCount + } + logger(for: .transfer).notice("event=\(event.rawValue, privacy: .public) run=\(run.uuidString, privacy: .public) comparison_phase=\(phase?.rawValue ?? "none", privacy: .public) outcome=\(outcome?.rawValue ?? "pending", privacy: .public) checked=\(checked ?? -1, privacy: .public)") + } +} diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferDiagnosticStore.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferDiagnosticStore.swift index 8ef23180..d63146f8 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferDiagnosticStore.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferDiagnosticStore.swift @@ -40,6 +40,12 @@ public final class TransferDiagnosticStore: @unchecked Sendable { var ordinal: Int? var verifyOutcome: SharedLogger.VerifyOutcome? var verifyConcurrency: Int? + var comparisonPhase: SharedLogger.ComparisonPhase? + var comparisonOutcome: SharedLogger.ComparisonOutcome? + var onlyInSourceCount: Int? + var onlyInDestinationCount: Int? + var mismatchedCount: Int? + var matchingCount: Int? /// The process's physical memory footprint, in MiB, when recorded. var footprintMB: Int? } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ComparisonDiagnosticTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ComparisonDiagnosticTests.swift new file mode 100644 index 00000000..218e9b37 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ComparisonDiagnosticTests.swift @@ -0,0 +1,70 @@ +import Foundation +import XCTest +@testable import BitMatchEngine + +final class ComparisonDiagnosticTests: XCTestCase { + private func events(_ run: UUID) throws -> [[String: Any]] { + let data = try TransferDiagnosticStore.shared.exportData() + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + return try XCTUnwrap(root["events"] as? [[String: Any]]).filter { $0["run"] as? String == run.uuidString } + } + func testSummaryCountsArePrivateAndDoNotChangeDifferences() async throws { + let fm = FileManager.default + let marker = "PRIVATE_PRODUCTION_\(UUID())" + let root = fm.temporaryDirectory.appendingPathComponent(marker) + defer { try? fm.removeItem(at: root) } + let left = root.appendingPathComponent("Source"), right = root.appendingPathComponent("Backup") + for folder in [left, right] { try fm.createDirectory(at: folder, withIntermediateDirectories: true) } + try Data("source".utf8).write(to: left.appendingPathComponent(marker)) + try Data("damage".utf8).write(to: right.appendingPathComponent(marker)) + try Data().write(to: left.appendingPathComponent("missing")) + try Data().write(to: right.appendingPathComponent("extra")) + let run = UUID() + let result = try await TransferDiagnostics.$runID.withValue(run) { + try await FolderComparer(fileAccess: LocalFileAccess(), checksum: ChecksumEngine.shared) + .compare(left: left, right: right, verificationMode: .standard, progress: { _ in }) + } + XCTAssertEqual(result.mismatchedCount, 1) + let records = try events(run) + XCTAssertEqual(records.first?["event"] as? String, "compareStarted") + XCTAssertEqual(records.last?["comparisonOutcome"] as? String, "completed") + XCTAssertEqual(records.last?["onlyInSourceCount"] as? Int, 1) + XCTAssertEqual(records.last?["onlyInDestinationCount"] as? Int, 1) + XCTAssertEqual(records.last?["mismatchedCount"] as? Int, 1) + XCTAssertFalse(String(decoding: try JSONSerialization.data(withJSONObject: records), as: UTF8.self).contains(marker)) + } + func testReadFailureRetainsPhaseAndDoesNotLogSuccessfulCompletion() async throws { + let run = UUID() + let marker = "PRIVATE_PATH_\(UUID())" + do { + _ = try await TransferDiagnostics.$runID.withValue(run) { + try await FolderComparer(fileAccess: FailingListing(), checksum: ChecksumEngine.shared) + .compare(left: URL(fileURLWithPath: "/\(marker)"), right: URL(fileURLWithPath: "/backup"), verificationMode: .standard, progress: { _ in }) + } + XCTFail("Expected unreadable source") + } catch { XCTAssertEqual((error as NSError).code, 13) } + let records = try events(run) + XCTAssertEqual(records.first { $0["event"] as? String == "comparePhase" }?["comparisonPhase"] as? String, "listingSource") + XCTAssertEqual(records.last?["comparisonOutcome"] as? String, "failed") + XCTAssertNil(records.last?["matchingCount"]) + XCTAssertFalse(String(decoding: try JSONSerialization.data(withJSONObject: records), as: UTF8.self).contains(marker)) + } + func testCancellationStillThrowsAndHasNoCompletedSummary() async throws { + let run = UUID() + let task = Task { + try await TransferDiagnostics.$runID.withValue(run) { + try await FolderComparer(fileAccess: CancelledListing(), checksum: ChecksumEngine.shared) + .compare(left: URL(fileURLWithPath: "/source"), right: URL(fileURLWithPath: "/backup"), verificationMode: .standard, progress: { _ in }) + } + } + do { _ = try await task.value; XCTFail("Cancellation must propagate") } + catch { XCTAssertTrue(error is CancellationError) } + XCTAssertEqual(try events(run).last?["comparisonOutcome"] as? String, "cancelled") + } +} +private final class FailingListing: FakeFileAccess, @unchecked Sendable { + override func getFileList(from folderURL: URL) async throws -> [URL] { throw NSError(domain: NSPOSIXErrorDomain, code: 13, userInfo: [NSFilePathErrorKey: folderURL.path]) } +} +private final class CancelledListing: FakeFileAccess, @unchecked Sendable { + override func getFileList(from folderURL: URL) async throws -> [URL] { throw CancellationError() } +} diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ExFATDestinationTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ExFATDestinationTests.swift index ff355d3d..5288b6f9 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ExFATDestinationTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ExFATDestinationTests.swift @@ -125,4 +125,47 @@ final class ExFATDestinationTests: XCTestCase { XCTAssertEqual(result.verificationResult?.isValid, true) } } + /// Real exFAT AppleDouble files generated by differing quarantine attributes. + /// Plant: bypass existing companion checksum validation; refusal assertions fail. + func testDifferentAutoGeneratedCompanionNeverOverwritesOrMakesTransferSafe() async throws { + let fm = FileManager.default + let source = image.deletingLastPathComponent().appendingPathComponent("Card") + let seed = mountPoint.appendingPathComponent("Seed") + let backup = mountPoint.appendingPathComponent("Backup") + let output = backup.appendingPathComponent("Card") + for directory in [source, seed, output] { try fm.createDirectory(at: directory, withIntermediateDirectories: true) } + let bytes = Data("unchanged media".utf8) + for directory in [source, seed, output] { try bytes.write(to: directory.appendingPathComponent("clip.bin")) } + func quarantine(_ file: URL, value: String) throws { + let process = Process(); process.executableURL = URL(fileURLWithPath: "/usr/bin/xattr") + process.arguments = ["-w", "com.apple.quarantine", value, file.path] + try process.run(); process.waitUntilExit(); XCTAssertEqual(process.terminationStatus, 0) + } + try quarantine(seed.appendingPathComponent("clip.bin"), value: "0081;00000001;FixtureSource;") + try quarantine(output.appendingPathComponent("clip.bin"), value: "0081;00000002;FixtureDestination;") + let sourceCompanion = try Data(contentsOf: seed.appendingPathComponent("._clip.bin")) + let originalDestinationCompanion = try Data(contentsOf: output.appendingPathComponent("._clip.bin")) + XCTAssertNotEqual(sourceCompanion, originalDestinationCompanion) + try sourceCompanion.write(to: source.appendingPathComponent("._clip.bin")) + let operation = try await TransferPipeline(fileSystem: LocalFileAccess(), checksum: ChecksumEngine.shared) + .performFileOperation(sourceURL: source, destinationURLs: [backup], verificationMode: .standard, + settings: CameraLabelSettings(), progressCallback: { _ in }, onFileResult: nil) + XCTAssertEqual(operation.results.count, 2) + let media = try XCTUnwrap(operation.results.first { $0.sourceURL.lastPathComponent == "clip.bin" }) + let companion = try XCTUnwrap(operation.results.first { $0.sourceURL.lastPathComponent == "._clip.bin" }) + XCTAssertEqual(media.outcome, .verified) + XCTAssertEqual(companion.outcome, .failed) + let verdict = TransferCompletion.verdict( + rows: TransferCompletion.rows(from: operation), + sourceFiles: [source.appendingPathComponent("clip.bin"), source.appendingPathComponent("._clip.bin")], + destinations: [backup], source: source, settings: CameraLabelSettings(), mode: .standard, + generateASCMHL: false, handoffIssues: [], reportIssue: nil, + project: .init(didPersist: true, locallySafe: nil)) + XCTAssertFalse(verdict.success) + XCTAssertTrue(companion.error?.localizedDescription.contains("hidden companion") == true) + XCTAssertEqual(try Data(contentsOf: output.appendingPathComponent("._clip.bin")), originalDestinationCompanion) + XCTAssertEqual(try Data(contentsOf: source.appendingPathComponent("._clip.bin")), sourceCompanion) + XCTAssertEqual(try Data(contentsOf: source.appendingPathComponent("clip.bin")), bytes) + } + } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/SavedChecksumCheckTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/SavedChecksumCheckTests.swift index 3f62e585..eaf6024a 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/SavedChecksumCheckTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/SavedChecksumCheckTests.swift @@ -94,6 +94,46 @@ struct SavedChecksumCheckTests { #expect(result.records.first?.kind == .ascMHL) } + /// Plant: use Dictionary(uniqueKeysWithValues:) for per-file record URLs. + @Test(arguments: [SavedChecksumCheck.RecordKind.ascMHL, .bitMatchReport]) + func multiFileRecordIsDeduplicatedWithoutLosingFiles(kind: SavedChecksumCheck.RecordKind) async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let first = try writeFile("first.mov", text: "first", under: root) + let second = try writeFile("second.mov", text: "second", under: root) + let record: URL + if kind == .ascMHL { + try writeMHL(root: root, path: "first.mov", sha256: sha256("first")) + record = root.appendingPathComponent("ascmhl/0001_test.mhl") + let xml = try String(contentsOf: record, encoding: .utf8) + .replacingOccurrences(of: "", with: + "second.mov\(sha256("second"))") + try xml.write(to: record, atomically: true, encoding: .utf8) + } else { + record = try writeReport(root: root, target: first, checksum: sha256("first"), date: date(2026, 2, 1)) + var object = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: record)) as? [String: Any]) + var rows = try #require(object["results"] as? [[String: Any]]) + rows.append(["target": second.path, "status": ResultOutcome.verified.statusText, "checksum": sha256("second")]) + object["results"] = rows + try JSONSerialization.data(withJSONObject: object).write(to: record) + } + let originalRecord = try Data(contentsOf: record) + let checker = makeChecker() + let discovery = try await checker.discover(under: root) + #expect(discovery.expectedFiles.count == 2) + #expect(discovery.records.count == 1) + #expect(discovery.records.first?.kind == kind) + let matching = try await checker.check(discovery) { _ in } + #expect(matching.isIntact) + #expect(matching.matchingPaths == ["first.mov", "second.mov"]) + try Data("broken".utf8).write(to: second) + let changed = try await checker.check(discovery) { _ in } + #expect(!changed.isIntact) + #expect(changed.matchingPaths == ["first.mov"]) + #expect(changed.changedPaths == ["second.mov"]) + #expect(try Data(contentsOf: record) == originalRecord) + } + @Test func rootWithoutRecordsReportsNotFound() async throws { let root = try makeRoot() defer { try? FileManager.default.removeItem(at: root) } diff --git a/Shared/Core/Services/OperationStateService.swift b/Shared/Core/Services/OperationStateService.swift index 7079adea..3769c16f 100644 --- a/Shared/Core/Services/OperationStateService.swift +++ b/Shared/Core/Services/OperationStateService.swift @@ -18,7 +18,7 @@ class OperationStateService: ObservableObject { @Published var pauseResumeCapabilities = PauseResumeCapabilities() // MARK: - Private State - private var currentOperationId: UUID? + private(set) var currentOperationId: UUID? /// Single source of truth for state-transition legality. `currentState` /// stays the published facade; every mutation goes through `applyTransition`. diff --git a/Shared/Core/Services/SharedAppCoordinator.swift b/Shared/Core/Services/SharedAppCoordinator.swift index aae45d46..91456a10 100644 --- a/Shared/Core/Services/SharedAppCoordinator.swift +++ b/Shared/Core/Services/SharedAppCoordinator.swift @@ -431,6 +431,7 @@ class SharedAppCoordinator: ObservableObject { var folderInfoLoadingState: [URL: Bool] { folderInfoService.folderInfoLoadingState } private var cancellables = Set() + private let sourceEjectability: (@Sendable (URL) -> Bool)? private var activeStartID: UUID? private var startCancellationRequested = false private var cancellationOrigin: TransferCancelOrigin = .user @@ -454,9 +455,11 @@ class SharedAppCoordinator: ObservableObject { projectStore: (any PhotographerJobStore)? = nil, photographerJobViewModel: PhotographerJobViewModel? = nil, defaults: UserDefaults = .standard, - physicalDiskIdentityProvider: any PhysicalDiskIdentityProviding = SystemPhysicalDiskIdentityProvider() + physicalDiskIdentityProvider: any PhysicalDiskIdentityProviding = SystemPhysicalDiskIdentityProvider(), + sourceEjectability: (@Sendable (URL) -> Bool)? = nil ) { self.platformManager = platformManager + self.sourceEjectability = sourceEjectability self.physicalDiskIdentityProvider = physicalDiskIdentityProvider self.defaults = defaults let environment = ProcessInfo.processInfo.environment @@ -1428,7 +1431,10 @@ class SharedAppCoordinator: ObservableObject { let records = transferJournal.records.filter { queueSessionRecordIDs.contains($0.id) } let mounted = Set(records.compactMap { record -> UUID? in guard let access = try? transferJournal.prepareSourceForEjection(id: record.id) else { return nil } - access.release() + defer { access.release() } + #if os(macOS) + guard (sourceEjectability?(access.sourceURL) ?? CardEjectService.isEjectable(access.sourceURL)) else { return nil } + #endif return record.id }) return QueueSessionPresentation.make( @@ -2248,7 +2254,7 @@ class SharedAppCoordinator: ObservableObject { func cancelOperation(origin: TransferCancelOrigin = .user) { cancellationOrigin = origin - SharedLogger.cancelEvent(origin, run: activeStartID) + SharedLogger.cancelEvent(origin, run: activeStartID ?? stateService.currentOperationId) queueIsRunning = false if activeStartID != nil { startCancellationRequested = true @@ -2468,14 +2474,16 @@ class SharedAppCoordinator: ObservableObject { speed: nil) do { - let stats = try await comparisonCoordinator.compareFolders( - left: left, - right: right, - verificationMode: comparedMode, - onProgress: { [weak self] prog in - self?.progress = prog - } - ) + let stats = try await TransferDiagnostics.$runID.withValue(operationID) { + try await comparisonCoordinator.compareFolders( + left: left, + right: right, + verificationMode: comparedMode, + onProgress: { [weak self] prog in + self?.progress = prog + } + ) + } if Task.isCancelled || comparisonCoordinator.isCancellationRequested { throw CancellationError() } diff --git a/Shared/Views/CompareResultsView.swift b/Shared/Views/CompareResultsView.swift index b54a7c4c..3ef93650 100644 --- a/Shared/Views/CompareResultsView.swift +++ b/Shared/Views/CompareResultsView.swift @@ -42,12 +42,12 @@ struct CompareResultsView: View { .foregroundStyle(.secondary) if !stats.isClean { pathSection( - title: "Only in \(leftName)", + title: "Missing from \(rightName)", systemImage: "minus.circle", paths: stats.onlyInLeftPaths ) pathSection( - title: "Only in \(rightName)", + title: "Extra in \(rightName)", systemImage: "plus.circle", paths: stats.onlyInRightPaths ) @@ -56,7 +56,7 @@ struct CompareResultsView: View { systemImage: "exclamationmark.triangle", paths: stats.mismatchedPaths ) - Text("Copy a path from the lists above when reporting a difference.") + Text("Expand a list to see the files. Extra files do not mean the matching copies are damaged. Missing files or mismatches need attention before clearing the source. Export differences includes file paths; diagnostics does not.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/docs/ISSUE8_SONY_VENICE.md b/docs/ISSUE8_SONY_VENICE.md index d725d20a..b5b2ebf5 100644 --- a/docs/ISSUE8_SONY_VENICE.md +++ b/docs/ISSUE8_SONY_VENICE.md @@ -41,3 +41,62 @@ BITMATCH_ISSUE8_METADATA=/private/local/card-folder arch -arm64 swift test --pac ``` Expanded original-metadata run: 12 metadata cases (eight mode/content combinations on internal storage, four Standard/content/filesystem combinations from exFAT and HFS+) plus four synthetic internal-storage controls passed in 22.588 seconds, zero failures. Independent SHA-256 checks confirmed all 33 original downloaded files remained unchanged. The earlier full engine suite passed; this follow-up changes only opt-in tests and notes, and does not claim a new full app/UI validation. + + +## Signed-app and diagnostic follow-up + +The official signed/notarized 0.2.4 build passed a picker-driven Standard copy of all 33 supplied metadata files to internal storage, followed by ASC MHL and PDF/CSV/JSON reports. Check reported 33 matching files. Independent SHA-256 checks matched all source/destination files. The bundle contains no original MOV footage, so this does not reproduce the reporter's complete card or physical reader. + +The follow-up adds comparison phase, count and terminal events correlated to the app run ID. These contain closed enums, counts and error classifications, never file names, footage paths or raw error messages. Failures and cancellation propagate. Differences lists now say missing/extra in the comparison folder, explain that an extra file alone does not imply damaged copies, and distinguish the path-bearing differences export from diagnostics. + +A disposable mounted exFAT regression reproduced different valid AppleDouble companion files generated by different quarantine attributes. Standard verified the identical media but refused the differing existing companion. Both companions and the source media were preserved, and the overall completion verdict remained unsafe. The change explains this refusal; it does not delete companions, overwrite them or exempt them from copy verification. This is a confirmed local conflict, not an established cause of issue #8. + +The walkthrough also found Eject offered for a completed internal-folder transfer, producing an OS error. Mac queue actions now require an ejectable volume, and the eject service returns a readable refusal for internal storage. + +Local validation of the production follow-up: + +- Full engine: 114 XCTest cases, 15 opt-in skips, zero failures (57.842s); 131 Swift Testing tests in 29 suites, zero failures (5.154s). +- Final focused comparison/exFAT run after adding the explicit unsafe verdict assertion: eight XCTest cases, zero failures (30.190s). +- Final Mac app and iOS Simulator builds: BUILD SUCCEEDED. +- Seeded mobile workflow/history snapshot tests: two tests passed; final workflow capture includes comparison at iPhone and iPad widths. Images were inspected for layout; this is not a physical-device picker test. +- Updated Mac app opened normally and displayed the intentional extra file under the new Extra list, with all 33 original files matching. Temporary selections and the test queue row were cleared; unrelated queue entries were preserved. +- Mac focused app test attempts (unsigned, then ad-hoc signed) stalled before tests started. Process samples show the host in dyld dependency loading. Only the owned stalled test processes were stopped. These are not passing tests, and no full Mac app-suite pass is claimed. +- git diff --check passed. Local logs and captures remain under dist/validation/followups, outside version control. No reporter metadata is included in commits. + +No new release is justified solely by this metadata-only reproduction. Keep issue #8 open pending the reporter's tested version and exact Copy & Verify error. + + +## Release review, 5 October 2026 + +The follow-up Mac test-host stall was resolved by moving derived data outside Desktop. LLDB observed dyld opening the built Sparkle framework under the Desktop checkout; the external build folder allowed the tests to run without signing, sandbox or privacy changes. This is an observed workaround, not a proven explanation of the OS loader behavior. + +Review found that Check cancellation logged no run ID because `activeStartID` belongs to copy operations. Cancellation now falls back to the current operation ID before clearing state. The comparison cancellation test failed with the old logging and passed with the fix. The bulk-eject test now explicitly models removable cards rather than treating internal temporary folders as removable. A separate test rejects internal folders, including bulk ejection. Removing the ejectability guard caused that test to fail by assertion. + +Local release gates: + +- Full Mac app suite: 1,063 passed, eight skipped, zero failed (xcresult summary; 1,071 tests). +- Optimized Release engine: 114 XCTest cases, 15 opt-in skips, zero failures; 131 Swift Testing tests across 29 suites, zero failures. +- Final Mac and iOS Simulator Release builds passed. +- Real-filesystem executor matrix: HFS+ source to exFAT and case-sensitive APFS, Standard, two destinations, initial transfer and verified-existing-file reuse. Both transfers passed with 52 result rows each, MHL and reports. This is one selected combination, not a rerun of the entire broad matrix. +- A Developer ID signed review archive was built through `Scripts/release_mac.sh` with notarization explicitly skipped. Its bundle version remains 0.2.4; it is a local review artifact, not a published update. +- Picker-driven signed-app Standard transfer: 33 original metadata files plus a 1 GiB synthetic file, HFS+ to exFAT and APFS. All 68 destination results verified; independent SHA-256 matched all 34 source files at both destinations. Both ASC MHL inventories contained all 34 files, and the JSON report recorded 68 rows and a safe verdict. PDF, CSV and JSON reports were generated. Success persisted after relaunch. +- A signed-app retry of 2,000 small files was cancelled through the menu. The dialog was opened while verification was visible, but the exported log proves confirmation reached the executor during reporting. The log recorded menu origin and explicit journal cancellation; history showed Interrupted / not safe to erase. No successful journal finish was recorded. All 2,000 source files and all 4,005 pre-existing destination files (including AppleDouble and MHL history) remained byte-identical. This walkthrough is reporting cancellation coverage, not mid-verification cancellation coverage. +- The retry's existing MHL history was refused rather than overwritten. This is expected safety behavior and is distinct from the clean initial MHL publication. + +Logs, xcresult summary and private diagnostic exports remain local under `dist/validation/release-review/`. No original MOV footage or physical Sony reader was tested, and issue #8's later Copy & Verify failure remains unproven. The maintenance changes can be evaluated for release on their own evidence; do not describe the reporter's issue as resolved. + +### Check picker crash found during final walkthrough + +Selecting a copied card containing a multi-file MHL repeatedly terminated the signed review app. LLDB on the local Debug build identified a Swift fatal duplicate-key trap in `SavedChecksumCheck.makeDiscovery`: every file points to the same record URL, but the record list used `Dictionary(uniqueKeysWithValues:)`. This also affected multi-file JSON reports. Existing tests used one file per record and missed it. + +The fix collects contributing records by URL after resolving the newest evidence per file. It preserves every file entry, report precedence and corruption detection. A parameterized two-file MHL/JSON regression reproduced both fatal traps before the fix; it verifies both files, changes one file, then requires the mismatch while preserving the record bytes. This is a confirmed Check bug, not evidence of the cause of issue #8's transfer failure. + +Final verification after the picker-crash fix: + +- The first parallel Release engine rerun passed all 114 XCTest cases (15 skips) and the new multi-file regression, but three pre-existing concurrency tests missed their start/completion deadlines while builds were running: `cancellationMidBroadcastPublishesNothingAndReportsNoSuccess`, `testStalePauseDoesNotBlockNextOperation`, and `failedRunWaitsForInFlightVerifiers`. The Swift Testing runner subsequently stalled; its process sample was saved and only that owned run was stopped. This run is not counted as a pass. +- With the builds finished, `swift test -c release --skip-build --no-parallel` passed: 114 XCTest cases, 15 skips, zero failures (68.075s); 132 Swift Testing tests in 29 suites, zero failures (32.090s). No test deadlines or assertions were weakened. +- Full Mac suite rerun: 1,063 passed, eight skipped, zero failures. Final Mac and iOS Simulator Release builds passed. The signed review archive was rebuilt with the final code. +- The rebuilt signed app selected the same exFAT backup without crashing and re-read its MHL: 34 files intact, one contributing MHL record. Folder comparison also completed with all 34 original files matching; 48 generated MHL/filesystem entries present only on the selected backup were reported separately. Its seven comparison diagnostic events share one run ID, end in completed with zero mismatches, and contain no private fixture names or paths. +- Independent hashes again confirmed all 34 source files unchanged. All three disposable images were detached without force and removed. Task-owned build caches were removed; signed review artifacts, reports, diagnostics, logs and xcresults were retained locally. Unrelated queue entries were preserved. + +These results support a maintenance release for the confirmed fixes, while keeping issue #8 open for the reporter's confirmation.