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
37 changes: 35 additions & 2 deletions Where/WhereCore/Sources/Backup/BackupCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -89,10 +106,16 @@ 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
}
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) {
Expand All @@ -105,10 +128,20 @@ public actor BackupCoordinator {
blobs: blobs,
)
}.value
onProgress(1)
previousExportDirectory = url.deletingLastPathComponent()
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
Expand Down
31 changes: 31 additions & 0 deletions Where/WhereCore/Tests/BackupCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,37 @@ 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)

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)
Expand Down
22 changes: 22 additions & 0 deletions Where/WhereUI/Sources/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1733,6 +1733,17 @@
}
}
},
"settings.backup.exporting" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Exporting…"
}
}
}
},
"settings.backup.footer" : {
"extractionState" : "manual",
"localizations" : {
Expand Down Expand Up @@ -1843,6 +1854,17 @@
}
}
},
"settings.backup.share" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Share backup"
}
}
}
},
"settings.backup.shareTitle" : {
"extractionState" : "manual",
"localizations" : {
Expand Down
16 changes: 7 additions & 9 deletions Where/WhereUI/Sources/Settings/BackupArchiveFile.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
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.
/// 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 build: @Sendable () async throws -> URL
let url: URL

static var transferRepresentation: some TransferRepresentation {
FileRepresentation(exportedContentType: .zip) { archive in
try await SentTransferredFile(archive.build())
SentTransferredFile(archive.url)
}
}
}
35 changes: 31 additions & 4 deletions Where/WhereUI/Sources/Settings/BackupModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -48,20 +48,47 @@ 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<Double>.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
}
}

/// 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
Expand Down
87 changes: 66 additions & 21 deletions Where/WhereUI/Sources/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: BackupArchiveFile(url: 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")
}
Expand All @@ -357,29 +380,51 @@ 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 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)
/// 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.
private func runExport() {
exportedArchiveURL = nil
Task {
if let url = await backup.exportBackup() {
exportedArchiveURL = url
}
return url
}
}

/// 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<URL, any Error>) {
switch result {
case let .success(url):
Expand Down
10 changes: 10 additions & 0 deletions Where/WhereUI/Sources/Shared/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading