From 0a1a692dcbb97e5c47f605c751ec16648f364d7a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 16:09:24 -0700 Subject: [PATCH 1/3] Export backup on a background task with in-app progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building the backup archive was driven by a ShareLink over a lazily-built Transferable, so tapping Export presented the share sheet immediately and left it in the system's blocking 'Preparing…' state while the (already off-main) encode + zip ran — reading as a freeze on a large database. Build the archive up-front on a background task instead, streaming progress to an in-app 'Exporting…' bar (mirroring import), then reveal a ShareLink to the ready file so the share sheet opens instantly. - BackupCoordinator.exportBackup gains an onProgress callback: the evidence-blob load drives the determinate leg (0…0.8, whole-percent throttled like import), then jumps to 1 once the opaque encode+zip writes the archive. - BackupModel.exportBackup marshals that progress onto backupProgress via the same AsyncStream + @MainActor observer pattern as importBackup. - SettingsView replaces the lazy ShareLink with an Export button (showing the progress label while exporting) plus a revealed 'Share backup' ShareLink over the ready URL; importProgressLabel is generalized to backupProgressLabel. - Remove the now-unused BackupArchiveFile; add exporting/share strings. Covered by BackupCoordinatorTests.exportReportsProgressUpToCompletion. Co-authored-by: Cursor --- .../Sources/Backup/BackupCoordinator.swift | 29 ++++++++- .../Tests/BackupCoordinatorTests.swift | 16 +++++ .../Sources/Resources/Localizable.xcstrings | 22 +++++++ .../Sources/Settings/BackupArchiveFile.swift | 19 ------ .../Sources/Settings/BackupModel.swift | 28 ++++++-- .../Sources/Settings/SettingsView.swift | 65 +++++++++++++------ Where/WhereUI/Sources/Shared/Strings.swift | 10 +++ 7 files changed, 143 insertions(+), 46 deletions(-) delete mode 100644 Where/WhereUI/Sources/Settings/BackupArchiveFile.swift diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index da852a6d..ec7bca80 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -74,11 +74,28 @@ public actor BackupCoordinator { self.onImport = onImport } + /// Fraction of the export the evidence-blob load accounts for. The load is + /// the only per-item loop we can subdivide, so it drives the determinate + /// leg; the opaque encode + zip that follows has no sub-progress, so we hold + /// here and jump to `1` once the archive file exists. + private static let exportBlobLoadFraction = 0.8 + /// Serialize the entire store (all four tables plus evidence blobs) to a /// `.zip` in a fresh temporary directory and return its URL, first purging /// the previous export's directory. The caller shares the file; the next /// export (or process exit) reclaims the disk. - public func exportBackup() async throws -> URL { + /// + /// `onProgress` is invoked with a fraction in `0...1` as the export + /// advances, throttled to whole-percent changes so a large export doesn't + /// flood the caller. Only the evidence-blob load reports incrementally (it's + /// the one per-item loop); the JSON encode + zip is a single opaque step, so + /// the fraction climbs to `exportBlobLoadFraction` during the load and then + /// jumps to `1` once the archive is written. It runs on this actor's + /// executor; a UI caller should marshal it back to the main actor (e.g. via + /// an `AsyncStream`). + public func exportBackup( + onProgress: @Sendable (Double) -> Void = { _ in }, + ) async throws -> URL { purgePreviousExport() let samples = try await store.allSamples() @@ -89,10 +106,17 @@ public actor BackupCoordinator { // in canonical order so the archive is stable. let trackedRegions = try await Region.inCanonicalOrder(store.trackedRegions()) var blobs: [UUID: Data] = [:] - for item in evidence { + var lastPercent = -1 + for (index, item) in evidence.enumerated() { if let blob = try await store.evidenceBlob(for: item.id) { blobs[item.id] = blob } + guard !evidence.isEmpty else { continue } + let fraction = Double(index + 1) / Double(evidence.count) * Self.exportBlobLoadFraction + let percent = Int(fraction * 100) + guard percent != lastPercent else { continue } + lastPercent = percent + onProgress(fraction) } let backupService = backupService let url = try await Task.detached(priority: .utility) { @@ -105,6 +129,7 @@ public actor BackupCoordinator { blobs: blobs, ) }.value + onProgress(1) previousExportDirectory = url.deletingLastPathComponent() return url } diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 607e2a3a..d3794023 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -210,6 +210,22 @@ struct BackupCoordinatorTests { #expect(FileManager.default.fileExists(atPath: second.path)) } + @Test func exportReportsProgressUpToCompletion() async throws { + let source = try Self.makeHarness() + try await Self.seed(source.store) + + let recorder = ProgressRecorder() + let url = try await source.coordinator.exportBackup { fraction in + recorder.record(fraction) + } + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let fractions = recorder.fractions + #expect(!fractions.isEmpty) + #expect(fractions.allSatisfy { $0 > 0 && $0 <= 1 }) + #expect(fractions.last == 1) + } + @Test func importReportsProgressUpToCompletion() async throws { let source = try Self.makeHarness() try await Self.seed(source.store) diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 667a5597..086e6e37 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -1733,6 +1733,17 @@ } } }, + "settings.backup.exporting" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporting…" + } + } + } + }, "settings.backup.footer" : { "extractionState" : "manual", "localizations" : { @@ -1843,6 +1854,17 @@ } } }, + "settings.backup.share" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share backup" + } + } + } + }, "settings.backup.shareTitle" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/BackupArchiveFile.swift b/Where/WhereUI/Sources/Settings/BackupArchiveFile.swift deleted file mode 100644 index 7f87f375..00000000 --- a/Where/WhereUI/Sources/Settings/BackupArchiveFile.swift +++ /dev/null @@ -1,19 +0,0 @@ -import CoreTransferable -import UniformTypeIdentifiers - -/// A whole-database backup that builds its `.zip` lazily, so `ShareLink(item:)` -/// can drive the export — and show the system's own "preparing…" progress — -/// instead of a custom `UIActivityViewController` bridge. -/// -/// The archive bytes are only written when the share sheet resolves the item, -/// via the `build` closure (which wraps `WhereModel.exportBackup()`). Throwing -/// from `build` aborts the share. -struct BackupArchiveFile: Transferable { - let build: @Sendable () async throws -> URL - - static var transferRepresentation: some TransferRepresentation { - FileRepresentation(exportedContentType: .zip) { archive in - try await SentTransferredFile(archive.build()) - } - } -} diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift index 759c9d25..35fd2828 100644 --- a/Where/WhereUI/Sources/Settings/BackupModel.swift +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -20,8 +20,8 @@ public final class BackupModel { public private(set) var backupState: BackupState = .idle - /// Fraction (`0...1`) of the in-flight import that has been written, for a - /// determinate progress bar. Reset to `0` whenever an import isn't running. + /// Fraction (`0...1`) of the in-flight export/import that has completed, for + /// a determinate progress bar. Reset to `0` whenever neither is running. public private(set) var backupProgress: Double = 0 /// Last backup failure, surfaced as an alert. Mutable so the alert binding @@ -48,14 +48,34 @@ public final class BackupModel { /// share sheet, or `nil` if the export failed (in which case `backupError` is /// set). The `BackupCoordinator` owns the temporary file's lifecycle — it /// reclaims the previous export's directory when the next export starts. + /// + /// Progress is streamed to `backupProgress` so the caller can show a + /// determinate "Exporting…" bar, using the same ordered-stream marshaling as + /// `importBackup` (see there for why). public func exportBackup() async -> URL? { backupState = .exporting - defer { backupState = .idle } + backupProgress = 0 + defer { + backupState = .idle + backupProgress = 0 + } + + let (progress, continuation) = AsyncStream.makeStream() + let observer = Task { @MainActor [weak self] in + for await fraction in progress { + self?.backupProgress = fraction + } + } + defer { observer.cancel() } + do { - let url = try await services.backup.exportBackup() + let url = try await services.backup.exportBackup { continuation.yield($0) } + continuation.finish() + await observer.value Self.logger.info("Exported backup archive") return url } catch { + continuation.finish() backupError = error.localizedDescription Self.logger.warning("Backup export failed: \(error.localizedDescription)") return nil diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 0b0fe2c4..54e1b82c 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -27,6 +27,10 @@ struct SettingsView: View { @State private var showResetConfirmation = false @State private var showAppIcon = false + /// Backup export: the ready-to-share archive built up-front, revealed as a + /// `ShareLink` once the background export finishes. + @State private var exportedArchiveURL: URL? + // Backup import: the picked file, the merge/replace choice, and the // success confirmation. @State private var showImporter = false @@ -331,22 +335,41 @@ struct SettingsView: View { private var backupSection: some View { Section { - // `ShareLink` builds the archive lazily through `BackupArchiveFile` - // and presents the native share sheet (with its own export - // progress), so no custom `UIActivityViewController` is needed. - ShareLink( - item: backupArchiveFile, - preview: SharePreview(Strings.settingsBackupShareTitle), - ) { - Label(Strings.settingsBackupExport, systemImage: "square.and.arrow.up") + // The archive is built up-front on a background task (with an + // in-app "Exporting…" bar), then shared through a `ShareLink` to the + // ready file — so the share sheet opens instantly instead of sitting + // in the system's blocking "Preparing…" state. + Button { + runExport() + } label: { + if backup.backupState == .exporting { + backupProgressLabel( + Strings.settingsBackupExporting, + systemImage: "square.and.arrow.up", + ) + } else { + Label(Strings.settingsBackupExport, systemImage: "square.and.arrow.up") + } } .disabled(backup.backupState != .idle) + if backup.backupState == .idle, let url = exportedArchiveURL { + ShareLink( + item: url, + preview: SharePreview(Strings.settingsBackupShareTitle), + ) { + Label(Strings.settingsBackupShare, systemImage: "square.and.arrow.up.on.square") + } + } + Button { showImporter = true } label: { if backup.backupState == .importing { - importProgressLabel + backupProgressLabel( + Strings.settingsBackupImporting, + systemImage: "square.and.arrow.down", + ) } else { Label(Strings.settingsBackupImport, systemImage: "square.and.arrow.down") } @@ -359,24 +382,24 @@ struct SettingsView: View { } } - /// Determinate progress for an in-flight import, driven by - /// `backup.backupProgress` as the backup coordinator writes each row. - private var importProgressLabel: some View { + /// Determinate progress for an in-flight export or import, driven by + /// `backup.backupProgress` as the backup coordinator makes progress. + private func backupProgressLabel(_ title: String, systemImage: String) -> some View { VStack(alignment: .leading, spacing: 4) { - Label(Strings.settingsBackupImporting, systemImage: "square.and.arrow.down") + Label(title, systemImage: systemImage) ProgressView(value: backup.backupProgress) } } - /// Lazily-built backup for `ShareLink`. The closure runs only when the - /// share sheet resolves the item; a failed export sets `backup.backupError` - /// (surfacing the alert) and throws to abort the share. - private var backupArchiveFile: BackupArchiveFile { - BackupArchiveFile { [backup] in - guard let url = await backup.exportBackup() else { - throw CocoaError(.fileWriteUnknown) + /// Build the archive in the background, then reveal the share row. Clearing + /// `exportedArchiveURL` first hides the stale share link — the coordinator + /// purges the previous export's directory when this new export starts. + private func runExport() { + exportedArchiveURL = nil + Task { + if let url = await backup.exportBackup() { + exportedArchiveURL = url } - return url } } diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 83a53639..d2731069 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -826,6 +826,16 @@ enum Strings { localized("settings.backup.export") } + static var settingsBackupExporting: String { + localized("settings.backup.exporting") + } + + /// Label for the share row revealed once a background export has produced a + /// ready archive file. + static var settingsBackupShare: String { + localized("settings.backup.share") + } + /// Title shown in the system share sheet preview for an exported backup. static var settingsBackupShareTitle: String { localized("settings.backup.shareTitle") From e210019f62a01da32f2479666dcd71075cb4b12e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 16:16:58 -0700 Subject: [PATCH 2/3] Address export review: drop dead guard, keep explicit .zip type - Remove the unreachable evidence.isEmpty guard in exportBackup's progress loop (the loop body only runs when evidence is non-empty, so count >= 1). - Reintroduce BackupArchiveFile as a non-lazy Transferable wrapping the ready archive URL, so ShareLink declares an explicit .zip content type instead of inferring it from the filename extension. Co-authored-by: Cursor --- .../Sources/Backup/BackupCoordinator.swift | 1 - .../Sources/Settings/BackupArchiveFile.swift | 17 +++++++++++++++++ .../WhereUI/Sources/Settings/SettingsView.swift | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 Where/WhereUI/Sources/Settings/BackupArchiveFile.swift diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index ec7bca80..4c588bb9 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -111,7 +111,6 @@ public actor BackupCoordinator { if let blob = try await store.evidenceBlob(for: item.id) { blobs[item.id] = blob } - guard !evidence.isEmpty else { continue } let fraction = Double(index + 1) / Double(evidence.count) * Self.exportBlobLoadFraction let percent = Int(fraction * 100) guard percent != lastPercent else { continue } diff --git a/Where/WhereUI/Sources/Settings/BackupArchiveFile.swift b/Where/WhereUI/Sources/Settings/BackupArchiveFile.swift new file mode 100644 index 00000000..47b8b3d5 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/BackupArchiveFile.swift @@ -0,0 +1,17 @@ +import CoreTransferable +import UniformTypeIdentifiers + +/// A ready-on-disk backup archive shared through `ShareLink`. The export is +/// built up-front (see `BackupModel.exportBackup`), so this just wraps the +/// finished file — but wrapping it in an explicit `.zip` `FileRepresentation`, +/// rather than sharing a bare `URL`, keeps the exported content type declared +/// instead of inferred from the filename extension. +struct BackupArchiveFile: Transferable { + let url: URL + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(exportedContentType: .zip) { archive in + SentTransferredFile(archive.url) + } + } +} diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 54e1b82c..1b7407d5 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -355,7 +355,7 @@ struct SettingsView: View { if backup.backupState == .idle, let url = exportedArchiveURL { ShareLink( - item: url, + item: BackupArchiveFile(url: url), preview: SharePreview(Strings.settingsBackupShareTitle), ) { Label(Strings.settingsBackupShare, systemImage: "square.and.arrow.up.on.square") From c4c37d5a0776c611c5153c64f88f5e217608ebed Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 16:29:22 -0700 Subject: [PATCH 3/3] Expire the offered backup export after 10 minutes A finished export left the 'Share backup' row (and its temp .zip) around indefinitely, so the link could go stale if the OS reclaimed the temp file. Auto-discard it after a retention window: a .task(id: exportedArchiveURL) sleeps 10 minutes, then hides the share row and deletes the temp file. The deletion routes through the coordinator, which owns the temp-file lifecycle, via a new BackupCoordinator.discardExport() (BackupModel.discardExport() forwards to it). Hiding before deleting closes the stale-tap window. Per the chosen direction this stays ShareLink-based, so hide-on-share-sheet- close isn't wired (ShareLink exposes no dismissal callback). Covered by BackupCoordinatorTests.discardExportDeletesTheExportDirectory. Co-authored-by: Cursor --- .../Sources/Backup/BackupCoordinator.swift | 9 ++++++++ .../Tests/BackupCoordinatorTests.swift | 15 +++++++++++++ .../Sources/Settings/BackupModel.swift | 7 ++++++ .../Sources/Settings/SettingsView.swift | 22 +++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 4c588bb9..fc2034be 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -133,6 +133,15 @@ public actor BackupCoordinator { return url } + /// Delete the most recent export's staging directory now, rather than + /// lazily on the next export. For a caller that's finished offering the + /// archive — e.g. a UI that times out its "share" affordance — so the temp + /// file doesn't linger until the next export or process exit. A no-op when + /// there's nothing left to reclaim. + public func discardExport() { + purgePreviousExport() + } + /// Delete the previous export's staging directory if we still have one. A /// failure here is non-fatal — a leftover temp directory only wastes a /// little disk — so it's logged rather than thrown, and never blocks the new diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index d3794023..45c5e7ed 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -210,6 +210,21 @@ struct BackupCoordinatorTests { #expect(FileManager.default.fileExists(atPath: second.path)) } + @Test func discardExportDeletesTheExportDirectory() async throws { + let harness = try Self.makeHarness() + try await Self.seed(harness.store) + + let url = try await harness.coordinator.exportBackup() + let directory = url.deletingLastPathComponent() + #expect(FileManager.default.fileExists(atPath: url.path)) + + await harness.coordinator.discardExport() + #expect(!FileManager.default.fileExists(atPath: directory.path)) + + // Idempotent: a second discard (nothing left to reclaim) is a no-op. + await harness.coordinator.discardExport() + } + @Test func exportReportsProgressUpToCompletion() async throws { let source = try Self.makeHarness() try await Self.seed(source.store) diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift index 35fd2828..e332e132 100644 --- a/Where/WhereUI/Sources/Settings/BackupModel.swift +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -82,6 +82,13 @@ public final class BackupModel { } } + /// Delete the most recent export's temp file now — for a caller that's done + /// offering it to share (e.g. its share affordance timed out). The + /// `BackupCoordinator` owns the file, so deletion routes through it. + public func discardExport() async { + await services.backup.discardExport() + } + /// Import a backup file with the chosen merge/replace strategy. Returns the /// import summary on success, or `nil` on failure (with `backupError` set). /// The committed import pings the store-change signal, so the scene's diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 1b7407d5..ce727ee3 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -380,6 +380,13 @@ struct SettingsView: View { } footer: { Text(Strings.settingsBackupFooter) } + // A finished export lingers in the temp directory; stop offering it (and + // reclaim the file) after a while so a stale link can't be shared. The + // task restarts whenever `exportedArchiveURL` changes and no-ops while + // it's `nil`. + .task(id: exportedArchiveURL) { + await expireExportIfNeeded() + } } /// Determinate progress for an in-flight export or import, driven by @@ -391,6 +398,9 @@ struct SettingsView: View { } } + /// How long a finished export stays offered before it's auto-discarded. + private static let exportRetention: Duration = .seconds(10 * 60) + /// Build the archive in the background, then reveal the share row. Clearing /// `exportedArchiveURL` first hides the stale share link — the coordinator /// purges the previous export's directory when this new export starts. @@ -403,6 +413,18 @@ struct SettingsView: View { } } + /// After a finished export has been offered for `exportRetention`, hide the + /// share row and delete the temp file. Hiding before the delete closes the + /// window where the row could point at an already-removed file. A no-op when + /// there's no export to expire (the `.task(id:)` also runs on `nil`). + private func expireExportIfNeeded() async { + guard exportedArchiveURL != nil else { return } + try? await Task.sleep(for: Self.exportRetention) + guard !Task.isCancelled else { return } + exportedArchiveURL = nil + await backup.discardExport() + } + private func handleImportSelection(_ result: Result) { switch result { case let .success(url):