A working macOS File Provider extension — the thing that makes a remote server appear under Locations in Finder as a real drive, with its own name, rather than as a mounted share with a generic network icon.
It is here because the documentation for this framework is thin in the places that hurt, and several of its rules are the kind you only learn by breaking something. Apple's web documentation is an abridgement of the SDK headers, and the paragraphs it drops are the ones that decide whether you lose a user's file.
The code is the smaller half of this repository. The findings are below.
Each of these cost real debugging in a production provider. They are listed worst-first, and every one is implemented in the source here with a comment pointing back at this list.
NSFileProviderItem.h states that the itemIdentifier "should not contain
sensitive information, as it may be recorded in system logs and diagnostic
files."
That is not a hypothetical warning. Using paths as identifiers, 48 distinct real filenames were found sitting in the Mac's unified log in plaintext — a library that held sealed legal and family documents, spilling its table of contents into a log any process could read.
Identifiers must be opaque. Not obfuscated — opaque. They carry no filename because they are not derived from one.
The error you hand back is not a message. It is an instruction, and the system carries it out:
| You return | The system does |
|---|---|
noSuchItem | Deletes the item from local disk |
| an unrecognised error | Treats it as transient and retries forever |
cannotSynchronize | Shows your reason and backs off |
An early version of the WebDAV client collapsed every non-207 response into "not found". A rotated password (401) or a server hiccup (500) therefore read as this file no longer exists, and the system dutifully evicted local copies of files that were sitting safely on the server the whole time.
nil from a stat must mean the server said the item is gone, and nothing else.
Only a 404 is an answer about the item; everything else is an answer about the
request. See DavClient.stat and FileProviderExtension.translate.
The framework lets you assign an identifier when an item is created —
NSFileProviderReplicatedExtension.h says the created item's identifier is "the
identifier assigned to that item by the provider rather than the identifier
passed in through the template".
It grants no equivalent licence on modify. Return a changed identifier there and the header defines it as a merge, after which "the system will keep one of the items and remove the other one from disk."
So an identifier must survive a rename. A path does not. This is the same finding as #1 arriving from the opposite direction: paths fail as identifiers for privacy and for correctness.
The system keeps its own store of every item your provider ever vended — identifiers, capabilities, versions — and it does not rebuild that store because your extension binary changed. Rebuilding the app is not enough. Restarting the daemon is not enough.
Switching from path identifiers to stable ids, the new extension was installed and correctly bound, and the drive still refused every write. The container was still marked read-only from the cached capabilities, so the system rejected each attempt locally and never called the provider at all.
No error. No server traffic. Nothing in any log. Just a drive that quietly would not take a file.
The fix is a schema version you bump yourself, which removes and re-adds the
domain — sanctioned by the header for a provider that does not need its disk
cache. See App/DriveDomain.swift, including why reimportItemsBelowItemWithIdentifier
is the wrong tool if your provider can write.
The second argument to the modifyItem completion handler is the set of fields
not applied. Returning the applied set is the easy way to get this exactly
backwards, and nothing complains when you do.
It will ask you to enumerate .workingSet and .trashContainer by name. Treat
those identifiers as paths and you send your backend nonsense — a
PROPFIND /dav/NSFileProviderWorkingSetContainerItemIdentifier 404ing in a loop,
which is what the logs showed.
They are legitimate containers. Answer them with an empty enumerator, not an error.
Without a server-side change feed, it is tempting to have enumerateChanges
re-report everything as updated and never report a deletion. That is not a sync;
it is a leak of stale state, and a file deleted on the server stays visible in
Finder forever.
The honest answer is syncAnchorExpired — the system then re-enumerates and
works out the deletions itself, which it can do and your extension cannot. Here
the anchor is a content fingerprint, so "nothing changed" stays cheap. See
DriveEnumerator.
Found while writing the reference server for this repo, which is why it is here.
The standard safe write is to stream into a temporary file and rename over the target, so a reader never sees a half-written file. That is correct for durability and wrong if identity is the inode: rename gives the path a new inode, so every save mints a new id for what the user considers the same document — which lands you in #3, where the system destroys one of the copies.
Measured: a PUT over an existing file moved its id from 687235-… to
687236-…. The client then re-read by the id it started with and got a 404.
The deeper lesson is that deriving identity from the inode couples your identity scheme to your write strategy. A backend that stores an explicit id — an xattr, a database row — can write however it likes, and is the better design. This repo derives from the inode because it is simple enough to read in one sitting, and documents the cost.
Related, and worth knowing before you trust (inode, birthtime): in testing,
an inode was reused within milliseconds of a delete. 687232 came back for a
different file on the very next write. The birth time is what kept the two apart.
FileManager.fileExistson~/Library/CloudStorage/…always returns false from inside the sandbox — that path is outside your container. Any UI gated on it is permanently disabled. AskNSFileProviderManager.getUserVisibleURLinstead.- That URL is security-scoped. Without
startAccessingSecurityScopedResource()the failure reads as " does not have permission to open ", which looks like a TCC problem and sends you to Full Disk Access. It is an unclaimed grant, not a missing one.
- Run
killall fileproviderdafter every extension rebuild. The daemon caches its provider set. Symptom is anlsthat hangs and a-2011. - File Provider extensions launch on demand. Absent from
pgrepis correct, not broken. - App Groups are a provisioned capability, so a File Provider extension
cannot be built with ad-hoc signing, and therefore is always sandboxed. Four
things must agree — app bundle id, extension bundle id (prefixed by the app's),
the App Group in both entitlements files, and
NSExtensionFileProviderDocumentGroup. When they drift, the extension enumerates nothing and says nothing. -configuration Releasedoes not clearget-task-allow. Xcode injects it whenever the signing identity is a development certificate, which is the only kind most people have. On a hardened-runtime binary that leavestask_for_pidopen — anything running as the user can attach to the process holding your credential, with no prompt. Deny it explicitly in the entitlements of both targets; they sign separately. See the comments inExtension/PlinthFileProvider.entitlements.- A host app must register a domain or the extension is inert no matter how
correctly it is signed and installed. Register from app scope, not a view's
.task— a suppressed launch window means the task never runs, silently.
App/ host app: registers the domain, holds the credential
Extension/ the provider itself — items, enumeration, reads, writes, errors
server/ a dependency-free reference backend, so this runs end to end
The extension is ~700 lines of Swift 6. The interesting files are
Extension/FileProviderExtension.swift (writes and the error mapping),
Extension/DriveItem.swift (identity and capabilities), and
App/DriveDomain.swift (registration and the schema migration).
The backend is plain WebDAV plus one addition that matters: every item carries a
stable opaque id and can be addressed by it, at /dav/.id/<id>. That is the
contract the extension needs from any backend you point it at.
# 1. the reference servercd server && node plinth-server.js --port 8080
# 2. prove its identity contract holds
./verify.sh 8080
# 3. the app
brew install xcodegen
# set DEVELOPMENT_TEAM in Config/Local.xcconfig, then:
xcodegen generate
open Plinth.xcodeprojBuild and run the app, enter any non-empty account and password, press Connect, and the drive appears under Locations in Finder.
Change the bundle identifiers and the App Group before you build — the defaults
are com.example.* and will not provision under your team.
Stated precisely, because "it builds" and "it works" are different claims and the difference is the whole point of the list above.
- The reference server's identity contract is verified.
server/verify.shasserts 11 properties and passes. It includes the negative case — an id must change on delete-and-recreate — because a check that only ever passes proves nothing. The suite was then mutation-tested by reintroducing finding #8 on purpose; it failed 4 assertions and named the right ones. - The Swift compiles clean. Both targets, Xcode 27, macOS 15 deployment target, no warnings.
- The signed build and the Finder behaviour of this repo are not verified. Signing it requires registering its App Group against a real Apple Developer team, which is not something to do casually. The findings above are not theoretical for that reason — they come from a production provider where all of this was exercised against a live library — but this particular extraction has been compiled, not run.
If you run it and something here is wrong, that is worth an issue.
MIT — see LICENSE. Copyright (c) 2026 Amur Labs LLC.