Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

1 Commit

Repository files navigation

plinth

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.


What this framework will do to you

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.

1. Item identifiers end up in system logs

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.

2. Returning noSuchItem deletes the user's file

The error you hand back is not a message. It is an instruction, and the system carries it out:

You returnThe system does
noSuchItemDeletes the item from local disk
an unrecognised errorTreats it as transient and retries forever
cannotSynchronizeShows 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.

3. A changed identifier on modify is a merge, and one copy is destroyed

The framework lets you assign an identifier when an item is createdNSFileProviderReplicatedExtension.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.

4. The system caches capabilities, and will not rebuild that cache

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.

5. stillPendingFields means the fields you did not apply

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.

6. The system asks for containers that do not exist on your server

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.

7. Reporting only additions is not a sync

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.

8. Atomic writes silently break inode-derived identity

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.

9. Sandbox traps that point somewhere else

  • FileManager.fileExists on ~/Library/CloudStorage/…always returns false from inside the sandbox — that path is outside your container. Any UI gated on it is permanently disabled. Ask NSFileProviderManager.getUserVisibleURL instead.
  • 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.

10. Things that are not code, and will still cost you a day

  • Run killall fileproviderd after every extension rebuild. The daemon caches its provider set. Symptom is an ls that hangs and a -2011.
  • File Provider extensions launch on demand. Absent from pgrep is 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 Release does not clear get-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 leaves task_for_pid open — 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 in Extension/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.

What is in here

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.

Running it

# 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.xcodeproj

Build 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.

On the verification of this repo

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.sh asserts 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.

License

MIT — see LICENSE. Copyright (c) 2026 Amur Labs LLC.

About

A working macOS File Provider extension - make a remote server appear as a real drive in Finder - and a written account of the framework traps it cost: identifiers leaking into system logs, noSuchItem deleting the user's file, cached capabilities that silently refuse every write.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages