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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions BitMatch-iPadTests/WorkflowSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ final class WorkflowSnapshotTests: XCTestCase {
name: "ipad-comparison-differences"
)

try capture(
ContentView(coordinator: fixture.coordinator),
size: CGSize(width: 393, height: 852),
name: "iphone-comparison-differences"
)

try fixture.seedCompletion()
await settle()
try capture(
Expand Down
48 changes: 24 additions & 24 deletions BitMatch.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@
CODE_SIGN_ENTITLEMENTS = BitMatch/BitMatch.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
Expand All @@ -654,7 +654,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatch;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand All @@ -672,7 +672,7 @@
CODE_SIGN_ENTITLEMENTS = BitMatch/BitMatch.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
Expand All @@ -683,7 +683,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatch;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand All @@ -698,11 +698,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand All @@ -716,11 +716,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand All @@ -733,10 +733,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand All @@ -749,10 +749,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand All @@ -767,7 +767,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand All @@ -784,7 +784,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPad";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -802,7 +802,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand All @@ -819,7 +819,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPad";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -837,11 +837,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadTests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -858,11 +858,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadTests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -879,11 +879,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadUITests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -899,11 +899,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
MARKETING_VERSION = 0.2.4;
MARKETING_VERSION = 0.2.5;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadUITests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down
3 changes: 2 additions & 1 deletion BitMatch/Core/Services/CardEjectService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ enum CardEjectService {

/// Nil on success; a plain, displayable message on failure.
static func eject(_ url: URL) async -> String? {
await Task.detached(priority: .userInitiated) {
guard isEjectable(url) else { return "This folder is not on an ejectable drive. There is nothing to eject." }
return await Task.detached(priority: .userInitiated) {
do {
// The source may be a folder on the card (DCIM); eject
// needs the volume itself.
Expand Down
6 changes: 6 additions & 0 deletions BitMatchTests/CardEjectionPresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ struct CardEjectionPresentationTests {
)
}

@Test func internalStorageCannotBeEjected() async {
let folder = FileManager.default.temporaryDirectory
#expect(!CardEjectService.isEjectable(folder))
#expect(await CardEjectService.eject(folder) == "This folder is not on an ejectable drive. There is nothing to eject.")
}

// MARK: shouldAutoEject: verified only

@Test func onlyAFullyVerifiedResultAutoEjects() {
Expand Down
29 changes: 28 additions & 1 deletion BitMatchTests/LocalTransferQueueIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,31 @@ final class LocalTransferQueueIntegrationTests: XCTestCase {
XCTAssertThrowsError(try coordinator.moveQueuedTransfer(id: firstID, to: 0))
}

/// Plant: remove the ejectability guard from queuePresentation.
func testInternalSourceDoesNotOfferOrAttemptBulkEjection() async throws {
let f = try QueueFixture()
defer { f.cleanup() }
let journal = LocalTransferJournal(fileURL: f.journalURL)
let coordinator = SharedAppCoordinator(
platformManager: QueuePlatformManager(fileOperations: QueueRecordingOperations()),
transferJournal: journal, defaults: f.defaults)
let id = try coordinator.enqueue(source: f.source, destinations: [f.destination])
try journal.markRunning(id: id)
try journal.finish(id: id, results: [ResultRow(
path: "clip.mov", status: ResultOutcome.verified.statusText,
size: 4, checksum: "abc", destination: "backup",
destinationPath: f.destination.appendingPathComponent("clip.mov").path
)], summary: "Verified", hadIssues: false)
XCTAssertFalse(CardEjectService.isEjectable(f.source))
XCTAssertTrue(coordinator.queuePresentation.ejectableCardIDs.isEmpty)
XCTAssertNotEqual(coordinator.queuePresentation.rows.first?.action, .eject)
let recorder = EjectRecorder()
let error = await coordinator.ejectAllSafeQueueSources { await recorder.record($0) }
XCTAssertNil(error)
let attempted = await recorder.urls
XCTAssertTrue(attempted.isEmpty)
}

func testEjectAllSafeCardsSkipsUnsafeRowsContinuesAndReportsFailures() async throws {
let f = try QueueFixture()
defer { f.cleanup() }
Expand All @@ -1280,7 +1305,9 @@ final class LocalTransferQueueIntegrationTests: XCTestCase {
let coordinator = SharedAppCoordinator(
platformManager: QueuePlatformManager(fileOperations: QueueRecordingOperations()),
transferJournal: journal,
defaults: f.defaults
defaults: f.defaults,
// Simulate removable cards; the fixture folders themselves are internal.
sourceEjectability: { _ in true }
)
let firstID = try coordinator.enqueue(source: f.source, destinations: [f.destination])
let secondID = try coordinator.enqueue(source: second, destinations: [f.destination])
Expand Down
20 changes: 19 additions & 1 deletion BitMatchTests/SharedCompareFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,25 @@ struct SharedCompareFlowTests {
let coordinator = await MainActor.run {
SharedAppCoordinator(platformManager: platform, defaults: .isolatedWorkflowDefaults())
}
checksum.onVerify = { await coordinator.cancelOperation() }
checksum.onVerify = {
await MainActor.run {
let run = coordinator.stateService.currentOperationId
coordinator.cancelOperation()
// Plant: log only activeStartID in cancelOperation. Compare has no
// copy start ID, so its user-cancel event would lose this run.
do {
let data = try TransferDiagnosticStore.shared.exportData()
let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let events = try #require(root["events"] as? [[String: Any]])
let id = try #require(run)
#expect(events.contains {
$0["run"] as? String == id.uuidString
&& $0["event"] as? String == "cancelOrigin"
&& $0["origin"] as? String == "user"
})
} catch { Issue.record(error) }
}
}
await MainActor.run {
coordinator.currentMode = .compareFolders
coordinator.verificationMode = .standard
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [0.2.5] - 2026-10-05

A reliability update for checking backups and investigating transfer problems.

- Fixed Check closing the app when a saved MHL or BitMatch report contains more than one file.
- Check now explains which folder has missing or extra files. An extra file alone does not mean the copied files are damaged.
- Diagnostics include comparison progress, outcomes and cancellation, without footage names or paths.
- Existing hidden macOS metadata conflicts now give a clearer explanation. Source files and existing backups remain protected from overwrite.
- Eject is no longer offered for folders on internal storage.

Tested locally with signed-app transfers, existing backups, cancellation, MHL and reports across HFS+, exFAT and APFS. The saved-checksum crash is confirmed and fixed; the exact cause of the Sony transfer error in issue #8 remains unconfirmed. If a transfer or check still fails, use **History → Export diagnostics** and include the exact error. Diagnostics exclude footage names and paths.

## [0.2.4] - 2026-10-04

A reliability update for large-file verification.
Expand Down
2 changes: 2 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ bash test.sh release-builds # macOS and iOS simulator Release builds

`ipad-test` refuses to guess a simulator; set `IOS_SIMULATOR_DESTINATION` to one installed on your machine. Set `DERIVED_DATA_ROOT` to reuse a build folder (default `.derived-data/`).

If the Mac test host stalls in `dyld` while opening Sparkle from a checkout under Desktop, try a build folder outside Desktop: `DERIVED_DATA_ROOT=/private/tmp/bitmatch-local-tests bash test.sh mac-test`. On Xcode 27.0 this allowed tests to execute without changing signing, sandbox or privacy settings. A loader stall is not a test pass; keep its log separate from the successful rerun.

`.github/workflows/ci.yml` defines Mac tests, the iPad/iPhone build and engine tests on push and pull request. Run the required checks locally before submitting changes; record any host or toolchain failures separately from passing tests.

### Change workflow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,15 @@ public final class DestinationWriter {


#if canImport(Darwin)
private static func reuseConflict(_ reason: String, relativePath: String) -> NSError {
guard (relativePath as NSString).lastPathComponent.hasPrefix("._") else {
return existingDestinationConflictError(reason)
}
return existingDestinationConflictError(
"A hidden companion file on the backup differs from the source. macOS can create or update these files on exFAT drives. BitMatch is refusing to overwrite it. Nothing was overwritten. Keep the source intact and export the results before retrying."
)
}

private static func validateReusableExistingDestinationFile(
source: URL,
destination: PinnedDestinationFile,
Expand All @@ -1074,7 +1083,7 @@ public final class DestinationWriter {
) async throws {
let destinationInfo = try destination.snapshot()
guard Int64(destinationInfo.st_size) == sourceSize else {
throw existingDestinationConflictError("Existing destination file differs in size")
throw reuseConflict("Existing destination file differs in size", relativePath: relativePath)
}

if verificationMode == .quick {
Expand All @@ -1093,7 +1102,7 @@ public final class DestinationWriter {
relativePath: relativePath,
hooks: hooks
) else {
throw existingDestinationConflictError("Existing destination file bytes differ; refusing to overwrite it")
throw reuseConflict("Existing destination file bytes differ; refusing to overwrite it", relativePath: relativePath)
}
}

Expand All @@ -1106,7 +1115,7 @@ public final class DestinationWriter {
relativePath: relativePath,
hooks: hooks
) else {
throw existingDestinationConflictError("Existing destination file checksum differs; refusing to overwrite it")
throw reuseConflict("Existing destination file checksum differs; refusing to overwrite it", relativePath: relativePath)
}

}
Expand Down
Loading
Loading