diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c0f563b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + +env: + DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer + +jobs: + mac-tests: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - run: xcodebuild -version + - run: bash test.sh mac-test + env: + DERIVED_DATA_ROOT: ${{ runner.temp }}/bitmatch-derived-data + + ipad-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - run: xcodebuild -version + - run: bash test.sh ipad-build + env: + DERIVED_DATA_ROOT: ${{ runner.temp }}/bitmatch-derived-data diff --git a/.gitignore b/.gitignore index 0fb9912..58f7e7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .AppleDouble .build/ +.derived-data/ .DS_Store .LSOverride .swiftpm/ @@ -22,4 +23,6 @@ dist/ build/ DerivedData/ mac-build.log +.superpowers/ +.worktrees/ Pods/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6966d52..e290fae 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,10 @@ ## Overview -BitMatch uses a shared core architecture that enables code reuse between macOS and iPad platforms while maintaining platform-specific optimizations. +BitMatch keeps one copy-and-verify operation path in the shared core. macOS and +iPad provide their own pickers, security/access integration, and SwiftUI views; +both platforms hand the resolved operation to the same executor and file +services. This keeps safety decisions and result semantics out of platform UI. ## Core Design Principles @@ -48,6 +51,21 @@ class SharedAppCoordinator: ObservableObject { } ``` +### Shared Copy-and-Verify Path + +`SharedAppCoordinator` prepares `CopyVerifyConfig` and delegates execution to +`CopyVerifyExecutor`. The executor owns operation timing, state, error +tracking, iPad background-task handling, result overflow, and report handoff. +It calls `PlatformManager.fileOperations.performFileOperation(...)`, which is +implemented by `SharedFileOperationsService` on both platforms. + +The shared service acquires access scopes, checks source and destination access, +runs `SafetyValidator`, enumerates the source, copies with bounded concurrency, +and records one current result per source/destination pair. Verification can +run in a bounded pipeline. The executor maps progress and rows back to each +platform UI, then seals completion state and generates enabled reports. A view +can explain readiness, but it cannot bypass this shared preflight. + ### Platform Managers #### macOS: MacOSPlatformManager @@ -145,6 +163,7 @@ protocol FileSystemService { Features: - Dynamic window sizing based on content - Mode-specific layouts +- Transfer-plan cards show source, destinations, options, and preflight status before transfer - Integrated report panel - Professional desktop interactions @@ -162,6 +181,7 @@ Features: - Collapsible sections - Professional card designs - Native iOS interactions +- The same transfer-plan presentation shows setup, readiness, warnings, and blocked states before transfer Key Components: - `CopyAndVerifyView`: Enhanced touch interface with collapsible sections @@ -172,25 +192,24 @@ Key Components: ## Flow Diagrams -### Copy & Verify (high level) +### Copy & Verify (shared operation path) -1. UI sets `sourceURL` and `destinationURLs` on `SharedAppCoordinator` -2. User taps Start → `startOperation()` -3. Coordinator runs readiness checks against the resolved final output roots -4. Coordinator starts services: +1. macOS or iPad selection UI sets source and destination URLs; its transfer-plan UI renders setup, readiness, warnings, or blockers. +2. User taps Start → the coordinator builds `CopyVerifyConfig` and runs readiness checks against the resolved final output roots. +3. `CopyVerifyExecutor` starts services: - `OperationTimingService.startOperation(...)` - `OperationStateService.startOperation(...)` - `ErrorReportingService.startErrorTracking(...)` -5. `SharedFileOperationsService.performFileOperation(...)` performs core preflight: +4. `CopyVerifyExecutor` invokes `PlatformManager.fileOperations.performFileOperation(...)`; both platforms reach `SharedFileOperationsService`. +5. `SharedFileOperationsService` performs core preflight: - source exists and is a directory - destinations are unique and writable - final destination roots are unique, non-nested, non-symlinked folders - source tree has no unsafe relative paths or portable-name collisions -6. `FileCopyService.copyAllSafely(...)` copies each file with temp-then-promote semantics and per-file error reporting -7. Verification runs inline or pipelined depending on mode -8. Progress updates → coordinator updates `progress` + timing service → UI reacts -9. On completion: state services finalize; results mapped to `ResultRow` list -10. If reports enabled, `SharedReportGenerationService` is invoked +6. `FileCopyService.copyAllSafely(...)` copies each file with temp-then-promote semantics and per-file error reporting. +7. Verification runs inline or in a bounded pipeline, depending on mode. +8. Progress and per-file rows flow through the executor to timing, state, and platform UI. +9. On completion, the executor coalesces current result rows, finalizes state, and invokes `SharedReportGenerationService` when reports are enabled. ### Compare Folders (basic) @@ -291,3 +310,14 @@ await withTaskGroup(of: Result.self) { group in /* cap concurrency */ } - User interaction flows - State management verification - Platform-specific behavior validation + +### Concurrency and Fault Diagnostics + +`BitMatchTests/ConcurrencyTests.swift` targets the shared `AsyncSemaphore`: +basic permit behavior, bounded concurrent access, stress, and cancellation. +Transfer integration and soak tests exercise the shared operation path, rather +than duplicating platform-specific copy logic. The focused APFS image harness +(`Scripts/run_apfs_fault_tests.sh`) verifies that one inaccessible destination +reports failure while another can finish. `Scripts/run_soak_tests.sh` runs a +seeded, repeatable transfer soak test. Physical cable, power, hub, and exFAT +fault cases require the procedure in `docs/HARDWARE_TESTING.md`. diff --git a/BitMatch-iPad/Assets.xcassets/AppIcon.appiconset/Contents.json b/BitMatch-iPad/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..cb33338 100644 --- a/BitMatch-iPad/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/BitMatch-iPad/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "bitmatchicon-v2.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/BitMatch-iPad/Assets.xcassets/AppIcon.appiconset/bitmatchicon-v2.png b/BitMatch-iPad/Assets.xcassets/AppIcon.appiconset/bitmatchicon-v2.png new file mode 100644 index 0000000..7ac2e4c Binary files /dev/null and b/BitMatch-iPad/Assets.xcassets/AppIcon.appiconset/bitmatchicon-v2.png differ diff --git a/BitMatch-iPad/Views/CopyAndVerifyView.swift b/BitMatch-iPad/Views/CopyAndVerifyView.swift index 6f2e94f..a2af4aa 100644 --- a/BitMatch-iPad/Views/CopyAndVerifyView.swift +++ b/BitMatch-iPad/Views/CopyAndVerifyView.swift @@ -5,6 +5,30 @@ struct CopyAndVerifyView: View { @ObservedObject var coordinator: SharedAppCoordinator @State private var cameraLabelExpanded = false @State private var verificationModeExpanded = false + @State private var optionsExpanded = false + + private var plan: TransferPlanPresentation { + let readiness = coordinator.operationReadinessAssessment + var blockingIssues = readiness.issues + if coordinator.sourceURL == nil || coordinator.destinationURLs.isEmpty { + // TransferPlanPresentation renders missing locations as setup states. + // Preserve only actual validation findings as blocked states. + blockingIssues.removeAll { + $0 == "No source folder selected" || $0 == "No destination folders selected" + } + } + return TransferPlanPresentation.make( + sourceURL: coordinator.sourceURL, + sourceInfo: coordinator.sourceFolderInfo?.asFolderInfo, + destinationURLs: coordinator.destinationURLs, + verificationMode: coordinator.verificationMode, + cameraSettings: coordinator.cameraLabelSettings, + reportSettings: coordinator.reportSettings, + isAnalyzing: coordinator.sourceURL.map { coordinator.isFolderInfoLoading(for: $0) } ?? false, + blockingIssues: blockingIssues, + warnings: readiness.warnings + ) + } var body: some View { VStack(spacing: 20) { @@ -13,26 +37,47 @@ struct CopyAndVerifyView: View { // Full interface VStack(spacing: 20) { - // Enhanced source and destination selection with HorizontalFlow-like design + // The selection cards remain the owner of iPad Files picker actions. EnhancedSourceDestinationView(coordinator: coordinator) - - // Collapsible destination folder labeling section (matching macOS) - CollapsibleLabelingSection( - coordinator: coordinator, - isExpanded: $cameraLabelExpanded - ) - - // Collapsible verification mode section (matching macOS) - CollapsibleVerificationSection( - coordinator: coordinator, - isExpanded: $verificationModeExpanded + + IpadTransferPlanPreflightCard(plan: plan) + IpadTransferPlanOptionSummary(plan: plan, isQuickMode: coordinator.verificationMode == .quick) + + DisclosureGroup(isExpanded: $optionsExpanded) { + VStack(spacing: 14) { + CollapsibleLabelingSection( + coordinator: coordinator, + isExpanded: $cameraLabelExpanded + ) + CollapsibleVerificationSection( + coordinator: coordinator, + isExpanded: $verificationModeExpanded + ) + ReportToggleCard(coordinator: coordinator) + } + .padding(.top, 12) + } label: { + HStack { + Label("Options", systemImage: "slider.horizontal.3") + Spacer() + Text("\(coordinator.verificationMode.rawValue) · \(coordinator.reportSettings.makeReport ? "Reports on" : "Reports off")") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.55)) + } + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white.opacity(0.9)) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.035)) ) - - // Report settings toggle - ReportToggleCard(coordinator: coordinator) - - // Enhanced start transfer button - StartTransferButtonView(coordinator: coordinator) + .accessibilityLabel("Options") + .accessibilityHint("Shows camera labels, verification, and report settings") + + StartTransferButtonView(coordinator: coordinator, plan: plan, showsReadinessBanner: false) } } .padding(.horizontal, 20) @@ -66,23 +111,108 @@ struct CopyAndVerifyHeaderView: View { } } +private struct IpadTransferPlanPreflightCard: View { + let plan: TransferPlanPresentation + + var body: some View { + let display = statusDisplay + HStack(alignment: .top, spacing: 12) { + Image(systemName: display.icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(display.tint) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 4) { + Text(display.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + Text(display.detail) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.68)) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(display.tint.opacity(0.1)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(display.tint.opacity(0.2), lineWidth: 1)) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("Preflight: \(display.title). \(display.detail)") + } + + private var statusDisplay: (title: String, detail: String, icon: String, tint: Color) { + switch plan.status { + case .ready: + return ("Ready to transfer", "Source and backups are ready.", "checkmark.circle.fill", .green) + case .analyzing(let message): + return ("Analyzing", message, "arrow.triangle.2.circlepath", .blue) + case .warning(let warnings): + return ("Ready with warnings", warnings.first ?? "Review options before starting.", "exclamationmark.triangle.fill", .orange) + case .blocked(let issues): + return ("Blocked", issues.first ?? "Resolve the issue to continue.", "xmark.octagon.fill", .red) + case .incomplete(let message): + return ("Needs setup", message, "info.circle.fill", .orange) + } + } +} + +private struct IpadTransferPlanOptionSummary: View { + let plan: TransferPlanPresentation + let isQuickMode: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 7) { + ForEach(plan.optionSummary, id: \.self) { summary in + Text(summary) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.76)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Capsule().fill(Color.white.opacity(0.07))) + } + } + } + + if isQuickMode { + Label("Quick mode does not use checksum verification.", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 12)) + .foregroundColor(.orange) + .accessibilityLabel("Warning: Quick mode does not use checksum verification") + } + } + } +} + struct EnhancedSourceDestinationView: View { @ObservedObject var coordinator: SharedAppCoordinator + @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { - HStack(spacing: 16) { - // Source section (left side) - ProfessionalSourceCard(coordinator: coordinator) - .frame(maxWidth: .infinity) - - // Arrow connector - Image(systemName: "arrow.right") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white.opacity(0.4)) - - // Destinations section (right side) - DestinationsFlowView(coordinator: coordinator) - .frame(maxWidth: .infinity) + Group { + if horizontalSizeClass == .regular { + ViewThatFits(in: .horizontal) { + HStack(spacing: 16) { + ProfessionalSourceCard(coordinator: coordinator) + .frame(minWidth: 280, maxWidth: .infinity) + + Image(systemName: "arrow.right") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white.opacity(0.4)) + + DestinationsFlowView(coordinator: coordinator) + .frame(minWidth: 280, maxWidth: .infinity) + } + + stackedCards + } + } else { + stackedCards + } } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -95,6 +225,13 @@ struct EnhancedSourceDestinationView: View { ) ) } + + private var stackedCards: some View { + VStack(spacing: 16) { + ProfessionalSourceCard(coordinator: coordinator) + DestinationsFlowView(coordinator: coordinator) + } + } } // CompactOperationView removed as it is handled by ModularContentView switching to OperationProgressView @@ -259,6 +396,7 @@ struct DestinationsFlowView: View { .foregroundColor(.white.opacity(0.9)) .padding(.horizontal, 12) .padding(.vertical, 6) + .frame(minHeight: 44) .background( RoundedRectangle(cornerRadius: 6) .fill(Color.white.opacity(0.1)) @@ -296,6 +434,7 @@ struct DestinationsFlowView: View { .foregroundColor(.white.opacity(0.6)) .padding(.horizontal, 10) .padding(.vertical, 6) + .frame(minHeight: 44) .background( RoundedRectangle(cornerRadius: 6) .fill(Color.white.opacity(0.05)) @@ -779,9 +918,11 @@ struct VerificationModeRow: View { struct StartTransferButtonView: View { @ObservedObject var coordinator: SharedAppCoordinator + var plan: TransferPlanPresentation? = nil + var showsReadinessBanner = true private var canStartTransfer: Bool { - coordinator.operationReadinessAssessment.isReady && + (plan?.canStart ?? coordinator.operationReadinessAssessment.isReady) && !coordinator.isOperationInProgress } @@ -793,13 +934,13 @@ struct StartTransferButtonView: View { } else if coordinator.destinationURLs.isEmpty { return "Add Backup Destinations" } else { - return "Start Copy & Verify" + return plan?.actionTitle ?? "Start Copy & Verify" } } var body: some View { VStack(spacing: 12) { - if coordinator.sourceURL != nil || !coordinator.destinationURLs.isEmpty { + if showsReadinessBanner && (coordinator.sourceURL != nil || !coordinator.destinationURLs.isEmpty) { ReadinessBannerView(assessment: coordinator.operationReadinessAssessment) } diff --git a/BitMatch-iPad/Views/ModularContentView.swift b/BitMatch-iPad/Views/ModularContentView.swift index f35c7e5..f08c151 100644 --- a/BitMatch-iPad/Views/ModularContentView.swift +++ b/BitMatch-iPad/Views/ModularContentView.swift @@ -140,6 +140,9 @@ struct IdleStateView: View { switch coordinator.currentMode { case .copyAndVerify: CopyAndVerifyView(coordinator: coordinator) + // Keep the transfer plan readable on wide iPads while + // leaving the compact operation interface untouched. + .frame(maxWidth: 1_100) case .compareFolders: CompareFoldersView(coordinator: coordinator) case .masterReport: diff --git a/BitMatch.xcodeproj/project.pbxproj b/BitMatch.xcodeproj/project.pbxproj index d57c22e..4ab8e23 100644 --- a/BitMatch.xcodeproj/project.pbxproj +++ b/BitMatch.xcodeproj/project.pbxproj @@ -640,6 +640,7 @@ PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatch; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; @@ -667,6 +668,7 @@ PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatch; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; @@ -768,6 +770,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPad"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -802,6 +805,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPad"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/BitMatch/App/AppCoordinator.swift b/BitMatch/App/AppCoordinator.swift index 410a845..514a65d 100644 --- a/BitMatch/App/AppCoordinator.swift +++ b/BitMatch/App/AppCoordinator.swift @@ -131,12 +131,13 @@ final class AppCoordinator: ObservableObject { // MARK: - Initialization init() { - setupObservers() - setupSharedCoreBindings() + setupFileSelectionBindings() + setupProgressBindings() + setupSharedCoordinatorBindings() setupCameraDetection() } - private func setupObservers() { + private func setupFileSelectionBindings() { // Camera detection with memory when source changes fileSelectionViewModel.$sourceURL.sink { [weak self] url in if let url = url { self?.cameraLabelViewModel.detectCameraWithMemory(at: url) } @@ -162,20 +163,33 @@ final class AppCoordinator: ObservableObject { .sink { [weak self] _ in self?.cameraLabelViewModel.onLabelChanged() } .store(in: &cancellables) - // Forward child VM changes to trigger UI refresh - for vm in [fileSelectionViewModel.objectWillChange.eraseToAnyPublisher(), - progressViewModel.objectWillChange.eraseToAnyPublisher()] { - vm.sink { [weak self] _ in - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 1_000_000) - self?.objectWillChange.send() - } - }.store(in: &cancellables) - } + Publishers.MergeMany( + fileSelectionViewModel.$sourceURL.map { _ in () }.eraseToAnyPublisher(), + fileSelectionViewModel.$destinationURLs.map { _ in () }.eraseToAnyPublisher(), + fileSelectionViewModel.$leftURL.map { _ in () }.eraseToAnyPublisher(), + fileSelectionViewModel.$rightURL.map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + } + + private func setupProgressBindings() { + Publishers.MergeMany( + progressViewModel.$fileCountTotal.map { _ in () }.eraseToAnyPublisher(), + progressViewModel.$interpolatedProgress.map { _ in () }.eraseToAnyPublisher(), + progressViewModel.$currentFileName.map { _ in () }.eraseToAnyPublisher(), + progressViewModel.$bytesPerSecond.map { _ in () }.eraseToAnyPublisher(), + progressViewModel.$filesPerSecond.map { _ in () }.eraseToAnyPublisher(), + progressViewModel.$estimatedTimeRemaining.map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) } // MARK: - Shared Core Bindings - private func setupSharedCoreBindings() { + private func setupSharedCoordinatorBindings() { // Map SharedAppCoordinator progress → ProgressViewModel sharedCoordinator.$progress.compactMap { $0 } .throttle(for: .milliseconds(120), scheduler: RunLoop.main, latest: true) @@ -220,13 +234,15 @@ final class AppCoordinator: ObservableObject { } }.store(in: &cancellables) - // Forward SharedAppCoordinator changes to trigger UI refresh - sharedCoordinator.objectWillChange.sink { [weak self] _ in - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 1_000_000) - self?.objectWillChange.send() - } - }.store(in: &cancellables) + Publishers.MergeMany( + sharedCoordinator.$isOperationInProgress.map { _ in () }.eraseToAnyPublisher(), + sharedCoordinator.$operationState.map { _ in () }.eraseToAnyPublisher(), + sharedCoordinator.$results.map { _ in () }.eraseToAnyPublisher(), + sharedCoordinator.$verificationMode.map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) } private func setupCameraDetection() { diff --git a/BitMatch/App/ContentView.swift b/BitMatch/App/ContentView.swift index 5dde5e6..c2c8b21 100644 --- a/BitMatch/App/ContentView.swift +++ b/BitMatch/App/ContentView.swift @@ -20,11 +20,12 @@ struct ContentView: View { @State private var preferencesWindowController: PreferencesWindowController? // Dynamic window height management - @State private var cameraLabelExpanded = false + @State private var transferOptionsExpanded = false @State private var verificationModeExpanded = false @State private var showCancelNotice = false @State private var showDropRejection = false @State private var dropRejectionMessage = "" + @Environment(\.accessibilityReduceMotion) private var reduceMotion // Calculate ideal window height based on content and current mode private var idealWindowHeight: CGFloat { @@ -46,13 +47,7 @@ struct ContentView: View { totalHeight += sourceDestinationHeight + controlPanelBaseHeight - // Add height for expanded sections - if cameraLabelExpanded { - totalHeight += 280 // Camera labeling section height - } - if verificationModeExpanded { - totalHeight += 150 // Verification mode section height - } + if transferOptionsExpanded { totalHeight += 330 } case .compareFolders: let foldersHeight: CGFloat = 180 // Both folder panels (matches the fixed height in CompareFoldersView) @@ -118,9 +113,9 @@ struct ContentView: View { private var styledMainContentView: some View { mainContentView .preferredColorScheme(.dark) - .animation(.spring(response: 0.4, dampingFraction: 0.85), value: coordinator.completionState) - .animation(.spring(response: 0.4, dampingFraction: 0.85), value: coordinator.isOperationInProgress) - .animation(.spring(response: 0.35, dampingFraction: 0.9), value: coordinator.currentMode) + .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.85), value: coordinator.completionState) + .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.85), value: coordinator.isOperationInProgress) + .animation(reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.9), value: coordinator.currentMode) } @ViewBuilder @@ -250,8 +245,7 @@ struct ContentView: View { CopyAndVerifyView( coordinator: coordinator, showReportSettings: .constant(false), - cameraLabelExpanded: $cameraLabelExpanded, - verificationModeExpanded: $verificationModeExpanded + optionsExpanded: $transferOptionsExpanded ) .transition(.asymmetric( insertion: .opacity.combined(with: .scale(scale: 0.98)), @@ -573,7 +567,7 @@ struct ContentView: View { updateWindowSize(width: idealWindowWidth, height: idealWindowHeight) } } - .onChange(of: cameraLabelExpanded) { _, _ in + .onChange(of: transferOptionsExpanded) { _, _ in if !coordinator.isOperationInProgress { updateWindowHeight(to: idealWindowHeight) } diff --git a/BitMatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/BitMatch/Assets.xcassets/AppIcon.appiconset/Contents.json index ae802f4..7cb2b7b 100644 --- a/BitMatch/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/BitMatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -48,7 +48,7 @@ "size" : "512x512" }, { - "filename" : "bitmatchicon.png", + "filename" : "bitmatchicon-v2.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/BitMatch/Assets.xcassets/AppIcon.appiconset/bitmatchicon-v2.png b/BitMatch/Assets.xcassets/AppIcon.appiconset/bitmatchicon-v2.png new file mode 100644 index 0000000..7ac2e4c Binary files /dev/null and b/BitMatch/Assets.xcassets/AppIcon.appiconset/bitmatchicon-v2.png differ diff --git a/BitMatch/Core/Services/ComparisonOperationService.swift b/BitMatch/Core/Services/ComparisonOperationService.swift deleted file mode 100644 index d542864..0000000 --- a/BitMatch/Core/Services/ComparisonOperationService.swift +++ /dev/null @@ -1,345 +0,0 @@ -// Core/Services/ComparisonOperationService.swift -import Foundation - -/// Service for handling file comparison operations -final class ComparisonOperationService { - - // MARK: - Comparison Operations - - static func performComparison( - left: URL, - right: URL, - verificationMode: VerificationMode, - progressViewModel: ProgressViewModel, - onProgress: @escaping ([ResultRow]) -> Void, - onComplete: @escaping () -> Void, - shouldGenerateMHL: Bool, - mhlCollector: MHLOperationService.MHLCollectorActor? - ) async throws { - try await SafetyValidator.performComparisonChecks(left: left, right: right) - - // Always perform real comparison in production testing - - // Count files for progress tracking - let totalFiles = try await countFilesInDirectory(at: left) - await MainActor.run { - progressViewModel.setFileCountTotal(totalFiles) - } - - // Limit verification concurrency to keep memory footprint predictable - let workers = min(2, ProcessInfo.processInfo.activeProcessorCount) - let needsMHL = shouldGenerateMHL - - // Process comparisons concurrently without materializing the full file list in memory - try await withThrowingTaskGroup(of: ResultRow.self) { group in - let semaphore = AsyncSemaphore(count: workers) - // Batch results to reduce UI churn and memory pressure - var batch: [ResultRow] = [] - batch.reserveCapacity(50) - - let fm = FileManager.default - let enumKeys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey] - let maxInFlightTasks = max(128, workers * 32) // bound task graph to keep memory stable - var inFlight = 0 - if let enumerator = fm.enumerator(at: left, includingPropertiesForKeys: enumKeys, options: []) { - while let nextObj = enumerator.nextObject() as? URL { - do { - let values = try nextObj.resourceValues(forKeys: Set(enumKeys)) - let isSymlink = values.isSymbolicLink ?? false - let isFile = values.isRegularFile ?? false - if isSymlink || !isFile { continue } - } catch { - continue - } - let fileURL = nextObj - // Backpressure: if too many tasks are queued, drain one result before queuing more - if inFlight >= maxInFlightTasks { - if let row = try await group.next() { - await cooperativePauseCheck() - await MainActor.run { - progressViewModel.incrementFileCompleted() - if row.status.contains("✅") || row.status.contains("Match") { - progressViewModel.incrementMatch() - } - } - batch.append(row) - if batch.count >= 50 { - let toAdd = batch - batch.removeAll(keepingCapacity: true) - onProgress(toAdd) - } - inFlight -= 1 - } - } - inFlight += 1 - group.addTask { - await semaphore.wait() - let relativePath = String(fileURL.path.dropFirst(left.path.count + 1)) - let counterpart = right.appendingPathComponent(relativePath) - - // Check if counterpart exists - guard FileManager.default.fileExists(atPath: counterpart.path) else { - await semaphore.signal() - return ResultRow(path: relativePath, status: "Missing in Destination", size: 0, checksum: nil, destination: nil) - } - - // Update progress: set current file name - await MainActor.run { - progressViewModel.setCurrentFile(fileURL.lastPathComponent) - } - - // Perform comparison - let result = await VerifyService.compare(leftURL: fileURL, rightURL: counterpart, relativePath: relativePath, verificationMode: verificationMode) - - // Collect checksum for MHL if needed - if needsMHL && (result.status.contains("✅") || result.status.contains("Match")) { - if let collector = mhlCollector { - do { - try await MHLOperationService.collectMHLEntry( - for: counterpart, - algorithm: .sha256, - collector: collector - ) - } catch { - SharedLogger.warning("Failed to collect MHL entry: \(error)", category: .transfer) - } - } - } - await semaphore.signal() - return result - } - } - } - // Process any remaining results in batches - while let row = try await group.next() { - await cooperativePauseCheck() - - await MainActor.run { - progressViewModel.incrementFileCompleted() - if row.status.contains("✅") || row.status.contains("Match") { - progressViewModel.incrementMatch() - } - } - - batch.append(row) - - if batch.count >= 50 { - let toAdd = batch - batch.removeAll(keepingCapacity: true) - onProgress(toAdd) - } - } - - if !batch.isEmpty { - onProgress(batch) - } - } - - // Check for extra files in destination - try await FileDiffService.checkForExtraFiles(in: right, comparedTo: left, onProgress: onProgress) - - onComplete() - } - - // MARK: - Copy and Verify Operations - - static func performCopyAndVerify( - source: URL, - destinations: [URL], - verificationMode: VerificationMode, - cameraLabelSettings: CameraLabelSettings, - progressViewModel: ProgressViewModel, - onProgress: @escaping ([ResultRow]) -> Void, - onComplete: @escaping () -> Void, - shouldGenerateMHL: Bool, - mhlCollector: MHLOperationService.MHLCollectorActor? - ) async throws { - - // Always perform real copy+verify in production testing - - // Safety checks. Validate resolved output roots before any directory - // creation performed by performSafetyChecks. - try SafetyValidator.validateResolvedDestinationRoots( - source: source, - destinations: destinations, - settings: cameraLabelSettings - ) - try await SafetyValidator.performSafetyChecks( - source: source, - destinations: destinations - ) - - // Establish file counts for progress (copy phase) without holding full list - let perDestFileCount = FileTreeEnumerator.countRegularFiles(base: source) - let totalPlanned = perDestFileCount * max(1, destinations.count) - await MainActor.run { - progressViewModel.setFileCountTotal(totalPlanned) - progressViewModel.configureDestinations(totalPerDestination: perDestFileCount, count: destinations.count) - progressViewModel.setProgressMessage("Copying files…") - } - - let destinationRootFor: (URL) -> URL = { destination in - SafetyValidator.resolvedDestinationRoot( - source: source, - destination: destination, - settings: cameraLabelSettings - ) - } - - // Pre-scan destinations to seed resume progress only when existing files can be verified. - let alreadyPresentPerDestination: [Int] = await withTaskGroup(of: Int.self, returning: [Int].self) { group in - for destination in destinations { - group.addTask { - let destRoot = destinationRootFor(destination) - return await PreScanService.countAlreadyPresentStreaming(sourceBase: source, destRoot: destRoot, verificationMode: verificationMode) - } - } - var counts: [Int] = [] - for await c in group { counts.append(c) } - return counts - } - let seeded = alreadyPresentPerDestination.reduce(0, +) - if seeded > 0 { - await MainActor.run { - progressViewModel.incrementFileCompleted(seeded) - // Bump per-destination counters to reflect seeded progress - for (idx, c) in alreadyPresentPerDestination.enumerated() where c > 0 { - for _ in 0..= 3 { - progressViewModel.setProgressMessage("Resuming: \(seeded) already present…") - } - } - } - // Reuse enumerated file list via closure capture when copying to each destination - - // Diagnostics - let resolvedDestinations = destinations.map { destinationRootFor($0).path } - SharedLogger.info("Starting Copy & Verify - Source: \(source.path), Destinations: \(resolvedDestinations)", category: .transfer) - - // Copy files to all destinations. - // Use conservative per-destination concurrency to keep memory stable under large file loads. - let workers = 1 - await withThrowingTaskGroup(of: Void.self) { group in - for (destIndex, destination) in destinations.enumerated() { - group.addTask { - try Task.checkCancellation() - // Create a top-level folder named after the source (e.g., card volume name) - let destRoot = destinationRootFor(destination) - do { - if !FileManager.default.fileExists(atPath: destRoot.path) { - try FileManager.default.createDirectory(at: destRoot, withIntermediateDirectories: true, attributes: nil) - } - } catch { - SharedLogger.error("Failed to prepare destination folder \(destRoot.path): \(error.localizedDescription)", category: .transfer) - throw error - } - SharedLogger.debug("Copying to: \(destRoot.path)", category: .transfer) - - try await FileCopyService.copyAllSafely( - from: source, - toRoot: destRoot, - verificationMode: verificationMode, - workers: workers, - preEnumeratedFiles: nil, - pauseCheck: nil, - onProgress: { fileName, fileSize in - // Dispatch to main actor without making this closure async - Task { @MainActor in - // Skip UI updates if cancelled to reduce warnings/spam - guard !Task.isCancelled else { return } - progressViewModel.setCurrentFile(fileName, size: fileSize) - progressViewModel.updateBytesProcessed(fileSize) - progressViewModel.incrementFileCompleted() - progressViewModel.incrementDestinationCompleted(index: destIndex) - } - }, - onError: { fileName, error in - SharedLogger.error("Copy error for \(fileName): \(error.localizedDescription)", category: .transfer) - let errorRow = ResultRow( - path: fileName, - status: "❌ Copy Failed: \(error.localizedDescription)", - size: 0, - checksum: nil, - destination: destination.lastPathComponent, - destinationPath: destination.path - ) - onProgress([errorRow]) - } - ) - } - } - } - - // Respect cancellation between copy and verify phases - try Task.checkCancellation() - - // Verify copied files (compare against the per-card subfolder we created) - for destination in destinations { - let destRoot = destinationRootFor(destination) - try Task.checkCancellation() - try await performComparison( - left: source, - right: destRoot, - verificationMode: verificationMode, - progressViewModel: progressViewModel, - onProgress: onProgress, - onComplete: {}, - shouldGenerateMHL: shouldGenerateMHL, - mhlCollector: mhlCollector - ) - } - - onComplete() - } - - // MARK: - Helper Methods - - - // MARK: - Helper Functions - - private static func cooperativePauseCheck() async { - try? await Task.sleep(nanoseconds: 1_000_000) - await Task.yield() - } - - private static func countFilesInDirectory(at url: URL) async throws -> Int { - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .utility).async { - do { - let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey]) - var count = 0 - while let fileURL = enumerator?.nextObject() as? URL { - let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) - if resourceValues.isRegularFile == true { - count += 1 - } - } - continuation.resume(returning: count) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - // No persistent state; helpers only - - // moved to VerifyService.compare - - private static func performSafetyChecks(source: URL, destinations: [URL]) async throws { - // Basic safety checks - guard FileManager.default.fileExists(atPath: source.path) else { - throw BitMatchError.fileNotFound(source) - } - - for dest in destinations { - let parentDir = dest.deletingLastPathComponent() - if !FileManager.default.fileExists(atPath: parentDir.path) { - try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) - } - } - } -} diff --git a/BitMatch/Core/Services/Configuration/AppConfiguration.swift b/BitMatch/Core/Services/Configuration/AppConfiguration.swift deleted file mode 100644 index 3e557cd..0000000 --- a/BitMatch/Core/Services/Configuration/AppConfiguration.swift +++ /dev/null @@ -1,115 +0,0 @@ -// Core/Services/Configuration/AppConfiguration.swift -import Foundation - -/// Centralized app configuration management -class AppConfiguration: ObservableObject { - static let shared = AppConfiguration() - - // MARK: - Transfer Settings - @Published var maxConcurrentTransfers: Int = 3 { - didSet { UserDefaults.standard.set(maxConcurrentTransfers, forKey: Keys.maxConcurrentTransfers) } - } - - @Published var verificationEnabled: Bool = true { - didSet { UserDefaults.standard.set(verificationEnabled, forKey: Keys.verificationEnabled) } - } - - @Published var checksumAlgorithm: ChecksumAlgorithm = .xxHash { - didSet { UserDefaults.standard.set(checksumAlgorithm.rawValue, forKey: Keys.checksumAlgorithm) } - } - - // MARK: - UI Settings - @Published var showDetailedProgress: Bool = true { - didSet { UserDefaults.standard.set(showDetailedProgress, forKey: Keys.showDetailedProgress) } - } - - @Published var autoCloseOnComplete: Bool = false { - didSet { UserDefaults.standard.set(autoCloseOnComplete, forKey: Keys.autoCloseOnComplete) } - } - - // MARK: - Performance Settings - @Published var bufferSize: Int = 1_048_576 { // 1MB default - didSet { UserDefaults.standard.set(bufferSize, forKey: Keys.bufferSize) } - } - - // MARK: - iOS Background Behavior - @Published var preventAutoLockDuringTransfer: Bool = true { - didSet { UserDefaults.standard.set(preventAutoLockDuringTransfer, forKey: Keys.preventAutoLockDuringTransfer) } - } - @Published var dimScreenWhileAwake: Bool = true { - didSet { UserDefaults.standard.set(dimScreenWhileAwake, forKey: Keys.dimScreenWhileAwake) } - } - - enum ChecksumAlgorithm: String, CaseIterable { - case md5 = "MD5" - case sha256 = "SHA256" - case xxHash = "xxHash" - - var displayName: String { - switch self { - case .md5: return "MD5 (Fast)" - case .sha256: return "SHA256 (Secure)" - case .xxHash: return "xxHash (Fastest)" - } - } - } - - private enum Keys { - static let maxConcurrentTransfers = "MaxConcurrentTransfers" - static let verificationEnabled = "VerificationEnabled" - static let checksumAlgorithm = "ChecksumAlgorithm" - static let showDetailedProgress = "ShowDetailedProgress" - static let autoCloseOnComplete = "AutoCloseOnComplete" - static let bufferSize = "BufferSize" - static let preventAutoLockDuringTransfer = "PreventAutoLockDuringTransfer" - static let dimScreenWhileAwake = "DimScreenWhileAwake" - } - - private init() { - loadSettings() - AppLogger.info("App configuration loaded", category: .general) - } - - private func loadSettings() { - maxConcurrentTransfers = UserDefaults.standard.integer(forKey: Keys.maxConcurrentTransfers) != 0 - ? UserDefaults.standard.integer(forKey: Keys.maxConcurrentTransfers) : 3 - - verificationEnabled = UserDefaults.standard.bool(forKey: Keys.verificationEnabled) - - if let algorithmString = UserDefaults.standard.string(forKey: Keys.checksumAlgorithm), - let algorithm = ChecksumAlgorithm(rawValue: algorithmString) { - checksumAlgorithm = algorithm - } - - showDetailedProgress = UserDefaults.standard.bool(forKey: Keys.showDetailedProgress) - autoCloseOnComplete = UserDefaults.standard.bool(forKey: Keys.autoCloseOnComplete) - - bufferSize = UserDefaults.standard.integer(forKey: Keys.bufferSize) != 0 - ? UserDefaults.standard.integer(forKey: Keys.bufferSize) : 1_048_576 - - // Background behavior (defaults true/true for first run) - if UserDefaults.standard.object(forKey: Keys.preventAutoLockDuringTransfer) == nil { - preventAutoLockDuringTransfer = true - } else { - preventAutoLockDuringTransfer = UserDefaults.standard.bool(forKey: Keys.preventAutoLockDuringTransfer) - } - if UserDefaults.standard.object(forKey: Keys.dimScreenWhileAwake) == nil { - dimScreenWhileAwake = true - } else { - dimScreenWhileAwake = UserDefaults.standard.bool(forKey: Keys.dimScreenWhileAwake) - } - } - - func resetToDefaults() { - maxConcurrentTransfers = 3 - verificationEnabled = true - checksumAlgorithm = .xxHash - showDetailedProgress = true - autoCloseOnComplete = false - bufferSize = 1_048_576 - preventAutoLockDuringTransfer = true - dimScreenWhileAwake = true - - AppLogger.info("App configuration reset to defaults", category: .general) - } -} diff --git a/BitMatch/Core/Services/File/FileCounter.swift b/BitMatch/Core/Services/File/FileCounter.swift deleted file mode 100644 index 71a4b23..0000000 --- a/BitMatch/Core/Services/File/FileCounter.swift +++ /dev/null @@ -1,112 +0,0 @@ -// Core/Services/File/FileCounter.swift -import Foundation - -/// Handles file counting and enumeration operations -final class FileCounter { - - static func countFiles(at url: URL) async throws -> Int { - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - do { - let count = try countFilesSync(at: url) - continuation.resume(returning: count) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - private static func countFilesSync(at url: URL) throws -> Int { - let fm = FileManager.default - var count = 0 - - if let enumerator = fm.enumerator( - at: url, - includingPropertiesForKeys: [.isDirectoryKey], - options: [] - ) { - for case let fileURL as URL in enumerator { - let resourceValues = try fileURL.resourceValues(forKeys: [.isDirectoryKey]) - if resourceValues.isDirectory != true { - count += 1 - } - } - } - - return count - } - - /// Count files with retry logic for transient errors - static func countFilesWithRetry(at url: URL, maxRetries: Int = 3) async throws -> Int { - return try await retryWithBackoff(maxRetries: maxRetries) { - try await countFiles(at: url) - } - } - - // MARK: - Retry Logic - - static func retryWithBackoff( - maxRetries: Int = 3, - initialDelay: TimeInterval = 0.5, - backoffMultiplier: Double = 2.0, - operation: @escaping () async throws -> T - ) async throws -> T { - - var delay = initialDelay - var lastError: Error? - - for attempt in 0.. Bool { - let nsError = error as NSError - - // Network/filesystem transient errors - switch nsError.code { - case NSFileReadNoSuchFileError, - NSFileReadNoPermissionError, - NSFileReadCorruptFileError: - return false // Permanent errors - - case NSFileReadUnknownError, - NSFileReadTooLargeError, - NSFileReadUnknownStringEncodingError: - return true // Potentially transient - - default: - // Check for system-level errors that might be transient - if nsError.domain == NSPOSIXErrorDomain { - switch nsError.code { - case Int(EAGAIN), Int(EBUSY), Int(ETIMEDOUT), Int(ENOENT): - return true - default: - return false - } - } - - return false - } - } -} diff --git a/BitMatch/Core/Services/File/FileDiffService.swift b/BitMatch/Core/Services/File/FileDiffService.swift deleted file mode 100644 index 4cb5dce..0000000 --- a/BitMatch/Core/Services/File/FileDiffService.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -enum FileDiffService { - /// Detect extra files present in destination that are not in source and report them in batches via onProgress. - static func checkForExtraFiles( - in destination: URL, - comparedTo source: URL, - onProgress: @escaping ([ResultRow]) -> Void - ) async throws { - // Collect source file relative paths - let sourceFiles = await FileTreeEnumeratorPaths.collectRelativePaths(at: source, relativeTo: source) - - // Walk destination and find extras - var extraRows: [ResultRow] = [] - let fm = FileManager.default - if let enumerator = fm.enumerator(at: destination, includingPropertiesForKeys: [.isRegularFileKey], options: []) { - while let url = enumerator.nextObject() as? URL { - do { - if try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true { - let relative = String(url.path.dropFirst(destination.path.count + 1)) - if !sourceFiles.contains(relative) { - let size = (try? fm.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 - extraRows.append(ResultRow(path: relative, status: "Extra in Destination", size: size, checksum: nil, destination: nil)) - if extraRows.count >= 50 { - onProgress(extraRows) - extraRows.removeAll(keepingCapacity: true) - } - } - } - } catch { - // Ignore and keep scanning - } - } - } - - if !extraRows.isEmpty { onProgress(extraRows) } - } -} - -/// Helper to collect relative path sets efficiently. -enum FileTreeEnumeratorPaths { - static func collectRelativePaths(at url: URL, relativeTo base: URL) async -> Set { - return await withCheckedContinuation { continuation in - Task.detached(priority: .utility) { - var paths = Set() - let fm = FileManager.default - if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: []) { - while let u = enumerator.nextObject() as? URL { - if let isFile = try? u.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile, isFile == true { - let rel = String(u.path.dropFirst(base.path.count + 1)) - paths.insert(rel) - } - } - } - continuation.resume(returning: paths) - } - } - } -} diff --git a/BitMatch/Core/Services/File/TempFileManager.swift b/BitMatch/Core/Services/File/TempFileManager.swift deleted file mode 100644 index a560f63..0000000 --- a/BitMatch/Core/Services/File/TempFileManager.swift +++ /dev/null @@ -1,45 +0,0 @@ -// Core/Services/File/TempFileManager.swift -import Foundation - -/// Manages temporary files and cleanup operations -final class TempFileManager { - - // MARK: - Track temp files for cleanup - private static var activeTempFiles = Set() - private static let tempFilesLock = NSLock() - - static func addTempFile(_ url: URL) { - tempFilesLock.lock() - activeTempFiles.insert(url) - tempFilesLock.unlock() - } - - static func removeTempFile(_ url: URL) { - tempFilesLock.lock() - activeTempFiles.remove(url) - tempFilesLock.unlock() - } - - static func cleanupAllTempFiles() { - tempFilesLock.lock() - let files = activeTempFiles - tempFilesLock.unlock() - - let fm = FileManager.default - for file in files { - try? fm.removeItem(at: file) - } - - tempFilesLock.lock() - activeTempFiles.removeAll() - tempFilesLock.unlock() - } - - /// Get active temp files count for debugging - static var activeTempFileCount: Int { - tempFilesLock.lock() - defer { tempFilesLock.unlock() } - return activeTempFiles.count - } - -} diff --git a/BitMatch/Core/Services/File/UnifiedFileOperationService.swift b/BitMatch/Core/Services/File/UnifiedFileOperationService.swift deleted file mode 100644 index f234b41..0000000 --- a/BitMatch/Core/Services/File/UnifiedFileOperationService.swift +++ /dev/null @@ -1,144 +0,0 @@ -// Core/Services/File/UnifiedFileOperationService.swift -import Foundation - -/// Unified service that coordinates all file operations with safety, progress tracking, and cleanup -final class UnifiedFileOperationService { - static let shared = UnifiedFileOperationService() - private init() {} - - // MARK: - High-level File Operations - - /// Perform a complete file operation with safety checks, progress tracking, and cleanup - func performSecureCopyOperation( - from source: URL, - toDestinations destinations: [URL], - verificationMode: VerificationMode = .standard, - workers: Int = 4, - onProgress: @escaping (String, Int64) -> Void, - onError: @escaping (String, Error) -> Void - ) async throws { - - // Step 1: Safety validation - try await SafetyValidator.performSafetyChecks(source: source, destinations: destinations) - - // Step 2: Count files for progress tracking - let totalFiles = try await FileCounter.countFiles(at: source) - SharedLogger.info("Total files to process: \(totalFiles)", category: .transfer) - - // Step 3: Perform copying to each destination - for destination in destinations { - try await FileCopyService.copyAllSafely( - from: source, - toRoot: destination, - verificationMode: verificationMode, - workers: workers, - preEnumeratedFiles: nil, - pauseCheck: nil, - onProgress: onProgress, - onError: onError - ) - } - - // Step 4: Cleanup any temp files - TempFileManager.cleanupAllTempFiles() - } - - /// Get file count with progress callback - func getFileCount(at url: URL, onProgress: ((Int) -> Void)? = nil) async throws -> Int { - let count = try await FileCounter.countFiles(at: url) - onProgress?(count) - return count - } - - /// Validate multiple operations before execution - func validateMultipleOperations(_ operations: [(source: URL, destinations: [URL])]) async throws { - for operation in operations { - try await SafetyValidator.performSafetyChecks( - source: operation.source, - destinations: operation.destinations - ) - } - } - - /// Get system info for operation planning - func getOperationCapabilities() -> OperationCapabilities { - return OperationCapabilities( - maxConcurrentWorkers: ProcessInfo.processInfo.activeProcessorCount, - availableMemory: getAvailableMemory(), - tempFileCount: TempFileManager.activeTempFileCount - ) - } - - // MARK: - Temp File Management - - func registerTempFile(_ url: URL) { - TempFileManager.addTempFile(url) - } - - func cleanupTempFile(_ url: URL) { - TempFileManager.removeTempFile(url) - } - - func cleanupAllTempFiles() { - TempFileManager.cleanupAllTempFiles() - } - - // MARK: - Private Utilities - - private func getAvailableMemory() -> UInt64 { - var info = mach_task_basic_info() - var count = mach_msg_type_number_t(MemoryLayout.size)/4 - - let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { - $0.withMemoryRebound(to: integer_t.self, capacity: 1) { - task_info(mach_task_self_, - task_flavor_t(MACH_TASK_BASIC_INFO), - $0, - &count) - } - } - - if kerr == KERN_SUCCESS { - return info.resident_size - } else { - return 0 - } - } -} - -// MARK: - Supporting Types - -struct OperationCapabilities { - let maxConcurrentWorkers: Int - let availableMemory: UInt64 - let tempFileCount: Int - - var recommendedWorkers: Int { - // Conservative approach: use 75% of available cores - return max(1, Int(Double(maxConcurrentWorkers) * 0.75)) - } - - var memoryPressure: MemoryPressureLevel { - let gbMemory = Double(availableMemory) / (1024 * 1024 * 1024) - switch gbMemory { - case 0..<2.0: - return .high - case 2.0..<8.0: - return .medium - default: - return .low - } - } -} - -enum MemoryPressureLevel { - case low, medium, high - - var maxConcurrentOperations: Int { - switch self { - case .low: return 4 - case .medium: return 2 - case .high: return 1 - } - } -} diff --git a/BitMatch/Core/Services/File/VerifyService.swift b/BitMatch/Core/Services/File/VerifyService.swift deleted file mode 100644 index dac5548..0000000 --- a/BitMatch/Core/Services/File/VerifyService.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -enum VerifyService { - static func compare( - leftURL: URL, - rightURL: URL, - relativePath: String, - verificationMode: VerificationMode - ) async -> ResultRow { - do { - let leftSize = try FileManager.default.attributesOfItem(atPath: leftURL.path)[.size] as? Int64 ?? 0 - let rightSize = try FileManager.default.attributesOfItem(atPath: rightURL.path)[.size] as? Int64 ?? 0 - - guard leftSize == rightSize else { - return ResultRow(path: relativePath, status: "Size Mismatch", size: leftSize, checksum: nil, destination: nil) - } - - if verificationMode.useChecksum { - do { - let checksumService = SharedChecksumService.shared - let checksumAlgorithm = verificationMode.checksumTypes.first ?? .sha256 - - let leftChecksum = try await checksumService.generateChecksum(for: leftURL, type: checksumAlgorithm, useCache: false, progressCallback: nil) - let rightChecksum = try await checksumService.generateChecksum(for: rightURL, type: checksumAlgorithm, useCache: false, progressCallback: nil) - - let checksumMatch = leftChecksum == rightChecksum - let status = checksumMatch ? "✅ Verified Match" : "❌ Checksum Mismatch" - return ResultRow(path: relativePath, status: status, size: leftSize, checksum: leftChecksum, destination: nil) - } catch { - return ResultRow(path: relativePath, status: "❌ Checksum Error: \(error.localizedDescription)", size: leftSize, checksum: nil, destination: nil) - } - } else { - return ResultRow(path: relativePath, status: "✅ Match", size: leftSize, checksum: nil, destination: nil) - } - } catch { - return ResultRow(path: relativePath, status: "Error: \(error.localizedDescription)", size: 0, checksum: nil, destination: nil) - } - } -} diff --git a/BitMatch/Core/ViewModels/OperationViewModel.swift b/BitMatch/Core/ViewModels/OperationViewModel.swift deleted file mode 100644 index 82933a7..0000000 --- a/BitMatch/Core/ViewModels/OperationViewModel.swift +++ /dev/null @@ -1,597 +0,0 @@ -// Core/ViewModels/OperationViewModel.swift - Refactored to use focused services -import Foundation -import SwiftUI -import Combine -import UserNotifications -import AppKit - -@MainActor -final class OperationViewModel: ObservableObject { - // MARK: - Published Properties - @Published private(set) var state: OperationState = .idle - @Published var results: [ResultRow] = [] - @Published var verificationMode: VerificationMode = .standard - - // MHL Generation tracking - @Published var mhlGenerated = false - @Published var mhlFilePath: String? - @Published var isGeneratingMHL = false - - // Error display - @Published var showError = false - @Published var errorMessage: String? - - // Memory management - private let maxResultsInMemory = 10_000 - private var resultsOverflowFile: URL? - private let mhlCollectorActor = MHLOperationService.MHLCollectorActor() - - // Observable changes for UI updates - var statePublisher: AnyPublisher { - $state.eraseToAnyPublisher() - } - - // MARK: - Computed Properties - var isVerifying: Bool { state.isActive } - var isPaused: Bool { state.isPaused } - var canCancel: Bool { state.canCancel } - - var completionState: CompletionState { - switch state { - case .idle, .notStarted: - return .idle - case .inProgress, .copying, .verifying, .paused, .resuming: - return .inProgress - case .completed(let info): - return info.success ? .success(message: info.message) : .issues(message: info.message) - case .failed: - return .failed(message: "Operation failed") - case .cancelled: - return .failed(message: "Operation was cancelled") - } - } - - // MARK: - Private Properties - private var operationTask: Task? - private var pauseContinuation: CheckedContinuation? - private var jobID = UUID() - private var jobStart = Date() - - private var activeDestinations: [URL] = [] - private var currentMode: AppMode { - return fileSelectionViewModel.sourceURL != nil ? .copyAndVerify : .compareFolders - } - - // MARK: - Dependencies - private let progressViewModel: ProgressViewModel - private let fileSelectionViewModel: FileSelectionViewModel - private let cameraLabelViewModel: CameraLabelViewModel - private let settingsViewModel: SettingsViewModel - - // MARK: - Initialization - init(progressViewModel: ProgressViewModel, - fileSelectionViewModel: FileSelectionViewModel, - cameraLabelViewModel: CameraLabelViewModel, - settingsViewModel: SettingsViewModel) { - self.progressViewModel = progressViewModel - self.fileSelectionViewModel = fileSelectionViewModel - self.cameraLabelViewModel = cameraLabelViewModel - self.settingsViewModel = settingsViewModel - - // Check for interrupted operations on init - OperationResumeService.checkForInterruptedOperations { [weak self] operation in - self?.resumeOperation(operation) - } - } - - // MARK: - Public Methods - - func resetCompletionState() { - if case .completed = state { - state = .idle - } - isGeneratingMHL = false - } - - func startComparison() { - guard let left = fileSelectionViewModel.leftURL, - let right = fileSelectionViewModel.rightURL else { return } - - activeDestinations = [right] - prepareForOperation() - - operationTask = Task { - do { - state = .verifying - - try await ComparisonOperationService.performComparison( - left: left, - right: right, - verificationMode: verificationMode, - progressViewModel: progressViewModel, - onProgress: { [weak self] newResults in - Task { @MainActor [weak self] in - guard let self = self else { return } - self.addResults(newResults) - } - }, - onComplete: { [weak self] in - Task { @MainActor [weak self] in - await self?.handleOperationComplete() - } - }, - shouldGenerateMHL: MHLOperationService.shouldGenerateMHL(for: verificationMode), - mhlCollector: verificationMode.requiresMHL ? mhlCollectorActor : nil - ) - - } catch is CancellationError { - // User cancelled the operation; do not surface as an error state - return - } catch { - await handleOperationError(error) - } - } - } - - func startCopyAndVerify() { - guard let source = fileSelectionViewModel.sourceURL, - !fileSelectionViewModel.destinationURLs.isEmpty else { return } - - // Validate destinations are safe before any destination folders are created. - let validDestinations = fileSelectionViewModel.validateDestinations(for: source) - let invalidCount = fileSelectionViewModel.destinationURLs.count - validDestinations.count - - if invalidCount > 0 { - let destinationPathCounts = Dictionary( - grouping: fileSelectionViewModel.destinationURLs, - by: { $0.standardizedFileURL.resolvingSymlinksInPath().path } - ).mapValues(\.count) - let invalidReasons = fileSelectionViewModel.destinationURLs.compactMap { dest -> String? in - let path = dest.standardizedFileURL.resolvingSymlinksInPath().path - if (destinationPathCounts[path] ?? 0) > 1 { - return "\(dest.lastPathComponent): Destination is already selected" - } - if SafetyValidator.isProtectedSystemPath(dest) { - return "\(dest.lastPathComponent): System folders cannot be used as destinations" - } - if let reason = SafetyValidator.destinationSafetyIssue(source: source, destination: dest) { - return "\(dest.lastPathComponent): \(reason)" - } - return "\(dest.lastPathComponent): Destination is not safe for this transfer" - } - errorMessage = "Cannot start transfer. \(invalidReasons.joined(separator: "; "))" - showError = true - return - } - - activeDestinations = validDestinations - prepareForOperation() - let cameraSettings = cameraLabelViewModel.destinationLabelSettings - // Configure planned total bytes for bytes-based ETA (source size × destinations) - // Use safe multiplication to prevent overflow on large multi-destination copies - if let totalSize = fileSelectionViewModel.sourceFolderInfo?.totalSize { - let destCount = Int64(max(1, fileSelectionViewModel.destinationURLs.count)) - let maxSafeSize = Int64.max / destCount - let safeSourceSize = min(Int64(totalSize), maxSafeSize) - let total = safeSourceSize * destCount - progressViewModel.setPlannedTotalBytes(total) - } else { - progressViewModel.setPlannedTotalBytes(nil) - } - - operationTask = Task { - do { - state = .copying - - try await ComparisonOperationService.performCopyAndVerify( - source: source, - destinations: fileSelectionViewModel.destinationURLs, - verificationMode: verificationMode, - cameraLabelSettings: cameraSettings, - progressViewModel: progressViewModel, - onProgress: { [weak self] newResults in - Task { @MainActor [weak self] in - guard let self = self else { return } - self.addResults(newResults) - } - }, - onComplete: { [weak self] in - Task { @MainActor [weak self] in - await self?.handleOperationComplete() - } - }, - shouldGenerateMHL: MHLOperationService.shouldGenerateMHL(for: verificationMode), - mhlCollector: verificationMode.requiresMHL ? mhlCollectorActor : nil - ) - } catch is CancellationError { - return - } catch { - await handleOperationError(error) - } - } - } - - func cancelOperation() { - operationTask?.cancel() - operationTask = nil - activeDestinations = [] - - // Resume paused task if any - pauseContinuation?.resume() - pauseContinuation = nil - - Task { - await cleanupAfterOperation() - await MainActor.run { - state = .idle - progressViewModel.reset() - // Stop progress interpolation timer on cancel - progressViewModel.stopProgressTracking() - } - } - } - - func togglePause() { - // Implement cooperative pause by cancelling tasks and saving a checkpoint - guard state.isActive else { return } - let filesProcessed = progressViewModel.fileCountCompleted - let totalFiles = max(progressViewModel.fileCountTotal, filesProcessed) - let lastFile = progressViewModel.currentFileName ?? "unknown" - - // Save checkpoint for resume UI - OperationStateManager.createCheckpoint( - for: jobID, - filesProcessed: filesProcessed, - lastFile: lastFile, - totalCount: totalFiles - ) - - // Transition to paused state for UI - state = .paused(PauseInfo( - pausedAt: Date(), - currentFile: progressViewModel.currentFileName, - filesProcessed: filesProcessed, - totalFiles: totalFiles, - bytesProcessed: progressViewModel.getTotalBytesProcessed(), - reason: .userRequested - )) - - // Cancel current tasks; resume will restart and reuse only files that can be verified. - operationTask?.cancel() - operationTask = nil - // Resume paused continuation if any to unblock waits - pauseContinuation?.resume() - pauseContinuation = nil - } - - func resumeOperation(_ operation: OperationStateManager.PersistedOperation) { - let (newJobID, newJobStart) = OperationResumeService.prepareResumeData( - operation: operation, - fileSelectionViewModel: fileSelectionViewModel, - progressViewModel: progressViewModel, - verificationMode: &verificationMode - ) - - jobID = newJobID - jobStart = newJobStart - - // Restart the operation (actual resume logic would need more implementation) - if operation.mode == "copy" { - startCopyAndVerify() - } else { - startComparison() - } - } - - // MARK: - Private Methods - - private func prepareForOperation() { - results.removeAll() - results.reserveCapacity(min(1000, maxResultsInMemory)) - resultsOverflowFile = nil - - jobID = UUID() - jobStart = Date() - progressViewModel.reset() - - Task { [weak self] in - await self?.mhlCollectorActor.clear() - } - mhlGenerated = false - mhlFilePath = nil - isGeneratingMHL = false - - let initialOperation = OperationStateManager.PersistedOperation( - id: jobID, - startTime: jobStart, - mode: currentMode == .copyAndVerify ? "copy" : "compare", - sourceURL: fileSelectionViewModel.sourceURL ?? fileSelectionViewModel.leftURL ?? URL(fileURLWithPath: "/"), - destinationURLs: currentMode == .copyAndVerify - ? fileSelectionViewModel.destinationURLs - : [fileSelectionViewModel.rightURL].compactMap { $0 }, - verificationMode: verificationMode.rawValue, - lastProcessedFile: "Starting...", - processedCount: 0, - totalCount: 0, - checkpoints: [] - ) - OperationStateManager.saveState(initialOperation) - - state = .idle - // Future enhancement: implement operation queue for multiple pending tasks - // Task { - // await operationQueue.reset() - // } - } - - private func handleOperationComplete() async { - // Generate MHL if needed - if verificationMode.requiresMHL { - await generateMHLIfNeeded() - } - - let finishedAt = Date() - let exportContext = makeReportExportContext(finishedAt: finishedAt) - - await cleanupAfterOperation() - - // Determine completion status - let hasIssues = results.contains { !$0.isSuccessStatus } - var message = hasIssues ? "Completed with issues" : "All files verified successfully" - let reused = progressViewModel.reusedFileCopies - if reused > 0 { - message += " (Reused \(reused) copies)" - } - let _ = results.filter { $0.isSuccessStatus }.count - let _ = results.filter { !$0.isSuccessStatus }.count - let _ = Date().timeIntervalSince(jobStart) - - // Stop progress interpolation timer - progressViewModel.stopProgressTracking() - - if let context = exportContext { - Task.detached(priority: .utility) { - await ReportExporter.export( - mode: context.mode, - jobID: context.jobID, - started: context.started, - finished: context.finished, - sourceURL: context.source, - destinationURLs: context.destinations, - results: context.results, - fileCount: context.fileCount, - matchCount: context.matchCount, - prefs: context.prefs, - workers: context.workers, - totalBytesProcessed: context.totalBytes, - generateFullReport: context.generateFullReport - ) - } - } else { - NSLog("Report export skipped: unable to build export context (mode=\(currentMode), source=\(String(describing: fileSelectionViewModel.sourceURL)), destinations=\(fileSelectionViewModel.destinationURLs))") - } - - state = .completed(OperationCompletionInfo( - success: !hasIssues, - message: message - )) - // The operation finished; without this, the state file lingers and - // (with resume detection working) would resurface as "interrupted". - OperationStateManager.clearState(for: jobID) - sendCompletionNotification(success: !hasIssues, message: message) - } - - private func handleOperationError(_ error: Error) async { - // Treat cancellation as a non-error and return to idle without showing a failure page - if error is CancellationError { - await MainActor.run { - state = .idle - } - return - } - await cleanupAfterOperation() - let _ = results.filter { $0.isSuccessStatus }.count - let _ = results.filter { !$0.isSuccessStatus }.count - let _ = Date().timeIntervalSince(jobStart) - - // Stop progress interpolation timer on error - progressViewModel.stopProgressTracking() - - let errorMessage = "Operation failed: \(error.localizedDescription)" - state = .completed(OperationCompletionInfo( - success: false, - message: errorMessage - )) - sendCompletionNotification(success: false, message: errorMessage) - } - - // MARK: - Shared Core Integration Helpers - /// Allow mac adapter to update visible state based on shared coordinator events. - func setActiveStateFromShared() { - if case .inProgress = state { return } - state = .inProgress - } - - func setCompletionFromShared(success: Bool, message: String) { - // Stop interpolation timer to avoid UI drift - progressViewModel.stopProgressTracking() - state = .completed(OperationCompletionInfo(success: success, message: message)) - sendCompletionNotification(success: success, message: message) - } - - func setFailedFromShared(message: String = "Operation failed") { - progressViewModel.stopProgressTracking() - state = .failed - } - - func setCancelledFromShared() { - // Treat cancel as returning to idle selection screen - progressViewModel.stopProgressTracking() - state = .idle - } - - /// Update state based on shared progress stage to drive mac UI labels/bars - func applyStageFromShared(_ stage: ProgressStage) { - switch stage { - case .copying: - state = .copying - case .verifying: - state = .verifying - case .completed: - break - default: - if state == .idle || state == .notStarted { state = .inProgress } - } - } - - private struct ReportExportContext { - let mode: AppMode - let jobID: UUID - let started: Date - let finished: Date - let source: URL - let destinations: [URL] - let results: [ResultRow] - let fileCount: Int - let matchCount: Int - let prefs: ReportPrefs - let workers: Int - let totalBytes: Int64 - let generateFullReport: Bool - } - - private func makeReportExportContext(finishedAt: Date) -> ReportExportContext? { - let resultsSnapshot = results - guard !resultsSnapshot.isEmpty else { return nil } - - let mode = currentMode - guard mode != .masterReport else { return nil } - - let sourceURL: URL? - var destinations: [URL] = [] - - switch mode { - case .copyAndVerify: - sourceURL = fileSelectionViewModel.sourceURL - destinations = activeDestinations - case .compareFolders, .masterReport: - return nil - } - - guard let resolvedSource = sourceURL, !destinations.isEmpty else { return nil } - - let matchCount = resultsSnapshot.filter { $0.isSuccessStatus }.count - let prefsSnapshot = settingsViewModel.prefs - let totalBytes = progressViewModel.getTotalBytesProcessed() - let fileCount = resultsSnapshot.count - let workers = max(1, ProcessInfo.processInfo.activeProcessorCount) - - return ReportExportContext( - mode: mode, - jobID: jobID, - started: jobStart, - finished: finishedAt, - source: resolvedSource, - destinations: destinations, - results: resultsSnapshot, - fileCount: fileCount, - matchCount: matchCount, - prefs: prefsSnapshot, - workers: workers, - totalBytes: totalBytes, - generateFullReport: prefsSnapshot.makeReport - ) - } - - private func generateMHLIfNeeded() async { - guard verificationMode.requiresMHL else { return } - - let mhlEntries = await mhlCollectorActor.getEntries() - guard !mhlEntries.isEmpty else { return } - - isGeneratingMHL = true - - let sourceURL = fileSelectionViewModel.leftURL ?? fileSelectionViewModel.sourceURL ?? URL(fileURLWithPath: "/") - let destinationURLs = fileSelectionViewModel.rightURL.map { [$0] } - ?? (activeDestinations.isEmpty ? fileSelectionViewModel.destinationURLs : activeDestinations) - - do { - let (success, filenames) = try await MHLOperationService.generateMHLFiles( - from: mhlEntries, - source: sourceURL, - destinations: destinationURLs, - algorithm: settingsViewModel.prefs.checksumAlgorithm, - jobID: jobID, - jobStart: jobStart, - settingsViewModel: settingsViewModel, - onProgress: { [weak self] message in - Task { @MainActor [weak self] in - self?.progressViewModel.setProgressMessage(message) - } - } - ) - - mhlGenerated = success - mhlFilePath = filenames.joined(separator: ", ") - - } catch { - SharedLogger.error("Failed to generate MHL: \(error)", category: .transfer) - progressViewModel.setProgressMessage("MHL generation failed") - } - - isGeneratingMHL = false - } - - private func cleanupAfterOperation() async { - // Save partial results for potential resume - savePartialResults() - activeDestinations = [] - } - - // MARK: - Completion Notification - - private func sendCompletionNotification(success: Bool, message: String) { - // Play system sound - if success { - NSSound(named: "Glass")?.play() - } else { - NSSound(named: "Basso")?.play() - } - - // Send notification (useful when app is in background) - let content = UNMutableNotificationContent() - content.title = success ? "Transfer Complete" : "Transfer Failed" - content.body = message - content.sound = .default - - let request = UNNotificationRequest( - identifier: UUID().uuidString, - content: content, - trigger: nil - ) - - UNUserNotificationCenter.current().add(request) - } - - private func addResults(_ newResults: [ResultRow]) { - if results.count + newResults.count > maxResultsInMemory { - let errors = results.filter { !$0.isSuccessStatus } - let keepCount = maxResultsInMemory / 2 - let matchesToKeep = max(0, keepCount - errors.count) - let matches = results.filter { $0.isSuccessStatus }.suffix(matchesToKeep) - results = Array(errors + matches) - } - results.append(contentsOf: newResults) - } - - private func savePartialResults() { - guard results.count > 100 else { return } - OperationStateManager.createCheckpoint( - for: jobID, - filesProcessed: results.count, - lastFile: results.last?.path ?? "unknown", - totalCount: max(progressViewModel.fileCountTotal, results.count) - ) - } - -} diff --git a/BitMatch/Core/ViewModels/SettingsViewModel.swift b/BitMatch/Core/ViewModels/SettingsViewModel.swift index 23f3eca..1c0c371 100644 --- a/BitMatch/Core/ViewModels/SettingsViewModel.swift +++ b/BitMatch/Core/ViewModels/SettingsViewModel.swift @@ -29,7 +29,6 @@ final class SettingsViewModel: ObservableObject { func updateVerificationMode(_ mode: VerificationMode) { prefs.verifyWithChecksum = mode.useChecksum - // The OperationViewModel should observe this change } // MARK: - Notifications diff --git a/BitMatch/Utilities/AsyncUtils.swift b/BitMatch/Utilities/AsyncUtils.swift index 0cb508e..6ace063 100644 --- a/BitMatch/Utilities/AsyncUtils.swift +++ b/BitMatch/Utilities/AsyncUtils.swift @@ -51,7 +51,7 @@ struct BackgroundTask { static func execute( priority: TaskPriority = .utility, work: @Sendable @escaping () async throws -> T, - completion: @MainActor @escaping (Result) -> Void = { _ in } + completion: @MainActor @Sendable @escaping (Result) -> Void = { _ in } ) -> Task { return Task(priority: priority) { do { @@ -189,4 +189,4 @@ actor CollectionActor { func clear() { storage.removeAll() } -} \ No newline at end of file +} diff --git a/BitMatch/Views/CopyAndVerify/CopyAndVerifyView.swift b/BitMatch/Views/CopyAndVerify/CopyAndVerifyView.swift index 8ffca36..22dc7a0 100644 --- a/BitMatch/Views/CopyAndVerify/CopyAndVerifyView.swift +++ b/BitMatch/Views/CopyAndVerify/CopyAndVerifyView.swift @@ -1,49 +1,37 @@ // Views/CopyAndVerify/CopyAndVerifyView.swift import SwiftUI -import AppKit struct CopyAndVerifyView: View { @ObservedObject var coordinator: AppCoordinator @Binding var showReportSettings: Bool - @Binding var cameraLabelExpanded: Bool - @Binding var verificationModeExpanded: Bool - - @State private var showOperationError = false - @State private var operationErrorMessage: String? - @State private var showVerificationHelp = false + @Binding var optionsExpanded: Bool + + @Environment(\.accessibilityReduceMotion) private var reduceMotion - // Convenience accessors private var fileSelection: FileSelectionViewModel { coordinator.fileSelectionViewModel } - private var progress: ProgressViewModel { coordinator.progressViewModel } - private var cameraLabel: CameraLabelViewModel { coordinator.cameraLabelViewModel } - private var settings: SettingsViewModel { coordinator.settingsViewModel } - - private var currentFileName: String? { - if coordinator.isOperationInProgress, - let lastPath = coordinator.results.last?.path { - return URL(fileURLWithPath: lastPath).lastPathComponent - } - return nil - } - - private var speed: String? { - // Prefer average data rate - progress.formattedAverageDataRate ?? progress.formattedSpeed - } - - private var timeRemaining: String? { - progress.formattedTimeRemaining + private var plan: TransferPlanPresentation { + TransferPlanPresentation.make( + sourceURL: fileSelection.sourceURL, + sourceInfo: fileSelection.sourceFolderInfo, + destinationURLs: fileSelection.destinationURLs, + verificationMode: coordinator.verificationMode, + cameraSettings: coordinator.cameraLabelViewModel.destinationLabelSettings, + reportSettings: coordinator.settingsViewModel.prefs, + isAnalyzing: fileSelection.isFetchingSourceInfo, + blockingIssues: readinessIssues, + warnings: readinessWarnings + ) } + /// Keeps validation policy in the existing validator while supplying its results + /// to the presentation model. private var readinessIssues: [String] { guard let sourceURL = fileSelection.sourceURL else { return fileSelection.destinationURLs.isEmpty ? [] : ["Select a source folder"] } var issues: [String] = [] - if fileSelection.destinationURLs.isEmpty { - issues.append("Add at least one destination") - } + if fileSelection.destinationURLs.isEmpty { issues.append("Add at least one destination") } let uniquePaths = Set(fileSelection.destinationURLs.map { $0.standardizedFileURL.resolvingSymlinksInPath().path @@ -73,7 +61,6 @@ struct CopyAndVerifyView: View { } catch { issues.append(error.localizedDescription) } - return issues } @@ -82,599 +69,70 @@ struct CopyAndVerifyView: View { if coordinator.verificationMode == .quick { warnings.append("Quick mode only checks file size. Standard SHA-256 is safer for production transfers.") } - if let sourceSize = fileSelection.sourceFolderInfo?.totalSize { warnings.append(contentsOf: fileSelection.destinationURLs.compactMap { destination in guard let available = availableSpace(for: destination), available > 0 else { return nil } - let ratio = Double(sourceSize) / Double(available) - return ratio > 0.7 ? "Limited space on \(destination.lastPathComponent)" : nil + return Double(sourceSize) / Double(available) > 0.7 + ? "Limited space on \(destination.lastPathComponent)" : nil }) } - return warnings } - private var canStartCopy: Bool { - coordinator.canStartOperation && readinessIssues.isEmpty - } - - private var shouldShowReadiness: Bool { - fileSelection.sourceURL != nil || !fileSelection.destinationURLs.isEmpty - } - var body: some View { - VStack(spacing: 0) { + Group { if coordinator.isOperationInProgress { - // Compact transfer interface during operations compactOperationView } else { - // Full-size interface when idle - expandedIdleView - } - } - .animation(.spring(response: 0.5, dampingFraction: 0.8), value: coordinator.isOperationInProgress) - .animation(.spring(response: 0.4, dampingFraction: 0.85), value: coordinator.completionState) - .alert("Error", isPresented: $showOperationError) { - Button("OK") { - operationErrorMessage = nil + TransferPlanView( + coordinator: coordinator, + plan: plan, + optionsExpanded: $optionsExpanded, + selectionView: AnyView(HorizontalFlowView(coordinator: coordinator)), + onStart: start + ) } - } message: { - Text(operationErrorMessage ?? "An error occurred") - } - .onAppear { - updateModeEstimates() - } - .onChange(of: fileSelection.sourceURL) { _, _ in - updateModeEstimates() - } - .onChange(of: fileSelection.destinationURLs) { _, _ in - updateModeEstimates() - } - .onChange(of: fileSelection.sourceFolderInfo?.totalSize) { _, _ in - updateModeEstimates() } + .animation(reduceMotion ? nil : .spring(response: 0.45, dampingFraction: 0.84), value: coordinator.isOperationInProgress) } - - // MARK: - View Components - - @ViewBuilder + private var compactOperationView: some View { VStack(spacing: 0) { - // Compact header showing source → destinations - compactHeader - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background(Color.black.opacity(0.3)) - - // Main transfer queue - TransferQueueView(coordinator: coordinator) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .transition(.asymmetric( - insertion: .scale(scale: 0.95).combined(with: .move(edge: .top)).combined(with: .opacity), - removal: .scale(scale: 1.05).combined(with: .move(edge: .bottom)).combined(with: .opacity) - )) - } - } - - @ViewBuilder - private var expandedIdleView: some View { - VStack(spacing: 0) { - HorizontalFlowView(coordinator: coordinator) - .frame(maxHeight: .infinity) - - // Bottom control panel (only show when not verifying) - controlPanel - .padding(.top, 12) - } - .transition(.asymmetric( - insertion: .move(edge: .bottom).combined(with: .opacity), - removal: .move(edge: .top).combined(with: .opacity) - )) - } - - @ViewBuilder - private var compactHeader: some View { - HStack(spacing: 12) { - // Source info - if let sourceURL = fileSelection.sourceURL { - HStack(spacing: 6) { - Image(systemName: "folder.fill") - .font(.system(size: 12)) - .foregroundColor(.orange) - - Text(sourceURL.lastPathComponent) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.white) - } - } - - // Arrow - Image(systemName: "arrow.right") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.white.opacity(0.6)) - - // Destinations count - HStack(spacing: 6) { - Image(systemName: "externaldrive.fill") - .font(.system(size: 12)) + HStack(spacing: 10) { + Label(fileSelection.sourceURL?.lastPathComponent ?? "Source", systemImage: "folder.fill") + .foregroundColor(.orange) + Image(systemName: "arrow.right").foregroundColor(.white.opacity(0.5)) + Label("\(fileSelection.destinationURLs.count) destinations", systemImage: "externaldrive.fill") .foregroundColor(.blue) - - Text("\(fileSelection.destinationURLs.count) destinations") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.white) - } - - Spacer() - - // Overall progress (overall, not per-file) - if let speed = speed { - HStack(spacing: 4) { - Text(speed) - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.white.opacity(0.7)) - - if let filesLeft = progress.formattedFilesRemaining { - Text("• \(filesLeft)") - .font(.system(size: 10)) - .foregroundColor(.white.opacity(0.5)) - } - - if let timeRemaining = timeRemaining { - Text("• \(timeRemaining)") - .font(.system(size: 10)) - .foregroundColor(.white.opacity(0.5)) - } - } - } - - // Pause/Resume button - if coordinator.canPause || coordinator.canResume { - Button { - coordinator.togglePause() - } label: { - Image(systemName: coordinator.isPaused ? "play.fill" : "pause.fill") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(coordinator.isPaused ? .green : .white.opacity(0.8)) - .frame(width: 28, height: 28) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(coordinator.isPaused ? Color.green.opacity(0.2) : Color.white.opacity(0.1)) - ) - } - .buttonStyle(.plain) - .help(coordinator.isPaused ? "Resume" : "Pause") - } - - // Cancel button - Button { - coordinator.cancelOperation() - } label: { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.red.opacity(0.8)) - .frame(width: 28, height: 28) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.red.opacity(0.1)) - ) - } - .buttonStyle(.plain) - .help("Cancel") - } - } - - @ViewBuilder - private var controlPanel: some View { - Card { - VStack(spacing: 16) { - // Camera Label Settings - cameraLabelSection - - Divider() - .overlay(Color.white.opacity(0.1)) - - // Verification Mode - verificationModeSection - - Divider() - .overlay(Color.white.opacity(0.1)) - - if shouldShowReadiness { - readinessSection - - Divider() - .overlay(Color.white.opacity(0.1)) - } - - // Action buttons - actionSection - } - } - .padding(.horizontal, 20) - } - - @ViewBuilder - private var cameraLabelSection: some View { - VStack(spacing: 0) { - Button { - withAnimation(.spring(response: 0.3)) { - cameraLabelExpanded.toggle() - NotificationCenter.default.post(name: .cameraLabelExpandedChanged, object: nil) - } - } label: { - HStack { - Image(systemName: cameraLabelExpanded ? "chevron.down" : "chevron.right") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.green) - - Image(systemName: "camera.fill") - .font(.system(size: 12)) - .foregroundColor(.green) - - Text("Destination Folder Labeling") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white.opacity(0.9)) - - Spacer() - - if !cameraLabel.destinationLabelSettings.label.isEmpty { - Text(cameraLabel.destinationLabelSettings.label) - .font(.system(size: 10)) - .foregroundColor(.green) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background( - Capsule() - .fill(Color.green.opacity(0.2)) - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if cameraLabelExpanded { - VStack(spacing: 12) { - // Show camera fingerprint if detected - if let fingerprint = cameraLabel.currentFingerprint { - HStack { - Image(systemName: "info.circle.fill") - .font(.system(size: 10)) - .foregroundColor(.blue.opacity(0.5)) - Text("Detected: \(fingerprint.displayName)") - .font(.system(size: 10)) - .foregroundColor(.white.opacity(0.5)) - Spacer() - } - } - - Text("Folders will be labeled when copied to destinations") - .font(.system(size: 11)) - .foregroundColor(.white.opacity(0.4)) - .frame(maxWidth: .infinity, alignment: .leading) - - CameraLabelView( - settings: $coordinator.cameraLabelViewModel.destinationLabelSettings, - detectedCamera: cameraLabel.detectedCamera, - fingerprint: cameraLabel.currentFingerprint, - sourceURL: fileSelection.sourceURL - ) - } - .padding(.top, 8) - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .top)), - removal: .opacity.combined(with: .move(edge: .top)) - )) - } - } - } - - @ViewBuilder - private var verificationModeSection: some View { - VStack(spacing: 0) { - Button { - withAnimation(.spring(response: 0.3)) { - verificationModeExpanded.toggle() - NotificationCenter.default.post(name: .verificationModeExpandedChanged, object: nil) - } - } label: { - HStack { - Image(systemName: verificationModeExpanded ? "chevron.down" : "chevron.right") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.blue) - - Image(systemName: "checkmark.shield.fill") - .font(.system(size: 12)) - .foregroundColor(.blue) - - Text("Verification Mode") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white.opacity(0.9)) - - Text("(\(coordinator.verificationMode.rawValue))") - .font(.system(size: 11)) - .foregroundColor(.white.opacity(0.5)) - - Button { - showVerificationHelp.toggle() - } label: { - Image(systemName: "questionmark.circle") - .font(.system(size: 11)) - .foregroundColor(.white.opacity(0.4)) + Spacer() + if coordinator.canPause || coordinator.canResume { + Button { coordinator.togglePause() } label: { + Image(systemName: coordinator.isPaused ? "play.fill" : "pause.fill") } .buttonStyle(.plain) - .popover(isPresented: $showVerificationHelp, arrowEdge: .bottom) { - VStack(alignment: .leading, spacing: 8) { - Text("Verification Modes") - .font(.system(size: 13, weight: .semibold)) - Text("Quick: Compares file sizes only. Fast but less thorough.") - .font(.system(size: 11)) - Text("Standard: Computes SHA-256 checksums to verify byte-for-byte integrity.") - .font(.system(size: 11)) - Text("Paranoid: SHA-256 checksums + MHL report generation for archival proof.") - .font(.system(size: 11)) - } - .padding(12) - .frame(width: 300) - } - - Spacer() - - // Show MHL badge if current mode requires it - if coordinator.verificationMode.requiresMHL { - HStack(spacing: 3) { - Image(systemName: "doc.text.fill") - .font(.system(size: 8)) - Text("MHL") - .font(.system(size: 9, weight: .semibold)) - } - .foregroundColor(.orange) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color.orange.opacity(0.2)) - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if verificationModeExpanded { - VStack(spacing: 8) { - ForEach(VerificationMode.allCases) { mode in - HStack { - Image(systemName: coordinator.verificationMode == mode ? "checkmark.circle.fill" : "circle") - .font(.system(size: 14)) - .foregroundColor(coordinator.verificationMode == mode ? .green : .white.opacity(0.3)) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(mode.rawValue) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.white.opacity(0.9)) - - if mode.requiresMHL { - HStack(spacing: 3) { - Image(systemName: "doc.text.fill") - .font(.system(size: 8)) - Text("MHL") - .font(.system(size: 9, weight: .semibold)) - } - .foregroundColor(.orange) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color.orange.opacity(0.2)) - ) - } - - Spacer() - - // Time estimate - if let estimate = getTimeEstimate(for: mode) { - Text(estimate) - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.blue.opacity(0.8)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color.blue.opacity(0.15)) - ) - } - } - - Text(mode.description) - .font(.system(size: 11)) - .foregroundColor(.white.opacity(0.5)) - } - - Spacer() - } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation { - coordinator.verificationMode = mode - // Save preference - coordinator.saveVerificationMode() - } - } - } + .foregroundColor(coordinator.isPaused ? .green : .white.opacity(0.8)) + .accessibilityLabel(coordinator.isPaused ? "Resume transfer" : "Pause transfer") + .accessibilityHint(coordinator.isPaused ? "Resumes the current transfer" : "Pauses the current transfer") + .help(coordinator.isPaused ? "Resume transfer" : "Pause transfer") } - .padding(.top, 8) - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .top)), - removal: .opacity.combined(with: .move(edge: .top)) - )) - } - } - } - - @ViewBuilder - private var readinessSection: some View { - let issues = readinessIssues - let warnings = readinessWarnings - let hasIssues = !issues.isEmpty - let hasWarnings = !warnings.isEmpty - let tint: Color = hasIssues ? .red : (hasWarnings ? .orange : .green) - - HStack(alignment: .top, spacing: 10) { - Image(systemName: hasIssues ? "exclamationmark.triangle.fill" : (hasWarnings ? "exclamationmark.triangle" : "checkmark.circle.fill")) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(tint) - .frame(width: 18, height: 18) - - VStack(alignment: .leading, spacing: 3) { - Text(readinessTitle) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.white.opacity(0.9)) - - Text(readinessDetail(issues: issues, warnings: warnings)) - .font(.system(size: 10)) - .foregroundColor(.white.opacity(0.58)) - .lineLimit(2) + Button { coordinator.cancelOperation() } label: { Image(systemName: "xmark") } + .buttonStyle(.plain).foregroundColor(.red) + .accessibilityLabel("Cancel transfer") } - - Spacer() - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(tint.opacity(0.08)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(tint.opacity(0.18), lineWidth: 0.5) - ) - ) - } - - @ViewBuilder - private var actionSection: some View { - HStack { - // Report toggle - Toggle("Create PDF & CSV Report", isOn: $coordinator.settingsViewModel.prefs.makeReport) - .toggleStyle(.switch) - .tint(.green) - - Spacer() - - // Time estimate (when available) - if let estimate = coordinator.timeEstimate { - VStack(alignment: .trailing, spacing: 2) { - Text(estimate.formatted) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) - Text(estimate.speedSummary) - .font(.system(size: 11)) - .foregroundColor(.white.opacity(0.6)) - } - .padding(.trailing, 16) - } else if coordinator.isCalculatingEstimate { - ProgressView() - .scaleEffect(0.7) - .padding(.trailing, 16) - } - - // Action button - Button { - coordinator.switchMode(to: .copyAndVerify) - coordinator.startOperation() - } label: { - Label("Copy & Verify", systemImage: "arrow.right.doc.on.clipboard") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(canStartCopy ? .black : .white.opacity(0.45)) - .padding(.horizontal, 24) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(canStartCopy ? Color.green : Color.white.opacity(0.1)) - ) - } - .buttonStyle(.plain) - .disabled(!canStartCopy) - } - } - - private var readinessTitle: String { - if !readinessIssues.isEmpty { - return "Needs attention" - } - if !readinessWarnings.isEmpty { - return "Ready with warnings" + .font(.system(size: 12, weight: .medium)) + .padding(14) + .background(Color.black.opacity(0.3)) + TransferQueueView(coordinator: coordinator) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - return "Ready to transfer" } - private func readinessDetail(issues: [String], warnings: [String]) -> String { - if let issue = issues.first { - return issue - } - if let warning = warnings.first { - return warning - } - guard let info = fileSelection.sourceFolderInfo else { - return "Source and destination folders are selected." - } - let destinationText = "\(fileSelection.destinationURLs.count) destination\(fileSelection.destinationURLs.count == 1 ? "" : "s")" - return "\(info.formattedFileCount) files, \(info.formattedSize), \(destinationText)" + private func start() { + coordinator.switchMode(to: .copyAndVerify) + coordinator.startOperation() } private func availableSpace(for url: URL) -> Int64? { - do { - let values = try url.resourceValues(forKeys: [.volumeAvailableCapacityKey]) - if let capacity = values.volumeAvailableCapacity { - return Int64(capacity) - } - } catch { } - return nil - } - - // MARK: - Time Estimation - @State private var modeEstimates: [VerificationMode: String] = [:] - - private func getTimeEstimate(for mode: VerificationMode) -> String? { - return modeEstimates[mode] - } - - private func updateModeEstimates() { - guard let sourceURL = fileSelection.sourceURL, - !fileSelection.destinationURLs.isEmpty, - let totalBytes = fileSelection.sourceFolderInfo?.totalSize, - totalBytes > 0 else { - modeEstimates = [:] - return - } - - Task { - var estimates: [VerificationMode: String] = [:] - for mode in VerificationMode.allCases { - if let estimate = await DriveBenchmarkService.shared.estimateTransferTime( - sourceURL: sourceURL, - destinationURLs: fileSelection.destinationURLs, - totalBytes: totalBytes, - verificationMode: mode - ) { - estimates[mode] = estimate.formatted - } - } - await MainActor.run { - modeEstimates = estimates - } - } - } - - private func openFolderPanel() -> URL? { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.prompt = "Select Folder" - return panel.runModal() == .OK ? panel.url : nil + (try? url.resourceValues(forKeys: [.volumeAvailableCapacityKey]))?.volumeAvailableCapacity.map(Int64.init) } } diff --git a/BitMatch/Views/CopyAndVerify/TransferPlanView.swift b/BitMatch/Views/CopyAndVerify/TransferPlanView.swift new file mode 100644 index 0000000..205d90e --- /dev/null +++ b/BitMatch/Views/CopyAndVerify/TransferPlanView.swift @@ -0,0 +1,178 @@ +import SwiftUI + +/// The macOS transfer setup: select the locations, review preflight, then start. +struct TransferPlanView: View { + @ObservedObject var coordinator: AppCoordinator + let plan: TransferPlanPresentation + @Binding var optionsExpanded: Bool + let selectionView: AnyView + let onStart: () -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(spacing: 14) { + VStack(spacing: 10) { + HStack(spacing: 10) { + TransferPlanSourceCard(plan: plan) + Image(systemName: "arrow.right").foregroundColor(.white.opacity(0.35)) + TransferPlanDestinationsCard(plan: plan) + } + // This remains the single owner of folder panels and drop validation. + selectionView + } + .padding(.top, 4) + + TransferPlanPreflightCard(plan: plan) + optionSummary + TransferOptionsView(coordinator: coordinator, isExpanded: $optionsExpanded) + actionArea + } + .padding(.horizontal, 20) + .padding(.bottom, 8) + .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity)) + } + + private var optionSummary: some View { + VStack(alignment: .leading, spacing: 7) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(plan.optionSummary, id: \.self) { summary in + Text(summary).font(.system(size: 10, weight: .medium)) + .foregroundColor(.white.opacity(0.7)).padding(.horizontal, 8).padding(.vertical, 4) + .background(Capsule().fill(Color.white.opacity(0.07))) + } + } + } + if coordinator.verificationMode == .quick { + Label("Quick mode does not use checksum verification.", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 10)).foregroundColor(.orange) + .accessibilityLabel("Warning: Quick mode does not use checksum verification") + } + } + } + + private var actionArea: some View { + VStack(alignment: .leading, spacing: 8) { + if let estimate = coordinator.timeEstimate { + Text("Estimated time: \(estimate.formatted) · \(estimate.speedSummary)") + .font(.system(size: 11)).foregroundColor(.white.opacity(0.6)) + } else if coordinator.isCalculatingEstimate { + Label("Calculating transfer estimate…", systemImage: "clock") + .font(.system(size: 11)).foregroundColor(.blue) + } + Button(action: onStart) { + Label(plan.actionTitle, systemImage: "arrow.right.doc.on.clipboard") + .frame(maxWidth: .infinity) + .font(.system(size: 14, weight: .semibold)) + .padding(.vertical, 11) + } + .buttonStyle(.plain) + .foregroundColor(plan.canStart ? .black : .white.opacity(0.45)) + .background(RoundedRectangle(cornerRadius: 10).fill(plan.canStart ? Color.green : Color.white.opacity(0.1))) + .disabled(!plan.canStart) + .accessibilityLabel(plan.actionTitle) + .accessibilityHint(disabledReason ?? "Starts the transfer") + if let disabledReason { + Text(disabledReason).font(.system(size: 11)).foregroundColor(.white.opacity(0.58)) + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.035))) + } + + private var disabledReason: String? { + switch plan.status { + case .incomplete(let reason), .analyzing(let reason): return reason + case .blocked(let reasons): return reasons.first + case .ready, .warning: return nil + } + } +} + +struct TransferPlanSourceCard: View { + let plan: TransferPlanPresentation + var body: some View { + planCard(title: "SOURCE", icon: "folder.fill", tint: .orange, primary: plan.sourceTitle, detail: plan.sourceDetail) + .accessibilityElement(children: .combine) + .accessibilityLabel("Source: \(plan.sourceTitle)") + .accessibilityHint("Choose or drop a source folder below") + } +} + +struct TransferPlanDestinationsCard: View { + let plan: TransferPlanPresentation + var body: some View { + planCard(title: "BACKUPS", icon: "externaldrive.fill", tint: .blue, + primary: plan.destinationTitles.isEmpty ? "Add at least one backup" : plan.destinationTitles.joined(separator: ", "), + detail: plan.destinationDetail) + .accessibilityElement(children: .combine) + .accessibilityLabel("Backups: \(plan.destinationDetail)") + .accessibilityHint("Add, replace, or remove backup destinations below") + } +} + +private func planCard(title: String, icon: String, tint: Color, primary: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 5) { + Label(title, systemImage: icon).font(.system(size: 9, weight: .bold)).tracking(1).foregroundColor(tint) + Text(primary).font(.system(size: 13, weight: .semibold)).lineLimit(1).foregroundColor(.white) + Text(detail).font(.system(size: 10)).foregroundColor(.white.opacity(0.58)) + } + .frame(maxWidth: .infinity, alignment: .leading).padding(11) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))) +} + +struct TransferPlanPreflightCard: View { + let plan: TransferPlanPresentation + var body: some View { + let display = statusDisplay + HStack(alignment: .top, spacing: 10) { + Image(systemName: display.icon).foregroundColor(display.tint).font(.system(size: 14, weight: .semibold)) + VStack(alignment: .leading, spacing: 3) { + Text(display.title).font(.system(size: 12, weight: .semibold)).foregroundColor(.white) + Text(display.detail).font(.system(size: 11)).foregroundColor(.white.opacity(0.62)).fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(12).background(RoundedRectangle(cornerRadius: 10).fill(display.tint.opacity(0.1))) + .accessibilityElement(children: .combine).accessibilityLabel("Preflight: \(display.title). \(display.detail)") + } + private var statusDisplay: (title: String, detail: String, icon: String, tint: Color) { + switch plan.status { + case .ready: return ("Ready to transfer", "Source and backups are ready.", "checkmark.circle.fill", .green) + case .analyzing(let message): return ("Analyzing", message, "arrow.triangle.2.circlepath", .blue) + case .warning(let warnings): return ("Ready with warnings", warnings.first ?? "Review options before starting.", "exclamationmark.triangle.fill", .orange) + case .blocked(let issues): return ("Blocked", issues.first ?? "Resolve the issue to continue.", "xmark.octagon.fill", .red) + case .incomplete(let message): return ("Needs setup", message, "info.circle.fill", .orange) + } + } +} + +struct TransferOptionsView: View { + @ObservedObject var coordinator: AppCoordinator + @Binding var isExpanded: Bool + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 12) { + CameraLabelView(settings: $coordinator.cameraLabelViewModel.destinationLabelSettings, + detectedCamera: coordinator.cameraLabelViewModel.detectedCamera, + fingerprint: coordinator.cameraLabelViewModel.currentFingerprint, + sourceURL: coordinator.fileSelectionViewModel.sourceURL) + Divider().overlay(Color.white.opacity(0.12)) + Picker("Verification", selection: $coordinator.verificationMode) { + ForEach(VerificationMode.allCases) { Text($0.rawValue).tag($0) } + } + .onChange(of: coordinator.verificationMode) { _, _ in coordinator.saveVerificationMode() } + Toggle("Create PDF & CSV Report", isOn: $coordinator.settingsViewModel.prefs.makeReport).tint(.green) + }.padding(.top, 10) + } label: { + HStack { + Label("Options", systemImage: "slider.horizontal.3") + Spacer() + Text("\(coordinator.verificationMode.rawValue) · \(coordinator.settingsViewModel.prefs.makeReport ? "Reports on" : "Reports off")") + .font(.system(size: 10)).foregroundColor(.white.opacity(0.55)) + }.font(.system(size: 12, weight: .medium)).foregroundColor(.white.opacity(0.9)) + } + .padding(12).background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.035))) + .accessibilityLabel("Options").accessibilityHint("Shows camera labels, verification, and report settings") + } +} diff --git a/BitMatch/Views/HorizontalFlowView.swift b/BitMatch/Views/HorizontalFlowView.swift index 371604c..b2c754c 100644 --- a/BitMatch/Views/HorizontalFlowView.swift +++ b/BitMatch/Views/HorizontalFlowView.swift @@ -138,6 +138,8 @@ struct HorizontalFlowView: View { .foregroundColor(.red.opacity(0.7)) } .buttonStyle(.plain) + .accessibilityLabel("Remove source folder") + .accessibilityHint("Clears the selected source") } } @@ -184,6 +186,8 @@ struct HorizontalFlowView: View { } .buttonStyle(CustomButtonStyle()) .scaleEffect(0.9) + .accessibilityLabel("Choose source folder") + .accessibilityHint("Opens a folder picker for the source") } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( @@ -229,6 +233,8 @@ struct HorizontalFlowView: View { } .buttonStyle(CustomButtonStyle()) .scaleEffect(0.9) + .accessibilityLabel("Add backup destinations") + .accessibilityHint("Opens a folder picker for one or more backups") } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( @@ -290,6 +296,8 @@ struct HorizontalFlowView: View { } } .buttonStyle(.plain) + .accessibilityLabel("Add backup destination") + .accessibilityHint("Opens a folder picker for another backup") } } .frame(maxWidth: .infinity, alignment: .leading) @@ -322,6 +330,8 @@ struct HorizontalFlowView: View { .foregroundColor(.red.opacity(0.6)) } .buttonStyle(.plain) + .accessibilityLabel("Remove backup \(url.lastPathComponent)") + .accessibilityHint("Removes this backup destination") } } diff --git a/BitMatchTests/AppCoordinatorBindingTests.swift b/BitMatchTests/AppCoordinatorBindingTests.swift new file mode 100644 index 0000000..250b78b --- /dev/null +++ b/BitMatchTests/AppCoordinatorBindingTests.swift @@ -0,0 +1,120 @@ +import Combine +import XCTest +@testable import BitMatch + +@MainActor +final class AppCoordinatorBindingTests: XCTestCase { + func testFileSelectionChangeNotifiesOnNextRunLoopTurn() { + let coordinator = AppCoordinator() + drainMainRunLoop() + let notificationReceived = NotificationState() + let notification = coordinator.objectWillChange.sink { _ in + notificationReceived.recordIfEnabled() + } + defer { notification.cancel() } + + coordinator.fileSelectionViewModel.sourceURL = URL(fileURLWithPath: "/tmp/source") + notificationReceived.enable() + + assertNotificationReceivedOnNextRunLoopTurn(notificationReceived) + } + + func testSharedCoordinatorStateChangeNotifiesOnNextRunLoopTurn() { + let coordinator = AppCoordinator() + drainMainRunLoop() + let notificationReceived = NotificationState() + let notification = coordinator.objectWillChange.sink { _ in + notificationReceived.recordIfEnabled() + } + defer { notification.cancel() } + + XCTAssertEqual(coordinator.operationState, .notStarted) + coordinator.sharedCoordinator.operationState = .idle + XCTAssertEqual(coordinator.operationState, .idle) + notificationReceived.enable() + + assertNotificationReceivedOnNextRunLoopTurn(notificationReceived) + } + + func testFilesPerSecondChangeNotifiesOnNextRunLoopTurn() { + let coordinator = AppCoordinator() + drainMainRunLoop() + let notificationReceived = NotificationState() + let notification = coordinator.objectWillChange.sink { _ in + notificationReceived.recordIfEnabled() + } + defer { notification.cancel() } + + XCTAssertNil(coordinator.formattedSpeed) + notificationReceived.enable() + coordinator.progressViewModel.filesPerSecond = 24 + XCTAssertEqual(coordinator.formattedSpeed, "24 files/s") + + assertNotificationReceivedOnNextRunLoopTurn(notificationReceived) + } + + func testVerificationModeChangeNotifiesOnNextRunLoopTurn() { + let coordinator = AppCoordinator() + drainMainRunLoop() + let notificationReceived = NotificationState() + let notification = coordinator.objectWillChange.sink { _ in + notificationReceived.recordIfEnabled() + } + defer { notification.cancel() } + + let originalMode = coordinator.verificationMode + let updatedMode: VerificationMode = originalMode == .quick ? .standard : .quick + notificationReceived.enable() + coordinator.sharedCoordinator.verificationMode = updatedMode + XCTAssertEqual(coordinator.verificationMode, updatedMode) + + assertNotificationReceivedOnNextRunLoopTurn(notificationReceived) + coordinator.sharedCoordinator.verificationMode = originalMode + } + + private func drainMainRunLoop() { + let nextTurn = expectation(description: "main run loop drains") + RunLoop.main.perform { + nextTurn.fulfill() + } + wait(for: [nextTurn], timeout: 1) + } + + private func assertNotificationReceivedOnNextRunLoopTurn(_ notificationReceived: NotificationState) { + let nextTurn = expectation(description: "coordinator notifies on the next run loop turn") + RunLoop.main.perform { + let received = notificationReceived.received + XCTAssertTrue(received) + nextTurn.fulfill() + } + wait(for: [nextTurn], timeout: 1) + } +} + +private final class NotificationState: @unchecked Sendable { + private let lock = NSLock() + private var value = false + private var isEnabled = false + + var received: Bool { + lock.lock() + defer { lock.unlock() } + return value + } + + func enable() { + lock.lock() + isEnabled = true + lock.unlock() + } + + func recordIfEnabled() { + lock.lock() + guard isEnabled else { + lock.unlock() + return + } + value = true + lock.unlock() + } +} diff --git a/BitMatchTests/DisposableTransferFixtureTests.swift b/BitMatchTests/DisposableTransferFixtureTests.swift new file mode 100644 index 0000000..004ecf7 --- /dev/null +++ b/BitMatchTests/DisposableTransferFixtureTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing + +struct DisposableTransferFixtureTests { + + @Test + func identicalSeedsCreateIdenticalManifest() throws { + let left = try DisposableTransferFixture(seed: 42, fileCount: 3, bytesPerFile: 1_024) + let right = try DisposableTransferFixture(seed: 42, fileCount: 3, bytesPerFile: 1_024) + defer { + left.cleanup() + right.cleanup() + } + + #expect(left.manifest == right.manifest) + } + + @Test + func differentSeedsCreateDifferentManifest() throws { + let left = try DisposableTransferFixture(seed: 42, fileCount: 3, bytesPerFile: 1_024) + let right = try DisposableTransferFixture(seed: 43, fileCount: 3, bytesPerFile: 1_024) + defer { + left.cleanup() + right.cleanup() + } + + #expect(left.manifest != right.manifest) + } + + @Test + func createsCameraLayoutWithHiddenFileAndEmptyDirectory() throws { + let fixture = try DisposableTransferFixture(seed: 42, fileCount: 3, bytesPerFile: 1_024) + defer { fixture.cleanup() } + let fileManager = FileManager.default + + var isDirectory: ObjCBool = false + #expect(fileManager.fileExists( + atPath: fixture.source.appendingPathComponent(".camera-metadata").path, + isDirectory: &isDirectory + )) + #expect(!isDirectory.boolValue) + + let mediaDirectory = fixture.source.appendingPathComponent("DCIM/100MEDIA", isDirectory: true) + #expect(fileManager.fileExists(atPath: mediaDirectory.path, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + + let emptyDirectory = fixture.source.appendingPathComponent("EMPTY_SIDECARS", isDirectory: true) + #expect(fileManager.fileExists(atPath: emptyDirectory.path, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + #expect(try fileManager.contentsOfDirectory(atPath: emptyDirectory.path).isEmpty) + } + + @Test + func cleanupRefusesRootWithoutDisposableMarker() throws { + let fixture = try DisposableTransferFixture(seed: 42, fileCount: 1, bytesPerFile: 16) + let root = fixture.source.deletingLastPathComponent() + let marker = root.appendingPathComponent(".bitmatch-disposable-fixture") + try FileManager.default.removeItem(at: marker) + + fixture.cleanup() + + #expect(FileManager.default.fileExists(atPath: root.path)) + + try Data().write(to: marker) + fixture.cleanup() + } +} diff --git a/BitMatchTests/SafetyValidatorTests.swift b/BitMatchTests/SafetyValidatorTests.swift index bec3a8c..be94898 100644 --- a/BitMatchTests/SafetyValidatorTests.swift +++ b/BitMatchTests/SafetyValidatorTests.swift @@ -4,6 +4,23 @@ import XCTest final class SafetyValidatorTests: XCTestCase { + func testAvailableSpaceFallsBackWhenImportantUsageCapacityIsUnavailable() { + XCTAssertEqual( + SafetyValidator.resolvedAvailableSpace( + importantUsage: nil, + standardCapacity: 2_000_000_000 + ), + 2_000_000_000 + ) + XCTAssertEqual( + SafetyValidator.resolvedAvailableSpace( + importantUsage: 1_500_000_000, + standardCapacity: 2_000_000_000 + ), + 1_500_000_000 + ) + } + // MARK: - System Directory Rejection func testRejectsSystemDirectories() { diff --git a/BitMatchTests/SharedCompareFlowTests.swift b/BitMatchTests/SharedCompareFlowTests.swift index d3f439a..18ab432 100644 --- a/BitMatchTests/SharedCompareFlowTests.swift +++ b/BitMatchTests/SharedCompareFlowTests.swift @@ -242,6 +242,4 @@ private final class ScopeTrackingPlatformManager: PlatformManager { func presentAlert(title: String, message: String) async {} func presentError(_ error: Error) async {} func openURL(_ url: URL) async -> Bool { false } - func beginBackgroundTask(name: String?, expirationHandler: (() -> Void)?) -> Int { 0 } - func endBackgroundTask(_ id: Int) {} } diff --git a/BitMatchTests/TestHelpers/DisposableTransferFixture.swift b/BitMatchTests/TestHelpers/DisposableTransferFixture.swift new file mode 100644 index 0000000..f8f6a90 --- /dev/null +++ b/BitMatchTests/TestHelpers/DisposableTransferFixture.swift @@ -0,0 +1,65 @@ +import CryptoKit +import Foundation + +final class DisposableTransferFixture { + let source: URL + let destinations: [URL] + let manifest: [String: String] + + private let root: URL + + init(seed: UInt64, fileCount: Int, bytesPerFile: Int) throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("bitmatch-disposable-\(UUID().uuidString)", isDirectory: true) + let source = root.appendingPathComponent("SOURCE", isDirectory: true) + let mediaDirectory = source.appendingPathComponent("DCIM/100MEDIA", isDirectory: true) + let emptySidecars = source.appendingPathComponent("EMPTY_SIDECARS", isDirectory: true) + let destinations = ["DEST_A", "DEST_B"].map { + root.appendingPathComponent($0, isDirectory: true) + } + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try Data().write(to: root.appendingPathComponent(".bitmatch-disposable-fixture")) + try fileManager.createDirectory(at: mediaDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: emptySidecars, withIntermediateDirectories: true) + for destination in destinations { + try fileManager.createDirectory(at: destination, withIntermediateDirectories: true) + } + + var manifest: [String: String] = [:] + let metadataPath = ".camera-metadata" + let metadata = Data("BitMatch disposable camera fixture\n".utf8) + try metadata.write(to: source.appendingPathComponent(metadataPath)) + manifest[metadataPath] = Self.sha256(metadata) + + for fileIndex in 0.. String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/BitMatchTests/TransferFaultIntegrationTests.swift b/BitMatchTests/TransferFaultIntegrationTests.swift new file mode 100644 index 0000000..e6243a3 --- /dev/null +++ b/BitMatchTests/TransferFaultIntegrationTests.swift @@ -0,0 +1,469 @@ +import Foundation +import XCTest +#if canImport(Darwin) +import Darwin +#endif +@testable import BitMatch + +final class TransferFaultIntegrationTests: XCTestCase { + private static let serializationGate = DispatchSemaphore(value: 1) + + override func setUp() { + super.setUp() + Self.serializationGate.wait() + } + + override func tearDown() { + Self.serializationGate.signal() + super.tearDown() + } + + func testSourceMutationIsReportedAsFailureWithoutCorruptingPublishedOutput() async throws { + try await FileOperationsTestLock.shared.run { + let fixture = try DisposableTransferFixture(seed: 401, fileCount: 2, bytesPerFile: 64 * 1024) + defer { fixture.cleanup() } + let source = canonicalFileURL(fixture.source) + let targetRelativePath = "DCIM/100MEDIA/MEDIA_0000.bin" + let mutatingChecksum = MutatingChecksumService( + target: source.appendingPathComponent(targetRelativePath) + ) + let service = SharedFileOperationsService( + fileSystem: MacOSFileSystemService.shared, + checksum: mutatingChecksum + ) + + let operation = try await service.performFileOperation( + sourceURL: source, + destinationURLs: [fixture.destinations[0]], + verificationMode: .standard, + settings: CameraLabelSettings(), + estimatedTotalBytes: nil, + progressCallback: { _ in }, + onFileResult: nil + ) + + let targetResult = try XCTUnwrap(operation.results.first { + $0.sourceURL.path == source.appendingPathComponent(targetRelativePath).path + }, "Results: \(describe(operation.results))") + XCTAssertFalse(targetResult.success) + XCTAssertFalse(resultRow(from: targetResult).isSuccessStatus) + let publishedHash = try await SharedChecksumService.shared.generateChecksum( + for: targetResult.destinationURL, + type: .sha256, + useCache: false, + progressCallback: nil + ) + XCTAssertEqual(publishedHash, fixture.manifest[targetRelativePath]) + try await assertSuccessfulOutputHashes(in: operation, match: fixture.manifest) + } + } + + func testConflictingPreExistingFileRemainsUntouchedAndIsReportedAsFailure() async throws { + try await FileOperationsTestLock.shared.run { + let fixture = try DisposableTransferFixture(seed: 402, fileCount: 2, bytesPerFile: 4 * 1024) + defer { fixture.cleanup() } + let source = canonicalFileURL(fixture.source) + let settings = CameraLabelSettings() + let outputRoot = SafetyValidator.resolvedDestinationRoot( + source: source, + destination: fixture.destinations[0], + settings: settings + ) + let relativePath = "DCIM/100MEDIA/MEDIA_0000.bin" + let conflict = outputRoot.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: conflict.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let sentinel = Data("pre-existing user data".utf8) + try sentinel.write(to: conflict) + + let operation = try await makeService().performFileOperation( + sourceURL: source, + destinationURLs: [fixture.destinations[0]], + verificationMode: .standard, + settings: settings, + estimatedTotalBytes: nil, + progressCallback: { _ in }, + onFileResult: nil + ) + + XCTAssertEqual(try Data(contentsOf: conflict), sentinel) + let failed = try XCTUnwrap( + operation.results.first { $0.destinationURL.path == conflict.path }, + "Results: \(describe(operation.results))" + ) + XCTAssertFalse(failed.success) + XCTAssertFalse(resultRow(from: failed).isSuccessStatus) + try await assertSuccessfulOutputHashes(in: operation, match: fixture.manifest) + } + } + + func testCancellationRemovesTemporaryFiles() async throws { + try await FileOperationsTestLock.shared.run { + let fixture = try DisposableTransferFixture(seed: 403, fileCount: 12, bytesPerFile: 2 * 1024 * 1024) + defer { fixture.cleanup() } + let source = canonicalFileURL(fixture.source) + let service = makeService() + let cancellation = OneShot() + let successfulResult = CapturedSuccessfulResult() + + do { + _ = try await service.performFileOperation( + sourceURL: source, + destinationURLs: [fixture.destinations[0]], + verificationMode: .standard, + settings: CameraLabelSettings(), + estimatedTotalBytes: nil, + progressCallback: { _ in }, + onFileResult: { result in + if result.success { + successfulResult.capture(result) + } + if result.success, cancellation.take() { + service.cancelOperation() + } + } + ) + XCTFail("Expected cancellation") + } catch is CancellationError { + // Expected. + } + + let publishedResult = try XCTUnwrap( + successfulResult.value, + "Cancellation did not retain a known successful output" + ) + try await assertOutputHash( + for: publishedResult, + source: source, + manifest: fixture.manifest + ) + + let temporaryFiles = recursivelyEnumeratedPaths(at: fixture.destinations[0]).filter { + $0.lastPathComponent.hasPrefix(".bitmatch.tmp.") + } + XCTAssertTrue(temporaryFiles.isEmpty, "Cancellation left temporary files: \(temporaryFiles)") + } + } + + func testInaccessibleDestinationReportsFailuresWhileOtherDestinationSucceeds() async throws { + try await FileOperationsTestLock.shared.run { + let fixture = try DisposableTransferFixture(seed: 404, fileCount: 2, bytesPerFile: 4 * 1024) + defer { fixture.cleanup() } + let source = canonicalFileURL(fixture.source) + let settings = CameraLabelSettings() + let goodDestination = fixture.destinations[0] + let faultDestination = try validatedFaultDestination(from: fixture) + let inaccessibleRoot = SafetyValidator.resolvedDestinationRoot( + source: source, + destination: faultDestination, + settings: settings + ) + try FileManager.default.createDirectory(at: inaccessibleRoot, withIntermediateDirectories: true) + let faultFileSystem = FaultInjectingFileSystemService( + faultDestination: faultDestination, + inaccessibleRoot: inaccessibleRoot + ) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: inaccessibleRoot.path) + } + + let operation = try await makeService(fileSystem: faultFileSystem).performFileOperation( + sourceURL: source, + destinationURLs: [goodDestination, faultDestination], + verificationMode: .standard, + settings: settings, + estimatedTotalBytes: nil, + progressCallback: { _ in }, + onFileResult: nil + ) + + let goodRoot = SafetyValidator.resolvedDestinationRoot( + source: source, + destination: goodDestination, + settings: settings + ) + let goodResults = operation.results.filter { $0.destinationURL.path.hasPrefix(goodRoot.path + "/") } + let faultResults = operation.results.filter { + $0.destinationURL.path.hasPrefix(inaccessibleRoot.path + "/") + && manifestRelativePath(for: $0, source: source, manifest: fixture.manifest) != nil + } + XCTAssertTrue(faultFileSystem.didInjectFault, "Fault filesystem did not make destination inaccessible") + XCTAssertEqual(goodResults.count, fixture.manifest.count, describe(operation.results)) + XCTAssertTrue(goodResults.allSatisfy(\.success), describe(operation.results)) + XCTAssertEqual(faultResults.count, fixture.manifest.count, describe(operation.results)) + XCTAssertTrue(faultResults.allSatisfy { !$0.success }) + XCTAssertTrue(faultResults.map(resultRow).allSatisfy { !$0.isSuccessStatus }) + XCTAssertTrue(operation.results.filter { !$0.success }.map(resultRow).allSatisfy { !$0.isSuccessStatus }) + try await assertSuccessfulOutputHashes(in: operation, match: fixture.manifest) + } + } +} + +private func canonicalFileURL(_ url: URL) -> URL { + #if canImport(Darwin) + guard let resolved = realpath(url.path, nil) else { return url.standardizedFileURL } + defer { free(resolved) } + return URL(fileURLWithPath: String(cString: resolved), isDirectory: true) + #else + return url.resolvingSymlinksInPath().standardizedFileURL + #endif +} + +private func manifestRelativePath( + for result: FileOperationResult, + source: URL, + manifest: [String: String] +) -> String? { + let prefix = source.path + "/" + guard result.sourceURL.path.hasPrefix(prefix) else { return nil } + let relativePath = String(result.sourceURL.path.dropFirst(prefix.count)) + return manifest[relativePath] == nil ? nil : relativePath +} + +private func describe(_ results: [FileOperationResult]) -> String { + results.map { + "src=\($0.sourceURL.path), dst=\($0.destinationURL.path), success=\($0.success), status=\($0.statusDescription)" + }.joined(separator: " | ") +} + +private func makeService( + fileSystem: FileSystemService = MacOSFileSystemService.shared +) -> SharedFileOperationsService { + SharedFileOperationsService( + fileSystem: fileSystem, + checksum: SharedChecksumService.shared + ) +} + +private func resultRow(from result: FileOperationResult) -> ResultRow { + ResultRow( + path: result.sourceURL.path, + status: result.statusDescription, + size: result.fileSize, + checksum: result.verificationResult?.sourceChecksum, + destination: result.destinationURL.deletingLastPathComponent().lastPathComponent, + destinationPath: result.destinationURL.path + ) +} + +private func assertSuccessfulOutputHashes( + in operation: FileOperation, + match manifest: [String: String], + file: StaticString = #filePath, + line: UInt = #line +) async throws { + let successfulResults = operation.results.filter(\.success) + guard !successfulResults.isEmpty else { + XCTFail("Expected at least one successful output", file: file, line: line) + return + } + for result in successfulResults { + let sourcePrefix = operation.sourceURL.path + "/" + guard result.sourceURL.path.hasPrefix(sourcePrefix) else { + XCTFail("Result source escaped fixture", file: file, line: line) + continue + } + let relativePath = String(result.sourceURL.path.dropFirst(sourcePrefix.count)) + let expected = try XCTUnwrap(manifest[relativePath], file: file, line: line) + let actual = try await SharedChecksumService.shared.generateChecksum( + for: result.destinationURL, + type: .sha256, + useCache: false, + progressCallback: nil + ) + XCTAssertEqual(actual, expected, "Unexpected hash for \(relativePath)", file: file, line: line) + } +} + +private func assertOutputHash( + for result: FileOperationResult, + source: URL, + manifest: [String: String], + file: StaticString = #filePath, + line: UInt = #line +) async throws { + let sourcePrefix = source.path + "/" + let relativePath = try XCTUnwrap( + result.sourceURL.path.hasPrefix(sourcePrefix) + ? String(result.sourceURL.path.dropFirst(sourcePrefix.count)) + : nil, + "Successful result source escaped fixture", + file: file, + line: line + ) + let expected = try XCTUnwrap(manifest[relativePath], file: file, line: line) + let actual = try await SharedChecksumService.shared.generateChecksum( + for: result.destinationURL, + type: .sha256, + useCache: false, + progressCallback: nil + ) + XCTAssertEqual(actual, expected, "Unexpected hash for \(relativePath)", file: file, line: line) +} + +private func recursivelyEnumeratedPaths(at root: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { + return [] + } + return enumerator.compactMap { $0 as? URL } +} + +private func validatedFaultDestination(from fixture: DisposableTransferFixture) throws -> URL { + guard let path = ProcessInfo.processInfo.environment["BITMATCH_FAULT_VOLUME"], !path.isEmpty else { + return fixture.destinations[1] + } + let destination = canonicalFileURL(URL(fileURLWithPath: path, isDirectory: true)) + let marker = destination.appendingPathComponent(".bitmatch-disposable-fixture") + guard FileManager.default.fileExists(atPath: marker.path) else { + throw XCTSkip("BITMATCH_FAULT_VOLUME is not marked as a disposable fixture") + } + return destination +} + +private final class OneShot: @unchecked Sendable { + private let lock = NSLock() + private var available = true + + func take() -> Bool { + lock.lock() + defer { lock.unlock() } + guard available else { return false } + available = false + return true + } +} + +private final class CapturedSuccessfulResult: @unchecked Sendable { + private let lock = NSLock() + private var captured: FileOperationResult? + + var value: FileOperationResult? { + lock.lock() + defer { lock.unlock() } + return captured + } + + func capture(_ result: FileOperationResult) { + lock.lock() + defer { lock.unlock() } + if captured == nil { + captured = result + } + } +} + +private final class FaultInjectingFileSystemService: FileSystemService, @unchecked Sendable { + private let delegate = MacOSFileSystemService.shared + private let faultDestination: URL + private let inaccessibleRoot: URL + private let lock = NSLock() + private var injected = false + + init(faultDestination: URL, inaccessibleRoot: URL) { + self.faultDestination = faultDestination.standardizedFileURL + self.inaccessibleRoot = inaccessibleRoot + } + + var didInjectFault: Bool { + lock.lock() + defer { lock.unlock() } + return injected + } + + func selectSourceFolder() async -> URL? { await delegate.selectSourceFolder() } + func selectDestinationFolders() async -> [URL] { await delegate.selectDestinationFolders() } + func selectLeftFolder() async -> URL? { await delegate.selectLeftFolder() } + func selectRightFolder() async -> URL? { await delegate.selectRightFolder() } + func validateFileAccess(url: URL) async -> Bool { await delegate.validateFileAccess(url: url) } + func startAccessing(url: URL) -> Bool { delegate.startAccessing(url: url) } + func stopAccessing(url: URL) { delegate.stopAccessing(url: url) } + func getFileList(from folderURL: URL) async throws -> [URL] { + try await delegate.getFileList(from: folderURL) + } + nonisolated func getFileSize(for url: URL) throws -> Int64 { try delegate.getFileSize(for: url) } + nonisolated func createDirectory(at url: URL) throws { try delegate.createDirectory(at: url) } + + nonisolated func freeSpace(at url: URL) -> Int64 { + let available = delegate.freeSpace(at: url) + guard url.standardizedFileURL == faultDestination else { return available } + + lock.lock() + defer { lock.unlock() } + guard !injected else { return available } + do { + try FileManager.default.setAttributes( + [.posixPermissions: 0o000], + ofItemAtPath: inaccessibleRoot.path + ) + injected = true + } catch { + injected = false + } + return available + } +} + +private final class MutatingChecksumService: ChecksumService, @unchecked Sendable { + private let target: URL + private let lock = NSLock() + private var didMutate = false + + init(target: URL) { + self.target = target + } + + func generateChecksum( + for fileURL: URL, + type: ChecksumAlgorithm, + useCache: Bool, + progressCallback: ProgressCallback? + ) async throws -> String { + try await SharedChecksumService.shared.generateChecksum( + for: fileURL, + type: type, + useCache: useCache, + progressCallback: progressCallback + ) + } + + func verifyFileIntegrity( + sourceURL: URL, + destinationURL: URL, + type: ChecksumAlgorithm, + useCache: Bool, + progressCallback: ProgressCallback? + ) async throws -> VerificationResult { + if sourceURL.standardizedFileURL == target.standardizedFileURL, claimMutation() { + try Data("source changed after publication".utf8).write(to: target, options: .atomic) + } + return try await SharedChecksumService.shared.verifyFileIntegrity( + sourceURL: sourceURL, + destinationURL: destinationURL, + type: type, + useCache: useCache, + progressCallback: progressCallback + ) + } + + func performByteComparison( + sourceURL: URL, + destinationURL: URL, + progressCallback: ProgressCallback? + ) async throws -> Bool { + try await SharedChecksumService.shared.performByteComparison( + sourceURL: sourceURL, + destinationURL: destinationURL, + progressCallback: progressCallback + ) + } + + private func claimMutation() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !didMutate else { return false } + didMutate = true + return true + } +} diff --git a/BitMatchTests/TransferPlanPresentationTests.swift b/BitMatchTests/TransferPlanPresentationTests.swift new file mode 100644 index 0000000..be48507 --- /dev/null +++ b/BitMatchTests/TransferPlanPresentationTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing +@testable import BitMatch + +struct TransferPlanPresentationTests { + private let sourceURL = URL(fileURLWithPath: "/Source/A001") + private let destinationURL = URL(fileURLWithPath: "/Volumes/RAID_A") + + @Test + func emptyPlanExplainsTheMissingSource() { + let plan = TransferPlanPresentation.make( + sourceURL: nil, + sourceInfo: nil, + destinationURLs: [], + verificationMode: .standard, + cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(), + isAnalyzing: false, + blockingIssues: [], + warnings: [] + ) + + #expect(plan.sourceTitle == "Choose source") + #expect(plan.destinationDetail == "Add at least one backup") + #expect(plan.status == .incomplete("Choose a source folder")) + #expect(!plan.canStart) + } + + @Test + func analyzingPlanDisablesStartUntilSourceAnalysisFinishes() { + let plan = TransferPlanPresentation.make( + sourceURL: sourceURL, + sourceInfo: nil, + destinationURLs: [destinationURL], + verificationMode: .standard, + cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(), + isAnalyzing: true, + blockingIssues: [], + warnings: [] + ) + + #expect(plan.sourceTitle == "A001") + #expect(plan.sourceDetail == "Analyzing…") + #expect(plan.status == .analyzing("Analyzing source…")) + #expect(!plan.canStart) + } + + @Test + func standardReadyPlanUsesVerifiedAction() { + let plan = TransferPlanPresentation.make( + sourceURL: sourceURL, + sourceInfo: nil, + destinationURLs: [destinationURL], + verificationMode: .standard, + cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(), + isAnalyzing: false, + blockingIssues: [], + warnings: [] + ) + + #expect(plan.status == .ready) + #expect(plan.actionTitle == "Start verified copy") + #expect(plan.canStart) + } + + @Test + func warningPlanKeepsStartAvailableAndUsesQuickCopyAction() { + let plan = TransferPlanPresentation.make( + sourceURL: sourceURL, + sourceInfo: nil, + destinationURLs: [destinationURL], + verificationMode: .quick, + cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(), + isAnalyzing: false, + blockingIssues: [], + warnings: ["Quick mode does not verify checksums"] + ) + + #expect(plan.status == .warning(["Quick mode does not verify checksums"])) + #expect(plan.actionTitle == "Start copy without checksum verification") + #expect(plan.canStart) + } + + @Test + func blockedPlanTakesPrecedenceOverOtherStates() { + let plan = TransferPlanPresentation.make( + sourceURL: nil, + sourceInfo: nil, + destinationURLs: [], + verificationMode: .standard, + cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(), + isAnalyzing: true, + blockingIssues: ["Destination is the source folder"], + warnings: ["Quick mode does not verify checksums"] + ) + + #expect(plan.status == .blocked(["Destination is the source folder"])) + #expect(!plan.canStart) + } + + @Test + func optionSummaryIncludesCameraAndReportLabels() { + var cameraSettings = CameraLabelSettings() + cameraSettings.label = "B Cam" + var reportSettings = ReportPrefs() + reportSettings.generateCSV = false + + let plan = TransferPlanPresentation.make( + sourceURL: sourceURL, + sourceInfo: folderInfo, + destinationURLs: [destinationURL], + verificationMode: .standard, + cameraSettings: cameraSettings, + reportSettings: reportSettings, + isAnalyzing: false, + blockingIssues: [], + warnings: [] + ) + + #expect(plan.sourceDetail == "1,234 files · 1 GB") + #expect(plan.optionSummary.contains("Camera label: B Cam")) + #expect(plan.optionSummary.contains("Reports: PDF")) + } + + private var folderInfo: FolderInfo { + FolderInfo( + url: sourceURL, + fileCount: 1_234, + totalSize: 1_000_000_000, + lastModified: .distantPast, + isInternalDrive: false + ) + } +} diff --git a/BitMatchTests/TransferSoakTests.swift b/BitMatchTests/TransferSoakTests.swift new file mode 100644 index 0000000..0db5a55 --- /dev/null +++ b/BitMatchTests/TransferSoakTests.swift @@ -0,0 +1,199 @@ +import Foundation +import XCTest +#if canImport(Darwin) +import Darwin +#endif +@testable import BitMatch + +final class TransferSoakTests: XCTestCase { + func testSeededStandardTransferToTwoDestinations() async throws { + guard ProcessInfo.processInfo.environment["BITMATCH_RUN_SOAK"] == "1" else { + throw XCTSkip("Set BITMATCH_RUN_SOAK=1 to run the seeded transfer soak test") + } + + let environment = ProcessInfo.processInfo.environment + let seed = try parseUInt64(environment["BITMATCH_SOAK_SEED"], name: "BITMATCH_SOAK_SEED", default: 20_260_711) + let iterations = try parsePositiveInt( + environment["BITMATCH_SOAK_ITERATIONS"], + name: "BITMATCH_SOAK_ITERATIONS", + default: 25 + ) + var iterationSummaries: [SoakIterationSummary] = [] + + try await FileOperationsTestLock.shared.run { + for iteration in 0.. UInt64 { + guard let value, !value.isEmpty else { return defaultValue } + guard let parsed = UInt64(value) else { + throw SoakConfigurationError.invalidValue(name: name, value: value) + } + return parsed +} + +private func parsePositiveInt(_ value: String?, name: String, default defaultValue: Int) throws -> Int { + guard let value, !value.isEmpty else { return defaultValue } + guard let parsed = Int(value), parsed > 0 else { + throw SoakConfigurationError.invalidValue(name: name, value: value) + } + return parsed +} + +private enum SoakConfigurationError: LocalizedError { + case invalidValue(name: String, value: String) + + var errorDescription: String? { + switch self { + case let .invalidValue(name, value): + return "\(name) has invalid value: \(value)" + } + } +} + +private enum SoakVerificationError: LocalizedError { + case unexpectedResultCount(expected: Int, actual: Int, details: String) + case failedOperationResults(String) + case hashMismatch(relativePath: String, expected: String, actual: String) + + var errorDescription: String? { + switch self { + case let .unexpectedResultCount(expected, actual, details): + return "Expected \(expected) soak results, received \(actual): \(details)" + case let .failedOperationResults(details): + return "Soak operation contained failed results: \(details)" + case let .hashMismatch(relativePath, expected, actual): + return "SHA-256 mismatch for \(relativePath): expected \(expected), received \(actual)" + } + } +} + +private func relativeManifestPath(for result: FileOperationResult, source: URL) throws -> String { + let prefix = source.path + "/" + guard result.sourceURL.path.hasPrefix(prefix) else { + throw SoakConfigurationError.invalidValue(name: "result source path", value: result.sourceURL.path) + } + return String(result.sourceURL.path.dropFirst(prefix.count)) +} + +private func writeSoakSummary(_ summary: SoakSummary, to resultURL: URL) throws { + let parent = resultURL.deletingLastPathComponent().standardizedFileURL + let marker = parent.appendingPathComponent(".bitmatch-disposable-fixture") + guard FileManager.default.fileExists(atPath: marker.path) else { + throw SoakConfigurationError.invalidValue( + name: "BITMATCH_SOAK_RESULT parent (missing .bitmatch-disposable-fixture)", + value: parent.path + ) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(summary).write(to: resultURL, options: .atomic) +} + +private func soakCanonicalFileURL(_ url: URL) -> URL { + #if canImport(Darwin) + guard let resolved = realpath(url.path, nil) else { return url.standardizedFileURL } + defer { free(resolved) } + return URL(fileURLWithPath: String(cString: resolved), isDirectory: true) + #else + return url.resolvingSymlinksInPath().standardizedFileURL + #endif +} + +private func describeSoakResults(_ results: [FileOperationResult]) -> String { + results.map { + "src=\($0.sourceURL.path), dst=\($0.destinationURL.path), success=\($0.success), status=\($0.statusDescription)" + }.joined(separator: " | ") +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 50babfc..a32e7cb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -30,6 +30,8 @@ - Reworked copy behavior to avoid destructive overwrites - Added source-stability checks before publishing destination files - Ensured large result sets and reports retain the latest status for every file/destination +- Consolidated copy and verification through the shared executor and operation service +- Added focused concurrency diagnostics plus APFS fault and seeded soak harnesses ## Current Development Status @@ -41,6 +43,8 @@ - **Interface Consistency**: Both platforms feature-complete and visually consistent - **Compilation**: All syntax and type errors resolved - **Transfer Safety Core**: Conservative copy, verification, preflight, and reporting behavior for production-style offloads +- **Shared Operation Path**: macOS and iPad both execute through `CopyVerifyExecutor` and `SharedFileOperationsService` +- **Transfer Plan UI**: Both platforms show the selected source/destinations, options, and preflight state before a transfer starts ### 🎯 Active Development Areas - **UI Polish**: Minor visual refinements and animations @@ -181,9 +185,27 @@ Required behaviors: - skip symlink entries instead of following them outside the selected source - use uncached checksums for live verification/report sealing paths +### 5. Shared Execution Boundary +**Files**: +- `Shared/Core/Services/CopyVerifyExecutor.swift` +- `Shared/Core/Services/SharedFileOperationsService.swift` +- `Shared/Core/Models/TransferPlanPresentation.swift` + +**Pattern**: Platform views present selection and readiness, then the shared +coordinator creates a `CopyVerifyConfig` for `CopyVerifyExecutor`. The executor +owns timing, operation state, error tracking, result coalescing, report +handoff, and progress callbacks. It calls the shared file-operation service, +which performs the real preflight and transfer. Do not add a platform-only +transfer path or rely on UI validation as the safety boundary. + +The macOS `TransferPlanView` and iPad transfer-plan cards must describe the +same presentation model. Keep platform interaction styles appropriate to mouse +and touch, but keep their readiness meaning aligned. + ## Development Workflow ### 1. Making Changes to Shared Code +- Start with the narrowest relevant test; then run macOS tests and the iPad build - Test on both macOS and iPad targets - Ensure protocol contracts are maintained - Update both platform managers if needed @@ -206,6 +228,18 @@ Required behaviors: 5. Add error handling and edge cases 6. Test full operation flow +### 5. Safe Change Workflow +1. Inspect the shared path before changing copy, verification, preflight, state, or report behavior. +2. Add or update a focused regression test in `BitMatchTests`; use `ConcurrencyTests` for semaphore behavior and transfer integration tests for operation semantics. +3. Run `bash test.sh mac-test`. +4. Run `bash test.sh ipad-build` after every shared-code or UI-model change. +5. Run `bash test.sh release-builds` before release work. Run `bash test.sh ipad-test` only after setting an explicit `IOS_SIMULATOR_DESTINATION`. +6. Do not treat the automated tests as proof of physical-media safety. Use the fault/soak scripts and `docs/HARDWARE_TESTING.md` with throwaway data when the change can affect transfer reliability. + +The repository's GitHub Actions workflow runs `mac-test` and `ipad-build` for +pushes and pull requests. Keep these jobs green; do not replace their explicit +job names with a catch-all script invocation. + ## Debugging Guide ### Common Issues @@ -274,17 +308,50 @@ print("❌ Error: \(error)") ## Testing & Coverage +Run the named jobs from the repository root: + +```bash +bash test.sh mac-test +bash test.sh mac-build +bash test.sh ipad-build +IOS_SIMULATOR_DESTINATION='platform=iOS Simulator,name=iPad (A16)' bash test.sh ipad-test +bash test.sh release-builds +``` + +`mac-test` runs `BitMatchTests` on macOS. `ipad-build` is the portable shared +code gate when no iPad simulator is selected. `ipad-test` deliberately refuses +to guess a simulator; set `IOS_SIMULATOR_DESTINATION` to a destination installed +on the current machine. + - Run tests with coverage: - `xcodebuild test -scheme BitMatch -enableCodeCoverage YES -resultBundlePath coverage.xcresult` -- Run the safety-focused unit target: - - `xcodebuild test -scheme BitMatch -project BitMatch.xcodeproj -configuration Debug -destination platform=macOS,arch=arm64 -only-testing:BitMatchTests` -- Compile the iPad target after shared changes: - - `xcodebuild -scheme BitMatch-iPad -project BitMatch.xcodeproj -configuration Debug -destination generic/platform=iOS CODE_SIGNING_ALLOWED=NO build` - Coverage summary: - `xcrun xccov view --report coverage.xcresult` - JSON coverage for tooling/CI: - `xcrun xccov view --report --json coverage.xcresult` -- Convenience: `bash test.sh` + +### Reliability Diagnostics and Hardware Testing + +Use `Scripts/run_apfs_fault_tests.sh` for the automated APFS destination-fault +case. It creates and removes a marked, disposable APFS image, then checks the +case in which one destination becomes inaccessible while another succeeds. + +Use `Scripts/run_soak_tests.sh` for repeatable transfer stress. Set +`BITMATCH_SOAK_SEED` and `BITMATCH_SOAK_ITERATIONS` when reproducing a failure; +the defaults are `20260711` and `25`. The script prints validated JSON before +removing its marked temporary directory. + +For cable removal, sleep, low-power hubs, exFAT, and other physical-media +cases, follow [`docs/HARDWARE_TESTING.md`](docs/HARDWARE_TESTING.md). Use only +throwaway data and retain independent post-run hashes. The automated APFS test +does not prove that real hardware faults are safe. + +### Screenshot Maintenance + +`screenshot.png` remains unchanged in this documentation update. Do not replace +it with a mock, a stale simulator image, or a view containing personal paths or +media. Update it only after capturing a stable current app view with no personal +data. ## macOS Release diff --git a/Platforms/iOS/Services/IOSPlatformManager.swift b/Platforms/iOS/Services/IOSPlatformManager.swift index 7b412b3..28a0b4f 100644 --- a/Platforms/iOS/Services/IOSPlatformManager.swift +++ b/Platforms/iOS/Services/IOSPlatformManager.swift @@ -87,44 +87,4 @@ class IOSPlatformManager: PlatformManager { return true } - // MARK: - Background Tasks - - func beginBackgroundTask(name: String?, expirationHandler: (() -> Void)?) -> Int { - final class TaskIDBox { var id: UIBackgroundTaskIdentifier = .invalid } - let box = TaskIDBox() - - let register: () -> Void = { - box.id = UIApplication.shared.beginBackgroundTask(withName: name) { - expirationHandler?() - let current = box.id - if current != .invalid { - UIApplication.shared.endBackgroundTask(current) - box.id = .invalid - } - } - } - - if Thread.isMainThread { - register() - } else { - DispatchQueue.main.sync(execute: register) - } - - return box.id.rawValue - } - - func endBackgroundTask(_ id: Int) { - let endTask: () -> Void = { - let identifier = UIBackgroundTaskIdentifier(rawValue: id) - if identifier != .invalid { - UIApplication.shared.endBackgroundTask(identifier) - } - } - - if Thread.isMainThread { - endTask() - } else { - DispatchQueue.main.sync(execute: endTask) - } - } } diff --git a/Platforms/macOS/Services/MacOSPlatformManager.swift b/Platforms/macOS/Services/MacOSPlatformManager.swift index 4f66e2e..d38fc56 100644 --- a/Platforms/macOS/Services/MacOSPlatformManager.swift +++ b/Platforms/macOS/Services/MacOSPlatformManager.swift @@ -73,16 +73,5 @@ class MacOSPlatformManager: PlatformManager { return true } - // MARK: - Background Tasks - - func beginBackgroundTask(name: String?, expirationHandler: (() -> Void)?) -> Int { - // macOS doesn't use UIKit background tasks in the same way; returning a dummy ID. - // Use ProcessInfo.processInfo.beginActivity(options:reason:) if preventing sleep is needed. - return 0 - } - - func endBackgroundTask(_ id: Int) { - // No-op - } } #endif diff --git a/README.md b/README.md index 8f960b3..77011e9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ Plug in your card and drives. BitMatch figures out which is which (1TB+ are dest - **Camera detection** for Sony, Canon, ARRI, RED, Blackmagic, Panasonic, Fujifilm, GoPro, DJI, Insta360, generic DCIM. Names backup folders after the camera. Autosorts A/B/C - **Folder compare** for stuff you already copied - **PDF reports** for producers who want documentation -- **Safe by default**: never modifies the source, never overwrites destination conflicts, copies through temp files and verifies by default with SHA-256 +- **Transfer plan on Mac and iPad**: shows selected source, backup destinations, verification/report choices, and preflight blockers before Start +- **Safe by default**: never modifies the source, never overwrites destination conflicts, copies through temp files, and verifies by default with SHA-256 ## Why It's Safe (probably) @@ -74,9 +75,17 @@ Requirements: Xcode 16+, macOS 15.5+ for the Mac app. The iPad target currently Tests: ```bash -bash test.sh +bash test.sh mac-test # macOS unit and integration tests +bash test.sh mac-build # macOS Debug build +bash test.sh ipad-build # iPad simulator Debug build +bash test.sh ipad-test # requires IOS_SIMULATOR_DESTINATION +bash test.sh release-builds # macOS and iPad Release builds ``` +CI runs `mac-test` and `ipad-build` on every push and pull request. For the +physical-media test procedure and the APFS fault/soak harnesses, see +[`docs/HARDWARE_TESTING.md`](docs/HARDWARE_TESTING.md). + ## FAQ **Does it generate MHL files?** Yes, in Paranoid mode. diff --git a/Scripts/run_apfs_fault_tests.sh b/Scripts/run_apfs_fault_tests.sh new file mode 100755 index 0000000..dfe24c3 --- /dev/null +++ b/Scripts/run_apfs_fault_tests.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +WORK=$(mktemp -d "${TMPDIR:-/tmp}/bitmatch-apfs-fault.XXXXXX") +HARNESS_MARKER="$WORK/.bitmatch-harness-root" +IMAGE="$WORK/fault-volume.dmg" +MOUNT="$WORK/mount" +DERIVED_DATA="$WORK/DerivedData" +RESULT_BUNDLE="$WORK/apfs-fault-results.xcresult" +ATTACHED=0 +touch "$HARNESS_MARKER" +mkdir "$MOUNT" +MOUNT_CANONICAL=$(cd "$MOUNT" && pwd -P) + +is_fault_volume_mounted() { + mount | grep -Fq " on $MOUNT_CANONICAL (" +} + +cleanup() { + local status=$? + trap - EXIT INT TERM + if [[ "$ATTACHED" == 1 ]]; then + hdiutil detach "$MOUNT_CANONICAL" >/dev/null 2>&1 \ + || hdiutil detach -force "$MOUNT_CANONICAL" >/dev/null 2>&1 \ + || true + fi + if is_fault_volume_mounted; then + echo "Fault image is still mounted; preserving harness directory: $WORK" >&2 + status=1 + elif [[ -f "$HARNESS_MARKER" ]]; then + rm -rf "$WORK" + else + echo "Refusing to remove unmarked APFS harness directory: $WORK" >&2 + status=1 + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# SafetyValidator requires a 1 GB free-space buffer before copying. A 2 GB +# image keeps this focused fault test above that production preflight threshold. +hdiutil create -quiet -size 2g -fs APFS -volname BitMatchFault "$IMAGE" +# Mark attachment as requiring cleanup before the attach command begins. This +# closes the signal window between a successful attach and state assignment. +ATTACHED=1 +hdiutil attach -quiet -nobrowse -mountpoint "$MOUNT" "$IMAGE" +touch "$MOUNT_CANONICAL/.bitmatch-disposable-fixture" + +xcodebuild -quiet \ + -project "$ROOT/BitMatch.xcodeproj" \ + -scheme BitMatch \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED_DATA" \ + CODE_SIGNING_ALLOWED=NO \ + build-for-testing + +XCTESTRUN=$(find "$DERIVED_DATA" -name '*.xctestrun' -print -quit) +[[ -n "$XCTESTRUN" ]] || { echo "Unable to locate generated xctestrun file" >&2; exit 1; } +ENVIRONMENT_PATH=':TestConfigurations:0:TestTargets:1:EnvironmentVariables' +/usr/libexec/PlistBuddy -c "Add $ENVIRONMENT_PATH:BITMATCH_FAULT_VOLUME string '$MOUNT_CANONICAL'" "$XCTESTRUN" + +if ! xcodebuild -quiet \ + test-without-building \ + -xctestrun "$XCTESTRUN" \ + -destination 'platform=macOS' \ + -resultBundlePath "$RESULT_BUNDLE" \ + -only-testing:BitMatchTests/TransferFaultIntegrationTests/testInaccessibleDestinationReportsFailuresWhileOtherDestinationSucceeds; then + xcrun xcresulttool get test-results tests --path "$RESULT_BUNDLE" --compact >&2 || true + exit 1 +fi diff --git a/Scripts/run_soak_tests.sh b/Scripts/run_soak_tests.sh new file mode 100755 index 0000000..cf79c6d --- /dev/null +++ b/Scripts/run_soak_tests.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +WORK=$(mktemp -d "${TMPDIR:-/tmp}/bitmatch-soak.XXXXXX") +MARKER="$WORK/.bitmatch-disposable-fixture" +DERIVED_DATA="$WORK/DerivedData" +RESULT="$WORK/soak-result.json" +touch "$MARKER" + +cleanup() { + local status=$? + trap - EXIT INT TERM + if [[ -f "$MARKER" ]]; then + rm -rf "$WORK" + else + echo "Refusing to remove unmarked soak directory: $WORK" >&2 + status=1 + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +BITMATCH_SOAK_SEED=${BITMATCH_SOAK_SEED:-20260711} +BITMATCH_SOAK_ITERATIONS=${BITMATCH_SOAK_ITERATIONS:-25} +[[ "$BITMATCH_SOAK_SEED" =~ ^[0-9]+$ ]] || { echo "BITMATCH_SOAK_SEED must be an unsigned integer" >&2; exit 64; } +[[ "$BITMATCH_SOAK_ITERATIONS" =~ ^[1-9][0-9]*$ ]] || { echo "BITMATCH_SOAK_ITERATIONS must be a positive integer" >&2; exit 64; } + +xcodebuild -quiet \ + -project "$ROOT/BitMatch.xcodeproj" \ + -scheme BitMatch \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED_DATA" \ + CODE_SIGNING_ALLOWED=NO \ + build-for-testing + +XCTESTRUN=$(find "$DERIVED_DATA" -name '*.xctestrun' -print -quit) +[[ -n "$XCTESTRUN" ]] || { echo "Unable to locate generated xctestrun file" >&2; exit 1; } +ENVIRONMENT_PATH=':TestConfigurations:0:TestTargets:1:EnvironmentVariables' +/usr/libexec/PlistBuddy -c "Add $ENVIRONMENT_PATH:BITMATCH_RUN_SOAK string '1'" "$XCTESTRUN" +/usr/libexec/PlistBuddy -c "Add $ENVIRONMENT_PATH:BITMATCH_SOAK_SEED string '$BITMATCH_SOAK_SEED'" "$XCTESTRUN" +/usr/libexec/PlistBuddy -c "Add $ENVIRONMENT_PATH:BITMATCH_SOAK_ITERATIONS string '$BITMATCH_SOAK_ITERATIONS'" "$XCTESTRUN" +/usr/libexec/PlistBuddy -c "Add $ENVIRONMENT_PATH:BITMATCH_SOAK_RESULT string '$RESULT'" "$XCTESTRUN" + +xcodebuild -quiet \ + test-without-building \ + -xctestrun "$XCTESTRUN" \ + -destination 'platform=macOS' \ + -only-testing:BitMatchTests/TransferSoakTests + +/usr/bin/python3 -m json.tool "$RESULT" >/dev/null +cat "$RESULT" diff --git a/Shared/Core/Models/TransferPlanPresentation.swift b/Shared/Core/Models/TransferPlanPresentation.swift new file mode 100644 index 0000000..ceffb51 --- /dev/null +++ b/Shared/Core/Models/TransferPlanPresentation.swift @@ -0,0 +1,145 @@ +import Foundation + +/// A view-ready summary of transfer setup state. +/// +/// This type deliberately contains no validation or transfer policy. Callers +/// provide the results of their existing validation and analysis work. +struct TransferPlanPresentation: Equatable { + enum Status: Equatable { + case incomplete(String) + case analyzing(String) + case ready + case warning([String]) + case blocked([String]) + } + + let sourceTitle: String + let sourceDetail: String + let destinationTitles: [String] + let destinationDetail: String + let status: Status + let optionSummary: [String] + let actionTitle: String + let canStart: Bool + + static func make( + sourceURL: URL?, + sourceInfo: FolderInfo?, + destinationURLs: [URL], + verificationMode: VerificationMode, + cameraSettings: CameraLabelSettings, + reportSettings: ReportPrefs, + isAnalyzing: Bool, + blockingIssues: [String], + warnings: [String] + ) -> Self { + let status = status( + sourceURL: sourceURL, + destinationURLs: destinationURLs, + isAnalyzing: isAnalyzing, + blockingIssues: blockingIssues, + warnings: warnings + ) + + return Self( + sourceTitle: sourceURL?.lastPathComponent ?? "Choose source", + sourceDetail: sourceDetail(for: sourceInfo, isAnalyzing: isAnalyzing), + destinationTitles: destinationURLs.map(\.lastPathComponent), + destinationDetail: destinationDetail(for: destinationURLs.count), + status: status, + optionSummary: optionSummary( + verificationMode: verificationMode, + cameraSettings: cameraSettings, + reportSettings: reportSettings + ), + actionTitle: verificationMode == .quick + ? "Start copy without checksum verification" + : "Start verified copy", + canStart: canStart(for: status) + ) + } + + private static func status( + sourceURL: URL?, + destinationURLs: [URL], + isAnalyzing: Bool, + blockingIssues: [String], + warnings: [String] + ) -> Status { + if !blockingIssues.isEmpty { + return .blocked(blockingIssues) + } + guard sourceURL != nil else { + return .incomplete("Choose a source folder") + } + guard !destinationURLs.isEmpty else { + return .incomplete("Add at least one backup destination") + } + if isAnalyzing { + return .analyzing("Analyzing source…") + } + if !warnings.isEmpty { + return .warning(warnings) + } + return .ready + } + + private static func sourceDetail(for sourceInfo: FolderInfo?, isAnalyzing: Bool) -> String { + if isAnalyzing { + return "Analyzing…" + } + guard let sourceInfo else { + return "Analysis pending" + } + return "\(formattedCount(sourceInfo.fileCount)) files · \(formattedSize(sourceInfo.totalSize))" + } + + private static func destinationDetail(for destinationCount: Int) -> String { + guard destinationCount > 0 else { + return "Add at least one backup" + } + let count = formattedCount(destinationCount) + return destinationCount == 1 ? "\(count) backup selected" : "\(count) backups selected" + } + + private static func optionSummary( + verificationMode: VerificationMode, + cameraSettings: CameraLabelSettings, + reportSettings: ReportPrefs + ) -> [String] { + [ + "Verification: \(verificationMode.rawValue)", + "Camera label: \(cameraSettings.label.isEmpty ? "Off" : cameraSettings.label)", + reportSummary(for: reportSettings) + ] + } + + private static func reportSummary(for settings: ReportPrefs) -> String { + guard settings.makeReport else { + return "Reports: Off" + } + var formats: [String] = [] + if settings.generatePDF { formats.append("PDF") } + if settings.generateCSV { formats.append("CSV") } + return formats.isEmpty ? "Reports: On" : "Reports: \(formats.joined(separator: ", "))" + } + + private static func canStart(for status: Status) -> Bool { + switch status { + case .ready, .warning: + return true + case .incomplete, .analyzing, .blocked: + return false + } + } + + private static func formattedCount(_ count: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: count)) ?? "\(count)" + } + + private static func formattedSize(_ size: Int64) -> String { + ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } +} diff --git a/Shared/Core/Services/DependencyContainer.swift b/Shared/Core/Services/DependencyContainer.swift deleted file mode 100644 index 880c80e..0000000 --- a/Shared/Core/Services/DependencyContainer.swift +++ /dev/null @@ -1,46 +0,0 @@ -// DependencyContainer.swift - Centralized dependency injection container -// Phase 3: Replace .shared singletons with injectable dependencies -import Foundation - -/// Centralized container for app-wide service dependencies -/// Replaces scattered .shared singletons with a single injectable source of truth -@MainActor -final class DependencyContainer { - // MARK: - Singleton (for gradual migration) - static let shared = DependencyContainer() - - // MARK: - Services - let folderInfoService: FolderInfoService - let checksumService: SharedChecksumService - let checksumCache: SharedChecksumCache - let backgroundTaskService: IOSBackgroundTaskService - - // MARK: - Initialization - - init( - folderInfoService: FolderInfoService? = nil, - checksumService: SharedChecksumService? = nil, - checksumCache: SharedChecksumCache? = nil, - backgroundTaskService: IOSBackgroundTaskService? = nil - ) { - self.folderInfoService = folderInfoService ?? FolderInfoService.shared - self.checksumService = checksumService ?? SharedChecksumService.shared - self.checksumCache = checksumCache ?? SharedChecksumCache.shared - self.backgroundTaskService = backgroundTaskService ?? IOSBackgroundTaskService.shared - } - - /// Create a container with mock services for testing - static func forTesting( - folderInfoService: FolderInfoService? = nil, - checksumService: SharedChecksumService? = nil, - checksumCache: SharedChecksumCache? = nil, - backgroundTaskService: IOSBackgroundTaskService? = nil - ) -> DependencyContainer { - return DependencyContainer( - folderInfoService: folderInfoService, - checksumService: checksumService, - checksumCache: checksumCache, - backgroundTaskService: backgroundTaskService - ) - } -} diff --git a/Shared/Core/Services/File/SafetyValidator.swift b/Shared/Core/Services/File/SafetyValidator.swift index cbf76b8..3493b8a 100644 --- a/Shared/Core/Services/File/SafetyValidator.swift +++ b/Shared/Core/Services/File/SafetyValidator.swift @@ -295,13 +295,26 @@ final class SafetyValidator { private static func getAvailableSpace(at url: URL) -> Int64 { do { - let resourceValues = try url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) - return resourceValues.volumeAvailableCapacityForImportantUsage ?? 0 + let resourceValues = try url.resourceValues(forKeys: [ + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityKey, + ]) + return resolvedAvailableSpace( + importantUsage: resourceValues.volumeAvailableCapacityForImportantUsage, + standardCapacity: resourceValues.volumeAvailableCapacity + ) } catch { return 0 } } + static func resolvedAvailableSpace( + importantUsage: Int64?, + standardCapacity: Int? + ) -> Int64 { + importantUsage ?? Int64(standardCapacity ?? 0) + } + private static let protectedSystemPrefixes: [String] = [ "/System", "/Library", "/usr", "/bin", "/sbin", "/private", "/var" ] diff --git a/Shared/Core/Services/IOSBackgroundTaskService.swift b/Shared/Core/Services/IOSBackgroundTaskService.swift index b9923e7..6548aa0 100644 --- a/Shared/Core/Services/IOSBackgroundTaskService.swift +++ b/Shared/Core/Services/IOSBackgroundTaskService.swift @@ -120,9 +120,9 @@ final class IOSBackgroundTaskService: ObservableObject { private func startBackgroundTimeMonitor() { stopBackgroundTimeMonitor() backgroundTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in - guard let self else { return } - let remaining = UIApplication.shared.backgroundTimeRemaining - Task { @MainActor in + Task { @MainActor [weak self] in + guard let self else { return } + let remaining = UIApplication.shared.backgroundTimeRemaining self.backgroundTimeRemainingSeconds = remaining self.isInBackground = UIApplication.shared.applicationState != .active if remaining.isFinite && remaining > 0 && remaining < 60 && !self.warnedLowBackgroundTime { diff --git a/Shared/Core/Services/ServiceProtocols.swift b/Shared/Core/Services/ServiceProtocols.swift index 030dc7f..36b85c6 100644 --- a/Shared/Core/Services/ServiceProtocols.swift +++ b/Shared/Core/Services/ServiceProtocols.swift @@ -82,9 +82,6 @@ protocol PlatformManager { func presentError(_ error: Error) async func openURL(_ url: URL) async -> Bool - // Background Task Management - func beginBackgroundTask(name: String?, expirationHandler: (() -> Void)?) -> Int - func endBackgroundTask(_ id: Int) } // MARK: - Shared Result Types diff --git a/Shared/Core/Services/SharedFileOperationsService.swift b/Shared/Core/Services/SharedFileOperationsService.swift index 0405971..bb8105e 100644 --- a/Shared/Core/Services/SharedFileOperationsService.swift +++ b/Shared/Core/Services/SharedFileOperationsService.swift @@ -161,6 +161,13 @@ class SharedFileOperationsService: FileOperationsService { func isPaused() -> Bool { paused } func pause() { paused = true } func resume() { paused = false } + + func waitIfPaused() async throws { + while paused && !Task.isCancelled { + try Task.checkCancellation() + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + } + } } init(fileSystem: FileSystemService, checksum: any ChecksumService) { @@ -217,10 +224,7 @@ class SharedFileOperationsService: FileOperationsService { } private func waitIfPaused() async throws { - while await pauseState.isPaused() && !Task.isCancelled { - try Task.checkCancellation() - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - } + try await pauseState.waitIfPaused() } // MARK: - Private Implementation @@ -246,9 +250,9 @@ class SharedFileOperationsService: FileOperationsService { )) await verifyCounter.reset() - SharedChecksumService.pauseCheck = { [weak self] in - guard let self else { return } - try await self.waitIfPaused() + let pauseState = self.pauseState + SharedChecksumService.pauseCheck = { + try await pauseState.waitIfPaused() } let didStartSourceScope = fileSystem.startAccessing(url: operation.sourceURL) var destinationScopes: [URL: Bool] = [:] @@ -377,9 +381,8 @@ class SharedFileOperationsService: FileOperationsService { verificationMode: operation.verificationMode, workers: copyWorkers, preEnumeratedFiles: preEnumeratedFiles.map { $0.url }, - pauseCheck: { [weak self] in - guard let self else { return } - try await self.waitIfPaused() + pauseCheck: { + try await pauseState.waitIfPaused() }, onProgress: { fileName, fileSize in let copyUpdate = await progressState.recordCopy( diff --git a/docs/HARDWARE_TESTING.md b/docs/HARDWARE_TESTING.md new file mode 100644 index 0000000..bcf26c4 --- /dev/null +++ b/docs/HARDWARE_TESTING.md @@ -0,0 +1,44 @@ +# Hardware fault-testing procedure + +Hardware fault tests can destroy or corrupt media. Use throwaway source data only. Before any cable-pull exercise, keep two independently verified backups on devices that will not be connected to the test system. A second copy on another partition of the same device is not an independent backup. + +## Before each run + +1. Disconnect every storage device that is not part of the test. +2. Label the throwaway source and both throwaway destinations unambiguously. +3. Verify the two offline backups by recomputing their hashes; do not rely on file counts or Finder previews. +4. Record macOS version, BitMatch revision, filesystem, enclosure, cable, hub, power source, and verification mode. +5. Start with small synthetic files whose expected SHA-256 manifest is already saved offline. +6. Confirm which device may be unplugged before starting the transfer. + +Never remove the last known-good copy. Never run these procedures against client media, irreplaceable camera cards, or a mounted production archive. + +## Fault matrix + +Run each case with APFS destinations, exFAT destinations, and mixed APFS/exFAT destinations where the hardware supports them. Repeat direct-attached and through a low-power or bus-powered hub, but do not exceed the hub or computer vendor's electrical limits. + +### Source removal + +Start a Standard transfer to two destinations, wait until active copying is visible, then remove only the throwaway source. Record the displayed failure, whether cancellation remains responsive, whether temporary files remain, and whether previously published outputs still match the offline manifest. Reconnect the source only after the operation has stopped. + +### One-destination removal + +Start a Standard transfer to two destinations and remove one throwaway destination while leaving the other connected. The removed destination must report failures; the remaining destination must finish and every successful output must match the manifest. Inspect both destinations for `.bitmatch.tmp.*` files after reconnecting the removed device. + +### Sleep and backgrounding + +During copy and again during verification, put the Mac to sleep and wake it after at least 30 seconds. Also send BitMatch to the background and switch users if that is part of the deployment workflow. Record whether progress resumes, fails explicitly, or stalls. Hash all successful outputs independently after the run. + +### Cancel and relaunch + +Cancel during copy, confirm activity stops, then quit and relaunch BitMatch. Repeat cancellation during verification. Confirm no temporary files remain and no canceled or failed row is classified as successful. A relaunch must not silently convert an incomplete transfer into success. + +### Low-power hubs + +Repeat the one-destination-removal and sleep cases through the approved low-power hub using the intended cable lengths. Watch for both explicit disconnects and transient resets. Record System Information's USB or Thunderbolt topology and system logs with the test notes. Do not intentionally overload, short, or thermally stress hardware. + +## Evidence to retain + +For every run, retain the seed or source manifest, BitMatch result export, independent post-run hashes, exact fault timing, expected outcome, actual outcome, and photos or topology notes that identify the devices. Treat a hang, silent retry, stale success row, unexpected overwrite, or leftover temporary file as a failure even if another destination completes. + +The automated APFS image test in `Scripts/run_apfs_fault_tests.sh` is a safer first step, but it does not simulate cable, bridge firmware, power-loss, or exFAT behavior. Passing it is not evidence that physical removal is safe. diff --git a/docs/superpowers/plans/2026-07-11-bitmatch-architecture-concurrency.md b/docs/superpowers/plans/2026-07-11-bitmatch-architecture-concurrency.md new file mode 100644 index 0000000..b0497f2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-bitmatch-architecture-concurrency.md @@ -0,0 +1,147 @@ +# BitMatch Architecture and Concurrency Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the confirmed legacy operation path, make adapter bindings explicit, and introduce concurrency diagnostics without changing transfer behavior. + +**Architecture:** Shared coordinators and shared services remain authoritative. The macOS coordinator projects shared state into existing presentation objects through explicit Combine pipelines. Unused platform background-task protocol methods disappear; `IOSBackgroundTaskService` remains the sole iOS background-task owner. + +**Tech Stack:** Swift 5 language mode, Combine, Swift concurrency diagnostics, Xcode project settings. + +## Global Constraints + +- Preserve settings keys, report formats, verification defaults, cancellation, pause, and resume behavior. +- Delete a type only after repository-wide reference tracing proves it unreachable. +- Build macOS and iPadOS after each deletion group. +- Add no third-party framework and do not migrate to Swift 6 language mode in this plan. + +--- + +### Task 1: Prove and remove the orphaned operation stack + +**Files:** +- Delete after reference proof: `BitMatch/Core/ViewModels/OperationViewModel.swift` +- Delete after reference proof: `BitMatch/Core/Services/ComparisonOperationService.swift` +- Delete after reference proof: `BitMatch/Core/Services/File/VerifyService.swift` +- Delete after reference proof: `BitMatch/Core/Services/File/FileDiffService.swift` +- Delete after reference proof: `BitMatch/Core/Services/File/UnifiedFileOperationService.swift` +- Delete after reference proof: `BitMatch/Core/Services/File/FileCounter.swift` +- Delete after reference proof: `BitMatch/Core/Services/File/TempFileManager.swift` +- Delete after reference proof: `BitMatch/Core/Services/Configuration/AppConfiguration.swift` +- Delete after reference proof: `Shared/Core/Services/DependencyContainer.swift` +- Modify: `BitMatch/Core/ViewModels/SettingsViewModel.swift` + +- [ ] **Step 1: Record the dependency proof** + +Run one `rg -n` query for every candidate name across `*.swift`. Expected: references occur only inside the candidate group, except the stale `OperationViewModel` comment in `SettingsViewModel.swift`. + +- [ ] **Step 2: Remove the stale comment and candidate files with apply_patch** + +Do not delete `SharedAppCoordinator`, `CopyVerifyExecutor`, `ComparisonCoordinator`, `ReportCoordinator`, `SharedFileOperationsService`, `FileCopyService`, or `SafetyValidator`. + +- [ ] **Step 3: Verify both targets and all unit tests** + +Run: `bash test.sh mac-test && bash test.sh ipad-build` +Expected: all tests pass and both targets compile. + +- [ ] **Step 4: Commit** + +```bash +git add -A BitMatch/Core Shared/Core BitMatchTests +git commit -m "refactor: remove orphaned operation architecture" +``` + +### Task 2: Replace delayed coordinator forwarding + +**Files:** +- Modify: `BitMatch/App/AppCoordinator.swift` +- Create: `BitMatchTests/AppCoordinatorBindingTests.swift` + +**Interfaces:** +- Produces: `setupFileSelectionBindings()`, `setupProgressBindings()`, and `setupSharedCoordinatorBindings()` private methods. + +- [ ] **Step 1: Write an observation test** + +On `@MainActor`, subscribe to `AppCoordinator.objectWillChange`, update `fileSelectionViewModel.sourceURL`, and assert a notification arrives on the next run-loop turn without sleeping for a fixed duration. Repeat for `sharedCoordinator.operationState` through a test-only transition already exposed by the coordinator. + +- [ ] **Step 2: Confirm the current implementation depends on fixed sleeps** + +Run: `rg -n 'Task\.sleep\(nanoseconds: 1_000_000\)' BitMatch/App/AppCoordinator.swift` +Expected: two matches. + +- [ ] **Step 3: Introduce explicit pipelines** + +Split `setupObservers()` by responsibility. Replace child `objectWillChange` forwarding with merged publishers for values used by `AppCoordinator` computed properties. Schedule delivery on `RunLoop.main` and call `objectWillChange.send()` without `Task.sleep`. Keep the existing throttled progress mapping and operation-state timer management. + +- [ ] **Step 4: Verify bindings and full behavior** + +Run: `bash test.sh mac-test && bash test.sh ipad-build` +Expected: all tests pass; `rg` finds no fixed one-millisecond forwarding sleep. + +- [ ] **Step 5: Commit** + +```bash +git add BitMatch/App/AppCoordinator.swift BitMatchTests/AppCoordinatorBindingTests.swift +git commit -m "refactor: make coordinator state bindings explicit" +``` + +### Task 3: Remove duplicate platform background-task APIs + +**Files:** +- Modify: `Shared/Core/Services/ServiceProtocols.swift` +- Modify: `Platforms/iOS/Services/IOSPlatformManager.swift` +- Modify: `Platforms/macOS/Services/MacOSPlatformManager.swift` +- Modify: `BitMatchTests/SharedCompareFlowTests.swift` + +**Interfaces:** +- Preserves: `IOSBackgroundTaskService.beginOperation(estimatedFiles:)` and `endOperation()` as the only background-task interface. + +- [ ] **Step 1: Prove the protocol methods have no callers** + +Run: `rg -n '\.(beginBackgroundTask|endBackgroundTask)\(' --glob '*.swift'` +Expected: only implementations and UIKit calls; no call through `PlatformManager`. + +- [ ] **Step 2: Remove the unused requirements and implementations** + +Delete `PlatformManager.beginBackgroundTask` and `endBackgroundTask`, both platform implementations, and the mock methods in `SharedCompareFlowTests`. This also removes `DispatchQueue.main.sync` from `IOSPlatformManager`. + +- [ ] **Step 3: Verify actor-owned background behavior still builds** + +Run: `bash test.sh mac-test && bash test.sh ipad-build` +Expected: all tests and builds pass; `rg -n 'DispatchQueue\.main\.sync' Platforms/iOS` returns no matches. + +- [ ] **Step 4: Commit** + +```bash +git add Shared/Core/Services/ServiceProtocols.swift Platforms BitMatchTests/SharedCompareFlowTests.swift +git commit -m "refactor: centralize iOS background task ownership" +``` + +### Task 4: Add targeted concurrency diagnostics + +**Files:** +- Modify: `BitMatch.xcodeproj/project.pbxproj` + +- [ ] **Step 1: Capture warnings before the setting change** + +Run both Debug builds with output saved outside the repository. Record warnings from project Swift files. + +- [ ] **Step 2: Set targeted checking for both app targets** + +Add `SWIFT_STRICT_CONCURRENCY = targeted;` to Debug and Release configurations for `BitMatch` and `BitMatch-iPad`. Keep `SWIFT_VERSION = 5.0`. + +- [ ] **Step 3: Fix diagnostics only in touched boundaries** + +Use `@MainActor`, `nonisolated`, or immutable `Sendable` data where the compiler identifies a real boundary. Do not add `@unchecked Sendable` to production code merely to silence a warning. + +- [ ] **Step 4: Verify Debug, Release, and tests** + +Run: `bash test.sh mac-test && bash test.sh ipad-build && bash test.sh release-builds` +Expected: all commands exit 0 with no new warnings in touched files. + +- [ ] **Step 5: Commit** + +```bash +git add BitMatch.xcodeproj Shared Platforms BitMatch +git commit -m "build: enable targeted concurrency diagnostics" +``` diff --git a/docs/superpowers/plans/2026-07-11-bitmatch-reliability-ci.md b/docs/superpowers/plans/2026-07-11-bitmatch-reliability-ci.md new file mode 100644 index 0000000..b812b87 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-bitmatch-reliability-ci.md @@ -0,0 +1,233 @@ +# BitMatch Reliability and CI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add reproducible builds, CI, disposable fault fixtures, a seeded soak test, and an honest hardware-failure procedure. + +**Architecture:** Shell entry points choose explicit Xcode jobs and isolate derived data. XCTest owns deterministic transfer exercises; shell wrappers prepare disposable APFS volumes and opt into long soak runs. CI builds both schemes and runs the macOS unit suite. + +**Tech Stack:** Bash, Xcode 16+, XCTest/Swift Testing, GitHub Actions, APFS disk images, SHA-256 through `SharedChecksumService`. + +## Global Constraints + +- Keep macOS 15.5 and iPadOS 18.5 deployment targets. +- Add no third-party dependency. +- Automated destructive operations may target only a marked temporary directory or a disk image created by the harness. +- Use a unique DerivedData directory for every concurrent Xcode job. +- Keep the existing 163-test macOS baseline green. + +--- + +### Task 1: Explicit local build and test commands + +**Files:** +- Modify: `test.sh` + +**Interfaces:** +- Produces: `bash test.sh mac-test|mac-build|ipad-build|ipad-test|release-builds` + +- [ ] **Step 1: Capture the baseline** + +Run: `bash test.sh` +Expected: 163 tests pass and the command exits 0. + +- [ ] **Step 2: Replace implicit arguments with named jobs** + +Implement a `case` statement with these exact defaults and commands: + +```bash +ROOT=$(cd "$(dirname "$0")" && pwd) +JOB=${1:-mac-test} +DERIVED_DATA_ROOT=${DERIVED_DATA_ROOT:-"$ROOT/.derived-data"} + +run_xcodebuild() { + local name=$1 + shift + xcodebuild -project "$ROOT/BitMatch.xcodeproj" \ + -derivedDataPath "$DERIVED_DATA_ROOT/$name" \ + CODE_SIGNING_ALLOWED=NO "$@" +} + +case "$JOB" in + mac-test) + run_xcodebuild mac-test test -scheme BitMatch -destination 'platform=macOS' -only-testing:BitMatchTests + ;; + mac-build) + run_xcodebuild mac-build build -scheme BitMatch -configuration Debug -destination 'platform=macOS' + ;; + ipad-build) + run_xcodebuild ipad-build build -scheme BitMatch-iPad -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' + ;; + ipad-test) + : "${IOS_SIMULATOR_DESTINATION:?Set IOS_SIMULATOR_DESTINATION, for example platform=iOS Simulator,name=iPad (A16)}" + run_xcodebuild ipad-test test -scheme BitMatch-iPad -destination "$IOS_SIMULATOR_DESTINATION" -only-testing:BitMatch-iPadTests + ;; + release-builds) + run_xcodebuild mac-release build -scheme BitMatch -configuration Release -destination 'platform=macOS' + run_xcodebuild ipad-release build -scheme BitMatch-iPad -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' + ;; + *) + echo "Usage: $0 {mac-test|mac-build|ipad-build|ipad-test|release-builds}" >&2 + exit 64 + ;; +esac +``` + +- [ ] **Step 3: Ignore local DerivedData** + +Add `.derived-data/` to `.gitignore`. + +- [ ] **Step 4: Verify all non-device jobs** + +Run: `bash test.sh mac-test && bash test.sh mac-build && bash test.sh ipad-build` +Expected: all commands exit 0; macOS reports 163 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add test.sh .gitignore +git commit -m "build: add explicit platform verification jobs" +``` + +### Task 2: GitHub Actions verification + +**Files:** +- Create: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: named jobs from Task 1. + +- [ ] **Step 1: Add isolated macOS test and iPad build jobs** + +Create a workflow triggered by `push` and `pull_request`. Use `macos-15`, `actions/checkout@v4`, `DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer`, and these commands: + +```yaml +jobs: + mac-tests: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - run: xcodebuild -version + - run: bash test.sh mac-test + env: + DERIVED_DATA_ROOT: ${{ runner.temp }}/bitmatch-derived-data + ipad-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - run: xcodebuild -version + - run: bash test.sh ipad-build + env: + DERIVED_DATA_ROOT: ${{ runner.temp }}/bitmatch-derived-data +``` + +- [ ] **Step 2: Validate YAML and commands locally** + +Run: `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/ci.yml")'` +Expected: exits 0. + +Run: `bash test.sh mac-test && bash test.sh ipad-build` +Expected: both commands exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: verify macOS tests and iPad build" +``` + +### Task 3: Disposable transfer fixture + +**Files:** +- Create: `BitMatchTests/TestHelpers/DisposableTransferFixture.swift` +- Create: `BitMatchTests/DisposableTransferFixtureTests.swift` + +**Interfaces:** +- Produces: `DisposableTransferFixture.init(seed:fileCount:bytesPerFile:)`, `source`, `destinations`, `manifest`, and `cleanup()`. + +- [ ] **Step 1: Write failing safety tests** + +Cover deterministic bytes for equal seeds, different bytes for different seeds, creation of hidden files and empty directories, and refusal to clean a URL that lacks the `.bitmatch-disposable-fixture` marker. + +```swift +@Test func identicalSeedsCreateIdenticalManifest() throws { + let left = try DisposableTransferFixture(seed: 42, fileCount: 3, bytesPerFile: 1024) + let right = try DisposableTransferFixture(seed: 42, fileCount: 3, bytesPerFile: 1024) + defer { left.cleanup(); right.cleanup() } + #expect(left.manifest == right.manifest) +} +``` + +- [ ] **Step 2: Run the new tests and confirm the type is missing** + +Run: `xcodebuild test -project BitMatch.xcodeproj -scheme BitMatch -destination 'platform=macOS' -only-testing:BitMatchTests/DisposableTransferFixtureTests` +Expected: compilation fails because `DisposableTransferFixture` is undefined. + +- [ ] **Step 3: Implement the marked fixture** + +Create a root under `FileManager.default.temporaryDirectory`, write `.bitmatch-disposable-fixture`, create `SOURCE`, `DEST_A`, and `DEST_B`, and generate bytes with `(seed &+ UInt64(fileIndex) &+ UInt64(byteIndex)) & 0xff`. Include `.camera-metadata`, `DCIM/100MEDIA`, and `EMPTY_SIDECARS`. Store manifest values as relative path to SHA-256. + +`cleanup()` must verify both conditions before removal: + +```swift +guard root.standardizedFileURL.path.hasPrefix(FileManager.default.temporaryDirectory.standardizedFileURL.path), + FileManager.default.fileExists(atPath: root.appendingPathComponent(".bitmatch-disposable-fixture").path) +else { return } +try? FileManager.default.removeItem(at: root) +``` + +- [ ] **Step 4: Verify focused and full tests** + +Run: `bash test.sh mac-test` +Expected: all baseline and fixture tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add BitMatchTests/TestHelpers/DisposableTransferFixture.swift BitMatchTests/DisposableTransferFixtureTests.swift +git commit -m "test: add safe deterministic transfer fixtures" +``` + +### Task 4: Fault and soak coverage + +**Files:** +- Create: `BitMatchTests/TransferFaultIntegrationTests.swift` +- Create: `BitMatchTests/TransferSoakTests.swift` +- Create: `Scripts/run_soak_tests.sh` +- Create: `Scripts/run_apfs_fault_tests.sh` +- Create: `docs/HARDWARE_TESTING.md` + +**Interfaces:** +- Consumes: `DisposableTransferFixture` and `SharedFileOperationsService.performFileOperation(...)`. +- Produces: opt-in environment variables `BITMATCH_RUN_SOAK`, `BITMATCH_SOAK_SEED`, `BITMATCH_SOAK_ITERATIONS`, and `BITMATCH_FAULT_VOLUME`. + +- [ ] **Step 1: Add failing integration tests** + +Add tests for source mutation, a conflicting pre-existing file, cancellation cleanup, and one inaccessible destination while another destination succeeds. Assert that successful output hashes equal the fixture manifest and that failures remain failures in `ResultRow` classification. + +- [ ] **Step 2: Implement the soak test** + +Skip unless `BITMATCH_RUN_SOAK == "1"`. For each seeded iteration, create a fixture, run Standard verification to both destinations, recompute every SHA-256 with `SharedChecksumService.generateChecksum(... useCache: false ...)`, and write a Codable JSON summary to `BITMATCH_SOAK_RESULT` when set. + +- [ ] **Step 3: Add safe shell wrappers** + +`run_soak_tests.sh` must set the opt-in variables and invoke only `TransferSoakTests`. `run_apfs_fault_tests.sh` must create its image under `mktemp -d`, attach it with `hdiutil`, place a `.bitmatch-disposable-fixture` marker at the mount point, pass the mount path through `BITMATCH_FAULT_VOLUME`, and detach it in a `trap`. + +- [ ] **Step 4: Document physical tests honestly** + +Document source removal, one-destination removal, sleep/backgrounding, cancel/relaunch, low-power hubs, and exFAT/APFS combinations. Require throwaway data and two independently verified backups before any cable-pull exercise. + +- [ ] **Step 5: Verify short tests and one soak iteration** + +Run: `bash test.sh mac-test` +Expected: all short tests pass; the soak test reports skipped. + +Run: `BITMATCH_SOAK_ITERATIONS=1 bash Scripts/run_soak_tests.sh` +Expected: one iteration passes and emits valid JSON. + +- [ ] **Step 6: Commit** + +```bash +git add BitMatchTests/TransferFaultIntegrationTests.swift BitMatchTests/TransferSoakTests.swift Scripts/run_soak_tests.sh Scripts/run_apfs_fault_tests.sh docs/HARDWARE_TESTING.md +git commit -m "test: add transfer fault and soak verification" +``` diff --git a/docs/superpowers/plans/2026-07-11-bitmatch-transfer-plan-ux.md b/docs/superpowers/plans/2026-07-11-bitmatch-transfer-plan-ux.md new file mode 100644 index 0000000..b506a80 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-bitmatch-transfer-plan-ux.md @@ -0,0 +1,180 @@ +# BitMatch Transfer-Plan UX Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the configuration-heavy idle screen with a concise transfer plan, preflight verdict, and one Options surface on macOS and iPadOS. + +**Architecture:** A pure presentation model summarizes existing selection and settings state. Views render it, but `SafetyValidator` and shared operation services remain authoritative. Platform layouts share hierarchy and copy without requiring identical geometry. + +**Tech Stack:** SwiftUI, existing design system, Swift Testing, macOS 15.5+, iPadOS 18.5+. + +## Global Constraints + +- Preserve drag and drop, keyboard shortcuts, recent destinations, mode navigation, saved preferences, and the compact in-progress UI. +- Keep the current dark palette, typography, and window footprint. +- Keep Quick-mode warnings and blocking readiness errors visible outside Options. +- Add no third-party dependency. +- Respect Reduce Motion and provide useful accessibility labels and focus order. + +--- + +### Task 1: Pure transfer-plan presentation model + +**Files:** +- Create: `Shared/Core/Models/TransferPlanPresentation.swift` +- Create: `BitMatchTests/TransferPlanPresentationTests.swift` + +**Interfaces:** +- Produces: `TransferPlanPresentation`, `TransferPlanPresentation.Status`, and `TransferPlanPresentation.make(...)`. + +- [ ] **Step 1: Write failing state tests** + +Test empty, analyzing, ready, warning, and blocked inputs. Verify Quick mode produces `Start copy without checksum verification`; Standard produces `Start verified copy`; report and camera labels appear in `optionSummary`. + +```swift +@Test func standardReadyPlanUsesVerifiedAction() { + let plan = TransferPlanPresentation.make( + sourceURL: URL(fileURLWithPath: "/Source/A001"), + sourceInfo: nil, + destinationURLs: [URL(fileURLWithPath: "/Volumes/RAID_A")], + verificationMode: .standard, + cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs(), + isAnalyzing: false, + blockingIssues: [], + warnings: [] + ) + #expect(plan.status == .ready) + #expect(plan.actionTitle == "Start verified copy") +} +``` + +- [ ] **Step 2: Run focused tests and confirm the model is missing** + +Run: `xcodebuild test -project BitMatch.xcodeproj -scheme BitMatch -destination 'platform=macOS' -only-testing:BitMatchTests/TransferPlanPresentationTests` +Expected: compilation fails because the type is undefined. + +- [ ] **Step 3: Implement the pure model** + +Define `Status: Equatable { case incomplete(String), analyzing(String), ready, warning([String]), blocked([String]) }`. Store `sourceTitle`, `sourceDetail`, `[String] destinationTitles`, `destinationDetail`, `status`, `[String] optionSummary`, `actionTitle`, and `canStart`. Format sizes with `ByteCountFormatter` and counts with `NumberFormatter`. Apply precedence: blocked, incomplete, analyzing, warning, ready. + +- [ ] **Step 4: Verify focused and full tests** + +Run: `bash test.sh mac-test` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Shared/Core/Models/TransferPlanPresentation.swift BitMatchTests/TransferPlanPresentationTests.swift +git commit -m "feat: model transfer plan presentation states" +``` + +### Task 2: macOS transfer-plan components + +**Files:** +- Create: `BitMatch/Views/CopyAndVerify/TransferPlanView.swift` +- Modify: `BitMatch/Views/CopyAndVerify/CopyAndVerifyView.swift` +- Modify: `BitMatch/App/ContentView.swift` + +**Interfaces:** +- Consumes: `TransferPlanPresentation` and existing `AppCoordinator` models. +- Produces: `TransferPlanView`, `TransferPlanSourceCard`, `TransferPlanDestinationsCard`, `TransferPlanPreflightCard`, and `TransferOptionsView`. + +- [ ] **Step 1: Add testable plan construction to `CopyAndVerifyView`** + +Move existing readiness issue and warning calculations into pure helper inputs for `TransferPlanPresentation.make`. Keep calls to `SafetyValidator.validateResolvedDestinationRoots` and existing free-space checks. + +- [ ] **Step 2: Build source and backup cards** + +Reuse existing selection and drag/drop actions from `HorizontalFlowView`. Source shows `Choose source`, `Analyzing…`, or ` files · `. Backups show `Add at least one backup`, destination names, and capacity status. Do not remove existing drop rejection handling. + +- [ ] **Step 3: Build the preflight and action hierarchy** + +Render green ready, blue analyzing, orange warning, and red blocked states. Put the reason beside every disabled action. Use the model's exact `actionTitle`; never label Quick mode as verified. + +- [ ] **Step 4: Consolidate secondary controls** + +Move the existing camera-label, verification, and report controls into one `TransferOptionsView` disclosure group. Outside it, render option-summary chips plus any Quick-mode warning. Remove the two independent expansion-height calculations from `ContentView` and use one Options expansion state. + +- [ ] **Step 5: Add accessibility and motion behavior** + +Use `@Environment(\.accessibilityReduceMotion)` to replace spring transitions with opacity when enabled. Add labels and hints for source selection, adding/removing backups, preflight state, Options, and start action. + +- [ ] **Step 6: Verify macOS states** + +Run: `bash test.sh mac-test && bash test.sh mac-build` +Expected: tests and build pass. Manually inspect empty, analyzing, ready, Quick warning, insufficient-space, duplicate-destination, and active states. + +- [ ] **Step 7: Commit** + +```bash +git add BitMatch/Views/CopyAndVerify BitMatch/App/ContentView.swift +git commit -m "feat: simplify macOS transfer setup" +``` + +### Task 3: iPadOS transfer-plan hierarchy + +**Files:** +- Modify: `BitMatch-iPad/Views/CopyAndVerifyView.swift` +- Modify: `BitMatch-iPad/Views/ModularContentView.swift` + +**Interfaces:** +- Consumes: `TransferPlanPresentation` and `SharedAppCoordinator`. + +- [ ] **Step 1: Replace the idle stack with the shared hierarchy** + +Retain `ProfessionalSourceCard`, destination picker behavior, and `StartTransferButtonView` actions, but order them as source, backups, preflight, option summary, Options, and primary action. + +- [ ] **Step 2: Merge iPad option disclosures** + +Place `CollapsibleLabelingSection`, `CollapsibleVerificationSection`, and `ReportToggleCard` inside one Options disclosure. Keep Quick warnings and readiness banners outside it. + +- [ ] **Step 3: Adapt for width and touch** + +Use side-by-side source/backups when regular horizontal size class has enough width; stack them otherwise. Preserve 44-point tap targets, VoiceOver order, Files picker sheets, and existing background-operation behavior. + +- [ ] **Step 4: Verify iPadOS and the macOS regression suite** + +Run: `bash test.sh ipad-build && bash test.sh mac-test` +Expected: both commands exit 0. Inspect compact and regular iPad widths in previews or an installed simulator. + +- [ ] **Step 5: Commit** + +```bash +git add BitMatch-iPad/Views/CopyAndVerifyView.swift BitMatch-iPad/Views/ModularContentView.swift +git commit -m "feat: simplify iPad transfer setup" +``` + +### Task 4: Final visual and documentation alignment + +**Files:** +- Modify: `README.md` +- Modify: `ARCHITECTURE.md` +- Modify: `DEVELOPMENT.md` +- Modify: `screenshot.png` + +- [ ] **Step 1: Verify final Debug and Release artifacts** + +Run: `bash test.sh mac-test && bash test.sh ipad-build && bash test.sh release-builds` +Expected: every command exits 0. + +- [ ] **Step 2: Capture the stable macOS setup screen** + +Use a representative disposable source and two disposable destinations. Capture the ready transfer-plan state at native resolution without personal volume names or client data. + +- [ ] **Step 3: Correct only stale documentation** + +Update architecture ownership, local/CI commands, soak and hardware-test links, the screenshot, and setup-flow descriptions. Preserve the README's candid trust story. + +- [ ] **Step 4: Run final checks** + +Run: `git diff --check && bash test.sh mac-test && bash test.sh ipad-build && bash test.sh release-builds` +Expected: no whitespace errors; all tests and builds pass. + +- [ ] **Step 5: Commit** + +```bash +git add README.md ARCHITECTURE.md DEVELOPMENT.md screenshot.png +git commit -m "docs: align BitMatch guides with the transfer plan workflow" +``` diff --git a/docs/superpowers/specs/2026-07-11-bitmatch-reliability-architecture-ux-design.md b/docs/superpowers/specs/2026-07-11-bitmatch-reliability-architecture-ux-design.md new file mode 100644 index 0000000..e2c0881 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-bitmatch-reliability-architecture-ux-design.md @@ -0,0 +1,189 @@ +# BitMatch Reliability, Architecture, and Setup UX Design + +Date: 2026-07-11 +Status: Approved design, pending implementation plan + +## Purpose + +Improve BitMatch without replacing its proven transfer core. The work will strengthen automated verification, finish the shared-core migration, clarify concurrency boundaries, and simplify transfer setup. Both app targets must remain buildable and testable after every stage. + +## Goals + +- Make transfer failure scenarios reproducible on disposable storage. +- Build both platforms and run the supported tests in continuous integration. +- Keep one canonical operation path in the shared core. +- Remove code proved unreachable from both app targets. +- Replace delayed change forwarding with explicit state bindings. +- Adopt stricter Swift concurrency diagnostics without a broad rewrite. +- Present setup as a clear transfer plan with a prominent safety verdict. +- Preserve transfer behavior, saved preferences, report formats, and in-progress UI. + +## Non-goals + +- Rewriting the copy, verification, report, or safety services. +- Replacing SwiftUI or introducing a third-party architecture framework. +- Claiming that software simulation reproduces a physical cable pull. +- Changing report file formats or invalidating saved preferences. +- Rewriting the README before implementation stabilizes. Documentation and screenshots will be corrected at the end. + +## Delivery Strategy + +The work will use staged consolidation. Each stage must build both schemes, pass all applicable tests, and leave the repository free of generated artifacts. + +1. Add CI, explicit test commands, and disposable fault-test infrastructure. +2. Remove confirmed dead code, simplify the macOS adapter, and tighten concurrency boundaries. +3. Implement the transfer-plan setup UI on macOS, then apply the same hierarchy to iPadOS. +4. Update documentation and screenshots to match the finished behavior. + +## Canonical Architecture + +`SharedAppCoordinator`, `CopyVerifyExecutor`, `ComparisonCoordinator`, `ReportCoordinator`, and the shared file-operation services remain the canonical operation path. `SafetyValidator` and the shared operation services remain authoritative even when the UI performs an earlier readiness check. + +The macOS `AppCoordinator` remains a compatibility adapter during this project. It may expose macOS-specific view models, but it must not maintain a competing operation state machine. Explicit Combine pipelines will replace delayed `objectWillChange` forwarding. State must flow in one direction: + +1. Selection and option controls update their existing models. +2. A start action creates the shared operation input. +3. The shared coordinator owns the operation lifecycle. +4. Explicit bindings project shared progress and results into macOS presentation state. + +Before deleting a legacy type, repository-wide reference checks and target builds must prove that neither app uses it. The initial candidates are the orphaned `OperationViewModel` operation stack, the unused `AppConfiguration`, and the unused `DependencyContainer` experiment. The implementation plan will list the exact files after dependency tracing. + +## Concurrency Boundaries + +The project will adopt stricter concurrency checking incrementally. This work will: + +- mark platform UI entry points with explicit actor isolation; +- remove synchronous main-queue hops where an actor-isolated or asynchronous contract can replace them; +- preserve cancellation and pause behavior across actor boundaries; +- avoid detached tasks unless the work must escape inherited actor context; +- fix new warnings in touched code before increasing the target-wide diagnostic level. + +The first pass will enable diagnostics that report problems without forcing an immediate Swift language-mode migration. Later stages may raise enforcement only when both targets build cleanly. + +## CI and Test Commands + +`test.sh` will expose explicit commands for supported build and test jobs. Command names and destinations must make the selected platform and test bundle clear. At minimum, local automation will support: + +- macOS Debug build; +- macOS unit tests; +- iPadOS simulator Debug build; +- iPadOS tests when a compatible simulator runtime is available; +- Release builds for both schemes. + +GitHub Actions will run on pushes and pull requests. It will build both schemes and run the macOS unit suite. iPadOS simulator tests will run only on a runner that provides the required runtime; the iPadOS build remains mandatory. + +CI must use isolated derived-data paths when jobs run concurrently. This prevents the Xcode build-database lock observed when two builds shared one directory. + +## Disposable Fault Testing + +Automated fault tests must operate only on temporary directories or newly created disposable disk images. Every script must validate its root before deleting, filling, unmounting, or corrupting data. + +The deterministic suite will cover: + +- insufficient destination space; +- source files that change or shrink during copying; +- cancellation followed by cleanup or resume; +- existing matching and conflicting destination files; +- unsafe nested, duplicate, symlinked, case-colliding, and Unicode-colliding paths; +- one failed destination in a multi-destination operation; +- checksum mismatch and report classification after a failed verification. + +Where APFS disk images provide useful control, the harness will create, size, mount, and dispose of them itself. Tests that can use ordinary temporary directories will remain unit or integration tests for speed. + +## Soak and Hardware Tests + +A local soak runner will generate representative camera-card trees containing large media files, sidecars, hidden files, empty directories, and nested folders. It will repeat transfers, independently recompute hashes, and emit a machine-readable summary. A seed will make failures reproducible. + +Physical disconnect testing remains a manual hardware procedure because CI cannot faithfully reproduce cable, hub, controller, and power failures. The repository will provide a concise checklist and a safe disposable-data setup for: + +- source removal during copy and verification; +- one destination disappearing while another remains mounted; +- sleep, backgrounding, cancellation, and relaunch; +- low-power or slow-device behavior; +- repeated operations across different file systems. + +## Transfer-Plan Presentation Model + +A small presentation model will summarize existing state for the setup UI. It may expose: + +- source name, analysis state, file count, and byte count; +- destination names, free-space state, and aggregate readiness; +- selected verification mode; +- camera-label summary; +- report summary; +- blocking issues and non-blocking warnings; +- primary-action title and enabled state. + +This model contains no copy, verification, path-resolution, or safety policy. It derives explanations from existing validators and view models. The shared core repeats all safety checks when an operation starts. + +## Setup-Screen UX + +The idle setup screen will retain BitMatch's dark palette, typography, navigation, and window footprint. It will replace the expanded configuration layout with a transfer-plan summary. + +The main hierarchy will be: + +1. Source card: selection action, folder name, analysis progress, file count, and size. +2. Backup card: destination selections and capacity status. +3. Preflight card: passed, analyzing, warning, or blocked state with specific reasons. +4. Compact option summary: verification, naming, and report settings. +5. Primary action: `Start verified copy` when checksum verification is active. + +Camera labeling, verification choices, and report settings will live in one Options surface. Quick mode and other material risks remain visible outside that surface. A disabled action must have an adjacent explanation. + +The UI will preserve drag and drop, keyboard shortcuts, recent destinations, mode navigation, and the compact in-progress interface. Touched controls will gain meaningful accessibility labels, predictable focus order, sufficient contrast, and reduced-motion behavior. + +iPadOS will use the same information hierarchy, adapted to its responsive layout. It will not copy the macOS arrangement pixel for pixel. + +## Error Handling + +The setup UI will distinguish these states: + +- incomplete selection; +- folder analysis in progress; +- preflight passed; +- non-blocking warning; +- blocking validation failure; +- operation-start failure after the final core validation. + +Messages must identify the affected source or destination and suggest a specific correction. The UI must never convert a core validation failure into success or suppress it because an earlier readiness check passed. + +Fault-test scripts must stop on unsafe roots, failed setup, or cleanup errors. They must retain diagnostic logs after a failed run. + +## Compatibility + +The implementation must preserve: + +- persisted verification and report preferences; +- destination-label settings; +- source and destination selection behavior; +- report schemas and output names unless an existing bug requires correction; +- resume and cancellation semantics; +- current verification defaults; +- macOS and iPadOS deployment targets. + +## Verification Gates + +Every stage must satisfy these gates before the next begins: + +- clean macOS Debug build; +- clean iPadOS simulator Debug build; +- all existing macOS unit tests pass; +- new tests for the stage pass; +- Release builds pass before final completion; +- no unexpected warnings introduced in touched files; +- `git status` contains no generated build, test, disk-image, or soak artifacts. + +The UI stage also requires visual checks at supported window sizes, keyboard navigation, reduced motion, and representative empty, analyzing, ready, warning, blocked, active, and completed states. + +## Completion Criteria + +The project is complete when: + +- contributors can reproduce supported builds and tests through documented commands; +- CI builds both app targets and runs the supported automated tests; +- disposable fault and soak tools produce reproducible results without touching arbitrary drives; +- the app uses one shared operation path without the confirmed legacy stack; +- touched concurrency boundaries build cleanly under the chosen diagnostics; +- macOS and iPadOS present a concise transfer plan before starting; +- current settings, reports, transfer behavior, and safety checks remain compatible; +- final documentation and screenshots describe the resulting product accurately. diff --git a/test.sh b/test.sh index efeffaa..c2551f3 100755 --- a/test.sh +++ b/test.sh @@ -1,16 +1,38 @@ #!/usr/bin/env bash set -euo pipefail -SCHEME=${1:-BitMatch} -DESTINATION=${DESTINATION:-platform=macOS} +ROOT=$(cd "$(dirname "$0")" && pwd) +JOB=${1:-mac-test} +DERIVED_DATA_ROOT=${DERIVED_DATA_ROOT:-"$ROOT/.derived-data"} -echo "Running unit tests for scheme: $SCHEME" -xcodebuild test \ - -project BitMatch.xcodeproj \ - -scheme "$SCHEME" \ - -destination "$DESTINATION" \ - CODE_SIGNING_ALLOWED=NO \ - -only-testing:BitMatchTests +run_xcodebuild() { + local name=$1 + shift + xcodebuild -project "$ROOT/BitMatch.xcodeproj" \ + -derivedDataPath "$DERIVED_DATA_ROOT/$name" \ + CODE_SIGNING_ALLOWED=NO "$@" +} -echo -echo "Done." +case "$JOB" in + mac-test) + run_xcodebuild mac-test test -scheme BitMatch -destination 'platform=macOS' -only-testing:BitMatchTests + ;; + mac-build) + run_xcodebuild mac-build build -scheme BitMatch -configuration Debug -destination 'platform=macOS' + ;; + ipad-build) + run_xcodebuild ipad-build build -scheme BitMatch-iPad -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' + ;; + ipad-test) + : "${IOS_SIMULATOR_DESTINATION:?Set IOS_SIMULATOR_DESTINATION, for example platform=iOS Simulator,name=iPad (A16)}" + run_xcodebuild ipad-test test -scheme BitMatch-iPad -destination "$IOS_SIMULATOR_DESTINATION" -only-testing:BitMatch-iPadTests + ;; + release-builds) + run_xcodebuild mac-release build -scheme BitMatch -configuration Release -destination 'platform=macOS' + run_xcodebuild ipad-release build -scheme BitMatch-iPad -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' + ;; + *) + echo "Usage: $0 {mac-test|mac-build|ipad-build|ipad-test|release-builds}" >&2 + exit 64 + ;; +esac