The OS calls us
The OS native provider layer is where files appear in Finder and Explorer, and where lazy hydration happens. It is the most novel architectural feature relative to a traditional sync system.
The inversion of control is the key idea: the adapter doesn't push to the OS and wait for a response. The OS calls the adapter — to enumerate items, to fetch content, to signal changes. Each of these callbacks has its own contract:
- Enumerate items. The OS asks the adapter to list the contents of a folder. The adapter returns metadata from the local engine index; no content bytes are needed.
- Fetch contents (with deadlines). When a user opens a placeholder file, the OS calls the adapter to hydrate it. The adapter must respond within a deadline — or explicitly fail the callback — by asking the daemon to fetch content from the cloud.
- Change callbacks. When the user creates, modifies, moves, or deletes a file, the OS notifies the adapter. The adapter translates these into
LocalChangevalues and reports them to the engine. Own-write suppression ensures that writes the adapter itself made do not feed back as false local changes.
notify for filesystem watching. Prototype and CLI only; not the production experience.
The PresentationAdapter interface
The engine talks to all adapters through a single trait. The engine knows nothing about notify events, File Provider callbacks, or CFAPI requests. It speaks in sync intent.
Engine → adapter (apply & reconcile)
#[async_trait::async_trait]
pub trait PresentationAdapter: Send + Sync {
async fn initialize(
&mut self,
callbacks: Arc<dyn EngineCallbacks>
) -> Result<()>;
async fn shutdown(&mut self) -> Result<()>;
fn hydration_strategy(&self)
-> HydrationStrategy;
// Remote -> local presentation
async fn apply_create(
&self, item: ItemView,
content: ContentRef) -> Result<()>;
async fn apply_update(
&self, item: ItemView,
content: ContentRef) -> Result<()>;
async fn apply_delete(
&self, item: ItemView) -> Result<()>;
async fn apply_move_rename(
&self, from: ItemView,
to: ItemView) -> Result<()>;
// Reconciliation
async fn enumerate_local(&self)
-> Result<Vec<LocalEntry>>;
async fn read_local_content(
&self, path: VPath,
expected_hash: Hash) -> Result<Bytes>;
async fn preserve_conflict_copy(
&self, path: VPath,
op_id: OpId,
device_id: DeviceId)
-> Result<Option<VPath>>;
}
Adapter → engine (callbacks)
#[async_trait::async_trait]
pub trait EngineCallbacks: Send + Sync {
// Local filesystem change observed
async fn report_local_change(
&self,
change: LocalChange) -> Result<()>;
// Called by lazy adapters when the OS
// asks for file content. Must support
// ranges, cancellation/deadlines, and
// retry-friendly errors.
async fn fetch_content(
&self,
request: ContentRequest)
-> Result<ContentStream>;
}
The ContentRequest carries a deadline and optional byte range. Lazy adapters forward this directly to the daemon, which satisfies it from cache or cloud.
| Method | Called when | Responsibility |
|---|---|---|
apply_create | Remote creates an item | Write bytes (eager) or register placeholder (lazy); create directory for folders |
apply_update | Remote modifies a file | Replace content (eager) or invalidate placeholder (lazy); suppress own-write echo |
apply_delete | Remote deletes an item | Remove file or directory from local filesystem |
apply_move_rename | Remote moves or renames | Move/rename at OS level; suppress echo |
enumerate_local | Engine reconciling | Return all current local items with kind, hash, size, and optional inode-based identity |
read_local_content | Engine uploading local change | Return bytes at path; verify against expected hash |
preserve_conflict_copy | Destructive remote change over dirty local bytes | Copy local file to conflict-named sibling; return None if source vanished |
macOS: File Provider
TinySync uses the modern replicated File Provider API (macOS 12+). The extension is thin — it receives OS enumeration and hydration callbacks and relays them to the daemon over a Unix domain socket. The daemon owns SQLite and credentials; the extension owns the OS callback lifecycle.
The vault is mounted under ~/Library/CloudStorage/TinySync/. Apple mandates this mount point; Dropbox fought it and lost. This is not negotiable.
Key behaviors of the FileProviderAdapter:
- Placeholders.
apply_createwithContentRef::Placeholderregisters metadata so Finder can show non-materialized items — name, size, icon — without consuming disk space for bytes. - Materialization. When a user opens a placeholder, File Provider issues a hydration callback to the extension. The extension forwards a
ContentRequest(with deadline) to the daemon via Unix socket. The daemon fetches or streams bytes from cloud and satisfies the callback. - Hydration deadlines. Callbacks must complete or be explicitly failed within the OS deadline. A failed callback surfaces a user-visible error in Finder; a timed-out one can stall the extension process.
- Enumerator anchors. File Provider uses anchor-based enumeration to track which items have been communicated to the OS. The adapter maps server
item_ids to File Provider item identifiers and maintains anchors correctly across restarts. - Local changes. User edits are translated into
LocalChangevalues with stableitem_ids where available and reported to the engine viaEngineCallbacks::report_local_change.
The extension has an OS-controlled lifecycle — the OS can spawn multiple instances, invalidate them, or cancel in-progress callbacks. This is exactly why the sync engine lives in the daemon, not the extension. An extension crash or invalidation does not interrupt an ongoing upload or log replay.
Windows: CFAPI
On Windows, TinySync uses the Cloud Filter API (CFAPI). Unlike File Provider's extension model, the provider runs as a separate process that registers a sync root and connects to it.
Key behaviors of the CFAPIAdapter:
- Sync root registration. At startup the provider calls
CfRegisterSyncRoot, thenCfConnectSyncRootto begin receiving callbacks. The sync root is a directory that Explorer renders as a cloud drive. - Placeholder states. CFAPI tracks explicit states per file: online-only, partially-hydrated, fully-hydrated, pinned, unpinned. The adapter tracks these transitions. Pinned files are kept locally; unpinned files revert to online-only placeholders.
- Byte-range hydration. CFAPI hydration callbacks may request byte ranges, not always the whole file. The adapter passes this through as a
ContentRequestwith aByteRangefield; the daemon satisfies it from a stream or cached content. - Antivirus and indexers. Windows Defender, search indexers, and backup agents routinely open placeholder files, triggering hydration. The adapter handles this gracefully — transient
STATUS_CLOUD_FILE_*failures are retried where safe. - Process isolation. The provider is kept as a separate process (not a DLL in Explorer) to isolate shell integration failures. A provider crash does not take Explorer down.
The daemon communicates with the CFAPI provider process over a named pipe, using the same length-prefixed JSON IPC protocol as the macOS extension uses over its Unix domain socket.
Why this layer is the moat
A sync transport delivers rows or events. Only the adapter layer turns them into something Finder or Explorer can render. This distinction matters: a system that correctly syncs data across devices has not solved the OS integration problem. Placeholder registration, hydration deadlines, anchor-based enumeration, and own-write suppression are each independent failure domains that do not exist at the transport layer.
The adapter layer is also where the deepest edge cases live: antivirus interference, hydration races under concurrent callbacks, placeholder state corruption after a crash, and enumerator anchor mismatches after a panic-resync. These are not solvable at the engine or protocol level — they are inherent to the OS integration contract.
Invariant 8 from the architecture reference captures the commitment: the engine does not own OS callbacks; the adapter does not own sync semantics. The PresentationAdapter boundary is strict. The engine never imports notify, File Provider, or CFAPI types. Adapters never import conflict policy or change-log sequencing. Keeping this boundary clean is what makes the engine testable against a MockAdapter and what makes adapter development independently verifiable.