I am going to take apart, layer by layer, roughly 90 lines of Swift that let my app write a single line into a file owned by a different app, inside that app's own iCloud Drive container, with no plugin and no server in between. Four layers, one API most iOS developers never touch on purpose, and one bug that cost me a weekend because I trusted FileHandle more than I trusted Apple's warning label on it.
What's in this teardown:
- Why an iOS app cannot just guess another app's iCloud Drive path, and what
UIDocumentPickerViewControlleractually grants you - Why the permission it grants goes stale, and the bookmark trick that survives a relaunch
- Why a plain append can silently corrupt a file mid-sync, and what
NSFileCoordinatorchanges about that - The one case none of the above solves, and how I detect it instead of pretending it can't happen
The thing I'm taking apart
Since the end of May, a small iOS note-to-email app I build has had an optional feature: send a memo, and a timestamped line also lands in a markdown vault stored in iCloud Drive, formatted the way a daily note expects. No server round trip, no companion plugin installed inside the vault. One app writes into a folder it does not own, on a filesystem two different processes are actively syncing at the same time.
The app is called Simple Memo, and I wrote about the two-layer capture-then-curate shape of its note system in an earlier post. This one is about the pipe underneath it, not the shape: the part that actually moves bytes across the sandbox boundary. That premise hides four separate problems, and I built the feature in the wrong order the first time. I wrote the "append a string to a file" part first, decided it was basically done, and only found the other three problems by making them happen on my own phone. Here they are, in the order iOS actually enforces them, not the order I discovered them.
Why can't I just hardcode the vault path?
The instinct is to construct a path: ~/Library/Mobile Documents/iCloud~md~<vault app>/Documents/<vault name>/Daily/2026-07-03.md, or something close to it, and open a FileHandle on it. On macOS this sometimes works, because the sandbox is looser and Full Disk Access is a checkbox away. On iOS it does not work, on purpose. Every app's iCloud container is a separate sandbox; one app cannot enumerate or open another app's container by path, no matter how well you know the naming convention, because the naming convention is not a contract — it is an implementation detail Apple can and does change between OS versions.
The only sanctioned door in is UIDocumentPickerViewController. The user picks a folder (their vault folder, in my case) through the system file picker, and iOS hands your process a security-scoped URL — a token, effectively, not just a path string. That URL is only valid while you call startAccessingSecurityScopedResource() on it, and only for as long as the system feels like honoring it in that session. This is the first real design constraint: the user has to grant access once, explicitly, through UI you do not control. There is no way to skip that dialog and no way to pre-select the folder for them. I tried, because a one-tap flow was the whole point of the app; Apple's sandbox model says no, and the "why" is the same reason your banking app can't read your email app's cache: cross-app storage access is opt-in per user gesture, not per developer intent.
func requestVaultAccess(from viewController: UIViewController) {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
picker.delegate = self
viewController.present(picker, animated: true)
}
func documentPicker(_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]) {
guard let vaultURL = urls.first else { return }
guard vaultURL.startAccessingSecurityScopedResource() else {
// grant refused or already revoked — surface this, don't silently no-op
return
}
defer { vaultURL.stopAccessingSecurityScopedResource() }
persistBookmark(for: vaultURL)
}
That last line, persistBookmark, is where layer two starts, and it is where I made my first wrong assumption.
Why does the permission go stale between launches?
A security-scoped URL does not survive process death. Quit the app, relaunch it, and the URL you stored in memory (or, if you were careless, in UserDefaults as a plain path string) is worthless — you'll get a permission error on the very first write, every single time, and the failure mode looks exactly like a bug in your write code instead of what it actually is: a missing re-authorization step.
The fix is a security-scoped bookmark, which is a small opaque Data blob you create once and can resolve back into a working URL on every future launch, without asking the user again:
func persistBookmark(for url: URL) throws {
let bookmark = try url.bookmarkData(
options: [],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
UserDefaults.standard.set(bookmark, forKey: "vaultBookmark")
}
func resolveVaultURL() -> URL? {
guard let data = UserDefaults.standard.data(forKey: "vaultBookmark") else { return nil }
var isStale = false
guard let url = try? URL(
resolvingBookmarkData: data,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &isStale
) else { return nil }
if isStale {
// the folder moved, got renamed, or iCloud re-issued its identity — bookmark is dead, re-prompt
return nil
}
return url
}
isStale is not a hint you can ignore. I ignored it for about two weeks of internal testing, on the theory that a stale bookmark would still resolve to something close enough. It does not. A stale bookmark that you use anyway will sometimes resolve to a URL that looks correct and fails on the write, and sometimes resolve to nothing your app can read at all, and the difference depends on exactly how the underlying folder moved — which is not something I control, because the user can rename their vault, or iCloud can re-shuffle the container during a sync conflict, entirely outside my app's runtime. Once isStale comes back true, the only honest move is to drop the bookmark and ask the user to re-grant access through the picker. I now show one line of UI for that ("Reconnect your vault folder") instead of failing the write silently, which is what the app did for those first two weeks.
Why does a plain append corrupt the file mid-sync?
This is the layer that cost me the weekend, and it is the one most tutorials about FileHandle do not mention at all, because most tutorials are not writing into a file that a different process (the iCloud daemon) might be reading, uploading, or rewriting at the exact same moment.
A naive append looks reasonable:
let handle = try FileHandle(forWritingTo: fileURL)
handle.seekToEndOfFile()
handle.write(lineData)
try handle.close()
This works close to 100% of the time on a phone sitting idle. It becomes unreliable the moment the file is actively syncing — for example, right after the user edited the same daily note on their Mac and iCloud is mid-upload of a newer version when your write lands. I reproduced this by editing the same daily note on my Mac and firing a memo from my phone within the same few seconds, on a loop, forty-some times. Three of those runs produced a daily note with a line inserted in the middle of another line's bytes: not a merge conflict dev.to readers would recognize from git, just corrupted UTF-8 you'd only notice by opening the file and seeing garbled text.
FileHandle has no concept of "someone else might be touching this file right now." NSFileCoordinator does — it is the actual mechanism iCloud Drive apps are supposed to use for exactly this, and it existed the entire time; I just did not reach for it on the first pass because the "write a string to a file" tutorials online almost never mention that a coordinator exists.
func appendLine(_ line: String, to fileURL: URL) throws {
let coordinator = NSFileCoordinator()
var coordinationError: NSError?
var writeError: Error?
coordinator.coordinate(writingItemAt: fileURL, options: .forMerging, error: &coordinationError) { safeURL in
do {
if !FileManager.default.fileExists(atPath: safeURL.path) {
try Data().write(to: safeURL)
}
let handle = try FileHandle(forWritingTo: safeURL)
defer { try? handle.close() }
handle.seekToEndOfFile()
handle.write(Data((line + "\n").utf8))
} catch {
writeError = error
}
}
if let coordinationError { throw coordinationError }
if let writeError { throw writeError }
}
Two details in that block are the ones I would flag if I were reviewing someone else's PR. First, .forMerging, not the default writing intent, which assumes you are replacing the whole file. .forMerging tells the coordinator (and, by extension, any NSFilePresenter the other app registered, including that app's own sync layer if it's watching) that this is an additive change, which changes how the coordinator schedules your access relative to a concurrent iCloud upload instead of just serializing "biggest write wins." Second, the coordinator hands you a safeURL inside the closure — not the URL you passed in. Writing to the original fileURL instead of the coordinator-provided one defeats the entire point; I found that mistake in my own first draft by re-reading the closure a third time, not by a test failing.
I re-ran the same forty-loop stress test after switching to the coordinator. Zero corrupted files across three separate runs. That is not a controlled benchmark (it is a home-brew stress test on one Mac and one phone), but it is the difference between the bug reproducing reliably and not reproducing at all, and for a background feature nobody watches while it runs, "not reproducing" is the bar that matters.
The naive path vs. the coordinated path
Plain FileHandle append |
NSFileCoordinator + .forMerging
|
|
|---|---|---|
| Survives a concurrent iCloud upload | No — corrupted 3/40 runs in my test | Yes — 0/40 in the same test |
| Works after an app relaunch | Only with a manual re-grant | Yes, via bookmark resolution |
| Detects a placeholder (not-yet-downloaded) file | No — throws a generic "file not found" | No by itself — needs an explicit download check (below) |
| Extra code vs. a plain append | — | About 15 lines |
| Requires the user to grant folder access once | Yes | Yes (same requirement either way) |
The one thing this doesn't solve
None of the above helps if the target file is an iCloud placeholder your phone hasn't downloaded yet — which happens the first time you point the app at a vault someone else set up on a different device, or after the OS evicts an old file to save local storage. NSFileCoordinator will hand you a safeURL that exists in the directory listing and still fails the moment you try to actually read or write its bytes, because the bytes are not on the device.
You have to check for this explicitly, before you coordinate the write:
let values = try fileURL.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey])
if values.ubiquitousItemDownloadingStatus != .current {
try FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
// and then either poll resourceValues again, or accept the write will
// land on next app launch — I chose the second, on purpose
}
I chose to let the write silently defer to the next launch rather than block the UI on a download of unknown length over an unknown connection. That is the one deliberate trade-off in this whole pipe: I could hold the user's memo in a queue and retry the download-then-write on a timer, which would feel more "real-time," or I could accept that a first-time cross-device vault sync is rare enough that a one-launch delay is an acceptable cost for not adding a retry queue to a feature that already has three other failure modes to track. I picked the second, and I'm not fully sure it's the right call — it's the kind of decision that looks obviously correct until someone hits it on a slow hotel Wi-Fi and can't tell why their note didn't show up.
A few questions I keep getting
Does this need the Files app permission, or a special entitlement?
No special entitlement. UIDocumentPickerViewController for folder access is a standard framework API; the entitlement you do need is the ordinary iCloud Documents capability if you also want your own app's container to sync, which is a separate concern from writing into someone else's.
Does this work if the other app isn't running?
Yes, the write happens against the filesystem, not against the other app's process. The receiving app does not need to be open on any device for the append to land; it will pick up the change the next time it's opened or the next time it polls for changes, same as any other iCloud-synced edit made from another device.
Why not just use a plugin inside the other app instead?
Because that requires the user to install and configure something inside a second app, which is a much heavier ask than granting a folder once through a system picker — and it ties your feature's reliability to another app's plugin API staying stable, which is a dependency I did not want to own.
The one thing that mattered after taking it apart
Every layer here exists because of the same rule: iOS does not trust one app's judgment about another app's files, and it is right not to. The permission model, the bookmark, the coordinator — none of it is incidental complexity you can shortcut past with a clever enough FileHandle call. The closest thing to a lesson I'd underline twice is that .forMerging line: the entire corruption bug disappeared behind one enum case I would have found in the documentation on day one if I had gone looking for "how do other apps write into iCloud files safely" instead of "how do I append to a file in Swift." The second question has a five-line answer that is wrong for this exact situation, and the wrong answer looks completely fine until two devices touch the same file within the same second.
If you've built a write path into a file iCloud is actively syncing, I'd like to know whether NSFileCoordinator alone solved it for you, or whether you also had to add your own conflict resolution on top of it — that's the part I'm least confident I've fully covered.
I write at @simple_memo. Simple Memo is the iOS app behind the pipe this post takes apart. On the days I have background note-forwarding turned on, the same write path lands a line in my vault.
Top comments (0)