Surface sync failures in Settings via lastSyncError

Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS
This commit is contained in:
2026-07-04 12:15:13 -04:00
parent 61ab9359ee
commit 0c489fbbc7
3 changed files with 63 additions and 8 deletions
+40 -8
View File
@@ -20,6 +20,10 @@ final class SyncEngine {
private(set) var iCloudStatus: ICloudStatus = .checking
private(set) var isSyncing = false
/// Last non-fatal sync failure, surfaced for the UI to render. Cleared on the
/// next successful write or full reconcile.
private(set) var lastSyncError: String?
/// Called after the cache changes (local or remote). The watch bridge uses
/// this to push fresh state to the watch.
var onCacheChanged: (() -> Void)?
@@ -168,7 +172,7 @@ final class SyncEngine {
deleteCachedEntity(jsonRelativePath: path)
}
}
try? context.save()
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
@@ -184,7 +188,7 @@ final class SyncEngine {
func ingestFromWatch(_ doc: WorkoutDocument) async {
await save(workout: doc)
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
try? context.save()
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
@@ -192,15 +196,23 @@ final class SyncEngine {
func save(split doc: SplitDocument) async {
guard let fm = fileManager else { return }
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
do {
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath)
lastSyncError = nil
} catch {
report("Failed to save split", error)
}
// Cache updates reactively via the monitor.
}
func save(workout doc: WorkoutDocument) async {
guard let fm = fileManager else { return }
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
do {
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath)
lastSyncError = nil
} catch {
report("Failed to save workout", error)
}
// Drive the HealthKit writer: a watch-recorded doc cancels any pending estimate;
// a completed doc (re)considers an estimate (the writer dedupes on metrics).
if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) }
@@ -220,8 +232,9 @@ final class SyncEngine {
let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date())
do {
try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath)
lastSyncError = nil
} catch {
print("[Sync] delete failed for \(id): \(error)")
report("Failed to delete \(id)", error)
}
}
@@ -237,6 +250,7 @@ final class SyncEngine {
// treat an unreadable file as absent. The next monitor event or
// reconcile retries it.
log.error("import: read failed for \(relativePath, privacy: .public): \(error)")
report("Failed to read \(relativePath)", error)
return
}
@@ -279,6 +293,7 @@ final class SyncEngine {
// how evicted records used to vanish on storage-constrained
// devices.
log.error("reconcile: read failed for \(path, privacy: .public): \(error)")
report("Failed to read \(path)", error)
unreadablePaths.insert(path)
continue
}
@@ -308,7 +323,14 @@ final class SyncEngine {
context.delete(w)
}
}
try? context.save()
do {
try context.save()
// A fully clean pass supersedes any earlier failure; a pass with
// unreadable files keeps its own report visible.
if unreadablePaths.isEmpty { lastSyncError = nil }
} catch {
report("Cache save failed", error)
}
onCacheChanged?()
}
@@ -343,4 +365,14 @@ final class SyncEngine {
}
}
}
// MARK: - Error Reporting
/// Record a non-fatal sync failure: log it and publish it as `lastSyncError`
/// for the UI to render. Replaces silent `try?` / print-only handling.
private func report(_ message: String, _ error: Error? = nil) {
let full = error.map { "\(message): \($0.localizedDescription)" } ?? message
print("[Sync] \(full)")
lastSyncError = full
}
}
@@ -105,6 +105,27 @@ struct SettingsView: View {
}
}
// MARK: - iCloud Sync Section
Section {
switch sync.iCloudStatus {
case .checking:
Label("Connecting…", systemImage: "arrow.triangle.2.circlepath.icloud")
.foregroundStyle(.secondary)
case .available:
Label("Connected", systemImage: "checkmark.icloud")
.foregroundStyle(.secondary)
case .unavailable:
Label("iCloud unavailable", systemImage: "xmark.icloud")
.foregroundStyle(.orange)
}
if let error = sync.lastSyncError {
Label(error, systemImage: "exclamationmark.icloud.fill")
.foregroundStyle(.orange)
}
} header: {
Text("iCloud Sync")
}
// MARK: - About Section
Section {
IndieAbout(configuration: AppInfoConfiguration(