TinySync Understand Integrate Decide FAQ Playground ↗
Understand · Chapter 5 of 5

Meeting the OS

File Provider, CFAPI, and why the OS adapter layer is the hard part.

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:

Eager — FolderWatcherAdapter
Remote file bytes downloaded immediately on create/update. Uses Rust notify for filesystem watching. Prototype and CLI only; not the production experience.
Lazy — File Provider / CFAPI
On remote create, only metadata is registered as a placeholder. Content bytes fetched on demand when the user opens the file. Only opened files consume disk space. This is the product 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.

MethodCalled whenResponsibility
apply_createRemote creates an itemWrite bytes (eager) or register placeholder (lazy); create directory for folders
apply_updateRemote modifies a fileReplace content (eager) or invalidate placeholder (lazy); suppress own-write echo
apply_deleteRemote deletes an itemRemove file or directory from local filesystem
apply_move_renameRemote moves or renamesMove/rename at OS level; suppress echo
enumerate_localEngine reconcilingReturn all current local items with kind, hash, size, and optional inode-based identity
read_local_contentEngine uploading local changeReturn bytes at path; verify against expected hash
preserve_conflict_copyDestructive remote change over dirty local bytesCopy local file to conflict-named sibling; return None if source vanished
Own-write suppression
When the engine writes a remote update to disk, the filesystem watcher or OS callback fires for that write. The adapter must suppress this echo — otherwise the engine thinks the user created a new local change and triggers an upload loop. Suppression uses operation tokens, item IDs, content hashes, and short-lived in-flight records.

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:

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:

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.

Why reusing a web sync stack doesn't transfer
A web app's sync stack — whether a CRDTs library, a Postgres-backed REST API, or a local-first framework — delivers state to a JavaScript runtime. That runtime renders the state in a browser. The File Provider and CFAPI APIs require a native OS extension or process with a specific callback lifecycle, placeholder management, and system-level permissions that have no analog in a browser context. This is why the PowerSync decision record treats PowerSync as a capable sync transport while still requiring the full OS adapter layer above it, and why Scribe vs Drive finds that the two products share a sync skeleton but cannot share this 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.