Ports QuickRabbit's resolveConflictsIfAny (R4.3) into the eviction-safe read path: before every coordinated read, pick the NSFileVersion with the latest modification date, promote it to the canonical file inside a coordinated write, mark all conflicts resolved, and prune the losers. Without this, conflict siblings from two-device edits (workout logs edited mid-session are the risky case) accumulate silently and a read returns whichever version the filesystem hands back. Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS
229 lines
11 KiB
Swift
229 lines
11 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 }
|
|
}
|
|
|
|
/// 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"
|
|
])
|
|
}
|
|
}
|