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
+2
View File
@@ -1,5 +1,7 @@
**July 2026** **July 2026**
Sync problems are now shown in Settings instead of failing silently.
Fixed workouts and splits vanishing from the app when iPhone storage ran low: when iOS offloads their files to iCloud to free up space, Workouts now downloads them back automatically instead of treating them as gone. Fixed workouts and splits vanishing from the app when iPhone storage ran low: when iOS offloads their files to iCloud to free up space, Workouts now downloads them back automatically instead of treating them as gone.
When the same workout or split is edited on two devices around the same time, all devices now settle on the most recent change instead of some keeping an older version. When the same workout or split is edited on two devices around the same time, all devices now settle on the most recent change instead of some keeping an older version.
+40 -8
View File
@@ -20,6 +20,10 @@ final class SyncEngine {
private(set) var iCloudStatus: ICloudStatus = .checking private(set) var iCloudStatus: ICloudStatus = .checking
private(set) var isSyncing = false 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 /// Called after the cache changes (local or remote). The watch bridge uses
/// this to push fresh state to the watch. /// this to push fresh state to the watch.
var onCacheChanged: (() -> Void)? var onCacheChanged: (() -> Void)?
@@ -168,7 +172,7 @@ final class SyncEngine {
deleteCachedEntity(jsonRelativePath: path) deleteCachedEntity(jsonRelativePath: path)
} }
} }
try? context.save() do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?() onCacheChanged?()
} }
@@ -184,7 +188,7 @@ final class SyncEngine {
func ingestFromWatch(_ doc: WorkoutDocument) async { func ingestFromWatch(_ doc: WorkoutDocument) async {
await save(workout: doc) await save(workout: doc)
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context) CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
try? context.save() do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?() onCacheChanged?()
} }
@@ -192,15 +196,23 @@ final class SyncEngine {
func save(split doc: SplitDocument) async { func save(split doc: SplitDocument) async {
guard let fm = fileManager else { return } guard let fm = fileManager else { return }
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) } do {
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") } 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. // Cache updates reactively via the monitor.
} }
func save(workout doc: WorkoutDocument) async { func save(workout doc: WorkoutDocument) async {
guard let fm = fileManager else { return } guard let fm = fileManager else { return }
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) } do {
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") } 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; // Drive the HealthKit writer: a watch-recorded doc cancels any pending estimate;
// a completed doc (re)considers an estimate (the writer dedupes on metrics). // a completed doc (re)considers an estimate (the writer dedupes on metrics).
if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) } if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) }
@@ -220,8 +232,9 @@ final class SyncEngine {
let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date()) let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date())
do { do {
try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath) try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath)
lastSyncError = nil
} catch { } 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 // treat an unreadable file as absent. The next monitor event or
// reconcile retries it. // reconcile retries it.
log.error("import: read failed for \(relativePath, privacy: .public): \(error)") log.error("import: read failed for \(relativePath, privacy: .public): \(error)")
report("Failed to read \(relativePath)", error)
return return
} }
@@ -279,6 +293,7 @@ final class SyncEngine {
// how evicted records used to vanish on storage-constrained // how evicted records used to vanish on storage-constrained
// devices. // devices.
log.error("reconcile: read failed for \(path, privacy: .public): \(error)") log.error("reconcile: read failed for \(path, privacy: .public): \(error)")
report("Failed to read \(path)", error)
unreadablePaths.insert(path) unreadablePaths.insert(path)
continue continue
} }
@@ -308,7 +323,14 @@ final class SyncEngine {
context.delete(w) 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?() 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 // MARK: - About Section
Section { Section {
IndieAbout(configuration: AppInfoConfiguration( IndieAbout(configuration: AppInfoConfiguration(