A small, focused Swift package for working with files in an iCloud Drive directory: coordinate reads and writes, watch a directory for external changes, and surface conflicts. Nothing more.
It wraps the awkward parts of NSFileCoordinator, NSMetadataQuery,
NSFilePresenter, and NSFileVersion behind an async/await surface, with no
opinions about your document model, your index, or how you merge conflicting
edits. Those belong to your app.
- iOS 16 / macOS 13 or later
- Swift 5.9+
The platform floor is set by Duration and Task.sleep(for:), which the package
uses internally. It depends on no newer API.
Add the package to your Package.swift:
dependencies: [
.package(url: "https://github.com/tjhootman/FileCoordination.git", from: "1.0.0")
]The package exposes exactly five things — coordinated file access, per-path write
serialization, directory watching, conflict surfacing, and the resolver that gets
you a directory to point them at. Everything else is an implementation detail and
is deliberately internal.
Coordinated, atomic Data read/write/delete. Writes go through a
temp-file-then-rename so a reader never sees a half-written file.
let file = CoordinatedFile()
try await file.write(Data("# Note\n".utf8), to: url)
let data = try await file.read(at: url)
try await file.delete(at: url)A coordinated write is atomic on its own, but a read-modify-write (toggle a task
in a file, apply a debounced edit) spans two coordinated operations — and two such
sequences on the same file will clobber each other without serialization between
them. PathSerializer gives that guarantee, keyed per path so different files
still run in parallel.
let serializer = PathSerializer()
let path = UbiquityResolver.relativePath(of: url, root: root)
await serializer.enqueue(path: path) {
let data = try? await file.read(at: url)
// derive new content …
try? await file.write(newData, to: url)
}Use enqueueThrowing for operations that can throw (the error is rethrown and the
chain still advances). This is intrinsic to safe coordinated writing, which is why
it is public rather than hidden.
One AsyncStream<[URL]> combining three detection mechanisms — NSMetadataQuery
(iCloud-aware), NSFilePresenter (coordinated writes by other processes), and an
optional periodic full rescan (the safety net for un-coordinated local edits).
Subscribe once.
let watcher = DirectoryWatcher(
directory: notesRoot,
options: .init(extensions: ["md"], periodicRescan: 60)
)
watcher.start()
for await changedURLs in watcher.changes {
// re-read these files
}
watcher.stop()extensions filters by file extension (case-insensitive; empty watches all
files). periodicRescan is the safety-net cadence in seconds, or nil to run
event-driven only. A URL may be reported by more than one mechanism, so treat an
emission as "this file may have changed — re-read it," which is idempotent.
Reports that an iCloud conflict sibling exists, and clears one. It stops there — it does not decide how to merge. Merge policy belongs to your app.
let source = NSFileVersionConflictSource()
let siblings = try await source.unresolvedConflicts(at: noteURL)
// ... your app decides what to keep ...
try await source.resolveConflict(at: noteURL, conflictURL: sibling)ConflictSource is a protocol; inject a fixture double to test your
conflict-handling logic without iCloud (see ConflictSourceTests for a reference
double).
Resolves an iCloud Drive ubiquity container, falling back to the local Documents folder when iCloud is unavailable — and says which happened, so you can warn the user instead of silently degrading. The container identifier is injected, so the package carries no app-specific constant.
let resolver = UbiquityResolver(containerID: "iCloud.com.example.app")
switch await resolver.resolve() {
case .iCloud(let url): // syncing
case .localFallback(let url): // warn the user — not backed up
}It has no document model, no persistence/index, and no merge engine. In
particular, deciding how to reconcile two divergent versions of a file (three-way
merge, task-aware union, last-writer-wins) is your policy — this package only
tells you a conflict exists and lets you clear it once you've decided. Keeping
that boundary is a deliberate design rule; see CLAUDE.md.
swift test runs the deterministic suite — coordinated file I/O, atomic writes,
per-path serialization, the periodic scanner, resolution logic, the metadata
result filter, and the conflict-source contract — with no simulator or iCloud
account. This is what CI runs.
Live integration tests are gated and skipped by default. The DirectoryWatcher
suite registers a real NSFilePresenter, starts a live NSMetadataQuery, and
performs coordinated writes into a presented directory. That machinery does not run
reliably on a headless CI runner — there's no running main run loop, and the
file-coordination and Spotlight daemons behave differently or block — so the suite
would hang there despite passing in ~1s on a real machine. Run it locally:
FILECOORDINATION_LIVE_TESTS=1 swift testThis is deliberate, and it follows the same reasoning as the NSMetadataQuery
limitation below: real file-coordination behavior is integration-verified on a real
machine, not asserted in headless CI.
What that split means per component:
NSMetadataQuery— never unit-tested (needs a metadata daemon and real events). Its result-extraction and extension-filtering logic is factored into a pure function,MetadataResultFilter, which is fully tested in CI.NSFilePresenter— covered, but only in the gated live suite: its callbacks fire for in-process coordinated writes, so the local run exercises it without iCloud. This closes the zero-coverage gap the path had in the origin project.NSFileVersionConflictSource— integration-tested (it needs real conflict siblings); theConflictSourceprotocol contract is unit-tested in CI through a double.
MIT — see LICENSE.