From d648364ce307af81b76ff25e97a537bd63863032 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 10 Oct 2026 20:06:02 -0400 Subject: [PATCH 1/2] Recheck queued folder identities and show recovery per backup --- .../Views/AdaptiveQueuePresentation.swift | 3 + .../WorkflowSnapshotTests.swift | 28 +++++ BitMatch/App/ContentView.swift | 28 ++++- BitMatchTests/LocalTransferJournalTests.swift | 26 +++++ .../LocalTransferQueueIntegrationTests.swift | 76 +++++++++++++ BitMatchTests/MainScreenQueueTests.swift | 62 ++++++++++- .../SharedCoordinatorTestSupport.swift | 1 + .../TransferRecoverySummaryTests.swift | 51 +++++++++ BitMatchTests/WorkflowSnapshotTests.swift | 25 +++++ CHANGELOG.md | 5 + .../BitMatchEngine/TransferJournal.swift | 104 +++++++++++++++--- .../QueueAdmissionTests.swift | 100 +++++++++++++++++ Shared/Core/Models/ResultPresentation.swift | 42 +++++++ .../Core/Services/LocalTransferJournal.swift | 12 +- .../Core/Services/SharedAppCoordinator.swift | 69 +++++++++++- .../Services/TransferHistoryDocument.swift | 10 +- Shared/Views/TransferLibraryView.swift | 47 ++++++++ Shared/Views/TransferQueueSection.swift | 3 + docs/QUEUE_RECOVERY_VALIDATION.md | 45 ++++++++ docs/TODO.md | 10 +- 20 files changed, 718 insertions(+), 29 deletions(-) create mode 100644 BitMatchTests/TransferRecoverySummaryTests.swift create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift create mode 100644 docs/QUEUE_RECOVERY_VALIDATION.md diff --git a/BitMatch-iPad/Views/AdaptiveQueuePresentation.swift b/BitMatch-iPad/Views/AdaptiveQueuePresentation.swift index d0e0708e..73a29b51 100644 --- a/BitMatch-iPad/Views/AdaptiveQueuePresentation.swift +++ b/BitMatch-iPad/Views/AdaptiveQueuePresentation.swift @@ -88,6 +88,9 @@ struct CompactQueuePresentation: View { value: accessory.isVisible ) .sensoryFeedback(.success, trigger: state.successFeedbackCount) + .onChange(of: coordinator.editingSetupTransferID) { _, id in + if id != nil { state.showingQueue = false } + } .onChange(of: coordinator.queuePresentation.rows) { oldRows, newRows in state.update(previousRows: oldRows, currentRows: newRows) } diff --git a/BitMatch-iPadTests/WorkflowSnapshotTests.swift b/BitMatch-iPadTests/WorkflowSnapshotTests.swift index ff93484a..8539242f 100644 --- a/BitMatch-iPadTests/WorkflowSnapshotTests.swift +++ b/BitMatch-iPadTests/WorkflowSnapshotTests.swift @@ -203,6 +203,34 @@ final class WorkflowSnapshotTests: XCTestCase { try? data.write(to: directory.appendingPathComponent("\(name).png"), options: .atomic) } + func testCaptureRecoveryDetails() throws { + try requireCaptureConfiguration() + let fixture = try SnapshotFixture() + defer { fixture.restoreGlobalPreferences() } + var previous = LocalTransferRecord(id: UUID(), createdAt: Date(timeIntervalSince1970: 1791600000), + source: try LocalTransferResource(url: fixture.source), + destinations: try [fixture.backup, fixture.secondBackup].map(LocalTransferResource.init(url:)), + verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + previous.state = .interrupted + var retry = LocalTransferRecord(id: UUID(), createdAt: Date(), source: previous.source, + destinations: previous.destinations, verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(), retryOf: previous.id) + retry.state = .interrupted + retry.plannedFileCounts = [5, 5] + retry.results = [ResultRow(path: "a.mov", status: ResultOutcome.verified.statusText, size: 1, + checksum: "abc", destination: fixture.backup.lastPathComponent, + destinationPath: fixture.backup.appendingPathComponent("a.mov").path, wasReused: true), + ResultRow(path: "b.mov", status: ResultOutcome.failed.statusText, size: 1, checksum: nil, + destination: fixture.secondBackup.lastPathComponent, + destinationPath: fixture.secondBackup.appendingPathComponent("b.mov").path)] + try capture(TransferRecoveryDetails(record: retry, history: [retry, previous]) + .padding().frame(maxWidth: .infinity, alignment: .leading), + size: CGSize(width: 393, height: 480), name: "iphone-recovery-details") + try capture(TransferRecoveryDetails(record: retry, history: [retry, previous]) + .padding().frame(maxWidth: .infinity, alignment: .leading), + size: CGSize(width: 820, height: 600), name: "ipad-recovery-details") + } + private func settle() async { for _ in 0..<8 { await Task.yield() } try? await Task.sleep(nanoseconds: 100_000_000) diff --git a/BitMatch/App/ContentView.swift b/BitMatch/App/ContentView.swift index a6004467..fe8f24a7 100644 --- a/BitMatch/App/ContentView.swift +++ b/BitMatch/App/ContentView.swift @@ -252,7 +252,13 @@ struct MacMainView: View { .environment(\.hiddenQueueRowID, queueFlight?.rowID) .overlay(alignment: .topLeading) { queueFlightCard } .onPreferenceChange(ComposerFrameKey.self) { frame in - if let frame { composerFrame = frame } + if let frame { + composerFrame = frame + if queueFlight?.returnsToComposer == true, frame != flightTarget { + flightTarget = frame + withAnimation(.spring(duration: 0.45, bounce: 0.1)) { queueFlight?.frame = frame } + } + } } .onPreferenceChange(QueueRowFramesKey.self) { frames in queueRowFramesChanged(frames) @@ -271,6 +277,7 @@ struct MacMainView: View { let rowID: UUID let text: String var frame: CGRect + var returnsToComposer = false } @ViewBuilder @@ -318,7 +325,7 @@ struct MacMainView: View { queueRowFrames = frames // The new row is still sliding into place while the card flies; keep // aiming at where it actually is. - if let flight = queueFlight, let target = frames[flight.rowID], target != flightTarget { + if let flight = queueFlight, !flight.returnsToComposer, let target = frames[flight.rowID], target != flightTarget { flightTarget = target withAnimation(.spring(duration: 0.45, bounce: 0.1)) { queueFlight?.frame = target } } @@ -546,6 +553,7 @@ struct MacMainView: View { ) { queueTransitionClearTask?.cancel() var committedID: UUID? + let previousEditingID = coordinator.editingSetupTransferID let animation: Animation = reduceMotion ? .easeInOut(duration: 0.18) : .spring(duration: 0.4, bounce: 0.18) @@ -554,6 +562,22 @@ struct MacMainView: View { withAnimation(animation) { committedID = operation() } + if !reduceMotion, previousEditingID == nil, + let id = coordinator.editingSetupTransferID, + let from = queueRowFrames[id], let to = composerFrame, + let source = coordinator.sourceURL { + clearedComposer = nil + pendingFlightRow = nil + flightEndTask?.cancel() + flightTarget = to + queueFlight = QueueFlight(rowID: id, text: composerSummary(for: source), frame: from, returnsToComposer: true) + withAnimation(.spring(duration: 0.55, bounce: 0.12)) { queueFlight?.frame = to } + flightEndTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(700)) + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: 0.12)) { queueFlight = nil } + } + } guard committedID != nil else { return } queueTransitionClearTask = Task { try? await Task.sleep(for: .milliseconds(500)) diff --git a/BitMatchTests/LocalTransferJournalTests.swift b/BitMatchTests/LocalTransferJournalTests.swift index 5fc1358f..1290ed7d 100644 --- a/BitMatchTests/LocalTransferJournalTests.swift +++ b/BitMatchTests/LocalTransferJournalTests.swift @@ -77,6 +77,9 @@ struct LocalTransferJournalTests { verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs() ) var object = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(record)) as? [String: Any]) + var oldSource = try #require(object["source"] as? [String: Any]) + oldSource.removeValue(forKey: "creationDate") + object["source"] = oldSource object.removeValue(forKey: "sourceFingerprint") object.removeValue(forKey: "independentDestinationCount") @@ -84,10 +87,33 @@ struct LocalTransferJournalTests { LocalTransferRecord.self, from: JSONSerialization.data(withJSONObject: object) ) + #expect(decoded.source.creationDate == nil) + #expect(decoded.retryOf == nil) + #expect(decoded.plannedFileCounts == nil) #expect(decoded.sourceFingerprint == nil) #expect(decoded.independentDestinationCount == nil) } + @Test func recoveryExportsKeepLineageAndCountsWithoutAccessTokens() throws { + let f = try fixture() + defer { try? FileManager.default.removeItem(at: f.root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: f.source), + destinations: [try LocalTransferResource(url: f.destination)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(), retryOf: UUID()) + record.plannedFileCounts = [2] + record.results = [ResultRow(path: "a.mov", status: ResultOutcome.verified.statusText, size: 1, + checksum: "abc", destination: "Backup", wasReused: true)] + let json = try TransferHistoryDocument(record: record, asCSV: false) + let payload = try #require(JSONSerialization.jsonObject(with: json.data) as? [String: Any]) + #expect(payload["retryOf"] as? String == record.retryOf?.uuidString) + #expect(payload["plannedFileCounts"] as? [Int] == [2]) + #expect(!String(decoding: json.data, as: UTF8.self).contains(record.source.bookmark.base64EncodedString())) + let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) + #expect(csv.contains("client,reused")) + #expect(csv.contains("\"retryOf\",\"\(try #require(record.retryOf).uuidString)\"")) + #expect(csv.contains("\"plannedFileCounts\",\"2\"")) + } + @Test func runningAttemptPersistsIndependentDestinationCount() throws { let f = try fixture() defer { try? FileManager.default.removeItem(at: f.root) } diff --git a/BitMatchTests/LocalTransferQueueIntegrationTests.swift b/BitMatchTests/LocalTransferQueueIntegrationTests.swift index 546bfa75..fddb29ae 100644 --- a/BitMatchTests/LocalTransferQueueIntegrationTests.swift +++ b/BitMatchTests/LocalTransferQueueIntegrationTests.swift @@ -61,6 +61,7 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { for record in [first, second] { let row = try XCTUnwrap(record.results.first) XCTAssertTrue(row.isSuccessStatus) + XCTAssertEqual(record.plannedFileCounts, [1]) let sourceData = try Data(contentsOf: URL(fileURLWithPath: row.path)) let copiedData = try Data(contentsOf: URL(fileURLWithPath: XCTUnwrap(row.destinationPath))) XCTAssertEqual(sourceData, copiedData) @@ -72,6 +73,55 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { XCTAssertTrue(onDisk.allSatisfy { $0.state == .completed && $0.results.count == 1 }) } + func testRealRecoveryMatrixRetainsConflictsAndCountsReusedAndNewCopies() async throws { + for mode in [VerificationMode.standard, .thorough, .paranoid] { + let f = try QueueFixture() + defer { f.cleanup() } + let secondBackup = f.root.appendingPathComponent("Backup B") + let output = f.destination.appendingPathComponent(f.source.lastPathComponent) + for url in [secondBackup, output] { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } + let existing = try Data(contentsOf: f.source.appendingPathComponent("clip.mov")) + try existing.write(to: output.appendingPathComponent("clip.mov")) + try Data("new file".utf8).write(to: f.source.appendingPathComponent("new.bin")) + try Data("original source contents".utf8).write(to: f.source.appendingPathComponent("conflict.bin")) + let conflict = Data("pre-existing contents must survive".utf8) + try conflict.write(to: output.appendingPathComponent("conflict.bin")) + let journal = LocalTransferJournal(fileURL: f.journalURL) + let original = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination, secondBackup], + verificationMode: mode, cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(makeReport: false), generateASCMHL: false) + try journal.markRunning(id: original) + try journal.recordPlan(id: original, fileCounts: [3, 3]) + try journal.interrupt(id: original, summary: "Previous interrupted attempt", results: []) + let retry = try journal.requeue(id: original) + 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(20)) { @MainActor in + journal.records.first { $0.id == retry }?.state == .issues && !coordinator.isOperationInProgress + } + XCTAssertTrue(ended, coordinator.queueMessage ?? "Recovery did not settle") + let record = try XCTUnwrap(journal.records.first { $0.id == retry }) + XCTAssertEqual(record.retryOf, original) + XCTAssertEqual(record.plannedFileCounts, [3, 3]) + let counts = TransferRecoverySummary.make(record) + XCTAssertEqual(counts.count, 2) + XCTAssertEqual(counts[0].reused, 1) + XCTAssertEqual(counts[0].copied, 1) + XCTAssertEqual(counts[0].failed, 1) + XCTAssertEqual(counts[0].pending, 0) + XCTAssertEqual(counts[1].copied, 3) + XCTAssertEqual(counts[1].failed, 0) + XCTAssertEqual(counts[1].pending, 0) + XCTAssertEqual(try Data(contentsOf: output.appendingPathComponent("conflict.bin")), conflict) + XCTAssertEqual(try Data(contentsOf: f.source.appendingPathComponent("clip.mov")), existing) + XCTAssertEqual(try Data(contentsOf: f.source.appendingPathComponent("conflict.bin")), Data("original source contents".utf8)) + XCTAssertEqual(journal.records.first { $0.id == original }?.state, .interrupted) + XCTAssertTrue(journal.records.first { $0.id == original }?.results.isEmpty == true) + } + } + func testQueuedEmptySourceIsRejectedAndNeverLooksComplete() async throws { let f = try QueueFixture() defer { f.cleanup() } @@ -968,6 +1018,32 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { ).isEmpty) } + func testReplacementAfterPreflightNeverReachesTheCopyExecutor() async throws { + for replaceSource in [true, false] { + let f = try QueueFixture() + defer { f.cleanup() } + let selected = replaceSource ? f.source : f.destination + let journal = LocalTransferJournal(fileURL: f.journalURL) { _ in + try FileManager.default.moveItem(at: selected, to: f.root.appendingPathComponent("original-moved")) + try FileManager.default.createDirectory(at: selected, withIntermediateDirectories: true) + try Data("replacement".utf8).write(to: selected.appendingPathComponent("different.mov")) + } + let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination], + verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + let operations = QueueRecordingOperations() + let coordinator = SharedAppCoordinator(platformManager: QueuePlatformManager(fileOperations: operations), + transferJournal: journal, defaults: f.defaults) + coordinator.startQueue() + let paused = await waitUntil { @MainActor in coordinator.queuePausedRecordID == id } + XCTAssertTrue(paused) + let starts = await operations.starts + XCTAssertTrue(starts.isEmpty) + XCTAssertEqual(journal.records.first?.state, .failed) + XCTAssertNil(journal.records.first?.startedAt) + XCTAssertTrue(journal.records.first?.results.isEmpty == true) + } + } + func testMarkRunningFailureIsJournaledAsSkippableFailureBeforePause() async throws { struct InjectedStartError: LocalizedError { var errorDescription: String? { "Injected markRunning failure" } diff --git a/BitMatchTests/MainScreenQueueTests.swift b/BitMatchTests/MainScreenQueueTests.swift index fae78f12..ba8f3c3a 100644 --- a/BitMatchTests/MainScreenQueueTests.swift +++ b/BitMatchTests/MainScreenQueueTests.swift @@ -59,6 +59,7 @@ struct MainScreenQueueTests { let fixture = try await SharedProjectFixture.make(prepareCard: false) defer { fixture.folders.cleanup() } let coordinator = fixture.coordinator + #expect(await waitUntil { coordinator.canEnqueueSelection }) try coordinator.enqueueSelection() let id = try #require(coordinator.transferJournal.records.first?.id) try coordinator.transferJournal.markRunning(id: id) @@ -247,6 +248,8 @@ struct MainScreenQueueTests { let coordinator = fixture.coordinator coordinator.verificationMode = .standard coordinator.cameraLabelSettings.label = "Original label" + coordinator.reportSettings.operatorName = "Alex" + coordinator.reportSettings.notes = "Keep original notes" try coordinator.enqueueSelection() let id = try #require(coordinator.stagedSetupTransfers.first?.id) let originalDestinations = coordinator.destinationURLs @@ -261,11 +264,13 @@ struct MainScreenQueueTests { try coordinator.editSetupTransfer(id) coordinator.destinationURLs = [fixture.folders.primary] coordinator.verificationMode = .paranoid + coordinator.reportSettings.notes = "Discarded edit" coordinator.cancelSetupTransferEdit() #expect(coordinator.editingSetupTransferID == nil) #expect(coordinator.sourceURL == nil) #expect(coordinator.destinationURLs == originalDestinations) #expect(coordinator.verificationMode == .standard) + #expect(coordinator.reportSettings.notes == "Keep original notes") let replacementSource = fixture.folders.root.appendingPathComponent("replacement-card", isDirectory: true) try FileManager.default.createDirectory(at: replacementSource, withIntermediateDirectories: true) @@ -291,9 +296,64 @@ struct MainScreenQueueTests { #expect(!updated.generateASCMHL) #expect(updated.cameraSettings.label == "Camera C") #expect(!updated.reportSettings.makeReport) + #expect(updated.reportSettings.operatorName == "Alex") + #expect(updated.reportSettings.notes == "Keep original notes") #expect(coordinator.sourceURL == nil) } + @Test(arguments: [true, false]) + func replacedSetupSelectionCannotBeQueued(replaceSource: Bool) async throws { + let fixture = try await SharedProjectFixture.make(prepareCard: false) + defer { fixture.folders.cleanup() } + let selected = replaceSource ? fixture.folders.source : fixture.folders.primary + try FileManager.default.moveItem(at: selected, to: fixture.folders.root.appendingPathComponent("original-moved")) + try FileManager.default.createDirectory(at: selected, withIntermediateDirectories: true) + try Data("replacement".utf8).write(to: selected.appendingPathComponent("replacement.bin")) + #expect(throws: (any Error).self) { try fixture.coordinator.enqueueSelection() } + #expect(fixture.coordinator.transferJournal.records.isEmpty) + await fixture.coordinator.startOperation() + #expect(await fixture.operations.starts.isEmpty) + #expect(fixture.coordinator.operationState == .failed) + } + + @Test func editingNeverAdoptsAReplacementAtTheSavedPath() async throws { + let fixture = try await SharedProjectFixture.make(prepareCard: false) + defer { fixture.folders.cleanup() } + let coordinator = fixture.coordinator + try coordinator.enqueueSelection() + let id = try #require(coordinator.stagedSetupTransfers.first?.id) + let source = fixture.folders.source + try FileManager.default.moveItem(at: source, to: fixture.folders.root.appendingPathComponent("original-moved")) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try Data("replacement".utf8).write(to: source.appendingPathComponent("replacement.mov")) + do { + try coordinator.editSetupTransfer(id) + let loaded = try #require(coordinator.sourceURL) + #expect(loaded != source) + } catch { + #expect(coordinator.editingSetupTransferID == nil) + #expect(coordinator.sourceURL == nil) + } + coordinator.cancelSetupTransferEdit() + } + + @Test func replacementWhileEditingCannotBeSavedAsTheOriginalSelection() async throws { + let fixture = try await SharedProjectFixture.make(prepareCard: false) + defer { fixture.folders.cleanup() } + let coordinator = fixture.coordinator + try coordinator.enqueueSelection() + let id = try #require(coordinator.stagedSetupTransfers.first?.id) + try coordinator.editSetupTransfer(id) + #expect(await waitUntil { !coordinator.isAnalysingSource }) + let original = try #require(coordinator.transferJournal.records.first { $0.id == id }) + try FileManager.default.moveItem(at: fixture.folders.primary, to: fixture.folders.root.appendingPathComponent("old-backup")) + try FileManager.default.createDirectory(at: fixture.folders.primary, withIntermediateDirectories: true) + #expect(throws: (any Error).self) { try coordinator.enqueueSelection() } + #expect(coordinator.editingSetupTransferID == id) + #expect(coordinator.transferJournal.records.first { $0.id == id }?.destinations.first?.resourceID == original.destinations.first?.resourceID) + coordinator.cancelSetupTransferEdit() + } + /// Plant: restore the `!isOperationInProgress` guard in /// `editSetupTransfer`; the waiting card can no longer use the composer. @Test func waitingCardCanBeEditedWhileAnotherRunIsActive() async throws { @@ -313,7 +373,7 @@ struct MainScreenQueueTests { try coordinator.editSetupTransfer(waitingID) #expect(coordinator.editingSetupTransferID == waitingID) - #expect(coordinator.sourceURL == fixture.folders.source) + #expect(coordinator.sourceURL?.resolvingSymlinksInPath() == fixture.folders.source.resolvingSymlinksInPath()) coordinator.cancelOperation() await fixture.operations.release() _ = await run.value diff --git a/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift b/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift index e426bd73..87e67ae0 100644 --- a/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift +++ b/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift @@ -217,6 +217,7 @@ final class SharedProjectFixture { await waitUntil(timeout: .seconds(5)) { coordinator.sourceFolderInfo?.url == folders.source && !coordinator.isFolderInfoLoading(for: folders.source) + && !coordinator.appleDoubleSelection.isReviewing } } diff --git a/BitMatchTests/TransferRecoverySummaryTests.swift b/BitMatchTests/TransferRecoverySummaryTests.swift new file mode 100644 index 00000000..0efb6578 --- /dev/null +++ b/BitMatchTests/TransferRecoverySummaryTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import BitMatch +import BitMatchEngine + +struct TransferRecoverySummaryTests { + @Test func savedEvidenceSeparatesDestinationsAndDoesNotInflateDuplicates() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("recovery-counts-\(UUID())") + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("Card") + let backups = [root.appendingPathComponent("Backup A"), root.appendingPathComponent("Backup B")] + for url in [source] + backups { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: source), + destinations: try backups.map(LocalTransferResource.init(url:)), verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(), retryOf: UUID()) + record.state = .interrupted + record.plannedFileCounts = [5, 5] + func row(_ path: String, _ outcome: ResultOutcome, _ destination: Int, reused: Bool = false) -> ResultRow { + ResultRow(path: path, status: outcome.statusText, size: 1, checksum: outcome.isVerified ? "abc" : nil, + destination: backups[destination].lastPathComponent, + destinationPath: backups[destination].appendingPathComponent("Card/\(path)").path, wasReused: reused) + } + record.results = [row("a", .verified, 0, reused: true), row("a", .verified, 0, reused: true), + row("b", .verified, 0), row("c", .failed, 0), row("._a", .excludedAppleDouble, 0), + row("a", .copiedUnverified, 1), row("b", .verified, 1), row("b", .checksumMismatch, 1)] + var groups = TransferRecoverySummary.make(record) + #expect(groups.count == 2) + #expect(groups[0].reused == 1) + #expect(groups[0].copied == 1) + #expect(groups[0].failed == 1) + #expect(groups[0].excluded == 1) + #expect(groups[0].pending == 2) + #expect(groups[1].reused == 0) + #expect(groups[1].copied == 0) + #expect(groups[1].unverified == 1) + #expect(groups[1].failed == 1) + #expect(groups[1].pending == 3) + record.plannedFileCounts = nil + #expect(TransferRecoverySummary.make(record).allSatisfy { $0.pending == nil }) + record.plannedFileCounts = [1, 5] // Inconsistent evidence cannot fabricate zero pending. + #expect(TransferRecoverySummary.make(record)[0].pending == nil) + record.results.append(ResultRow(path: "unknown", status: ResultOutcome.verified.statusText, size: 1, + checksum: "abc", destination: "Unmatched backup")) + groups = TransferRecoverySummary.make(record) + #expect(groups.count == 3) + #expect(groups.allSatisfy { $0.pending == nil }) + // Presentation must not mutate the unsafe terminal state or its underlying evidence. + #expect(record.state == .interrupted) + #expect(record.results.count == 9) + } +} diff --git a/BitMatchTests/WorkflowSnapshotTests.swift b/BitMatchTests/WorkflowSnapshotTests.swift index eef8091d..a9b86c3b 100644 --- a/BitMatchTests/WorkflowSnapshotTests.swift +++ b/BitMatchTests/WorkflowSnapshotTests.swift @@ -72,6 +72,31 @@ final class WorkflowSnapshotTests: XCTestCase { size: CGSize(width: 580, height: 340), name: "mac-transfer-details") } + func testCaptureRecoveryDetails() throws { + try requireCaptureConfiguration() + let fixture = try SnapshotFixture() + defer { fixture.restoreGlobalPreferences() } + var previous = LocalTransferRecord(id: UUID(), createdAt: Date(timeIntervalSince1970: 1791600000), + source: try LocalTransferResource(url: fixture.source), + destinations: try [fixture.backup, fixture.secondBackup].map(LocalTransferResource.init(url:)), + verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + previous.state = .interrupted + var retry = LocalTransferRecord(id: UUID(), createdAt: Date(), source: previous.source, + destinations: previous.destinations, verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(), retryOf: previous.id) + retry.state = .interrupted + retry.plannedFileCounts = [5, 5] + retry.results = [ResultRow(path: "a.mov", status: ResultOutcome.verified.statusText, size: 1, + checksum: "abc", destination: fixture.backup.lastPathComponent, + destinationPath: fixture.backup.appendingPathComponent("a.mov").path, wasReused: true), + ResultRow(path: "b.mov", status: ResultOutcome.failed.statusText, size: 1, checksum: nil, + destination: fixture.secondBackup.lastPathComponent, + destinationPath: fixture.secondBackup.appendingPathComponent("b.mov").path)] + try capture(TransferRecoveryDetails(record: retry, history: [retry, previous]) + .padding().frame(maxWidth: .infinity, alignment: .leading), + size: CGSize(width: 620, height: 430), name: "mac-recovery-details") + } + private func settle() async { for _ in 0..<8 { await Task.yield() } try? await Task.sleep(nanoseconds: 100_000_000) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc2f805..7b0cce36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Recheck the original source and backup folders when editing or starting a transfer. Replaced or disconnected folders are refused before copying, even when the path looks unchanged. +- Catch queued cards that would share or nest their output folders, including two cards named “NO NAME.” +- Expanded queue and History details show reused, newly copied, failed and pending files for each backup. Retries link to the previous attempt, which stays in History; unknown older counts stay unknown. JSON and CSV history exports retain the recovery details. +- Editing a queued card brings it back up into Setup on Mac and closes the queue sheet on iPhone. Notes, operator and saved transfer settings stay with the card. + ## [0.2.7] - 2026-10-10 - Moved AppleDouble handling to Settings → File handling. Recognized metadata companions are ignored by default for unset preferences; saved choices and queued transfers keep their policy. Other hidden files and camera sidecars are preserved. diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift index 3f6dbb03..dde59e7f 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift @@ -18,15 +18,19 @@ public struct LocalTransferResource: Codable, Sendable { public let bookmark: Data public let volumeID: String? public let resourceID: String? + public let creationDate: Date? public init(url: URL) throws { let scoped = url.startAccessingSecurityScopedResource() defer { if scoped { url.stopAccessingSecurityScopedResource() } } - let values = try url.resourceValues(forKeys: [.isDirectoryKey, .volumeUUIDStringKey, .fileResourceIdentifierKey]) + var current = url + current.removeAllCachedResourceValues() + let values = try current.resourceValues(forKeys: [.isDirectoryKey, .volumeUUIDStringKey, .fileResourceIdentifierKey, .creationDateKey]) guard values.isDirectory == true else { throw LocalTransferJournalError.unavailable(url.lastPathComponent) } self.url = url volumeID = values.volumeUUIDString resourceID = values.fileResourceIdentifier.map { String(describing: $0) } + creationDate = values.creationDate #if os(macOS) bookmark = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) #else @@ -39,7 +43,7 @@ public struct LocalTransferResource: Codable, Sendable { let resolved = try resolve() let scoped = resolved.startAccessingSecurityScopedResource() do { - try validate(resolved) + try validateIdentity(at: resolved) return LocalTransferReadAccess(url: resolved, scoped: scoped) } catch { if scoped { resolved.stopAccessingSecurityScopedResource() } @@ -58,8 +62,11 @@ public struct LocalTransferResource: Codable, Sendable { return resolved } - fileprivate func validate(_ resolved: URL) throws { - let values = try resolved.resourceValues(forKeys: [.isDirectoryKey, .volumeUUIDStringKey, .fileResourceIdentifierKey]) + /// Validate the URL about to be used, rather than following a bookmark elsewhere. + public func validateIdentity(at resolved: URL) throws { + var current = resolved + current.removeAllCachedResourceValues() + let values = try current.resourceValues(forKeys: [.isDirectoryKey, .volumeUUIDStringKey, .fileResourceIdentifierKey, .creationDateKey]) // A bookmark keeps the selected URL reachable, but it is not enough to // prove that a path still names the original folder. Require both // identity components before using a persisted location. Treating a @@ -75,6 +82,7 @@ public struct LocalTransferResource: Codable, Sendable { values.isDirectory == true, resolvedVolumeID == volumeID, String(describing: resolvedResourceID) == resourceID, + creationDate == nil || values.creationDate == creationDate, FileManager.default.isReadableFile(atPath: resolved.path) else { throw LocalTransferJournalError.unavailable(url.lastPathComponent) } @@ -115,6 +123,10 @@ public struct LocalTransferRecord: Identifiable, Codable, Sendable { public var results: [ResultRow] = [] /// Added after launch; nil decodes records written by older versions. public var sourceFingerprint: String? + /// A retry is a separate attempt; its predecessor remains immutable. + public let retryOf: UUID? + /// Selected files per destination, from the engine manifest (exclusions omitted). + public var plannedFileCounts: [Int]? /// Physical drives represented by `destinations`. Nil for older records /// whose disk independence was never assessed. public var independentDestinationCount: Int? @@ -125,7 +137,7 @@ public struct LocalTransferRecord: Identifiable, Codable, Sendable { public init(id: UUID, createdAt: Date, source: LocalTransferResource, destinations: [LocalTransferResource], verificationMode: VerificationMode, cameraSettings: CameraLabelSettings, reportSettings: ReportPrefs, generateASCMHL: Bool = true, projectID: UUID? = nil, projectCardID: UUID? = nil, - independentDestinationCount: Int? = nil) { + independentDestinationCount: Int? = nil, retryOf: UUID? = nil) { self.id = id self.createdAt = createdAt self.source = source @@ -137,13 +149,14 @@ public struct LocalTransferRecord: Identifiable, Codable, Sendable { self.projectID = projectID self.projectCardID = projectCardID self.independentDestinationCount = independentDestinationCount + self.retryOf = retryOf } private enum CodingKeys: String, CodingKey { case id, createdAt, source, destinations, verificationMode, cameraSettings, reportSettings case generateASCMHL, projectID, projectCardID, state, startedAt, endedAt case copyDurationSeconds, verifyDurationSeconds, performanceTelemetry, summary, results, sourceFingerprint - case independentDestinationCount + case independentDestinationCount, retryOf, plannedFileCounts } public init(from decoder: Decoder) throws { @@ -174,6 +187,8 @@ public struct LocalTransferRecord: Identifiable, Codable, Sendable { results = try c.decode([ResultRow].self, forKey: .results) sourceFingerprint = try c.decodeIfPresent(String.self, forKey: .sourceFingerprint) independentDestinationCount = try c.decodeIfPresent(Int.self, forKey: .independentDestinationCount) + retryOf = try c.decodeIfPresent(UUID.self, forKey: .retryOf) + plannedFileCounts = try c.decodeIfPresent([Int].self, forKey: .plannedFileCounts) } public var issueCount: Int { results.filter { !$0.isSuccessStatus }.count } } @@ -303,7 +318,7 @@ public final class TransferJournal: Sendable { destinations: original.destinations, verificationMode: original.verificationMode, cameraSettings: original.cameraSettings, reportSettings: original.reportSettings, generateASCMHL: generateASCMHL ?? original.generateASCMHL, - projectID: original.projectID, projectCardID: original.projectCardID) + projectID: original.projectID, projectCardID: original.projectCardID, retryOf: original.id) try commit { [retry] + $0 } return retry.id } @@ -319,7 +334,7 @@ public final class TransferJournal: Sendable { for resource in resources { let resolved = try resource.resolve() if resolved.startAccessingSecurityScopedResource() { scopedURLs.append(resolved) } - try resource.validate(resolved) + try resource.validateIdentity(at: resolved) resolvedURLs.append(resolved) } return LocalTransferAccess(sourceURL: resolvedURLs[0], destinationURLs: Array(resolvedURLs.dropFirst()), scopedURLs: scopedURLs) @@ -340,7 +355,7 @@ public final class TransferJournal: Sendable { do { let resolved = try record.source.resolve() if resolved.startAccessingSecurityScopedResource() { scopedURLs.append(resolved) } - try record.source.validate(resolved) + try record.source.validateIdentity(at: resolved) return LocalTransferAccess(sourceURL: resolved, destinationURLs: [], scopedURLs: scopedURLs) } catch { scopedURLs.forEach { $0.stopAccessingSecurityScopedResource() } @@ -360,7 +375,7 @@ public final class TransferJournal: Sendable { let resolved = try resource.resolve() let scoped = resolved.startAccessingSecurityScopedResource() defer { if scoped { resolved.stopAccessingSecurityScopedResource() } } - try resource.validate(resolved) + try resource.validateIdentity(at: resolved) } catch { stale.append(index) } @@ -411,7 +426,7 @@ public final class TransferJournal: Sendable { // Keep a readable, directory-only replacement in the journal. Without // this check a matching but inaccessible picker result would look // connected until the next queue attempt. - try refreshed.validate(refreshedURL) + try refreshed.validateIdentity(at: refreshedURL) // File identifiers may be reused once a folder is deleted, so a recreated // folder at the same path can present matching identifiers. The original // folder necessarily predates the transfer record; anything younger is a @@ -422,11 +437,25 @@ public final class TransferJournal: Sendable { } } - public func markRunning(id: UUID, independentDestinationCount: Int? = nil) throws { + public func markRunning(id: UUID, independentDestinationCount: Int? = nil, + sourceURL: URL? = nil, destinationURLs: [URL]? = nil, + sessionRecordIDs: Set = []) throws { try commit { records in guard !records.contains(where: { $0.state == .running }) else { throw LocalTransferJournalError.invalidState } return try Self.updated(records, id: id) { record in guard record.state == .queued || record.canRetry else { throw LocalTransferJournalError.invalidState } + // Repeat identity checks after asynchronous preflight, against the exact + // paths the executor will open. A bookmark may now point elsewhere. + let source = sourceURL ?? record.source.url + let destinations = destinationURLs ?? record.destinations.map(\.url) + guard destinations.count == record.destinations.count else { + throw LocalTransferJournalError.invalidState + } + try record.source.validateIdentity(at: source) + for (resource, url) in zip(record.destinations, destinations) { + try resource.validateIdentity(at: url) + } + try Self.validateQueueOutputs(record: record, source: source, destinations: destinations, records: records, sessionRecordIDs: sessionRecordIDs) record.state = .running record.startedAt = Date() record.endedAt = nil @@ -436,6 +465,52 @@ public final class TransferJournal: Sendable { } } + /// Save once, before results settle. Unknown counts in older records stay unknown. + public func recordPlan(id: UUID, fileCounts: [Int]) throws { + try update(id: id) { record in + guard record.state == .running, fileCounts.count == record.destinations.count, + fileCounts.allSatisfy({ $0 >= 0 }), + record.plannedFileCounts == nil || record.plannedFileCounts == fileCounts else { + throw LocalTransferJournalError.invalidState + } + record.plannedFileCounts = fileCounts + } + } + + private static func validateQueueOutputs(record: LocalTransferRecord, source: URL, + destinations: [URL], records: [LocalTransferRecord], + sessionRecordIDs: Set) throws { + func roots(source: URL, destinations: [URL], settings: CameraLabelSettings) throws -> [String] { + try destinations.map { + let root = try SafetyValidator.resolvedDestinationRootChecked(source: source, destination: $0, settings: settings) + // Portable names: also catch case-only collisions on exFAT/HFS+. + return PathContainment.comparablePath(root.resolvingSymlinksInPath().path) + .precomposedStringWithCanonicalMapping.lowercased() + } + } + let outputs = try roots(source: source, destinations: destinations, settings: record.cameraSettings) + for other in records where other.id != record.id && (other.state == .queued || other.state == .running + || (sessionRecordIDs.contains(other.id) && other.startedAt != nil)) { + // Rechecking the same original card is an intentional supported operation. + if record.source.volumeID != nil, record.source.resourceID != nil, + record.source.volumeID == other.source.volumeID, + record.source.resourceID == other.source.resourceID, + record.source.creationDate == other.source.creationDate { continue } + let resources = [other.source] + other.destinations + let urls = resources.map { (try? $0.resolve()) ?? $0.url } + let scoped = urls.filter { $0.startAccessingSecurityScopedResource() } + defer { scoped.forEach { $0.stopAccessingSecurityScopedResource() } } + let otherOutputs = try roots(source: urls[0], destinations: Array(urls.dropFirst()), settings: other.cameraSettings) + if outputs.contains(where: { a in otherOutputs.contains { b in + PathContainment.isWithin(a, root: b) || PathContainment.isWithin(b, root: a) + } }) { + throw FileOperationError.unsafeOperation( + "Two cards in this queue use the same or nested output folders. Change a card’s folder name or backup location before starting. Nothing was copied for this attempt." + ) + } + } + } + /// A queued card whose original source cannot be opened never ran. Keep /// that distinction durable instead of turning it into an interruption. public func fail(id: UUID, summary: String) throws { @@ -486,7 +561,10 @@ public final class TransferJournal: Sendable { verificationMode: verificationMode, cameraSettings: cameraSettings, reportSettings: reportSettings, - generateASCMHL: generateASCMHL + generateASCMHL: generateASCMHL, + retryOf: source.volumeID == record.source.volumeID + && source.resourceID == record.source.resourceID + && source.creationDate == record.source.creationDate ? record.retryOf : nil ) } } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift new file mode 100644 index 00000000..b62533fc --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing +@testable import BitMatchEngine + +struct QueueAdmissionTests { + private func fixture() throws -> (root: URL, source: URL, backup: URL, journal: TransferJournal) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("queue-admission-\(UUID())") + let source = root.appendingPathComponent("reader-a/NO NAME") + let backup = root.appendingPathComponent("Backup") + for url in [source, backup] { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } + try Data("source remains untouched".utf8).write(to: source.appendingPathComponent("clip.mov")) + return (root, source, backup, TransferJournal(fileURL: root.appendingPathComponent("journal.json"))) + } + + @Test(arguments: [true, false]) + func replacementAtStartIsRefused(replaceSource: Bool) 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()) + let access = try f.journal.prepareToRun(id: id) + defer { access.release() } + let selected = replaceSource ? access.sourceURL : access.destinationURLs[0] + try FileManager.default.moveItem(at: selected, to: f.root.appendingPathComponent("original")) + try FileManager.default.createDirectory(at: selected, withIntermediateDirectories: true) + #expect(throws: (any Error).self) { + try f.journal.markRunning(id: id, sourceURL: access.sourceURL, destinationURLs: access.destinationURLs) + } + #expect(f.journal.records.first?.state == .queued) + #expect(f.journal.records.first?.startedAt == nil) + } + + @Test(arguments: ["NO NAME", "no name", "nested", "sibling", "same-source"]) + func queuedOutputMatrix(caseName: String) throws { + let f = try fixture() + defer { try? FileManager.default.removeItem(at: f.root) } + let second = caseName == "same-source" ? f.source : f.root.appendingPathComponent("reader-b/\(caseName)") + try FileManager.default.createDirectory(at: second, withIntermediateDirectories: true) + let firstID = try f.journal.enqueue(sourceURL: f.source, destinationURLs: [f.backup], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + var settings = CameraLabelSettings() + if caseName == "nested" { settings.destinationPathComponents = ["NO NAME", "nested"] } + try f.journal.enqueue(sourceURL: second, destinationURLs: [f.backup], verificationMode: .standard, + cameraSettings: settings, reportSettings: ReportPrefs()) + if ["sibling", "same-source"].contains(caseName) { + try f.journal.markRunning(id: firstID) + #expect(f.journal.records.first { $0.id == firstID }?.state == .running) + } else { + #expect(throws: (any Error).self) { try f.journal.markRunning(id: firstID) } + #expect(f.journal.records.allSatisfy { $0.state == .queued }) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: f.backup.path).isEmpty) + #expect(try String(contentsOf: f.source.appendingPathComponent("clip.mov"), encoding: .utf8) == "source remains untouched") + } + + @Test func finishedCardStillReservesItsOutputWithinTheCurrentSession() throws { + let f = try fixture() + defer { try? FileManager.default.removeItem(at: f.root) } + let first = try f.journal.enqueue(sourceURL: f.source, destinationURLs: [f.backup], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + try f.journal.markRunning(id: first) + try f.journal.interrupt(id: first, summary: "Interrupted", results: []) + // Renaming/ejecting the source later must not move the output reservation. + try FileManager.default.moveItem(at: f.source, to: f.root.appendingPathComponent("renamed-source")) + let secondSource = f.root.appendingPathComponent("reader-b/NO NAME") + try FileManager.default.createDirectory(at: secondSource, withIntermediateDirectories: true) + let second = try f.journal.enqueue(sourceURL: secondSource, destinationURLs: [f.backup], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + #expect(throws: (any Error).self) { try f.journal.markRunning(id: second, sessionRecordIDs: [first, second]) } + #expect(f.journal.records.first?.state == .queued) + } + + @Test func retryLineageAndPlanSurviveRoundTripWithoutChangingPreviousAttempt() throws { + let f = try fixture() + defer { try? FileManager.default.removeItem(at: f.root) } + var reports = ReportPrefs() + reports.operatorName = "Alex" + reports.notes = "Morning card" + let id = try f.journal.enqueue(sourceURL: f.source, destinationURLs: [f.backup], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: reports) + try f.journal.markRunning(id: id) + try f.journal.recordPlan(id: id, fileCounts: [4]) + #expect(throws: (any Error).self) { try f.journal.recordPlan(id: id, fileCounts: [-1]) } + #expect(throws: (any Error).self) { try f.journal.recordPlan(id: id, fileCounts: [3]) } + try f.journal.interrupt(id: id, summary: "Interrupted", results: []) + let original = try JSONEncoder().encode(#require(f.journal.records.first)) + let retry = try f.journal.requeue(id: id) + try f.journal.replaceQueued(id: retry, sourceURL: f.source, destinationURLs: [f.backup], + verificationMode: .thorough, cameraSettings: CameraLabelSettings(), + reportSettings: reports, generateASCMHL: false) + let decoded = try JSONDecoder().decode([LocalTransferRecord].self, from: JSONEncoder().encode(f.journal.records)) + #expect(decoded.first?.retryOf == id) + #expect(decoded.first?.plannedFileCounts == nil) + #expect(decoded.first?.reportSettings.notes == "Morning card") + #expect(decoded.last?.plannedFileCounts == [4]) + // Compare decoded values through sorted JSON to avoid key-order dependence. + let encoder = JSONEncoder(); encoder.outputFormatting = .sortedKeys + #expect(try encoder.encode(decoded.last) == encoder.encode(JSONDecoder().decode(LocalTransferRecord.self, from: original))) + } +} diff --git a/Shared/Core/Models/ResultPresentation.swift b/Shared/Core/Models/ResultPresentation.swift index d1bc78b5..a45a4de1 100644 --- a/Shared/Core/Models/ResultPresentation.swift +++ b/Shared/Core/Models/ResultPresentation.swift @@ -438,3 +438,45 @@ struct DestinationResultSummary: Identifiable { return summaries } } + +/// Describes saved attempt evidence; never participates in the safety verdict. +struct TransferRecoverySummary: Identifiable { + let id: String + let title: String + let reused: Int + let copied: Int + let unverified: Int + let failed: Int + let excluded: Int + let pending: Int? + + static func make(_ record: LocalTransferRecord) -> [Self] { + let groups = DestinationResultSummary.make(rows: record.results, destinations: record.destinations.map(\.url)) + let hasUnassigned = groups.count > record.destinations.count + return groups.enumerated().map { index, group in + // Duplicate/conflicting evidence must not inflate success or hide an issue. + let files = Dictionary(grouping: group.rows, by: \.path).values + var reused = 0, copied = 0, unverified = 0, failed = 0, excluded = 0 + for rows in files { + if rows.allSatisfy({ ResultOutcome(statusText: $0.status) == .excludedAppleDouble }) { + excluded += 1 + } else if !rows.allSatisfy(\.isSuccessStatus) { + failed += 1 + } else if !rows.allSatisfy(\.isVerifiedStatus) { + unverified += 1 + } else if rows.allSatisfy(\.wasReused) { + reused += 1 + } else { + copied += 1 + } + } + let settled = reused + copied + unverified + failed + let plan = record.plannedFileCounts + let expected = !hasUnassigned && plan?.count == record.destinations.count && index < record.destinations.count + ? plan?[index] : nil + return Self(id: group.id, title: group.title, reused: reused, copied: copied, + unverified: unverified, failed: failed, excluded: excluded, + pending: expected.flatMap { $0 >= settled ? $0 - settled : nil }) + } + } +} diff --git a/Shared/Core/Services/LocalTransferJournal.swift b/Shared/Core/Services/LocalTransferJournal.swift index daba3226..f7ae69d2 100644 --- a/Shared/Core/Services/LocalTransferJournal.swift +++ b/Shared/Core/Services/LocalTransferJournal.swift @@ -102,10 +102,18 @@ final class LocalTransferJournal: ObservableObject { try store.reauthorize(id: id, resourceIndex: resourceIndex, newURL: newURL) } - func markRunning(id: UUID, independentDestinationCount: Int? = nil) throws { + func markRunning(id: UUID, independentDestinationCount: Int? = nil, + sourceURL: URL? = nil, destinationURLs: [URL]? = nil, + sessionRecordIDs: Set = []) throws { defer { refresh() } try beforeMarkRunning?(id) - try store.markRunning(id: id, independentDestinationCount: independentDestinationCount) + try store.markRunning(id: id, independentDestinationCount: independentDestinationCount, + sourceURL: sourceURL, destinationURLs: destinationURLs, sessionRecordIDs: sessionRecordIDs) + } + + func recordPlan(id: UUID, fileCounts: [Int]) throws { + defer { refresh() } + try store.recordPlan(id: id, fileCounts: fileCounts) } func fail(id: UUID, summary: String) throws { diff --git a/Shared/Core/Services/SharedAppCoordinator.swift b/Shared/Core/Services/SharedAppCoordinator.swift index f4bb0593..675d34cd 100644 --- a/Shared/Core/Services/SharedAppCoordinator.swift +++ b/Shared/Core/Services/SharedAppCoordinator.swift @@ -74,6 +74,7 @@ struct TransferRunContext { @MainActor private final class TransferRunResults { private(set) var rows: [ResultRow] = [] + var attemptedPlanSave = false func upsert(_ row: ResultRow) { if let index = rows.firstIndex(where: { @@ -193,6 +194,8 @@ class SharedAppCoordinator: ObservableObject { @Published var demoQueueAutoOpen = false private var isClearingSnapshottedComposerSource = false private struct SetupComposerSnapshot { + let sourceIdentity: LocalTransferResource? + let destinationIdentities: [URL: LocalTransferResource] let source: URL? let destinations: [URL] let verificationMode: VerificationMode @@ -201,6 +204,7 @@ class SharedAppCoordinator: ObservableObject { let generateASCMHL: Bool } private var composerBeforeEditing: SetupComposerSnapshot? + private var editingOriginalAccess: LocalTransferAccess? private struct ReviewedSelectionSnapshot { let destinations: [URL] let verificationMode: VerificationMode @@ -295,13 +299,34 @@ class SharedAppCoordinator: ObservableObject { } // MARK: - File Selection State - @Published var sourceURL: URL? + private var selectedSourceIdentity: LocalTransferResource? + private var selectedDestinationIdentities: [URL: LocalTransferResource] = [:] + @Published var sourceURL: URL? { + didSet { selectedSourceIdentity = sourceURL.flatMap { try? LocalTransferResource(url: $0) } } + } @Published var destinationURLs: [URL] = [] { didSet { + // Retain the identity of unchanged picks when another backup is added. + selectedDestinationIdentities = destinationURLs.reduce(into: [:]) { identities, url in + identities[url] = selectedDestinationIdentities[url] ?? (try? LocalTransferResource(url: url)) + } destinationVolumeNames = destinationURLs.map(DestinationVolumeLabel.resolve) scheduleDestinationIndependenceAssessment() } } + private func validateComposerSelection(source: URL, destinations: [URL]) throws { + let scoped = ([source] + destinations).filter { $0.startAccessingSecurityScopedResource() } + defer { scoped.forEach { $0.stopAccessingSecurityScopedResource() } } + guard let selectedSourceIdentity else { throw LocalTransferJournalError.unavailable(source.lastPathComponent) } + try selectedSourceIdentity.validateIdentity(at: source) + for destination in destinations { + guard let identity = selectedDestinationIdentities[destination] else { + throw LocalTransferJournalError.unavailable(destination.lastPathComponent) + } + try identity.validateIdentity(at: destination) + } + } + /// Resolved once per selection change, never once per progress tick. private(set) var destinationVolumeNames: [String] = [] /// Resolved off the main actor when selection changes and confirmed when @@ -1016,8 +1041,18 @@ class SharedAppCoordinator: ObservableObject { guard canEnqueueSelection, let sourceURL else { throw FileOperationError.unsafeOperation("Choose a source and destinations for a one-time transfer first.") } + try validateComposerSelection(source: sourceURL, destinations: destinationURLs) let committedID: UUID if let id = editingSetupTransferID { + if let access = editingOriginalAccess, + let original = transferJournal.records.first(where: { $0.id == id }) { + if sourceURL == access.sourceURL { try original.source.validateIdentity(at: sourceURL) } + for url in destinationURLs { + if let index = access.destinationURLs.firstIndex(of: url) { + try original.destinations[index].validateIdentity(at: url) + } + } + } let scopedURLs = ([sourceURL] + destinationURLs).filter { $0.startAccessingSecurityScopedResource() } defer { scopedURLs.forEach { $0.stopAccessingSecurityScopedResource() } } if let refusal = destinationURLs.lazy.compactMap({ @@ -1042,6 +1077,7 @@ class SharedAppCoordinator: ObservableObject { ) editingSetupTransferID = nil composerBeforeEditing = nil + editingOriginalAccess = nil committedID = id } else { if let running = runningOneTimeTransfer { @@ -1063,6 +1099,10 @@ class SharedAppCoordinator: ObservableObject { let record = stagedSetupTransfers.first(where: { $0.id == id }) else { throw FileOperationError.unsafeOperation("Only a waiting setup transfer can be edited.") } + // Resolve before changing the composer. A new drive at the saved path + // must never be silently adopted by Edit. + let access = try transferJournal.prepareToRun(id: id) + editingOriginalAccess = access // A stopped queue may still be showing the preceding card's outcome. // Return to Setup before loading the waiting snapshot into its composer. if queueSessionEnded && showsOutcomeSummary { @@ -1070,6 +1110,8 @@ class SharedAppCoordinator: ObservableObject { sourceURL = nil } composerBeforeEditing = SetupComposerSnapshot( + sourceIdentity: selectedSourceIdentity, + destinationIdentities: selectedDestinationIdentities, source: sourceURL, destinations: destinationURLs, verificationMode: verificationMode, @@ -1080,9 +1122,9 @@ class SharedAppCoordinator: ObservableObject { editingSetupTransferID = id cameraLabels.clearDetectionPreservingSettings() isReplayingQueuedTransfer = true - sourceURL = record.source.url + sourceURL = access.sourceURL isReplayingQueuedTransfer = false - destinationURLs = record.destinations.map(\.url) + destinationURLs = access.destinationURLs verificationMode = record.verificationMode cameraLabelSettings = record.cameraSettings reportSettings = record.reportSettings @@ -1096,12 +1138,15 @@ class SharedAppCoordinator: ObservableObject { sourceURL = snapshot.source isReplayingQueuedTransfer = false destinationURLs = snapshot.destinations + selectedSourceIdentity = snapshot.sourceIdentity + selectedDestinationIdentities = snapshot.destinationIdentities verificationMode = snapshot.verificationMode cameraLabelSettings = snapshot.cameraSettings reportSettings = snapshot.reportSettings generateASCMHL = snapshot.generateASCMHL editingSetupTransferID = nil composerBeforeEditing = nil + editingOriginalAccess = nil } /// Starts the cards assembled on Setup. If more than one card is ready, @@ -1695,6 +1740,14 @@ class SharedAppCoordinator: ObservableObject { guard self.sourceURL == sourceURL, destinationURLs == selectedDestinations, verificationMode == selectedVerificationMode else { return } + do { try validateComposerSelection(source: sourceURL, destinations: selectedDestinations) } + catch { + operationState = .failed + queueMessage = error.localizedDescription + updateProjectLifecycle(for: .failed, projectIdentity: projectIdentity) + await platformManager.presentError(error) + return + } destinationIndependence = independence let sourceInfo = self.sourceFolderInfo.flatMap { $0.url.standardizedFileURL == sourceURL.standardizedFileURL ? $0 : nil @@ -1792,7 +1845,10 @@ class SharedAppCoordinator: ObservableObject { ) try transferJournal.markRunning( id: recordID, - independentDestinationCount: initialContext.independentDestinationCount + independentDestinationCount: initialContext.independentDestinationCount, + sourceURL: initialContext.sourceURL, + destinationURLs: initialContext.destinationURLs, + sessionRecordIDs: Set(queueSessionRecordIDs) ) context = initialContext.recording(in: recordID) activeJournalRecordID = recordID @@ -1915,6 +1971,11 @@ class SharedAppCoordinator: ObservableObject { self.activeStartID == startID, !self.startCancellationRequested else { return } self.progress = progressUpdate + 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) } + } if let projectIdentity { self.photographerJobViewModel.updateProgressStage( progressUpdate.currentStage, diff --git a/Shared/Core/Services/TransferHistoryDocument.swift b/Shared/Core/Services/TransferHistoryDocument.swift index a6d6bf91..e5a5e027 100644 --- a/Shared/Core/Services/TransferHistoryDocument.swift +++ b/Shared/Core/Services/TransferHistoryDocument.swift @@ -16,12 +16,12 @@ struct TransferHistoryDocument: FileDocument { init(record: LocalTransferRecord, asCSV: Bool) throws { 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\n" + let header = "source,destination,status,bytes,checksum,verification,transfer_state,transfer_summary,project,asc_mhl,client,reused\n" let rows = record.results.map { row in [row.path, row.destinationPath ?? row.destination ?? "", row.status, String(row.size), row.checksum ?? "", record.verificationMode.rawValue, record.state.rawValue, record.summary, record.reportSettings.projectName, record.generateASCMHL ? "requested" : "not requested", - record.reportSettings.clientName].map(quote).joined(separator: ",") + record.reportSettings.clientName, row.wasReused ? "true" : "false"].map(quote).joined(separator: ",") } func pair(_ key: String, _ value: String) -> String { quote(key) + "," + quote(value) } func detail(_ value: String) -> String { @@ -32,6 +32,8 @@ struct TransferHistoryDocument: FileDocument { let telemetry = record.performanceTelemetry let timingSummary = [ "\n# Summary", + pair("retryOf", record.retryOf?.uuidString ?? ""), + pair("plannedFileCounts", record.plannedFileCounts?.map(String.init).joined(separator: ";") ?? ""), pair("operatorName", detail(record.reportSettings.operatorName)), pair("notes", detail(record.reportSettings.notes)), pair("copyDurationSeconds", record.copyDurationSeconds.map { String($0) } ?? ""), @@ -49,6 +51,8 @@ struct TransferHistoryDocument: FileDocument { // Do not export security-scoped bookmarks or credentials from the journal. struct Report: Encodable { let id: UUID + let retryOf: UUID? + let plannedFileCounts: [Int]? let source: String let destinations: [String] let state: LocalTransferState @@ -68,7 +72,7 @@ struct TransferHistoryDocument: FileDocument { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 - data = try encoder.encode(Report(id: record.id, source: record.source.url.path, + 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, projectName: record.reportSettings.projectName, clientName: record.reportSettings.clientName, diff --git a/Shared/Views/TransferLibraryView.swift b/Shared/Views/TransferLibraryView.swift index d950cff7..f99dabb7 100644 --- a/Shared/Views/TransferLibraryView.swift +++ b/Shared/Views/TransferLibraryView.swift @@ -273,6 +273,7 @@ struct TransferLibraryView: View { } Text("Counts describe saved result rows, including exclusions. Missing measurements are shown as —.") .foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + TransferRecoveryDetails(record: record, history: coordinator.transferJournal.records) Text("Source: \(record.source.url.path)").textSelection(.enabled) ForEach(record.destinations.indices, id: \.self) { index in Text("Destination: \(record.destinations[index].url.path)").textSelection(.enabled) @@ -512,3 +513,49 @@ private struct TransferDiagnosticsDocument: FileDocument { FileWrapper(regularFileWithContents: data) } } + +/// Kept inside expanded transfer details on every platform. +struct TransferRecoveryDetails: View { + let record: LocalTransferRecord + let history: [LocalTransferRecord] + + private var summaries: [TransferRecoverySummary] { TransferRecoverySummary.make(record) } + private var isRecovery: Bool { + record.retryOf != nil || record.results.contains(where: \.wasReused) + || [.interrupted, .cancelled, .failed, .issues].contains(record.state) + } + + var body: some View { + if isRecovery { + VStack(alignment: .leading, spacing: 8) { + if let previousID = record.retryOf { + if let previous = history.first(where: { $0.id == previousID }) { + Text("Retry of \(previous.createdAt.formatted(date: .abbreviated, time: .shortened))") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("Retry · previous attempt no longer in history") + .font(.caption).foregroundStyle(.secondary) + } + } + ForEach(summaries) { summary in + VStack(alignment: .leading, spacing: 3) { + Text(summary.title).font(.caption.weight(.semibold)) + Text("Verified: \(summary.reused) reused · \(summary.copied) copied") + if summary.unverified > 0 { Text("\(summary.unverified) copied, unverified") } + Text("\(summary.failed) failed · \(summary.pending.map(String.init) ?? "—") pending") + if summary.excluded > 0 { Text("\(summary.excluded) excluded") } + } + .font(.caption) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .combine) + } + if summaries.contains(where: { $0.pending == nil || ($0.pending ?? 0) > 0 }) { + Text(summaries.contains(where: { $0.pending == nil }) + ? "Pending: no saved result. — means the file count wasn’t recorded." + : "Pending files have no saved result.") + .font(.caption).foregroundStyle(.secondary) + } + } + } + } +} diff --git a/Shared/Views/TransferQueueSection.swift b/Shared/Views/TransferQueueSection.swift index 9d7558c5..5607bd9d 100644 --- a/Shared/Views/TransferQueueSection.swift +++ b/Shared/Views/TransferQueueSection.swift @@ -596,6 +596,9 @@ struct TransferQueueSection: View { } else if let outcome = row.outcome { finishedDetails(row, outcome: outcome) } + if row.isFinished, let record = coordinator.transferJournal.records.first(where: { $0.id == row.id }) { + TransferRecoveryDetails(record: record, history: coordinator.transferJournal.records) + } } private var liveProgressPresentation: TransferProgressPresentation { diff --git a/docs/QUEUE_RECOVERY_VALIDATION.md b/docs/QUEUE_RECOVERY_VALIDATION.md new file mode 100644 index 00000000..0041b861 --- /dev/null +++ b/docs/QUEUE_RECOVERY_VALIDATION.md @@ -0,0 +1,45 @@ +# Queue editing, admission and recovery + +October 10, 2026. Based on `main` at `13be2f05`, developed on `feature/queue-recovery`. This is a pre-merge validation record, not a release or a resolution of a user's hardware report. + +## Changes + +- Edit resolves the original bookmarked folders before changing Setup and retains their access while editing. Unchanged selections are checked again when saving. Cancel restores the previous composer, including its original folder identities, operator and notes. +- Setup captures folder identities when selected. Start validates them with fresh filesystem values, then journal admission checks the actual execution URLs after asynchronous preflight. Identity includes volume, resource ID and, for new records, creation date. Missing old creation dates decode normally. +- Different cards in the queue cannot target the same or nested output folders. Case-only differences are treated conservatively for portable destinations. A started card retains its reservation within the current queue session. An intentional retry of the same original source remains supported. +- Retries retain a link to their previous attempt; editing a retry to use a different source removes that link. The old attempt remains unchanged. +- The engine's selected-file counts are saved once when progress first supplies them. Expanded queue and History details show reused/verified, copied/verified, unverified, failed, excluded and pending counts per backup. “Copied” means copied during this attempt, not necessarily copied before. Pending means no saved result; older or ambiguous evidence shows an unknown count. These figures do not decide the safety verdict. +- JSON and CSV history exports include the attempt link and planned counts; CSV also carries each row's reuse flag. Access tokens are not exported. PDF layout and ASC MHL semantics are unchanged. +- Mac Edit uses the reverse card animation, with Reduce Motion respected. Compact mobile Edit closes the queue sheet to reveal Setup. + +## Reproductions and tests + +The first new replacement test failed despite the added identity check: Foundation returned cached resource values from an already-read URL. Clearing cached resource values made both source and destination replacement tests pass. This is observed red-to-green evidence, not an inferred failure. + +The new regressions cover: + +- Replacement source and backup folders while sitting in Setup, while editing, and between queued preflight and admission. The app-level admission test asserts the copy executor never starts. +- Identical, case-only and nested queue output collisions; distinct sibling output folders and an intentional same-source recheck; reservations after a previous attempt starts. +- Retry lineage, settings and planned counts surviving serialization; old records without the new fields; export without bookmarks. +- Three real recovery transfers, one in each of Standard, Thorough and Paranoid, with two backups. Backup A begins with one matching file and one conflicting file; Backup B is empty. The engine reuses the matching file after verification, copies missing files, preserves the conflict, and leaves the retry unsafe. Counts match both destinations. The source and original interrupted record remain unchanged. +- Duplicate/conflicting result rows, Quick/unverified results, intentional exclusions, missing older plans and unassigned destination evidence. None can inflate successful counts or invent zero pending. +- Edit/cancel/save preserving order, mode, report settings, operator and notes, including editing while another transfer runs. + +Local validation: + +- `arch -arm64 swift test --package-path Packages/BitMatchEngine --build-system native --jobs 4`: 123 XCTest cases, 15 intentional skips, zero failures; 138 Swift Testing tests, zero failures. Includes mounted exFAT handoffs and the existing AppleDouble filesystem matrix. +- Mac full suite (`xcodebuild test`, `BitMatchTests`, `-parallel-testing-enabled NO`): 1,097 passed, 12 opt-in/hardware skips, zero failures. This run includes the final code and all new Mac regressions. The four opt-in workflow capture tests also passed in the separate capture run. +- iPhone 17 Pro simulator: all 71 tests passed, including opt-in rendered workflows. +- iPad Air 11-inch (M4) simulator: all 71 tests passed, including opt-in rendered workflows. +- Inspected recovery details on Mac, iPhone and iPad, plus expanded History at 320 points. No clipping in the added details. The captures exercise production views with synthetic records, not a physical-device transfer or a signed release walkthrough. +- `git diff --check`: clean. + +Raw logs, result summaries and PNGs are retained locally in `dist/validation/queue-recovery/`. + +Failed intermediate runs are retained too. The first Mac run caught an assertion comparing `/var` with `/private/var` after bookmark resolution; the test now compares resolved URLs. A setup fixture also needed to wait for its AppleDouble review, not just its folder-size scan. A later engine run under concurrent builds hit existing short deadlines in three concurrency tests; the complete engine rerun without competing BitMatch builds passed. Running all opt-in AppKit captures alongside the Mac suite hit the existing five-second source-scan deadline once; all four capture tests passed. Ordinary tests and captures are assessed separately; failed runs are not counted as passes. + +## Limits + +These are local filesystem, mounted disk-image, model and simulator tests. They do not establish real reader/cable/dock reliability. The admission check narrows the selection-to-execution gap; descriptor-pinned engine protections remain necessary during a run. Recovery rechecks whole files, not byte offsets. Pending counts describe saved evidence, not a claim that an unrecorded file was never written. An attempt removed from History no longer supplies a visible predecessor date, but its retry ID remains saved. + +A signed release walkthrough and physical iPhone/iPad access/background testing remain release checks. No release was published by this change. diff --git a/docs/TODO.md b/docs/TODO.md index 6f9ecf23..8e2e114f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,6 +1,6 @@ # To do -The working list for BitMatch, reviewed against `main` at `964d094b` on October 10, 2026. These are roadmap candidates, not instructions to implement every feature or a promised release scope. The first quick-win batch is implemented on `feature/todo-reports` for review; see [validation](TODO_REPORTS_VALIDATION.md). When something ships, record it in the [changelog](../CHANGELOG.md) and remove the finished task here. +The working list for BitMatch, updated after 0.2.7 on October 10, 2026. These are roadmap candidates, not instructions to implement every feature or a promised release scope. The report and transfer-details batch shipped in 0.2.7; see [validation](TODO_REPORTS_VALIDATION.md). When something ships, record it in the [changelog](../CHANGELOG.md) and remove the finished task here. **Source media → trustworthy copies → independent verification → accurate evidence → safe-to-erase decision.** @@ -20,15 +20,17 @@ Investigate #16 first. Then follow evidence and actual user reports. Preserve so ## P1 — Reliability and recovery +The queue/recovery items marked complete below are implemented on `feature/queue-recovery`, pending merge; see [validation and limits](QUEUE_RECOVERY_VALIDATION.md). + - [ ] **Storage-aware verification scheduling.** Replace the CPU-derived default with scheduling informed by physical storage identity and measured behavior. Avoid competing large sequential reads on one HDD; permit useful concurrency across independent SSD/NVMe devices. Account for shared devices and buses only where reliably identifiable. Preserve diagnostic worker-count overrides and benchmark actual throughput and memory use on physical hardware, including the two-drive test below. **Acceptance:** independent destination readback remains complete, cancellation still stops the run, and unknown topology gets a conservative policy rather than invented independence. - [ ] **Bounded source-read retries and “Don't format the card” warnings.** Cover both source changes and read instability, separately from destination failures. Retry transient source I/O failures a small, bounded number of times and record each attempt and reason. Separate verified backup contents from source-media health: a recovered read must still warn against formatting or reusing an unstable card until investigated. Distinguish that from a failed backup. Explore chunk retries only with proven hash continuity and source identity checks. **Acceptance:** injected recoverable and persistent faults produce accurate counts, evidence and warnings; retries never hide changed data or turn exhaustion into success. -- [ ] **Explicit interrupted-transfer recovery.** Build on existing journal retries, identity checks and independent verification before whole-file reuse. Show reused, recopied, failed and pending files per destination; retain the old attempt. **Acceptance:** no unproven existing file is overwritten, original volume/folder identity remains required, and the original attempt remains interrupted/unsafe; only a fully verified new attempt can earn a safe verdict. Describe complete-file recovery honestly; do not claim byte-offset resume. +- [x] **Explicit interrupted-transfer recovery.** Build on existing journal retries, identity checks and independent verification before whole-file reuse. Show reused, recopied, failed and pending files per destination; retain the old attempt. **Acceptance:** no unproven existing file is overwritten, original volume/folder identity remains required, and the original attempt remains interrupted/unsafe; only a fully verified new attempt can earn a safe verdict. Describe complete-file recovery honestly; do not claim byte-offset resume. - [ ] **Repair a failed destination from a verified sibling.** After recovery provenance is defined, investigate SSD A → failed HDD B repair without rereading the original card unnecessarily. Require authoritative file evidence, revalidate A, independently verify B, and record the original source, repair source and new attempt in reports/history. **Acceptance:** stale or changed sibling evidence cannot authorize a repair; existing unverified files are never overwritten; copies on the same physical device never count as independent backups. -- [ ] **Check everything again at Start.** Existing engine preflight checks output roots, overlaps and access; journal preparation checks stored identities. Audit remaining gaps between selection, queueing and execution. Cover card/drive disconnect or replacement and two queued cards resolving to the same output folder (for example two “NO NAME” cards without a card number in the naming pattern). **Acceptance:** refuse unsafe starts before copying, explain why nothing was copied, and retain regressions for each gap found. +- [x] **Check everything again at Start.** Existing engine preflight checks output roots, overlaps and access; journal preparation checks stored identities. Audit remaining gaps between selection, queueing and execution. Cover card/drive disconnect or replacement and two queued cards resolving to the same output folder (for example two “NO NAME” cards without a card number in the naming pattern). **Acceptance:** refuse unsafe starts before copying, explain why nothing was copied, and retain regressions for each gap found. - [x] **Finish the card-untouched regression.** `SourceTreeUnchangedTests` already compares paths, contents, sizes, modification dates and permissions through the app executor in every verification mode, with reports and MHL enabled. The quick-win batch adds creation dates, ownership and extended attributes, plus cancellation-after-readback and report-publication-failure tests; coordinate with #16's real companion fixtures. **Acceptance:** the full source snapshot is unchanged after success, cancellation and failure; unsupported metadata checks are reported as such, not counted as passes. @@ -90,7 +92,7 @@ These remain ongoing gates, not optional competitor features. Use disposable dat - [ ] **Tests without permission prompts.** Investigate drive-access prompts that stall Mac tests; prefer owned temporary fixtures and scoped test configuration without weakening release entitlements. The documented DerivedData-outside-Desktop workaround addresses a separate loader stall. - [ ] **Buffer benchmark first run.** Avoid measuring freshly written files still in cache; evict/rewrite before timing or discard a warm-up run, and verify that the measured read actually reaches storage. -- [ ] **Reverse flight on Edit.** Loading a queued card back into setup should carry it up the same way Start carries it down. +- [x] **Reverse flight on Edit.** Mac Edit carries the queued card back to setup, respecting Reduce Motion. Compact mobile Edit dismisses the queue sheet. Settings, operator and notes remain snapshotted. ## Out of scope From ac361a2b0e19a9c87c1e5f197d1be074eaf29c21 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 10 Oct 2026 20:36:19 -0400 Subject: [PATCH 2/2] Align recovery details and record signed walkthrough --- Shared/Views/TransferLibraryView.swift | 1 + docs/QUEUE_RECOVERY_VALIDATION.md | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Shared/Views/TransferLibraryView.swift b/Shared/Views/TransferLibraryView.swift index f99dabb7..0e81eeaf 100644 --- a/Shared/Views/TransferLibraryView.swift +++ b/Shared/Views/TransferLibraryView.swift @@ -556,6 +556,7 @@ struct TransferRecoveryDetails: View { .font(.caption).foregroundStyle(.secondary) } } + .frame(maxWidth: .infinity, alignment: .leading) } } } diff --git a/docs/QUEUE_RECOVERY_VALIDATION.md b/docs/QUEUE_RECOVERY_VALIDATION.md index 0041b861..7ea5ae23 100644 --- a/docs/QUEUE_RECOVERY_VALIDATION.md +++ b/docs/QUEUE_RECOVERY_VALIDATION.md @@ -42,4 +42,23 @@ Failed intermediate runs are retained too. The first Mac run caught an assertion These are local filesystem, mounted disk-image, model and simulator tests. They do not establish real reader/cable/dock reliability. The admission check narrows the selection-to-execution gap; descriptor-pinned engine protections remain necessary during a run. Recovery rechecks whole files, not byte offsets. Pending counts describe saved evidence, not a claim that an unrecorded file was never written. An attempt removed from History no longer supplies a visible predecessor date, but its retry ID remains saved. -A signed release walkthrough and physical iPhone/iPad access/background testing remain release checks. No release was published by this change. +## Signed Mac walkthrough + +The walkthrough began with a local universal Release archive built by `Scripts/release_mac.sh` with `SKIP_NOTARIZE=1`, using the existing Developer ID and sandbox entitlements. It was launched from its archive; nothing was installed over the released app, notarized, tagged or published. The only production follow-up was left-aligning recovery counts inside the expanded queue row. A fresh final archive used the same Release configuration and signing sequence; strict deep signature verification passed. + +Disposable mounted volumes provided an APFS source and APFS/exFAT backups. The production UI and native folder pickers exercised: + +- Queue → Edit → Cancel, then Edit → Update: both backups, Standard verification, ASC MHL, reports, operator and notes were retained. Cancel restored the previous notes; Update reached the generated report. +- A four-file transfer including a hidden sidecar and an empty folder: both backups matched the source, ASC MHL was published on both, and PDF/CSV/JSON reports were saved. The app showed a verified completion. +- A three-file recovery case: APFS began with a matching file and a conflicting file; exFAT began empty. APFS showed **1 reused, 1 copied, 1 failed, 0 pending**; exFAT showed **0 reused, 3 copied, 0 failed, 0 pending**. The conflict was byte-for-byte unchanged, and the overall result needed attention. +- After moving only the disposable conflict aside, Retry showed **2 reused + 1 copied** on APFS and **3 reused** on exFAT. All media verified, but the existing ASC MHL history correctly kept the handoff outcome unsafe. The explicit **Retry without ASC MHL** then reverified all six copies and completed successfully. History retained the earlier failed attempts and retry links. +- Replacing the selected source folder before Start: the app refused it with “Its original folder could not be confirmed.” Neither backup gained an output folder for the replacement. +- The final signed archive retained the retry evidence after relaunch, displayed the corrected left alignment and ejected the verified disposable source through the app. Setup preferences and selections were restored afterward. + +Independent file checks confirmed source hashes, sizes, modification times and permissions were unchanged. Four JSON reports preserved the operator and edited notes and recorded the expected safety sequence: success, conflict/unsafe, existing-history/unsafe, success without new MHL. Failure reports append issue details to the operator's notes; the initial probe incorrectly expected exact note equality and was corrected to check the preserved first line plus the failure verdict. + +One native folder-picker attempt left Choose Source disabled after Go to Folder. Cancelling, reopening and navigating into the same folder enabled it. This was a transient UI observation, not a reproduced BitMatch code defect. + +Raw signed-build logs, sample reports/MHL and independent file-check results are in the local validation directory. All three disposable images were detached and removed after retaining the small evidence files. Physical iPhone/iPad access/background testing remains outside this Mac and simulator coverage. A future published release still needs notarization and distribution checks. + +After the alignment fix, the full Mac suite passed again: **1,097 passed, 12 skipped, zero failures**. `mac-closeout-summary.json` and `mac-closeout.log` retain that result. The simulator test counts above precede the alignment-only follow-up. A fresh shared iPhone/iPad Release simulator build (arm64 and x86_64) then succeeded; its log is `ios-closeout-build.log`. The signed Release build still reports existing warnings about the updater conformance, a trailing closure, unused animation results, AppIntents metadata and the app category; this is not a warning-free build claim.