From 1f2df491dba03d7bd541ec3cb95598f1df50f535 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 5 Jul 2026 08:04:29 -0400 Subject: [PATCH] Migrate the sync file layer onto the IndieSync package Replaces the app-local copies of the extracted storage core with the IndieSync 0.1.0 package (pinned from the new tag): ICloudFileManager -> DocumentFileStore + TombstoneStore, ICloudFileMonitor -> MetadataObserver (batched events with the content-date churn gate), and the shared ULID / DocumentCoder / Tombstone / VersionedDocument pieces. SyncEngine stays app-specific and is rewired onto the package types; documents rename currentSchema -> currentSchemaVersion to adopt the package protocol. ULID.make() remains as a shim minting the string form the app keys on. Behavior-preserving: identical JSON bytes (same coder config), same stub wire format (kind encodes as the same strings), same soft-delete ordering, same 30-day prune. WorkoutDocument keeps its local-calendar month bucketing rather than adopting TimeBucketedLayout's UTC paths -- changing derivation would strand existing files. The watch target links the package too (ULID + DocumentCoder via Shared); iOS, watchOS, and widget targets all build. --- CLAUDE.md | 19 +- Shared/Connectivity/WCPayload.swift | 1 + Shared/Model/Documents.swift | 60 +---- Shared/Model/Entities.swift | 1 + Shared/Model/Mappers.swift | 4 +- Shared/Model/ULID.swift | 73 +----- Shared/Screenshots/ScreenshotSeed.swift | 1 + .../Views/WorkoutLogListView.swift | 1 + Workouts/Seed/SplitSeeder.swift | 3 +- Workouts/Sync/ICloudFileManager.swift | 228 ------------------ Workouts/Sync/ICloudFileMonitor.swift | 111 --------- Workouts/Sync/SyncEngine.swift | 104 ++++---- .../Views/Exercises/ExerciseListView.swift | 3 +- Workouts/Views/Splits/SplitAddEditView.swift | 3 +- Workouts/Views/Splits/SplitDetailView.swift | 1 + .../WorkoutLogs/WorkoutLogListView.swift | 1 + .../Views/WorkoutLogs/WorkoutLogsView.swift | 3 +- project.yml | 5 + 18 files changed, 98 insertions(+), 524 deletions(-) delete mode 100644 Workouts/Sync/ICloudFileManager.swift delete mode 100644 Workouts/Sync/ICloudFileMonitor.swift diff --git a/CLAUDE.md b/CLAUDE.md index 2978a55..32b6207 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,20 +24,19 @@ A workout tracking app for iPhone and Apple Watch, built with Swift 6 and SwiftU ### Persistence & Sync (iCloud Drive documents) - **Source of truth**: JSON documents in the iCloud Drive container `iCloud.dev.rzen.indie.Workouts`. One file per aggregate: `Splits/.json` and `Workouts/YYYY/MM/.json` (month-bucketed; ULIDs sort chronologically). - **SwiftData cache**: `WorkoutsModelContainer` (`Shared/Persistence/`) builds a local SwiftData store as a *rebuildable read-through cache*, created with `cloudKitDatabase: .none`. It is wiped on a schema-version bump or an iCloud account change. Views read it via `@Query`. -- **One-way data flow (iPhone)**: view → `SyncEngine.save(...)` writes a file → `ICloudFileMonitor` (an `NSMetadataQuery`) emits a delta → `CacheMapper` upserts SwiftData → `@Query` views refresh. The phone is the **sole writer** of iCloud Drive. -- **Key types** (under `Workouts/Sync/`): - - `SyncEngine` (`@Observable @MainActor`) — orchestrator. `connect()` resolves the ubiquity container and reconciles; `save`/`delete` write files; `handle(event:)` applies observer deltas; `ingestFromWatch(_:)` writes + upserts the cache directly. Exposes `iCloudStatus` and an `onCacheChanged` callback. - - `ICloudFileManager` (`actor`) — all `NSFileCoordinator` file I/O, off the main thread. - - `ICloudFileMonitor` (`@MainActor`) — wraps the `NSMetadataQuery`, emitting added/modified/removed deltas as an `AsyncStream`. +- **One-way data flow (iPhone)**: view → `SyncEngine.save(...)` writes a file → `MetadataObserver` (an `NSMetadataQuery`) emits a delta batch → `CacheMapper` upserts SwiftData → `@Query` views refresh. The phone is the **sole writer** of iCloud Drive. +- **Key types**: + - `SyncEngine` (`@Observable @MainActor`, in `Workouts/Sync/`) — the app-specific orchestrator. `connect()` resolves the ubiquity container and reconciles; `save`/`delete` write files; `handle(_:)` applies observer delta batches; `ingestFromWatch(_:)` writes + upserts the cache directly. Exposes `iCloudStatus` and an `onCacheChanged` callback. + - The file-side core comes from the **IndieSync** SPM package (`https://git.rzen.dev/rzen/indie-sync.git`): `DocumentFileStore` (actor — all `NSFileCoordinator` I/O, conflict resolution, eviction-safe reads, placeholder-aware enumeration), `TombstoneStore` (soft-delete stubs, resurrection veto, grace-period pruning), `MetadataObserver` (`@MainActor` — wraps the `NSMetadataQuery`, emitting added/modified/removed batches as an `AsyncStream`), plus `ULID`, `DocumentCoder`, `SyncError`, and the `VersionedDocument` protocol. See the `icloud-sync-engine` skill for the architecture invariants. - **Soft deletes**: a delete writes a `Tombstone` to `Stubs/.json` then removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period. - **iCloud is required**: `RootGateView` (`Workouts/Views/`) gates the app on `SyncEngine.iCloudStatus` — there is no local-only mode. ### Data Layer (three shapes + a stateless mapper) All in `Shared/Model/`: -- **Codable documents** (`Documents.swift`) — the on-disk / wire format: `SplitDocument` (embeds `[ExerciseDocument]`) and `WorkoutDocument` (embeds `[WorkoutLogDocument]`), plus `Tombstone`, a `VersionedDocument` schema gate (quarantines files from newer app versions), and a shared `DocumentCoder`. +- **Codable documents** (`Documents.swift`) — the on-disk / wire format: `SplitDocument` (embeds `[ExerciseDocument]`) and `WorkoutDocument` (embeds `[WorkoutLogDocument]`), conforming to IndieSync's `VersionedDocument` schema gate (quarantines files from newer app versions). `Tombstone` and `DocumentCoder` come from IndieSync. `WorkoutDocument.relativePath` keeps the app's own local-calendar month bucketing (predates IndieSync's UTC `TimeBucketedLayout`; changing it would strand existing files). - **SwiftData `@Model` cache entities** (`Entities.swift`) — `Split`, `Exercise`, `Workout`, `WorkoutLog`; each keyed by a stable `id: String` ULID (`@Attribute(.unique)`), with cascade relationships and a `jsonRelativePath` back to its file. - **Stateless mappers** (`Mappers.swift`) — `init(from: entity)` for cache → document; `CacheMapper.upsert…` for document → cache (used *only* by the observer, reconcile, and the watch bridge). -- **Identifiers** (`ULID.swift`) — 26-char Crockford base32, chronologically sortable. +- **Identifiers** (`ULID.swift`) — a thin shim over IndieSync's `ULID`: `ULID.make()` mints the 26-char Crockford base32 string (chronologically sortable) that documents and entities key on. - **Enums** (`Enums.swift`) — `WorkoutStatus`, `LoadType`. ### Core Aggregates @@ -60,7 +59,7 @@ All in `Shared/Model/`: - iOS 26 / watchOS 26; Swift 6 with `SWIFT_STRICT_CONCURRENCY: complete` on both targets. - Build number is set from the git commit count by `Scripts/update_build_number.sh` (a post-build phase); `Scripts/` also holds the App Store / TestFlight release pipeline. - Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only. **No CloudKit service, no App Group.** -- SPM packages: `IndieAbout` (in-app About / changelog / license UI) and `Yams` (YAML parsing). +- SPM packages: `IndieSync` (file-side iCloud sync core), `IndieAbout` (in-app About / changelog / license UI), and `Yams` (YAML parsing). ### Key Directories - `Shared/` (compiled into both targets): `Model/`, `Persistence/`, `Connectivity/`, `Utils/`, `Screenshots/` @@ -77,12 +76,12 @@ Seeded on demand, never automatically — an empty cache at launch is indistingu ### Data Model Changes - A new persisted field must be added in three places: the Codable `…Document` (on-disk shape), the `@Model` cache entity, and *both directions* of the mapper in `Mappers.swift`. -- Bump a document's `currentSchema` when its on-disk shape changes incompatibly. +- Bump a document's `currentSchemaVersion` when its on-disk shape changes incompatibly. - All writes go through `SyncEngine` (`save` / `delete`) — never mutate the SwiftData cache directly, since it is rebuilt from files. - Mint IDs with `ULID.make()`; they are stable across cache rebuilds. ### Concurrency -- Code is Swift 6 strict-concurrency clean. UI and orchestration types are `@MainActor`; file I/O lives in the `ICloudFileManager` actor. +- Code is Swift 6 strict-concurrency clean. UI and orchestration types are `@MainActor`; file I/O lives in IndieSync's `DocumentFileStore` actor. ### UI - SwiftUI-first: `NavigationStack`, `@Query`-backed lists, form-based add/edit, sheet presentations, and swipe actions. diff --git a/Shared/Connectivity/WCPayload.swift b/Shared/Connectivity/WCPayload.swift index c61d560..61e732d 100644 --- a/Shared/Connectivity/WCPayload.swift +++ b/Shared/Connectivity/WCPayload.swift @@ -1,3 +1,4 @@ +import IndieSync import Foundation /// Wire format for the iPhone↔Watch bridge. The phone is the only device that diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index 701c4e7..dbd4185 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -1,4 +1,5 @@ import Foundation +import IndieSync /// On-disk JSON shape for each aggregate. Independent from the SwiftData cache /// entities so the wire format can evolve without dragging the cache schema. @@ -29,7 +30,7 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable { /// quarantining the user's whole routine. var activityType: Int? - static let currentSchema = 1 + static let currentSchemaVersion = 1 var relativePath: String { "Splits/\(id).json" } } @@ -69,7 +70,7 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable { // Bumped 1→2 when `metrics` was added: the captured HR/calorie data is // irreplaceable, so the forward-gate must quarantine these files on older apps // rather than let them strip `metrics` on rewrite. - static let currentSchema = 2 + static let currentSchemaVersion = 2 var relativePath: String { Self.relativePath(id: id, start: start) } @@ -158,58 +159,7 @@ struct WorkoutMetrics: Codable, Sendable, Equatable { // MARK: - Forward-compatibility gate -/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written -/// by a newer app version; it must be quarantined, not partially decoded (Codable -/// silently drops unknown keys) and later rewritten — which would downgrade it -/// and lose the newer fields. -protocol VersionedDocument { - var schemaVersion: Int { get } - static var currentSchema: Int { get } -} - +// `VersionedDocument` (the `isReadable` quarantine gate for files written by a +// newer app version), `Tombstone`, and `DocumentCoder` all live in IndieSync now. extension SplitDocument: VersionedDocument {} extension WorkoutDocument: VersionedDocument {} - -extension VersionedDocument { - /// True if this build can safely read (and rewrite) the document. - var isReadable: Bool { schemaVersion <= Self.currentSchema } -} - -// MARK: - Soft-delete tombstone - -/// Lives at `Stubs/.json` and tells every device "this aggregate has been -/// deleted" — important when a remote device missed the `.removed` event for the -/// live file because it was offline. After `gracePeriod` any device can prune it. -struct Tombstone: Codable, Sendable, Equatable { - enum Kind: String, Codable, Sendable { - case split - case workout - } - - var id: String // the aggregate's ULID - var kind: Kind - var deletedAt: Date - - var relativePath: String { "Stubs/\(id).json" } - - static let gracePeriod: TimeInterval = 30 * 24 * 60 * 60 -} - -// MARK: - JSON coding - -/// Shared encoder/decoder. ISO-8601 dates and sorted keys for human-readable, -/// diff-friendly files when the container is browsed via the Files app. -enum DocumentCoder { - static let encoder: JSONEncoder = { - let e = JSONEncoder() - e.outputFormatting = [.prettyPrinted, .sortedKeys] - e.dateEncodingStrategy = .iso8601 - return e - }() - - static let decoder: JSONDecoder = { - let d = JSONDecoder() - d.dateDecodingStrategy = .iso8601 - return d - }() -} diff --git a/Shared/Model/Entities.swift b/Shared/Model/Entities.swift index 2b53622..cec8f6f 100644 --- a/Shared/Model/Entities.swift +++ b/Shared/Model/Entities.swift @@ -1,3 +1,4 @@ +import IndieSync import Foundation import SwiftData diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift index 34e1b6e..f3589cf 100644 --- a/Shared/Model/Mappers.swift +++ b/Shared/Model/Mappers.swift @@ -24,7 +24,7 @@ extension ExerciseDocument { extension SplitDocument { init(from split: Split) { - self.init(schemaVersion: Self.currentSchema, id: split.id, name: split.name, + self.init(schemaVersion: Self.currentSchemaVersion, id: split.id, name: split.name, color: split.color, systemImage: split.systemImage, order: split.order, createdAt: split.createdAt, updatedAt: split.updatedAt, exercises: split.exercisesArray.map(ExerciseDocument.init(from:)), @@ -43,7 +43,7 @@ extension WorkoutLogDocument { extension WorkoutDocument { init(from workout: Workout) { - self.init(schemaVersion: Self.currentSchema, id: workout.id, splitID: workout.splitID, + self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, splitID: workout.splitID, splitName: workout.splitName, start: workout.start, end: workout.end, status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt, logs: workout.logsArray.map(WorkoutLogDocument.init(from:)), diff --git a/Shared/Model/ULID.swift b/Shared/Model/ULID.swift index 2c7314b..89dc0dc 100644 --- a/Shared/Model/ULID.swift +++ b/Shared/Model/ULID.swift @@ -1,68 +1,11 @@ import Foundation +import IndieSync -/// Minimal ULID generator. -/// -/// 26-character Crockford base32 string: 10 chars of millisecond timestamp + -/// 16 chars of randomness. Lexicographic order matches chronological order, -/// so workout documents sort newest-last by id and file listings stay ordered. -/// -/// Spec: https://github.com/ulid/spec -enum ULID { - /// Crockford base32 alphabet — no I, L, O, U to avoid visual confusion. - private static let alphabet: [Character] = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ") - - /// Mints a fresh ULID for the current instant. - static func make() -> String { - let timestamp = UInt64(Date().timeIntervalSince1970 * 1000) - var randomness = [UInt8](repeating: 0, count: 10) - _ = randomness.withUnsafeMutableBytes { buffer in - SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!) - } - return encode(timestamp: timestamp, randomness: randomness) - } - - /// True if `s` is a 26-char ULID using the Crockford alphabet. - static func isValid(_ s: String) -> Bool { - guard s.count == 26 else { return false } - let allowed = Set(alphabet) - return s.allSatisfy { allowed.contains($0) } - } - - /// Decodes the timestamp portion of a ULID, if valid. - static func timestamp(of ulid: String) -> Date? { - guard ulid.count == 26 else { return nil } - let prefix = ulid.prefix(10) - var value: UInt64 = 0 - let lookup = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt64($0)) }) - for char in prefix { - guard let digit = lookup[char] else { return nil } - value = (value << 5) | digit - } - return Date(timeIntervalSince1970: Double(value) / 1000) - } - - // MARK: - Internal - - private static func encode(timestamp: UInt64, randomness: [UInt8]) -> String { - precondition(randomness.count == 10, "ULID randomness must be 10 bytes (80 bits)") - // 48-bit timestamp → 10 base32 chars (50 bits, top 2 unused). - var output = [Character](repeating: "0", count: 26) - var t = timestamp & ((1 << 48) - 1) - for i in stride(from: 9, through: 0, by: -1) { - output[i] = alphabet[Int(t & 0x1F)] - t >>= 5 - } - // 80-bit randomness → 16 base32 chars. - var bigEnd = randomness - for i in stride(from: 25, through: 10, by: -1) { - var carry: UInt16 = 0 - for j in 0..> 5) - carry = combined & 0x1F - } - output[i] = alphabet[Int(UInt8(carry))] - } - return String(output) - } +// The ULID implementation lives in the IndieSync package. Workouts' documents +// and cache entities key on the 26-char string form, so this shim keeps the +// app-wide `ULID.make()` minting entry point unchanged. +extension ULID { + /// Mints a fresh ULID for the current instant, as its canonical + /// 26-character Crockford base32 string. + static func make() -> String { ULID().stringValue } } diff --git a/Shared/Screenshots/ScreenshotSeed.swift b/Shared/Screenshots/ScreenshotSeed.swift index 2a4e98d..b7a3129 100644 --- a/Shared/Screenshots/ScreenshotSeed.swift +++ b/Shared/Screenshots/ScreenshotSeed.swift @@ -1,4 +1,5 @@ #if DEBUG +import IndieSync import Foundation import SwiftData diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index cc758e9..42e006d 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -5,6 +5,7 @@ // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // +import IndieSync import SwiftUI import SwiftData diff --git a/Workouts/Seed/SplitSeeder.swift b/Workouts/Seed/SplitSeeder.swift index 4a9777a..9209650 100644 --- a/Workouts/Seed/SplitSeeder.swift +++ b/Workouts/Seed/SplitSeeder.swift @@ -1,3 +1,4 @@ +import IndieSync import Foundation import SwiftData @@ -58,7 +59,7 @@ enum SplitSeeder { ) } return SplitDocument( - schemaVersion: SplitDocument.currentSchema, id: ULID.make(), + schemaVersion: SplitDocument.currentSchemaVersion, id: ULID.make(), name: split.name, color: split.color, systemImage: split.icon, order: order, createdAt: Date(), updatedAt: Date(), exercises: exercises, activityType: split.activity.rawValue diff --git a/Workouts/Sync/ICloudFileManager.swift b/Workouts/Sync/ICloudFileManager.swift deleted file mode 100644 index a20878a..0000000 --- a/Workouts/Sync/ICloudFileManager.swift +++ /dev/null @@ -1,228 +0,0 @@ -import Foundation - -/// All iCloud Drive file I/O, isolated to an actor so blocking `NSFileCoordinator` -/// calls stay off the main thread. Paths are relative to the container's -/// `Documents/` directory (e.g. `Splits/.json`, `Stubs/.json`). -actor ICloudFileManager { - let documentsURL: URL - - init(containerURL: URL) { - self.documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true) - } - - /// Create the directory skeleton. Actor-isolated (NOT in init) so the - /// potentially-blocking file touch runs on the actor's executor, never main. - func prepareDirectories() { - for sub in ["Splits", "Workouts", "Stubs"] { - let url = documentsURL.appendingPathComponent(sub, isDirectory: true) - try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - } - } - - // MARK: - Coordinated primitives - - func write(_ data: Data, to relativePath: String) throws { - let fileURL = documentsURL.appendingPathComponent(relativePath) - try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - var coordError: NSError? - var writeError: Error? - NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forReplacing, error: &coordError) { url in - do { try data.write(to: url, options: .atomic) } - catch { writeError = error } - } - if let error = coordError ?? writeError { throw error } - } - - /// Eviction-safe, conflict-safe read: resolves any iCloud conflict versions, - /// materializes an evicted file (bounded wait), then does the coordinated - /// read — so callers never decode a dataless placeholder or a stale conflict - /// sibling. Throws on download timeout rather than skipping — a silently - /// dropped evicted file looks to the user like the record was deleted. - func read(relativePath: String) async throws -> Data { - resolveConflictsIfAny(at: documentsURL.appendingPathComponent(relativePath)) - try await ensureDownloaded(relativePath: relativePath) - let fileURL = documentsURL.appendingPathComponent(relativePath) - var coordError: NSError? - var result: Result? - NSFileCoordinator().coordinate(readingItemAt: fileURL, options: [], error: &coordError) { url in - do { result = .success(try Data(contentsOf: url)) } - catch { result = .failure(error) } - } - if let coordError { throw coordError } - switch result { - case .success(let data): return data - case .failure(let error): throw error - case .none: throw CocoaError(.fileReadUnknown) - } - } - - func remove(relativePath: String) throws { - let fileURL = documentsURL.appendingPathComponent(relativePath) - guard FileManager.default.fileExists(atPath: fileURL.path) else { return } - var coordError: NSError? - var deleteError: Error? - NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forDeleting, error: &coordError) { url in - do { try FileManager.default.removeItem(at: url) } - catch { deleteError = error } - } - if let error = coordError ?? deleteError { throw error } - } - - func fileExists(_ relativePath: String) -> Bool { - FileManager.default.fileExists(atPath: documentsURL.appendingPathComponent(relativePath).path) - } - - // MARK: - Soft delete - - /// Writes a tombstone stub then removes the live file. Other devices learn of - /// the delete via the stub even if they were offline for the file removal. - func writeTombstoneAndRemove(_ tombstone: Tombstone, livePath: String) throws { - let data = try DocumentCoder.encoder.encode(tombstone) - try write(data, to: tombstone.relativePath) - try remove(relativePath: livePath) - } - - // MARK: - Enumeration - - /// Relative paths of all live data files (`Splits/…`, `Workouts/…`), excluding `Stubs/`. - func listDataFiles() -> [String] { - listJSON().filter { !$0.hasPrefix("Stubs/") } - } - - /// All tombstones currently on disk (evicted stubs are downloaded first — - /// old stubs are routinely evicted, and cleanup needs their `deletedAt`). - func listTombstones() async -> [Tombstone] { - var tombstones: [Tombstone] = [] - for path in listJSON() where path.hasPrefix("Stubs/") { - guard let data = try? await read(relativePath: path) else { continue } - if let tombstone = try? DocumentCoder.decoder.decode(Tombstone.self, from: data) { - tombstones.append(tombstone) - } - } - return tombstones - } - - /// IDs of all tombstones, derived from stub filenames alone. Unlike - /// `listTombstones()` this needs no reads, so an evicted stub still counts — - /// reconcile must never treat a record as live because its stub happened to - /// be evicted. - func listTombstoneIDs() -> Set { - Set(listJSON().filter { $0.hasPrefix("Stubs/") } - .map { ($0 as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "") }) - } - - /// Whether a deletion stub exists for an id — placeholder-aware, so an - /// evicted stub still vetoes a resurrecting live file. - func stubExists(id: String) -> Bool { - let stubs = documentsURL.appendingPathComponent("Stubs", isDirectory: true) - return FileManager.default.fileExists(atPath: stubs.appendingPathComponent("\(id).json").path) - || FileManager.default.fileExists(atPath: stubs.appendingPathComponent(".\(id).json.icloud").path) - } - - /// Deliberately does NOT pass `.skipsHiddenFiles`: iOS materializes evicted - /// iCloud files as hidden placeholder dotfiles (`..json.icloud`), and - /// skipping hidden files would drop every evicted record from the listing — - /// reconcile would then prune their cache entities as "file gone". - /// Placeholder names are mapped back to their real `.json`. - private func listJSON() -> [String] { - let base = documentsURL.path + "/" - guard let enumerator = FileManager.default.enumerator( - at: documentsURL, - includingPropertiesForKeys: nil, - options: [] - ) else { return [] } - var seen: Set = [] - var paths: [String] = [] - for case let url as URL in enumerator { - let full = url.path - guard full.hasPrefix(base) else { continue } - guard let real = Self.realRelativePath(fromRaw: String(full.dropFirst(base.count))), - seen.insert(real).inserted else { continue } - paths.append(real) - } - return paths - } - - /// Map a raw relative path from the enumerator — which may be an eviction - /// placeholder like `Workouts/2026/03/..json.icloud` — to the real - /// path `Workouts/2026/03/.json`. Returns nil for non-JSON entries. - nonisolated static func realRelativePath(fromRaw raw: String) -> String? { - var components = raw.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - guard var name = components.popLast() else { return nil } - if name.hasPrefix("."), name.hasSuffix(".icloud") { - name = String(name.dropFirst().dropLast(".icloud".count)) - } - guard name.hasSuffix(".json") else { return nil } - components.append(name) - return components.joined(separator: "/") - } - - // MARK: - Conflict resolution - - /// If iCloud Drive created conflict versions (concurrent writes from - /// different devices), pick whichever has the latest modification date, - /// promote it to the canonical file, and mark every conflict resolved. - /// Whole-file last-writer-wins by mod-date — honest for one-file-per-record - /// data. Left unresolved, conflict siblings accumulate silently and a - /// coordinated read returns whichever version the filesystem hands back. - private func resolveConflictsIfAny(at fileURL: URL) { - let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: fileURL) ?? [] - guard !conflicts.isEmpty else { return } - - let currentDate = NSFileVersion.currentVersionOfItem(at: fileURL)?.modificationDate ?? .distantPast - var winnerDate = currentDate - var winnerVersion: NSFileVersion? - - for version in conflicts { - if let date = version.modificationDate, date > winnerDate { - winnerDate = date - winnerVersion = version - } - } - - // Promote the winner and prune the losers inside a coordinated write — - // these are real file mutations (replaceItem / removeOtherVersions) and - // must not race a concurrent write or the iCloud daemon; NSFileVersion - // does not coordinate internally. - var coordError: NSError? - NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forReplacing, error: &coordError) { url in - if let winner = winnerVersion { - do { - try winner.replaceItem(at: url, options: []) - } catch { - print("[Sync] Conflict promotion failed for \(url.lastPathComponent): \(error)") - } - } - for version in conflicts { - version.isResolved = true - } - try? NSFileVersion.removeOtherVersionsOfItem(at: url) - } - if let coordError { - print("[Sync] Conflict coordination failed for \(fileURL.lastPathComponent): \(coordError)") - } - } - - // MARK: - Eviction - - /// Triggers a download for an evicted file and polls until it materializes. - /// `startDownloadingUbiquitousItem` is fire-and-forget with no completion - /// callback, so a bounded poll (30s) is the only way to wait; on timeout this - /// throws rather than letting the caller read a placeholder. - func ensureDownloaded(relativePath: String) async throws { - let fileURL = documentsURL.appendingPathComponent(relativePath) - let values = try fileURL.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]) - guard let status = values.ubiquitousItemDownloadingStatus, status != .current else { return } - - try FileManager.default.startDownloadingUbiquitousItem(at: fileURL) - for _ in 0..<60 { - try await Task.sleep(for: .milliseconds(500)) - let updated = try fileURL.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]) - if let s = updated.ubiquitousItemDownloadingStatus, s == .current { return } - } - throw CocoaError(.fileReadNoPermission, userInfo: [ - NSLocalizedDescriptionKey: "Timed out downloading \(relativePath) from iCloud" - ]) - } -} diff --git a/Workouts/Sync/ICloudFileMonitor.swift b/Workouts/Sync/ICloudFileMonitor.swift deleted file mode 100644 index e2974dd..0000000 --- a/Workouts/Sync/ICloudFileMonitor.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation - -/// Wraps a single `NSMetadataQuery` over the container's `Documents/` scope and -/// emits add/modify/remove events (paths relative to `Documents/`) via an -/// `AsyncStream`. `@MainActor` because `NSMetadataQuery` posts on, and must be -/// driven from, the main thread. -@MainActor -final class ICloudFileMonitor { - enum FileChangeEvent: Sendable { - case added(relativePath: String) - case modified(relativePath: String) - case removed(relativePath: String) - } - - private let documentsURL: URL - private var query: NSMetadataQuery? - private var knownFiles: Set = [] - private var continuation: AsyncStream.Continuation? - - init(documentsURL: URL) { - self.documentsURL = documentsURL - } - - func events() -> AsyncStream { - AsyncStream { continuation in - self.continuation = continuation - continuation.onTermination = { @Sendable _ in - Task { @MainActor in self.stop() } - } - } - } - - func start() { - let query = NSMetadataQuery() - query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] - query.predicate = NSPredicate(format: "%K LIKE '*.json'", NSMetadataItemFSNameKey) - self.query = query - - NotificationCenter.default.addObserver( - self, selector: #selector(queryDidFinishGathering(_:)), - name: .NSMetadataQueryDidFinishGathering, object: query) - NotificationCenter.default.addObserver( - self, selector: #selector(queryDidUpdate(_:)), - name: .NSMetadataQueryDidUpdate, object: query) - - query.start() - } - - func stop() { - query?.stop() - if let query { NotificationCenter.default.removeObserver(self, name: nil, object: query) } - query = nil - } - - // MARK: - Notifications - - @objc private func queryDidFinishGathering(_ notification: Notification) { - query?.disableUpdates() - defer { query?.enableUpdates() } - // Seed the baseline only; do NOT emit (else every existing file would fire - // `.added` on each launch). The engine's connect-time `reconcile()` does the - // initial import; this query then reports only live deltas after the baseline. - knownFiles = Set(currentRelativePaths()) - } - - @objc private func queryDidUpdate(_ notification: Notification) { - query?.disableUpdates() - defer { query?.enableUpdates() } - - let currentFiles = Set(currentRelativePaths()) - - for file in currentFiles.subtracting(knownFiles) { - continuation?.yield(.added(relativePath: file)) - } - for file in knownFiles.subtracting(currentFiles) { - continuation?.yield(.removed(relativePath: file)) - } - if let updated = notification.userInfo?[NSMetadataQueryUpdateChangedItemsKey] as? [NSMetadataItem] { - for item in updated { - if let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL, - let path = relativePath(from: url), knownFiles.contains(path) { - continuation?.yield(.modified(relativePath: path)) - } - } - } - knownFiles = currentFiles - } - - // MARK: - Helpers - - private func currentRelativePaths() -> [String] { - guard let query else { return [] } - var paths: [String] = [] - for i in 0.. String? { - let base = documentsURL.path + "/" - let full = url.path - guard full.hasPrefix(base) else { return nil } - let relative = String(full.dropFirst(base.count)) - return relative.hasSuffix(".json") ? relative : nil - } -} diff --git a/Workouts/Sync/SyncEngine.swift b/Workouts/Sync/SyncEngine.swift index 7622da1..0388720 100644 --- a/Workouts/Sync/SyncEngine.swift +++ b/Workouts/Sync/SyncEngine.swift @@ -1,4 +1,5 @@ import Foundation +import IndieSync import SwiftData import Observation import os @@ -38,8 +39,9 @@ final class SyncEngine { 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 store: DocumentFileStore? + private var tombstones: TombstoneStore? + private var monitor: MetadataObserver? private var monitorTask: Task? private var connectAttempt = 0 @@ -107,7 +109,7 @@ final class SyncEngine { guard let containerURL = resolved else { return } log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)") - let fm = ICloudFileManager(containerURL: containerURL) + let store = DocumentFileStore(root: containerURL.appendingPathComponent("Documents", isDirectory: true)) // 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 @@ -121,17 +123,18 @@ final class SyncEngine { } } log.info("connect[\(attempt)]: preparing directories…") - await fm.prepareDirectories() + await store.prepareDirectories(["Splits", "Workouts", "Stubs"]) safety.cancel() guard attempt == connectAttempt else { return } - self.fileManager = fm + self.store = store + self.tombstones = TombstoneStore(store: store) iCloudStatus = .available log.info("connect[\(attempt)]: directories ready → available") WorkoutsModelContainer.persistCurrentIdentityToken() await reconcile() - startMonitoring(documentsURL: fm.documentsURL) + startMonitoring(documentsURL: store.rootURL) cleanupOldStubs() } @@ -149,27 +152,29 @@ final class SyncEngine { private func startMonitoring(documentsURL: URL) { monitorTask?.cancel() - let monitor = ICloudFileMonitor(documentsURL: documentsURL) + let monitor = MetadataObserver(documentsURL: documentsURL) self.monitor = monitor monitor.start() monitorTask = Task { [weak self] in - for await event in monitor.events() { - await self?.handle(event) + for await batch in monitor.events() { + await self?.handle(batch) } } } - 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) + private func handle(_ batch: [FileChangeEvent]) async { + for event in batch { + 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) + } } } do { try context.save() } catch { report("Cache save failed", error) } @@ -195,9 +200,9 @@ final class SyncEngine { // MARK: - Public CRUD (write path: files only) func save(split doc: SplitDocument) async { - guard let fm = fileManager else { return } + guard let store else { return } do { - try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) + try await store.write(doc, to: doc.relativePath) lastSyncError = nil } catch { report("Failed to save split", error) @@ -206,9 +211,9 @@ final class SyncEngine { } func save(workout doc: WorkoutDocument) async { - guard let fm = fileManager else { return } + guard let store else { return } do { - try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) + try await store.write(doc, to: doc.relativePath) lastSyncError = nil } catch { report("Failed to save workout", error) @@ -220,18 +225,20 @@ final class SyncEngine { } func delete(split: Split) async { - await softDelete(id: split.id, kind: .split, livePath: split.jsonRelativePath) + 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) + 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()) + /// Writes a tombstone stub then removes the live file. Other devices learn of + /// the delete via the stub even if they were offline for the file removal. + private func softDelete(id: String, kind: String, livePath: String) async { + guard let store, let tombstones else { return } do { - try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath) + try await tombstones.writeTombstone(Tombstone(id: id, deletedAt: Date(), kind: kind)) + try await store.remove(at: livePath) lastSyncError = nil } catch { report("Failed to delete \(id)", error) @@ -241,10 +248,10 @@ final class SyncEngine { // MARK: - Import / reconcile private func importFile(relativePath: String) async { - guard let fm = fileManager else { return } + guard let store, let tombstones else { return } let data: Data do { - data = try await fm.read(relativePath: relativePath) + data = try await store.readData(from: relativePath) } catch { // Includes eviction-download timeouts — log loudly, never silently // treat an unreadable file as absent. The next monitor event or @@ -255,12 +262,12 @@ final class SyncEngine { } if relativePath.hasPrefix("Splits/") { - guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { return } - if await fm.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); return } + guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return } + if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: 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.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); return } + guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return } + if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return } CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context) } } @@ -269,14 +276,14 @@ final class SyncEngine { /// 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 } + guard let store, let tombstones else { return } isSyncing = true defer { isSyncing = false } // IDs from stub filenames, not stub contents — an evicted stub must // still count as a tombstone or the deleted record resurrects. - let tombstoned = await fm.listTombstoneIDs() - let dataFiles = await fm.listDataFiles() + let tombstoned = await tombstones.listStubIDs() + let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") } var liveSplitIDs = Set() var liveWorkoutIDs = Set() @@ -285,7 +292,7 @@ final class SyncEngine { for path in dataFiles { let data: Data do { - data = try await fm.read(relativePath: path) + data = try await store.readData(from: path) } catch { // A failed read (eviction-download timeout, coordination error) // is not proof the record is gone — remember the path so the @@ -298,13 +305,13 @@ final class SyncEngine { 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 } + guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue } + if tombstoned.contains(doc.id) { try? await store.remove(at: 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 } + guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue } + if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue } CacheMapper.upsertWorkout(doc, relativePath: path, into: context) liveWorkoutIDs.insert(doc.id) } @@ -357,12 +364,11 @@ final class SyncEngine { // MARK: - Maintenance private func cleanupOldStubs() { - guard let fm = fileManager else { return } + guard let tombstones 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) - } + // Downloads evicted stubs to read their deletedAt, prunes past the + // 30-day grace period. + try? await tombstones.prune() } } diff --git a/Workouts/Views/Exercises/ExerciseListView.swift b/Workouts/Views/Exercises/ExerciseListView.swift index c491d45..61aebac 100644 --- a/Workouts/Views/Exercises/ExerciseListView.swift +++ b/Workouts/Views/Exercises/ExerciseListView.swift @@ -7,6 +7,7 @@ // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // +import IndieSync import SwiftUI import SwiftData @@ -207,7 +208,7 @@ struct ExerciseListView: View { ) } let doc = WorkoutDocument( - schemaVersion: WorkoutDocument.currentSchema, + schemaVersion: WorkoutDocument.currentSchemaVersion, id: ULID.make(), splitID: split.id, splitName: split.name, diff --git a/Workouts/Views/Splits/SplitAddEditView.swift b/Workouts/Views/Splits/SplitAddEditView.swift index 9c0fba0..ad1f89e 100644 --- a/Workouts/Views/Splits/SplitAddEditView.swift +++ b/Workouts/Views/Splits/SplitAddEditView.swift @@ -7,6 +7,7 @@ // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // +import IndieSync import SwiftUI import SwiftData @@ -157,7 +158,7 @@ struct SplitAddEditView: View { // Create new split let existing = (try? modelContext.fetch(FetchDescriptor())) ?? [] let doc = SplitDocument( - schemaVersion: SplitDocument.currentSchema, + schemaVersion: SplitDocument.currentSchemaVersion, id: ULID.make(), name: name, color: color, diff --git a/Workouts/Views/Splits/SplitDetailView.swift b/Workouts/Views/Splits/SplitDetailView.swift index c66260a..9dbc2e8 100644 --- a/Workouts/Views/Splits/SplitDetailView.swift +++ b/Workouts/Views/Splits/SplitDetailView.swift @@ -7,6 +7,7 @@ // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // +import IndieSync import SwiftUI import SwiftData diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index c5d2e17..6d197a8 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -7,6 +7,7 @@ // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // +import IndieSync import SwiftUI import SwiftData diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift index 2fd608b..86dcf27 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift @@ -7,6 +7,7 @@ // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // +import IndieSync import SwiftUI import SwiftData @@ -238,7 +239,7 @@ struct SplitPickerSheet: View { // A freshly started workout has no `end` — only completion stamps it. let doc = WorkoutDocument( - schemaVersion: WorkoutDocument.currentSchema, + schemaVersion: WorkoutDocument.currentSchemaVersion, id: ULID.make(), splitID: split.id, splitName: split.name, diff --git a/project.yml b/project.yml index 2e3553e..b70441c 100644 --- a/project.yml +++ b/project.yml @@ -19,6 +19,9 @@ packages: IndieAbout: url: https://git.rzen.dev/rzen/indie-about.git from: "0.2.2" + IndieSync: + url: https://git.rzen.dev/rzen/indie-sync.git + from: "0.1.0" Yams: url: https://github.com/jpsim/Yams from: "6.0.0" @@ -45,6 +48,7 @@ targets: type: file dependencies: - package: IndieAbout + - package: IndieSync - package: Yams - target: Workouts Watch App postBuildScripts: @@ -81,6 +85,7 @@ targets: - "Resources/Info-*.plist" - "Resources/*.entitlements" dependencies: + - package: IndieSync - target: Workouts Watch Widget embed: true postBuildScripts: