85d0eaddbb
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.
119 lines
4.9 KiB
Swift
119 lines
4.9 KiB
Swift
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)
|
|
}
|
|
}
|