Workouts 2.0: re-base persistence on iCloud Drive documents
Replace Core Data + NSPersistentCloudKitContainer + App-Group store + WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents architecture: - iCloud Drive JSON documents are the sole source of truth (one file per aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a rebuildable SwiftData cache populated only by an NSMetadataQuery observer and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate. - Shared model layer (ULID, Codable *Documents + stateless mappers, @Model cache entities, SwiftData container) compiled into both targets. - New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone is the sole writer of iCloud Drive, the watch round-trips documents. - AppServices DI + iCloud-required root gate; Swift 6 strict concurrency. - Starter splits generated on demand from the bundled YAML catalogs. - Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments entitlement (drop CloudKit/App Group/aps-environment). - Duration stored as Int seconds (was a Date epoch hack); fix workout end-on-create, undismissable delete dialog, toolbar-hiding nav stacks, and the Settings placeholder. - Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the Scripts/ TestFlight pipeline (release.sh + ASC API scripts). MARKETING_VERSION 2.0.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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 }
|
||||
}
|
||||
|
||||
func read(relativePath: String) throws -> Data {
|
||||
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.
|
||||
func listTombstones() -> [Tombstone] {
|
||||
listJSON().filter { $0.hasPrefix("Stubs/") }.compactMap { path in
|
||||
guard let data = try? read(relativePath: path) else { return nil }
|
||||
return try? DocumentCoder.decoder.decode(Tombstone.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private func listJSON() -> [String] {
|
||||
let base = documentsURL.path + "/"
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: documentsURL,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return [] }
|
||||
var paths: [String] = []
|
||||
for case let url as URL in enumerator where url.pathExtension == "json" {
|
||||
let full = url.path
|
||||
if full.hasPrefix(base) { paths.append(String(full.dropFirst(base.count))) }
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// MARK: - Eviction
|
||||
|
||||
/// Triggers a download for an evicted file and polls until it materializes.
|
||||
func ensureDownloaded(relativePath: String) {
|
||||
let fileURL = documentsURL.appendingPathComponent(relativePath)
|
||||
guard let values = try? fileURL.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]),
|
||||
let status = values.ubiquitousItemDownloadingStatus, status != .current else { return }
|
||||
try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user