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.
This commit is contained in:
2026-07-05 08:04:29 -04:00
parent 3e63adf363
commit 1f2df491db
18 changed files with 98 additions and 524 deletions
+9 -10
View File
@@ -24,20 +24,19 @@ A workout tracking app for iPhone and Apple Watch, built with Swift 6 and SwiftU
### Persistence & Sync (iCloud Drive documents) ### 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/<ULID>.json` and `Workouts/YYYY/MM/<ULID>.json` (month-bucketed; ULIDs sort chronologically). - **Source of truth**: JSON documents in the iCloud Drive container `iCloud.dev.rzen.indie.Workouts`. One file per aggregate: `Splits/<ULID>.json` and `Workouts/YYYY/MM/<ULID>.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`. - **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. - **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** (under `Workouts/Sync/`): - **Key types**:
- `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. - `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.
- `ICloudFileManager` (`actor`) — all `NSFileCoordinator` file I/O, off the main thread. - 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.
- `ICloudFileMonitor` (`@MainActor`) — wraps the `NSMetadataQuery`, emitting added/modified/removed deltas as an `AsyncStream`.
- **Soft deletes**: a delete writes a `Tombstone` to `Stubs/<id>.json` then removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period. - **Soft deletes**: a delete writes a `Tombstone` to `Stubs/<id>.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. - **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) ### Data Layer (three shapes + a stateless mapper)
All in `Shared/Model/`: 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. - **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). - **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`. - **Enums** (`Enums.swift`) — `WorkoutStatus`, `LoadType`.
### Core Aggregates ### Core Aggregates
@@ -60,7 +59,7 @@ All in `Shared/Model/`:
- iOS 26 / watchOS 26; Swift 6 with `SWIFT_STRICT_CONCURRENCY: complete` on both targets. - 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. - 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.** - 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 ### Key Directories
- `Shared/` (compiled into both targets): `Model/`, `Persistence/`, `Connectivity/`, `Utils/`, `Screenshots/` - `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 ### 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`. - 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. - 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. - Mint IDs with `ULID.make()`; they are stable across cache rebuilds.
### Concurrency ### 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 ### UI
- SwiftUI-first: `NavigationStack`, `@Query`-backed lists, form-based add/edit, sheet presentations, and swipe actions. - SwiftUI-first: `NavigationStack`, `@Query`-backed lists, form-based add/edit, sheet presentations, and swipe actions.
+1
View File
@@ -1,3 +1,4 @@
import IndieSync
import Foundation import Foundation
/// Wire format for the iPhoneWatch bridge. The phone is the only device that /// Wire format for the iPhoneWatch bridge. The phone is the only device that
+5 -55
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import IndieSync
/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache /// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
/// entities so the wire format can evolve without dragging the cache schema. /// 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. /// quarantining the user's whole routine.
var activityType: Int? var activityType: Int?
static let currentSchema = 1 static let currentSchemaVersion = 1
var relativePath: String { "Splits/\(id).json" } var relativePath: String { "Splits/\(id).json" }
} }
@@ -69,7 +70,7 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
// Bumped 12 when `metrics` was added: the captured HR/calorie data is // Bumped 12 when `metrics` was added: the captured HR/calorie data is
// irreplaceable, so the forward-gate must quarantine these files on older apps // irreplaceable, so the forward-gate must quarantine these files on older apps
// rather than let them strip `metrics` on rewrite. // 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) } var relativePath: String { Self.relativePath(id: id, start: start) }
@@ -158,58 +159,7 @@ struct WorkoutMetrics: Codable, Sendable, Equatable {
// MARK: - Forward-compatibility gate // MARK: - Forward-compatibility gate
/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written // `VersionedDocument` (the `isReadable` quarantine gate for files written by a
/// by a newer app version; it must be quarantined, not partially decoded (Codable // newer app version), `Tombstone`, and `DocumentCoder` all live in IndieSync now.
/// 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 }
}
extension SplitDocument: VersionedDocument {} extension SplitDocument: VersionedDocument {}
extension WorkoutDocument: 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/<id>.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
}()
}
+1
View File
@@ -1,3 +1,4 @@
import IndieSync
import Foundation import Foundation
import SwiftData import SwiftData
+2 -2
View File
@@ -24,7 +24,7 @@ extension ExerciseDocument {
extension SplitDocument { extension SplitDocument {
init(from split: Split) { 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, color: split.color, systemImage: split.systemImage, order: split.order,
createdAt: split.createdAt, updatedAt: split.updatedAt, createdAt: split.createdAt, updatedAt: split.updatedAt,
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)), exercises: split.exercisesArray.map(ExerciseDocument.init(from:)),
@@ -43,7 +43,7 @@ extension WorkoutLogDocument {
extension WorkoutDocument { extension WorkoutDocument {
init(from workout: Workout) { 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, splitName: workout.splitName, start: workout.start, end: workout.end,
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt, status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)), logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
+8 -65
View File
@@ -1,68 +1,11 @@
import Foundation import Foundation
import IndieSync
/// Minimal ULID generator. // 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
/// 26-character Crockford base32 string: 10 chars of millisecond timestamp + // app-wide `ULID.make()` minting entry point unchanged.
/// 16 chars of randomness. Lexicographic order matches chronological order, extension ULID {
/// so workout documents sort newest-last by id and file listings stay ordered. /// Mints a fresh ULID for the current instant, as its canonical
/// /// 26-character Crockford base32 string.
/// Spec: https://github.com/ulid/spec static func make() -> String { ULID().stringValue }
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..<bigEnd.count {
let combined = UInt16(bigEnd[j]) | (carry << 8)
bigEnd[j] = UInt8(combined >> 5)
carry = combined & 0x1F
}
output[i] = alphabet[Int(UInt8(carry))]
}
return String(output)
}
} }
+1
View File
@@ -1,4 +1,5 @@
#if DEBUG #if DEBUG
import IndieSync
import Foundation import Foundation
import SwiftData import SwiftData
@@ -5,6 +5,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import IndieSync
import SwiftUI import SwiftUI
import SwiftData import SwiftData
+2 -1
View File
@@ -1,3 +1,4 @@
import IndieSync
import Foundation import Foundation
import SwiftData import SwiftData
@@ -58,7 +59,7 @@ enum SplitSeeder {
) )
} }
return SplitDocument( 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, name: split.name, color: split.color, systemImage: split.icon, order: order,
createdAt: Date(), updatedAt: Date(), exercises: exercises, createdAt: Date(), updatedAt: Date(), exercises: exercises,
activityType: split.activity.rawValue activityType: split.activity.rawValue
-228
View File
@@ -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/<ULID>.json`, `Stubs/<ULID>.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<Data, Error>?
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<String> {
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 (`.<name>.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 `<name>.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<String> = []
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/.<ULID>.json.icloud` to the real
/// path `Workouts/2026/03/<ULID>.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"
])
}
}
-111
View File
@@ -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<String> = []
private var continuation: AsyncStream<FileChangeEvent>.Continuation?
init(documentsURL: URL) {
self.documentsURL = documentsURL
}
func events() -> AsyncStream<FileChangeEvent> {
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..<query.resultCount {
if let item = query.result(at: i) as? NSMetadataItem,
let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL,
let path = relativePath(from: url) {
paths.append(path)
}
}
return paths
}
private func relativePath(from url: URL) -> 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
}
}
+45 -39
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import IndieSync
import SwiftData import SwiftData
import Observation import Observation
import os import os
@@ -38,8 +39,9 @@ final class SyncEngine {
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync") private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
private let modelContainer: ModelContainer private let modelContainer: ModelContainer
private var fileManager: ICloudFileManager? private var store: DocumentFileStore?
private var monitor: ICloudFileMonitor? private var tombstones: TombstoneStore?
private var monitor: MetadataObserver?
private var monitorTask: Task<Void, Never>? private var monitorTask: Task<Void, Never>?
private var connectAttempt = 0 private var connectAttempt = 0
@@ -107,7 +109,7 @@ final class SyncEngine {
guard let containerURL = resolved else { return } guard let containerURL = resolved else { return }
log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)") 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 // 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 // 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…") log.info("connect[\(attempt)]: preparing directories…")
await fm.prepareDirectories() await store.prepareDirectories(["Splits", "Workouts", "Stubs"])
safety.cancel() safety.cancel()
guard attempt == connectAttempt else { return } guard attempt == connectAttempt else { return }
self.fileManager = fm self.store = store
self.tombstones = TombstoneStore(store: store)
iCloudStatus = .available iCloudStatus = .available
log.info("connect[\(attempt)]: directories ready → available") log.info("connect[\(attempt)]: directories ready → available")
WorkoutsModelContainer.persistCurrentIdentityToken() WorkoutsModelContainer.persistCurrentIdentityToken()
await reconcile() await reconcile()
startMonitoring(documentsURL: fm.documentsURL) startMonitoring(documentsURL: store.rootURL)
cleanupOldStubs() cleanupOldStubs()
} }
@@ -149,17 +152,18 @@ final class SyncEngine {
private func startMonitoring(documentsURL: URL) { private func startMonitoring(documentsURL: URL) {
monitorTask?.cancel() monitorTask?.cancel()
let monitor = ICloudFileMonitor(documentsURL: documentsURL) let monitor = MetadataObserver(documentsURL: documentsURL)
self.monitor = monitor self.monitor = monitor
monitor.start() monitor.start()
monitorTask = Task { [weak self] in monitorTask = Task { [weak self] in
for await event in monitor.events() { for await batch in monitor.events() {
await self?.handle(event) await self?.handle(batch)
} }
} }
} }
private func handle(_ event: ICloudFileMonitor.FileChangeEvent) async { private func handle(_ batch: [FileChangeEvent]) async {
for event in batch {
switch event { switch event {
case .added(let path), .modified(let path): case .added(let path), .modified(let path):
if path.hasPrefix("Stubs/") { if path.hasPrefix("Stubs/") {
@@ -172,6 +176,7 @@ final class SyncEngine {
deleteCachedEntity(jsonRelativePath: path) deleteCachedEntity(jsonRelativePath: path)
} }
} }
}
do { try context.save() } catch { report("Cache save failed", error) } do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?() onCacheChanged?()
} }
@@ -195,9 +200,9 @@ final class SyncEngine {
// MARK: - Public CRUD (write path: files only) // MARK: - Public CRUD (write path: files only)
func save(split doc: SplitDocument) async { func save(split doc: SplitDocument) async {
guard let fm = fileManager else { return } guard let store else { return }
do { do {
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) try await store.write(doc, to: doc.relativePath)
lastSyncError = nil lastSyncError = nil
} catch { } catch {
report("Failed to save split", error) report("Failed to save split", error)
@@ -206,9 +211,9 @@ final class SyncEngine {
} }
func save(workout doc: WorkoutDocument) async { func save(workout doc: WorkoutDocument) async {
guard let fm = fileManager else { return } guard let store else { return }
do { do {
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) try await store.write(doc, to: doc.relativePath)
lastSyncError = nil lastSyncError = nil
} catch { } catch {
report("Failed to save workout", error) report("Failed to save workout", error)
@@ -220,18 +225,20 @@ final class SyncEngine {
} }
func delete(split: Split) async { 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 { 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 { /// Writes a tombstone stub then removes the live file. Other devices learn of
guard let fm = fileManager else { return } /// the delete via the stub even if they were offline for the file removal.
let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date()) private func softDelete(id: String, kind: String, livePath: String) async {
guard let store, let tombstones else { return }
do { 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 lastSyncError = nil
} catch { } catch {
report("Failed to delete \(id)", error) report("Failed to delete \(id)", error)
@@ -241,10 +248,10 @@ final class SyncEngine {
// MARK: - Import / reconcile // MARK: - Import / reconcile
private func importFile(relativePath: String) async { private func importFile(relativePath: String) async {
guard let fm = fileManager else { return } guard let store, let tombstones else { return }
let data: Data let data: Data
do { do {
data = try await fm.read(relativePath: relativePath) data = try await store.readData(from: relativePath)
} catch { } catch {
// Includes eviction-download timeouts log loudly, never silently // Includes eviction-download timeouts log loudly, never silently
// treat an unreadable file as absent. The next monitor event or // treat an unreadable file as absent. The next monitor event or
@@ -255,12 +262,12 @@ final class SyncEngine {
} }
if relativePath.hasPrefix("Splits/") { if relativePath.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { return } guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
if await fm.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); return } if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context) CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Workouts/") { } else if relativePath.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return } guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
if await fm.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); return } if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context) 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 /// prunes entities whose file is gone or tombstoned. Runs on connect so
/// changes accumulated while the app was closed are picked up. /// changes accumulated while the app was closed are picked up.
private func reconcile() async { private func reconcile() async {
guard let fm = fileManager else { return } guard let store, let tombstones else { return }
isSyncing = true isSyncing = true
defer { isSyncing = false } defer { isSyncing = false }
// IDs from stub filenames, not stub contents an evicted stub must // IDs from stub filenames, not stub contents an evicted stub must
// still count as a tombstone or the deleted record resurrects. // still count as a tombstone or the deleted record resurrects.
let tombstoned = await fm.listTombstoneIDs() let tombstoned = await tombstones.listStubIDs()
let dataFiles = await fm.listDataFiles() let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") }
var liveSplitIDs = Set<String>() var liveSplitIDs = Set<String>()
var liveWorkoutIDs = Set<String>() var liveWorkoutIDs = Set<String>()
@@ -285,7 +292,7 @@ final class SyncEngine {
for path in dataFiles { for path in dataFiles {
let data: Data let data: Data
do { do {
data = try await fm.read(relativePath: path) data = try await store.readData(from: path)
} catch { } catch {
// A failed read (eviction-download timeout, coordination error) // A failed read (eviction-download timeout, coordination error)
// is not proof the record is gone remember the path so the // is not proof the record is gone remember the path so the
@@ -298,13 +305,13 @@ final class SyncEngine {
continue continue
} }
if path.hasPrefix("Splits/") { if path.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue } guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue } if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
CacheMapper.upsertSplit(doc, relativePath: path, into: context) CacheMapper.upsertSplit(doc, relativePath: path, into: context)
liveSplitIDs.insert(doc.id) liveSplitIDs.insert(doc.id)
} else if path.hasPrefix("Workouts/") { } else if path.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue } guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue } if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
CacheMapper.upsertWorkout(doc, relativePath: path, into: context) CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
liveWorkoutIDs.insert(doc.id) liveWorkoutIDs.insert(doc.id)
} }
@@ -357,12 +364,11 @@ final class SyncEngine {
// MARK: - Maintenance // MARK: - Maintenance
private func cleanupOldStubs() { private func cleanupOldStubs() {
guard let fm = fileManager else { return } guard let tombstones else { return }
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
let cutoff = Date().addingTimeInterval(-Tombstone.gracePeriod) // Downloads evicted stubs to read their deletedAt, prunes past the
for tombstone in await fm.listTombstones() where tombstone.deletedAt < cutoff { // 30-day grace period.
try? await fm.remove(relativePath: tombstone.relativePath) try? await tombstones.prune()
}
} }
} }
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import IndieSync
import SwiftUI import SwiftUI
import SwiftData import SwiftData
@@ -207,7 +208,7 @@ struct ExerciseListView: View {
) )
} }
let doc = WorkoutDocument( let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema, schemaVersion: WorkoutDocument.currentSchemaVersion,
id: ULID.make(), id: ULID.make(),
splitID: split.id, splitID: split.id,
splitName: split.name, splitName: split.name,
+2 -1
View File
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import IndieSync
import SwiftUI import SwiftUI
import SwiftData import SwiftData
@@ -157,7 +158,7 @@ struct SplitAddEditView: View {
// Create new split // Create new split
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? [] let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
let doc = SplitDocument( let doc = SplitDocument(
schemaVersion: SplitDocument.currentSchema, schemaVersion: SplitDocument.currentSchemaVersion,
id: ULID.make(), id: ULID.make(),
name: name, name: name,
color: color, color: color,
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import IndieSync
import SwiftUI import SwiftUI
import SwiftData import SwiftData
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import IndieSync
import SwiftUI import SwiftUI
import SwiftData import SwiftData
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import IndieSync
import SwiftUI import SwiftUI
import SwiftData import SwiftData
@@ -238,7 +239,7 @@ struct SplitPickerSheet: View {
// A freshly started workout has no `end` only completion stamps it. // A freshly started workout has no `end` only completion stamps it.
let doc = WorkoutDocument( let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema, schemaVersion: WorkoutDocument.currentSchemaVersion,
id: ULID.make(), id: ULID.make(),
splitID: split.id, splitID: split.id,
splitName: split.name, splitName: split.name,
+5
View File
@@ -19,6 +19,9 @@ packages:
IndieAbout: IndieAbout:
url: https://git.rzen.dev/rzen/indie-about.git url: https://git.rzen.dev/rzen/indie-about.git
from: "0.2.2" from: "0.2.2"
IndieSync:
url: https://git.rzen.dev/rzen/indie-sync.git
from: "0.1.0"
Yams: Yams:
url: https://github.com/jpsim/Yams url: https://github.com/jpsim/Yams
from: "6.0.0" from: "6.0.0"
@@ -45,6 +48,7 @@ targets:
type: file type: file
dependencies: dependencies:
- package: IndieAbout - package: IndieAbout
- package: IndieSync
- package: Yams - package: Yams
- target: Workouts Watch App - target: Workouts Watch App
postBuildScripts: postBuildScripts:
@@ -81,6 +85,7 @@ targets:
- "Resources/Info-*.plist" - "Resources/Info-*.plist"
- "Resources/*.entitlements" - "Resources/*.entitlements"
dependencies: dependencies:
- package: IndieSync
- target: Workouts Watch Widget - target: Workouts Watch Widget
embed: true embed: true
postBuildScripts: postBuildScripts: