Skip to content

Where: import/export backup (whole-database .zip)#11

Merged
kyleve merged 10 commits into
mainfrom
where-backup-import-export
Jun 7, 2026
Merged

Where: import/export backup (whole-database .zip)#11
kyleve merged 10 commits into
mainfrom
where-backup-import-export

Conversation

@kyleve

@kyleve kyleve commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a whole-database backup feature to the Where app, surfaced from a new Backup section in the Settings tab.

  • Export the entire SwiftData database (location samples, evidence metadata, manual days) plus evidence asset blobs into a single deflate-compressed .zip, then share it via the system share sheet (email / AirDrop / Files).
  • Import a backup .zip, choosing Merge (upsert into existing data) or Replace all (wipe first, then restore) at import time.
  • Plain (unencrypted) archive, protected by the transport.

Archive format

manifest.json         // versioned BackupArchive: samples + evidence + manual days + asset index
assets/<evidence-id>  // one file per evidence blob

Layering (UI never touches SwiftData)

  • WhereCore: extend WhereStore/SwiftDataStore with allEvidence(), allManualDays(), clearAll(); make DayPresence Codable; add BackupArchive + BackupService (zip codec); add WhereController.exportBackup() / importBackup(from:strategy:) (single perform transaction, security-scoped URL bracketing).
  • WhereUI: WhereModel bridges export/import with a BackupState + backupError; SettingsView adds Export (share sheet + temp cleanup) and Import (fileImporter -> merge/replace confirmationDialog -> success/error alert), both showing progress while in flight. New settings.backup.* string-catalog entries.

Dependency

  • Adds ZIPFoundation 0.9.20 to the WhereCore target (Foundation has no public zip reader). Committed as standalone groundwork, matching the existing swift-snapshot-testing external-SPM pattern.

Notes

  • Release builds are CloudKit-synced, so imports propagate to iCloud like any other store write.
  • Format is versioned (formatVersion) so future schema changes can refuse/migrate.

Test plan

  • tuist test WhereCoreTests — manifest + zip round-trip, non-zip rejection (BackupServiceTests); controller merge/replace round-trips, merge keeps pre-existing rows, replace wipes them, clearAll empties all tables (WhereControllerTests).
  • tuist test WhereUITests — model two-store round-trip + bogus-file failure (WhereModelBackupTests); backup string resolution + 3-arg import-summary ordering (StringsTests); SettingsView hosts the new section crash-free (ScreenHostingTests).
  • ./swiftformat --lint clean.
  • Manual: export -> email/save to Files; import with Merge and with Replace on a device.

Made with Cursor

kyleve and others added 7 commits June 5, 2026 13:56
Backup groundwork: extend WhereStore with allEvidence()/allManualDays()
(predicate-less reads) and clearAll() (full wipe for the replace import
strategy), implement them in SwiftDataStore, make DayPresence Codable so
it can ride in the backup manifest, and keep the ToggleFailingStore test
double conforming.

Closes plan step: store-readers.

Co-authored-by: Cursor <cursoragent@cursor.com>
Backup export/import needs a real .zip reader/writer; Foundation has no
public unzip API. Pin ZIPFoundation 0.9.20 (same external-SPM pattern as
swift-snapshot-testing) and link it into the WhereCore target. Unused
until the BackupService step; committed on its own as groundwork.

Closes plan step: package-dep.

Co-authored-by: Cursor <cursoragent@cursor.com>
Define the versioned, Codable BackupArchive (samples + evidence + manual
days + an asset index) and BackupService, which marshals a whole-database
backup to a deflate-compressed .zip (manifest.json + assets/<evidence-id>)
and reads one back into value types + blob bytes. Covered by round-trip
tests for the manifest JSON, the full zip, and a non-zip rejection.

Closes plan step: backup-types.

Co-authored-by: Cursor <cursoragent@cursor.com>
Expose the whole-database backup entry points: exportBackup() reads all
three tables plus evidence blobs and hands them to BackupService;
importBackup(from:strategy:) reads an archive (bracketing the
security-scoped document-picker URL) and writes it back in one perform
transaction, with .replace clearing the store first and .merge relying on
upsert semantics. ImportSummary reports the row counts. Covered by
merge/replace round-trip tests and a clearAll table-wipe test.

Closes plan step: controller-api.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add exportBackup()/importBackup(from:strategy:) plus a BackupState
(idle/exporting/importing) and a backupError channel so the Settings UI
can show progress, present the share sheet, and surface failures. Keep
the TestStore double conforming to the extended WhereStore. Covered by a
two-store round-trip and a bogus-file failure test.

Closes plan step: model-api.

Co-authored-by: Cursor <cursoragent@cursor.com>
New Backup section above the erase action: Export builds the .zip and
presents it in a UIActivityViewController share sheet (email / AirDrop /
Files), cleaning up the temp file on dismiss; Import opens a .zip via
fileImporter, then a merge/replace confirmation dialog, and reports a
success summary or error alert. Both rows show a spinner and disable
while work is in flight. Adds the settings.backup.* string catalog
entries and Strings accessors.

Closes plan step: settings-ui.

Co-authored-by: Cursor <cursoragent@cursor.com>
Round out the backup test coverage (BackupService, controller merge/
replace round-trips, clearAll, and the WhereModel bridge were added with
their respective steps): assert the new settings.backup.* catalog keys
resolve, and that the imported-summary message substitutes sample /
evidence / manual-day counts in the right order. Settings hosting stays
covered by the existing settingsViewHosts() test, which now builds the
new Backup section.

Closes plan step: tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread Where/WhereCore/Sources/WhereController.swift
@kyleve

kyleve commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@kyleve

kyleve commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

@cursoragent review

@cursor

cursor Bot commented Jun 7, 2026

Copy link
Copy Markdown

Addressed the review comment proactively.

  • WhereController.exportBackup() now snapshots store data on the controller actor, then runs ZIP creation in Task.detached(priority: .utility).
  • importBackup(from:strategy:) now also runs unzip/decode in a detached utility task before returning to the store transaction.
  • This makes the answer to the GitHub question explicit: backup zip work does not run on MainActor, and it no longer ties up the WhereController actor while compression/decompression is happening.

Committed and pushed:

  • e116210 Run backup zip work off controller actor

Verification:

  • Attempted ./swiftformat --lint and mise exec -- tuist test WhereCoreTests
  • Both are blocked in this Linux cloud environment because mise is not installed/on PATH, and no Swift/Tuist toolchain is available. The branch is clean and pushed.
Open in Web Open in Cursor 

Co-authored-by: Kyle Van Essen <k@kve.me>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3560d9619

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


private static func makeEncoder() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve sub-second timestamps in backups

When a user exports real GPS/evidence data whose Date values include fractional seconds, JSONEncoder.DateEncodingStrategy.iso8601 serializes only whole seconds, so importing the backup changes LocationSample.timestamp and Evidence.capturedAt instead of restoring rows exactly. This can collapse ordering for samples captured within the same second and makes the advertised row-for-row backup lossy; use a fractional-seconds or numeric date representation for the archive.

Useful? React with 👍 / 👎.

Comment on lines +159 to +163
guard let data = try? Data(contentsOf: assetURL) else {
Self.logger.fault(
"Backup asset missing for evidence \(entry.evidenceId, privacy: .public); skipping blob",
)
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail imports when listed evidence blobs are missing

When a backup is damaged or incomplete and the manifest lists an evidence asset that is absent or unreadable, this path logs the fault and continues, so the import later reports success while restoring the evidence metadata without its proof bytes. In the .replace flow that means the existing store is cleared and the user silently loses attachments from the backup; a missing manifest-listed asset should make readArchive throw before any mutation happens.

Useful? React with 👍 / 👎.

}
}

private func runImport(url: URL, strategy: WhereController.ImportStrategy) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be async or something so we can show the progress?

/// Bridges the model's optional `backupError` to the Bool an `.alert`
/// presentation needs, clearing it when the alert is dismissed.
private var backupErrorBinding: Binding<Bool> {
Binding(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be using get/set closure-based bindings. Please change this, AND update AGENTS.md so we stop adding these.


/// Thin `UIActivityViewController` bridge so the exported `.zip` can be
/// emailed, AirDropped, or saved to Files via the system share sheet.
private struct ShareSheet: UIViewControllerRepresentable {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this? I thought SwiftUI had a native share sheet? Or does it not for zips?

kyleve and others added 2 commits June 7, 2026 19:14
Addresses PR #11 review feedback:

- Export now uses a native `ShareLink` driven by a lazy `BackupArchiveFile`
  (`Transferable`) instead of the `UIActivityViewController` bridge, so the
  system shows its own export progress. The previous temp archive is cleaned
  up on the next export (ShareLink has no dismissal hook).
- Import shows determinate per-row progress: `WhereController.importBackup`
  takes an `@Sendable` progress callback (throttled to whole-percent steps),
  surfaced through an `AsyncStream` into `WhereModel.backupProgress` and a
  `ProgressView(value:)`.
- Replace the `backupErrorBinding` and `trackingBinding` closure `Binding`s
  with `WhereModel.isShowingBackupError` / `trackingEnabled` computed
  properties, bound directly; add an AGENTS.md convention banning
  `Binding(get:set:)` in views.
- Strings: drop the now-unused `settings.backup.exporting`, add
  `settings.backup.shareTitle` for the share preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
…xport

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	Where/WhereCore/Sources/WhereController.swift
#	Where/WhereCore/Tests/WhereControllerTests.swift
@kyleve kyleve merged commit 6aa120d into main Jun 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants