Surface sync failures in Settings via lastSyncError
Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user