The working to-do list is docs/TODO.md.
Read docs/THESIS.md for what BitMatch promises and the current plan, and ARCHITECTURE.md for how the code is laid out. AGENTS.md has the platform rules every change follows. This guide covers building, testing, debugging and releasing.
- Release validation uses local Xcode 27.1 (27A9275), Swift 6.4. Record the exact candidate and results in
docs/validation/RELEASE_<version>.md. OpenBitMatch.xcodeproj. - GitHub Actions CI is intentionally disabled; validation runs locally. The older Xcode 26.3 jobs failed before tests, and an abandoned Xcode 26.6 trial did not establish compatibility. Neither changes the app's macOS 15.5 minimum.
- Schemes:
BitMatch: the macOS app (macOS 15.5 or newer).BitMatch-iPad: the iPhone and iPad app (iOS/iPadOS 18.5 or newer). Despite the name it targets both device families.
- Both apps compile everything under
Shared/and depend on the local packagePackages/BitMatchEngine. Everything builds in Swift 6 language mode; keep it warning-free.bash test.sh engine-testruns the engine's own tests in seconds. - For an iPhone or iPad device build, set your development team in Signing & Capabilities.
- The engine and its rules live in
Packages/BitMatchEngine: copy, verify, safety, readiness (TransferReadiness), what may be chosen (DestinationSelectionPolicy,BackupTargetPolicy), the verdict (ResultOutcome,TransferCompletion), evidence and the journal. The one state owner,SharedAppCoordinator, and the presentation models live inShared/Core/. - Screens live in
Shared/Views/. Each draws a pure presentation value (*PresentationinShared/Core/Models/) and decides nothing itself. Put a new rule in the presentation or model, with a test, not in a view. - Platform code stays in
BitMatch/(Mac) orBitMatch-iPad/andPlatforms/iOS/(iPhone and iPad), and should be limited to what only that platform can do: pickers, drag and drop, panels and share sheets, Disk Arbitration and volume monitoring, SFTP (Mac only). Shared screens take these as slots or closures (SetupLocationsPlatform,MasterReportPlatform, theCoordinatorSetupScreenslots). - Files in
Shared/must compile on both platforms. Wrap AppKit or UIKit use in#if os(...).
Safety checks belong in the shared engine, not only in views. Readiness on Setup explains a problem early, but TransferPipeline and SafetyValidator must still refuse an unsafe source or backup when called directly, and BackupTargetPolicy must guard every path that adds a backup.
Required behavior (see Packages/BitMatchEngine/Sources/BitMatchEngine/File/SafetyValidator.swift, DestinationWriter.swift, BackupTargetPolicy.swift, and Packages/BitMatchEngine/Sources/BitMatchEngine/TransferPipeline.swift):
- never write to the source; never overwrite an existing destination file
- never replace an existing destination name; the hard-link path publishes a synced temporary file, while the exFAT/FAT path durably marks the attempt incomplete before exclusively creating the final name
- keep interrupted or failed output incomplete; only independent readback and the final source, destination and root checks can advance it to verified status
- refuse output roots that collide with the source, each other, symlinks, files or nested folders
- include hidden files and empty folders; apply only the recorded AppleDouble exclusion policy and skip symlink entries
- use uncached checksums for live verification and evidence
- write result status only through
ResultOutcome; a Quick copy is never "verified"
Platform views present the selection and readiness. Start (the button and ⌘R) calls SharedAppCoordinator.startCurrentMode(), which builds a CopyVerifyConfig for CopyVerifyExecutor. The executor owns timing, error tracking, result coalescing, the sleep assertion, ASC MHL and report handoff, and the verdict. It calls TransferPipeline, which runs the real preflight and transfer. Do not add a platform-only transfer path, and do not rely on UI validation as the safety boundary.
Live progress and per-file rows reach views through LiveProgressFeed and LiveResultsFeed, not through the coordinator's objectWillChange. A view that draws live progress or rows observes the feed directly; do not republish per-tick values on SharedAppCoordinator.
Run the named jobs from the repository root:
bash test.sh engine-test # shared engine tests
bash test.sh mac-test # BitMatchTests on macOS
bash test.sh mac-build # macOS Debug build
bash test.sh ipad-build # iOS simulator Debug build (the shared-code gate)
IOS_SIMULATOR_DESTINATION='platform=iOS Simulator,name=iPad (A16)' bash test.sh ipad-test
bash test.sh release-builds # macOS and iOS simulator Release buildsipad-test refuses to guess a simulator; set IOS_SIMULATOR_DESTINATION to one installed on your machine. Set DERIVED_DATA_ROOT to choose a build folder; the default temporary location is described below.
Ordinary mac-test, ipad-test and engine-test jobs clear inherited BITMATCH_* and TEST_RUNNER_BITMATCH_* probe controls, so an old shell setting cannot start an opt-in hardware or stress workload. They retain workflow snapshot capture and the PDF, preview, matrix and snapshot output directories. Use the dedicated probe runners or their documented direct commands for deliberate stress runs; the ordinary jobs do not count those skipped probes as validation.
The Mac and iOS test jobs request serial execution with -parallel-testing-enabled NO; the engine uses --no-parallel. This reduces contention between independent tests that share the main actor or use real temporary files. Tests still exercise their own concurrent copy, verification and cancellation work. Keep assertion deadlines unchanged, and record failed runs separately from passing reruns.
App build/test jobs use a stable per-checkout build folder under /private/tmp/bitmatch-local-tests-<uid>/ by default. This keeps the Mac test host and Sparkle outside Desktop, where an Xcode 27.0 loader stall was observed. DERIVED_DATA_ROOT=/your/chosen/folder bash test.sh mac-test still overrides the location; SwiftPM engine jobs keep their existing package build folder. Retain logs and test results before removing a closed build cache. This changes no signing, sandbox or privacy settings, and actual external-drive access can still require authorization. A loader stall is not a test pass; retain it separately from a successful rerun.
GitHub Actions CI was disabled on October 20, 2026. The workflow file is retained, but automatic jobs are not part of the project’s validation or release process. Run the required checks locally before submitting changes; record any host or toolchain failures separately from passing tests. Do not repair or re-enable GitHub CI unless Mike asks.
The offline Python regression suite runs with python3 -m unittest discover -s Scripts -p 'test_*.py'. Choose an interpreter with descriptor-relative filesystem APIs for the cleanup tests; the local Python 3.12 run passes, while a Python 3.9 build here lacks dir_fd support. Keep that guard rather than weakening ownership-checked cleanup to accommodate an unsupported interpreter.
- Read the shared path before changing copy, verification, preflight, state, readiness or report behavior.
- Add or update a focused test in
BitMatchTests(orBitMatch-iPadTestsfor iOS-only behavior). Tests that guard a rule carry a Plant: comment: a one-line production change that must make them fail. Try it, see the test fail, revert. - Run
bash test.sh mac-test. - Run
bash test.sh ipad-buildafter every change to shared code or a presentation model. - For UI changes, check an iPhone width (under 600 pt), an iPad split view (600–959 pt) and a Mac window from its 580 pt minimum to wide. Say which checks ran in a simulator and which on a device.
- Run
bash test.sh release-buildsbefore release work. - Automated tests are not proof of physical-media safety. Use the scripts below and
docs/HARDWARE_TESTING.md, with throwaway data, when a change can affect transfer reliability.
BitMatchTests/TestHelpers/FakeFileSystemService.swift: the one fakeFileSystemService.DisposableTransferFixtureandTestFixtures: real temporary folders.CameraCardLayouts: folder trees for camera cards.SharedCoordinatorTestSupport: builds aSharedAppCoordinatorfor tests.WorkflowSnapshotTests(both test targets) render the real screens with seeded data. They are skipped unlessBITMATCH_CAPTURE_WORKFLOW_SNAPSHOTS=1is set.
xcodebuild test -scheme BitMatch -enableCodeCoverage YES -resultBundlePath coverage.xcresult
xcrun xccov view --report coverage.xcresult
xcrun xccov view --report --json coverage.xcresultScripts/run_apfs_fault_tests.shcreates and removes a marked, disposable APFS image, then checks the case in which one destination becomes inaccessible while another succeeds.Scripts/run_soak_tests.shruns repeatable transfer stress. SetBITMATCH_SOAK_SEEDandBITMATCH_SOAK_ITERATIONSto reproduce a failure; the defaults are20260711and25. It prints validated JSON before removing its marked temporary folder.Scripts/ascmhl/validate_reference.shchecks generated ASC MHL against the official reference tooling.python Scripts/ascmhl/validate_corpus.py /path/to/pinned/ascmitc/mhlchecks the permanent external-tool corpus on Linux or macOS without Swift. Use Python 3.11+ with the pinned reference checkout installed; see the fixture README. This does not run the Swift reader or validate newly generated BitMatch output.- The large-file app-host memory probe has an explicit marked-fixture wrapper. Run its 64 MiB smoke workflow before the separate 119.6 GB workload; a smoke pass does not validate the larger run.
- For cable removal, sleep, low-power hubs, exFAT and other physical-media cases, follow docs/HARDWARE_TESTING.md and record results in docs/HARDWARE_COMPATIBILITY.md. The automated APFS and exFAT disk-image tests do not prove that real hardware faults are safe.
Debug builds of the Mac app have a Developer menu (BitMatch/App/BitMatchApp.swift, #if DEBUG):
- Open Interface Lab (⌥⌘L): launches a second app instance with
--interface-lab, a no-I/O visual harness with its own sample screens (BitMatch/Views/InterfaceLab/). - Enable/Disable Dev Mode (⌥⌘D). The items below need Dev Mode on.
- Fill Test Data (⌥⌘T): fills in a sample source, backups and detected camera.
- Stress Test (Small / Medium / Large): runs a real copy between generated folders in the temp directory.
- Verbose Dev Logs.
- Clear All Data: resets the last operation's results and state.
DevModeManager (BitMatch/Core/Services/DevModeManager.swift) implements them. None of this exists in Release builds. iPhone and iPad have no debug menu.
- Folder picking on iOS does nothing.
IOSFileSystemServiceretains its document-picker delegate (currentDelegate) until the picker finishes; check that it is still retained and that the picker is presented from a valid view controller. Look for "SWIFT TASK CONTINUATION MISUSE" in the console. - File access denied on iOS. Every scan, size check and copy must hold security-scoped access, released with
defer. SeeIOSFileSystemServiceandSharedAppCoordinator.executeOperation. - A backup is refused on a device but not in the simulator. Simulator paths sit under the Mac's
/Users; device Files locations sit under/private/var/mobile.SafetyValidator.isProtectedSystemPathtreats that as user storage on iOS only (BitMatch-iPadTests/IOSStoragePathTests.swift). - A view does not update. State that drives UI should be
@PublishedonSharedAppCoordinatoror a model it forwards, except live progress and rows, which views observe onLiveProgressFeed,LiveResultsFeedorProgressPresentationModeldirectly. - Concurrency. Do heavy file-system enumeration off the main actor and marshal results back. Do not iterate
NSDirectoryEnumeratorwithfor ininside async code; usewhile let url = enumerator.nextObject() as? URL. Do not call actor-isolated methods from nonisolated initializers. - Logging. Use
SharedLogger.AppLogger(Mac) forwards to it.
Prerequisites: a Developer ID Application certificate for the release team, a notarytool profile named bitmatch-notary (override with NOTARY_PROFILE), and a Sparkle EdDSA key in the login Keychain.
Release in this order:
- Raise
MARKETING_VERSIONandCURRENT_PROJECT_VERSIONin the project, and add the version toCHANGELOG.md. Sparkle decides "newer" by the build number, so an unchanged build number means nobody gets the update. Scripts/release_mac.sh <version>: signed, notarized DMG indist/. It re-signs Sparkle's helpers, which notarization requires.- Smoke-test the signed app in
dist/BitMatch-<version>.xcarchiveon real or sample drives. - Tag
v<version>and publish the GitHub release as Latest with the DMG and its.sha256. Scripts/bump_cask.sh <version>for Homebrew.Scripts/publish_appcast.sh <version>, so installed copies see the update.
xcrun notarytool store-credentials bitmatch-notary \
--apple-id "APPLE_ID_EMAIL" \
--team-id "AUJW7AGG26" \
--password "APP_SPECIFIC_PASSWORD"
Scripts/release_mac.sh 0.2.11The script builds, signs, notarizes, staples and checksums dist/BitMatch-<version>.dmg with a matching .sha256. SKIP_NOTARIZE=1 Scripts/release_mac.sh <version> checks signing and DMG creation without submitting to Apple. iPhone and iPad are build-from-source for now.
Every update must be signed with the same private key, or installed copies refuse it. It lives in exactly two places, and neither is this repo:
- The release Mac's login Keychain: a generic password with account
bitmatch(servicehttps://sparkle-project.org).Scripts/publish_appcast.shsigns with it from there; nothing needs to be found by hand. - The backup: an entry named "BitMatch Sparkle private key" in Apple's Passwords app. It syncs end-to-end encrypted to the maintainer's other Apple devices through iCloud Keychain.
The matching public key is SUPublicEDKey in Config/BitMatch-Info.plist. Never change it; doing so cuts off updates to every existing install.
To restore on a new Mac, copy the backup into a temporary file, run generate_keys --account bitmatch -f <file> from Sparkle's resolved package artifacts, then delete the file. To make a fresh backup export, run generate_keys --account bitmatch -x <file>. BitMatch uses its own Keychain account, bitmatch, separate from any other app's Sparkle key. Never commit an exported key.
Before tagging, run the signed app from dist/BitMatch-<version>.xcarchive against real or disk-image volumes: allow drive access, copy a card through verification, ASC MHL and reports, inspect its saved result, and relaunch. Check cancellation and any changed controls. A two-backup safe-to-erase walkthrough needs two independently identified destinations; two folders on one SSD do not qualify. Record unavailable hardware, skipped steps and earlier-revision results explicitly. Debug builds and tests do not exercise the sandboxed release paths.
After publishing the GitHub release, run Scripts/bump_cask.sh <version> for Homebrew, then publish the signed appcast entry last:
Scripts/publish_appcast.sh 0.2.11The script reads the archived app's build, short version and minimum macOS version, signs the matching DMG with Sparkle's sign_update and the bitmatch key in the login Keychain, updates appcast.xml in BitmatchApp/bitmatchapp.github.io, then commits and pushes the newest entry. Missing or incompatible minimum-version metadata stops the script before signing or network access. It uses the resolved Sparkle package's tool when available and downloads the matching Sparkle release tools otherwise. It never exports the private key.
Run python3 Scripts/test_appcast_minimum.py for offline metadata and script checks. These use disposable fixtures and stub every signing, GitHub, Git and Xcode command; they do not publish an update or change the app's deployment target.
Scripts/bump_cask.sh <version> checks the published DMG against the local checksum, updates Casks/bitmatch.rb in BitmatchApp/homebrew-tap, pushes it, and runs brew audit. brew upgrade bitmatch sees the release from then on.
- Keep shared vs platform boundaries: no AppKit or UIKit in shared files outside
#if os(...). - Main-actor correctness for anything that touches UI; progress callbacks reach the coordinator on the main actor.
- New copy or verify behavior has tests for overwrite, source change, hidden files, symlinks and reporting.
- Use
BitMatchErrororFileOperationErrorfor user-facing errors, with a message that says what to do. - No naked TODOs in committed code: write "Future enhancement:" with intent, or file an issue.
- Cancel does not delete copied files. Cancel-time cleanup stays disabled until the pipeline has more field validation.
- Do not replace screenshots with mocks, stale simulator images, or views that show personal paths or media.