208fa73f3d
Stop giving up on a slow iCloud container: a freshly enabled iCloud Drive that reports nil while still provisioning is now polled patiently for up to ~10 minutes instead of failing fast to the "unavailable" screen. A nil ubiquity token (not signed into iCloud at all) still fails immediately, and the connecting screen reveals an escape hatch at 28s for users who'd rather jump to setup than keep waiting. The connecting and iCloud-required screens are now branded — a purple gradient with teal accents — and the spinner becomes a custom comet-arc ConnectingIndicator around an iCloud glyph. Connecting copy escalates with the wait so a slow first connect reads as steady progress. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
305 lines
13 KiB
Swift
305 lines
13 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
import Observation
|
|
import os
|
|
|
|
enum ICloudStatus: Equatable {
|
|
case checking
|
|
case available
|
|
case unavailable
|
|
}
|
|
|
|
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
|
|
/// the sole source of truth: every save/delete writes files only; the metadata
|
|
/// observer (and the connect-time reconcile) is the sole mutator of the cache.
|
|
@Observable
|
|
@MainActor
|
|
final class SyncEngine {
|
|
nonisolated static let containerIdentifier = "iCloud.dev.rzen.indie.Workouts"
|
|
|
|
private(set) var iCloudStatus: ICloudStatus = .checking
|
|
private(set) var isSyncing = false
|
|
|
|
/// Called after the cache changes (local or remote). The watch bridge uses
|
|
/// this to push fresh state to the watch.
|
|
var onCacheChanged: (() -> Void)?
|
|
|
|
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
|
|
private let modelContainer: ModelContainer
|
|
private var fileManager: ICloudFileManager?
|
|
private var monitor: ICloudFileMonitor?
|
|
private var monitorTask: Task<Void, Never>?
|
|
private var connectAttempt = 0
|
|
|
|
/// How long `connect()` keeps polling for a still-provisioning iCloud
|
|
/// container before falling to the end-of-the-line gate. Deliberately long
|
|
/// (~10 min): as long as the user is signed into iCloud, a container that's
|
|
/// slow to come online should never be misreported as unavailable. Impatient
|
|
/// users bail sooner via the connecting screen's escape hatch (28s).
|
|
private static let connectTimeoutSeconds: TimeInterval = 600
|
|
|
|
private var context: ModelContext { modelContainer.mainContext }
|
|
|
|
init(container: ModelContainer) {
|
|
self.modelContainer = container
|
|
}
|
|
|
|
// MARK: - Connection (deferred, patient)
|
|
|
|
func connect() async {
|
|
guard iCloudStatus != .available else { return }
|
|
connectAttempt += 1
|
|
let attempt = connectAttempt
|
|
iCloudStatus = .checking
|
|
log.info("connect[\(attempt)]: resolving container \(Self.containerIdentifier, privacy: .public)")
|
|
|
|
// Definitive failure first: if the user isn't signed into iCloud at all,
|
|
// no container is ever coming — go straight to the end-of-the-line gate
|
|
// rather than spinning. (Signed-in-but-Drive-off still reports a token;
|
|
// that case falls through to the patient poll below and the escape hatch.)
|
|
let signedIn = await Task.detached {
|
|
FileManager.default.ubiquityIdentityToken != nil
|
|
}.value
|
|
guard attempt == connectAttempt else { return }
|
|
guard signedIn else {
|
|
log.error("connect[\(attempt)]: not signed into iCloud → unavailable")
|
|
iCloudStatus = .unavailable
|
|
return
|
|
}
|
|
|
|
// Signed in, but the container may still be provisioning — common right
|
|
// after enabling iCloud Drive. Keep polling patiently: we'd rather hold the
|
|
// spinner than misreport a working account as unavailable. We only give up
|
|
// after a considerable timeout; the user can bail sooner via the connecting
|
|
// screen's escape hatch (which bumps connectAttempt and stops this loop).
|
|
var resolved: URL?
|
|
let deadline = Date().addingTimeInterval(Self.connectTimeoutSeconds)
|
|
while resolved == nil {
|
|
let url = await Task.detached {
|
|
FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier)
|
|
}.value
|
|
guard attempt == connectAttempt else { return }
|
|
if let url {
|
|
resolved = url
|
|
break
|
|
}
|
|
if Date() >= deadline {
|
|
log.error("connect[\(attempt)]: container still nil after \(Int(Self.connectTimeoutSeconds))s → unavailable")
|
|
iCloudStatus = .unavailable
|
|
return
|
|
}
|
|
log.info("connect[\(attempt)]: container nil — still provisioning, retrying")
|
|
try? await Task.sleep(for: .seconds(2))
|
|
guard attempt == connectAttempt else { return }
|
|
}
|
|
guard let containerURL = resolved else { return }
|
|
log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)")
|
|
|
|
let fm = ICloudFileManager(containerURL: containerURL)
|
|
|
|
// Safety net only: prepareDirectories is a local op that effectively never
|
|
// blocks, but if the first container file op ever wedges we don't want an
|
|
// eternal spinner. This is generous — it isn't the connect path's clock.
|
|
let safety = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(30))
|
|
guard let self, !Task.isCancelled else { return }
|
|
if self.iCloudStatus == .checking, attempt == self.connectAttempt {
|
|
self.log.error("connect[\(attempt)]: prepareDirectories wedged 30s → unavailable")
|
|
self.iCloudStatus = .unavailable
|
|
}
|
|
}
|
|
log.info("connect[\(attempt)]: preparing directories…")
|
|
await fm.prepareDirectories()
|
|
safety.cancel()
|
|
guard attempt == connectAttempt else { return }
|
|
|
|
self.fileManager = fm
|
|
iCloudStatus = .available
|
|
log.info("connect[\(attempt)]: directories ready → available")
|
|
WorkoutsModelContainer.persistCurrentIdentityToken()
|
|
|
|
await reconcile()
|
|
startMonitoring(documentsURL: fm.documentsURL)
|
|
cleanupOldStubs()
|
|
}
|
|
|
|
/// Invoked from the connecting screen when the user chooses not to keep
|
|
/// waiting. Bumps `connectAttempt` to stop the in-flight poll loop, then drops
|
|
/// to the end-of-the-line gate (with its Try Again).
|
|
func abandonWaiting() {
|
|
guard iCloudStatus == .checking else { return }
|
|
connectAttempt += 1
|
|
iCloudStatus = .unavailable
|
|
log.info("connect: abandoned by user → unavailable")
|
|
}
|
|
|
|
// MARK: - Monitoring
|
|
|
|
private func startMonitoring(documentsURL: URL) {
|
|
monitorTask?.cancel()
|
|
let monitor = ICloudFileMonitor(documentsURL: documentsURL)
|
|
self.monitor = monitor
|
|
monitor.start()
|
|
monitorTask = Task { [weak self] in
|
|
for await event in monitor.events() {
|
|
await self?.handle(event)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handle(_ event: ICloudFileMonitor.FileChangeEvent) async {
|
|
switch event {
|
|
case .added(let path), .modified(let path):
|
|
if path.hasPrefix("Stubs/") {
|
|
deleteCachedEntity(id: idFromStubPath(path))
|
|
} else {
|
|
await importFile(relativePath: path)
|
|
}
|
|
case .removed(let path):
|
|
if !path.hasPrefix("Stubs/") {
|
|
deleteCachedEntity(jsonRelativePath: path)
|
|
}
|
|
}
|
|
try? context.save()
|
|
onCacheChanged?()
|
|
}
|
|
|
|
/// Apply a workout received from the watch. iCloud Drive stays the source of
|
|
/// truth (we write the file), but we also upsert the cache directly here.
|
|
///
|
|
/// The phone's own edits drive a local view copy, so they don't need this — but a
|
|
/// watch-originated change has nothing else refreshing the phone UI, and a
|
|
/// same-process file overwrite doesn't reliably wake the `NSMetadataQuery`
|
|
/// observer. Upserting the doc we just wrote keeps cache and file consistent (the
|
|
/// observer re-applies idempotently if it does fire) and lets open phone screens
|
|
/// reflect watch progress live.
|
|
func ingestFromWatch(_ doc: WorkoutDocument) async {
|
|
await save(workout: doc)
|
|
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
|
try? context.save()
|
|
onCacheChanged?()
|
|
}
|
|
|
|
// MARK: - Public CRUD (write path: files only)
|
|
|
|
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)") }
|
|
// 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)") }
|
|
}
|
|
|
|
func delete(split: Split) async {
|
|
await softDelete(id: split.id, kind: .split, livePath: split.jsonRelativePath)
|
|
}
|
|
|
|
func delete(workout: Workout) async {
|
|
await softDelete(id: workout.id, kind: .workout, livePath: workout.jsonRelativePath)
|
|
}
|
|
|
|
private func softDelete(id: String, kind: Tombstone.Kind, livePath: String) async {
|
|
guard let fm = fileManager else { return }
|
|
let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date())
|
|
do {
|
|
try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath)
|
|
} catch {
|
|
print("[Sync] delete failed for \(id): \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Import / reconcile
|
|
|
|
private func importFile(relativePath: String) async {
|
|
guard let fm = fileManager else { return }
|
|
guard let data = try? await fm.read(relativePath: relativePath) else { return }
|
|
|
|
if relativePath.hasPrefix("Splits/") {
|
|
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
|
|
if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
|
|
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
|
|
} else if relativePath.hasPrefix("Workouts/") {
|
|
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
|
|
if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
|
|
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
|
|
}
|
|
}
|
|
|
|
/// Full sync against the current file set — imports new/changed files and
|
|
/// prunes entities whose file is gone or tombstoned. Runs on connect so
|
|
/// changes accumulated while the app was closed are picked up.
|
|
private func reconcile() async {
|
|
guard let fm = fileManager else { return }
|
|
isSyncing = true
|
|
defer { isSyncing = false }
|
|
|
|
let tombstoned = Set(await fm.listTombstones().map(\.id))
|
|
let dataFiles = await fm.listDataFiles()
|
|
|
|
var liveSplitIDs = Set<String>()
|
|
var liveWorkoutIDs = Set<String>()
|
|
|
|
for path in dataFiles {
|
|
guard let data = try? await fm.read(relativePath: path) else { continue }
|
|
if path.hasPrefix("Splits/") {
|
|
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
|
|
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue }
|
|
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
|
|
liveSplitIDs.insert(doc.id)
|
|
} else if path.hasPrefix("Workouts/") {
|
|
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
|
|
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue }
|
|
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
|
|
liveWorkoutIDs.insert(doc.id)
|
|
}
|
|
}
|
|
|
|
// Prune cache entities no longer backed by a live file.
|
|
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
|
|
for s in splits where !liveSplitIDs.contains(s.id) { context.delete(s) }
|
|
}
|
|
if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
|
|
for w in workouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
|
|
}
|
|
try? context.save()
|
|
onCacheChanged?()
|
|
}
|
|
|
|
// MARK: - Cache deletes
|
|
|
|
private func deleteCachedEntity(id: String) {
|
|
if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
|
|
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
|
|
}
|
|
|
|
private func deleteCachedEntity(jsonRelativePath path: String) {
|
|
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
|
splits.forEach(context.delete)
|
|
}
|
|
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
|
workouts.forEach(context.delete)
|
|
}
|
|
}
|
|
|
|
private func idFromStubPath(_ path: String) -> String {
|
|
(path as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "")
|
|
}
|
|
|
|
// MARK: - Maintenance
|
|
|
|
private func cleanupOldStubs() {
|
|
guard let fm = fileManager else { return }
|
|
Task.detached(priority: .utility) {
|
|
let cutoff = Date().addingTimeInterval(-Tombstone.gracePeriod)
|
|
for tombstone in await fm.listTombstones() where tombstone.deletedAt < cutoff {
|
|
try? await fm.remove(relativePath: tombstone.relativePath)
|
|
}
|
|
}
|
|
}
|
|
}
|