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 } } func read(relativePath: String) throws -> Data { 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. 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) } }