Files
workouts/Workouts/Sync/ICloudFileManager.swift
T
rzen 22ea502d2c Make reads eviction-safe: download offloaded iCloud files instead of losing them
Wires the previously-dead ensureDownloaded into every read (it now polls,
bounded 30s, instead of fire-and-forget) and fixes the deeper hole: the
directory listing skipped hidden files, so evicted records — whose only local
trace is a hidden .<name>.json.icloud placeholder — never appeared in
reconcile at all and their cache entities were pruned as deleted.

Also: reconcile no longer prunes entities whose file failed to read this pass
(an eviction-download timeout is not a deletion); tombstone IDs come from stub
filenames so an evicted stub still vetoes resurrection; the stale-file stub
check is placeholder-aware; read failures log loudly instead of try?-skipping.

Verified with a harness compiling the real ICloudFileManager against a local
temp directory (placeholder mapping, dedup, tombstone derivation, soft-delete
round trip). Real eviction/download behavior needs a device pass.

Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS
2026-07-04 09:44:53 -04:00

181 lines
8.4 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 read: materializes an evicted file (bounded wait) before the
/// coordinated read, so callers never decode a dataless placeholder. 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 {
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: - 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"
])
}
}