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:
@@ -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/<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`.
|
||||
- **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/<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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import IndieSync
|
||||
import Foundation
|
||||
|
||||
/// Wire format for the iPhone↔Watch bridge. The phone is the only device that
|
||||
|
||||
@@ -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/<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,3 +1,4 @@
|
||||
import IndieSync
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
|
||||
@@ -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:)),
|
||||
|
||||
+8
-65
@@ -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..<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)
|
||||
}
|
||||
// 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 }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#if DEBUG
|
||||
import IndieSync
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
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,17 +152,18 @@ 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 {
|
||||
private func handle(_ batch: [FileChangeEvent]) async {
|
||||
for event in batch {
|
||||
switch event {
|
||||
case .added(let path), .modified(let path):
|
||||
if path.hasPrefix("Stubs/") {
|
||||
@@ -172,6 +176,7 @@ final class SyncEngine {
|
||||
deleteCachedEntity(jsonRelativePath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
do { try context.save() } catch { report("Cache save failed", error) }
|
||||
onCacheChanged?()
|
||||
}
|
||||
@@ -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<String>()
|
||||
var liveWorkoutIDs = Set<String>()
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Split>())) ?? []
|
||||
let doc = SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchema,
|
||||
schemaVersion: SplitDocument.currentSchemaVersion,
|
||||
id: ULID.make(),
|
||||
name: name,
|
||||
color: color,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user