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/.json`, `Stubs/.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? 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 { 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 (`..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 `.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 = [] 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/..json.icloud` — to the real /// path `Workouts/2026/03/.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" ]) } }