Portability

This page records an intention, not shipped behaviour. The Linux FUSE frontend is complete and the macOS File Provider one is experimental — and the groundwork this page argues for is now built, which the sections below bear out. It belongs next to Architecture, which names the three frontends, and Concurrency, which designs the state machine they would share.

Why this page exists at all

Projects of this kind rarely die of a missing feature. They die when the second platform turns out to cost a rewrite of the first, nobody can justify the money, and the port is quietly abandoned — leaving a codebase shaped around assumptions nobody dares to touch any more.

The defence is not to build all three now. It is to decide, while the state machine is still on paper, which knowledge is allowed to live where — and to write that decision down so a later contributor can tell a port from a violation. This is the last cheap moment: after the FSM is implemented, every one of these cuts costs a refactor of working code.

The goal is deliberately modest and testable:

  • FUSE is carried completely — no compromise for the sake of hypothetical platforms, no abstraction that exists only for a frontend nobody has written.

  • A second frontend is paid for in that frontend. It may add translation. It may not require the engine or the machine to be rewritten.

What a port actually is here

The three platforms do solve the same problem, but they place us on opposite sides of the filesystem:

  • FUSE — we are the filesystem. The kernel asks us for everything, getattr and lookup included, and we own the write buffer.

  • Windows Cloud Files API and macOS File Provider — we sit behind the filesystem. NTFS and APFS own the bytes and the metadata. The system answers attribute and directory queries from its own store and calls us for two things: fetch content, and "the user changed something".

Two consequences follow, and they are the whole reason this page is not just a list of API names:

About a third of our callbacks do not port — they disappear. getattr, open, statfs, opendir, releasedir have no counterpart where the OS answers them itself.

The write direction inverts. Today the frontend calls flush and we upload. There, the platform tells us afterwards that a file changed. The upload script is unaffected — preconditions, 412, conflict resolution, chunking are all server semantics — but its trigger moves, and with it the question of who owns the local copy.

The layering, and the cut that defines it

The tempting question is whether a shared core should hold the union of what the three platforms need or their intersection. Both answers are wrong, because they treat the core as a feature set.

The intersection of three platform APIs is nearly empty: conflict resolution, chunked upload and precondition logic appear in none of them, so they would be pushed out into three frontends and written three times — precisely the duplication a shared core exists to prevent. The union collects every platform’s peculiarity until the core’s behaviour depends on who is calling it, which cannot be tested.

The cut runs the other way. For any piece of code:

Does it depend on the semantics of the server — ETags, preconditions, chunking, conflicts, shares, quota — or on the semantics of the platform — identifiers, callback timing, who owns the dirty bytes?

Server semantics belong to the engine. Platform semantics belong to a frontend. The state machine belongs to neither: it is a mechanism.

Layer Knows Must never know

wusel-core — the engine

Nextcloud: WebDAV, ETags, preconditions, conflict resolution, chunking, the state database, pins, the cache

That a frontend exists at all; inodes; errno; who triggered the work

wusel-fsm — the machine

Objects have an identity, an operation is a script of steps, two operations on one object can collide, a step may be abandoned

What an inode is; whether writes are buffered by us or by the OS; how a reply reaches the caller

wusel-fuse, wusel-cfapi, wusel-fileprovider

Its own platform: callbacks, identifiers, completion objects, error codes, and whatever local-file ownership the platform imposes

Anything about Nextcloud. A frontend that needs an ETag is a layering bug

wusel — the product

Configuration, service lifecycle, CLI

The last row of the middle column is the one to hold on to: a frontend that has to reach for server knowledge is not a special case, it is a defect in the layering. That makes the rule reviewable rather than aspirational.

Why the machine gets its own crate

wusel-fsm could be a module inside the engine. It should not be, for one reason: a module boundary is a convention, and a crate boundary is enforced by the compiler. Inside the engine, the machine would inevitably reach for NodeRow, the state database and the WebDAV types, because they are within arm’s reach and nothing objects. As a crate it may depend on the engine — its alphabet is engine intents, which are the same on all three platforms — but it cannot depend on a frontend, and no frontend can quietly grow into it.

This is a small decision now and an expensive one later, which is the whole argument of this page in miniature.

The alphabet: intents, not callbacks

Today the scripts in the operation scripts are the FUSE callbacks. That is the coupling to break, and it is the single change with the most leverage: the machine’s vocabulary should be what the engine does, not what one kernel interface happens to call it.

Intent FUSE Windows Cloud Files macOS File Provider

Enumerate

readdir, lookupensure_loaded

FETCH_PLACEHOLDERS

enumerator with sync anchors

Fetch content

read (range)

FETCH_DATA (range)

fetchContents (whole file)

Materialise

create + first flush

change notification

createItem

Publish

flush, fsync, release

NOTIFY_*, USN journal

modifyItem(contents:)

Remove

unlink, rmdir

NOTIFY_DELETE

deleteItem

Move

rename

NOTIFY_RENAME

modifyItem (parent/name)

Refresh

syncer invalidation

same

same

Abandon

flush with reads outstanding

CANCEL_FETCH_DATA

progress cancellation

Attributes

getattr, …

— the OS answers

partly item(for:)

Two things this table settles. Cancellation is easier everywhere else — both other platforms have an explicit cancel callback, so our "abort is a request, checked between steps" design fits them directly and the flush trick stays a FUSE peculiarity. And content transfer needs no invention: ContentSource::read(node, offset, len) is already the shape Windows asks for, and hydrate_to(node, dest_path) is already the shape macOS asks for. Both exist today.

The other seam: desktop notifications and status

The engine reaches the desktop through a second trait, wusel_core::desktop::Desktop, and it is as platform-agnostic as the intent alphabet: the engine emits a structured Notice and a coarse Status and knows nothing about how either is shown. Notice::localize stays the one place we speak the user’s language; a backend only chooses the delivery mechanism.

pub trait Desktop {
    fn notify(&self, notice: &Notice);      // discrete alerts: a conflict copy, a parked upload
    fn set_status(&self, status: Status);   // idle / syncing / error — coarse, frequent
    fn file_changed(&self, abs_path: &str); // one file's per-item state changed
}

The three methods do not port the same way, and that split is the point.

Per-file status is native, and does not travel this trait at all on macOS. On Linux set_status/file_changed drive libcloudproviders so Nautilus paints an emblem. On macOS the File Provider is the store: an item’s downloaded/uploading/error state is a first-class property the extension already reports (the Publish/Fetch intents above), and Finder renders it. So the macOS backend leaves file_changed a no-op and set_status all but empty — the aggregate progress Finder shows for the domain is the system’s, not ours. This is precisely where the reference client goes wrong by re-inventing a tray icon; the native path is the whole reason to sit behind the File Provider.

Discrete notices are the one thing the backend must actively do — a "conflicted copy saved", a "permanently parked upload", a "connection lost". Linux posts them on the freedesktop notification bus; macOS posts them to Notification Center through the UserNotifications framework.

There the platform imposes one structural fact, and it decides how the daemon is packaged: only an application may post to Notification Center. UNUserNotificationCenter needs a bundle identifier and a one-time authorization; a bare launchd binary has neither, and its notifications are dropped in silence. So the daemon runs as a background agent app — an .app bundle with LSUIElement = true (no Dock icon, no menu bar), launched by launchd, signed with the notification entitlement. With an app identity it asks for authorization once and posts directly — and the notices come from the daemon, never from the File Provider extension, which macOS suspends and kills at will and which therefore must own no state.

How it is wired: the notice travels the socket

On Linux the backend is a Rust Desktop impl that links the engine directly. On macOS it cannot: the agent is a Swift app that drives the engine over the wusel serve Unix socket and never links it (see The socket frontend above). So the notice takes the same road as a change signal — a second push channel on the socket, alongside watch:

engine  --notify(&Notice)-->  IpcDesktop  --localize once-->  Notices fan-out
                                                                    |
                          socket `notices` stream  <----- stream_notices
                                                                    |
   NoticeWatcher (agent)  --UNUserNotificationCenter-->  a banner in the user's language

The pieces, and why each sits where it does:

  • wusel_ipc::IpcDesktop is the Desktop the engine is handed in cmd_serve (via Provider::set_desktop). Its notify calls Notice::localize once — still the single place we speak the user’s language — and pushes the rendered {severity, title, body} to a fan-out. This is deliberately platform-independent Rust: it compiles on Linux too, adds no cfg(target_os) to wusel-core, and keeps the whole notice path testable natively (crates/wusel-ipc/tests/serve_notices.rs). Localization stays in Rust because the string table must not be duplicated in Swift.

  • The locale comes from the engine’s environment (desktop::ui_locale()). A launchd-spawned agent usually has no LANG, so the agent hands the logged-in user’s UI language down when it spawns serve (ServeSupervisor sets LANG from Locale.preferredLanguages). Without it, notices default to English — safe, not wrong.

  • The notices op is the twin of watch: the connection goes one-way and carries pushed notice frames until the client hangs up. Two channels, not one, so the extension can subscribe to just the changes and the agent to just the notices.

  • NoticeWatcher (in the agent, never the extension — only an app may post, and the extension is ephemeral) reads the stream and posts each notice through UNUserNotificationCenter, mapping severity to the banner’s interruption level and sound. It reconnects with a short backoff if serve restarts, the mirror of ChangeWatcher.

The self-test rides the same path too. wusel desktop notify [severity] verifies the channel without waiting for a real sync event — the Linux command, unchanged. On Linux the CLI posts straight to the bus; on macOS the CLI cannot post (only the agent app may), so it connects to the running agent’s socket (auto-discovered, mirroring SharedPaths.socketPath), sends a test-notice, and the server injects the sample through IpcDesktop exactly as a real notice — the agent then shows the banner. The reply reports how many listeners received it, so "sent but no banner" (agent down, or notifications denied) is diagnosed rather than silent.

Connection health rides the same path. ConnectionLost/ConnectionRestored are the notices a user testing the app is most likely to see, and they only fire if something watches reachability. cmd_serve wires a wusel_core::health::Reachability tracker — fed by every DAV request and by the notify_push retry loop — to the same IpcDesktop, exactly as cmd_mount does on Linux. So an otherwise idle daemon still learns the connection went away, and came back, and says so.

Two properties carry over from the Linux backend, and they are the ones that matter: it never blocks or fails the caller (notify only enqueues onto an unbounded fan-out), and it adds no cfg(target_os) to wusel-core — the gate the second checkpoint below watches. Everything macOS-specific — the banner call, the authorization prompt, the locale hand-down — stays in the Swift agent; the Rust half is platform-independent.

Offline availability (pinning), and its two emblems

"Make available offline" is the same engine pin the Linux frontend and wusel pin use — a path recorded in pins.json, its content hydrated and kept. The macOS frontend exposes it as a Finder right-click action and reflects its state with an emblem. The whole feature is the pin store surfaced over the socket; the engine core is untouched.

The mechanism, end to end:

  • The action. The extension declares two NSExtensionFileProviderActions ("Make Available Offline" / "Remove Offline Availability", localized via Localizable.strings) and implements NSFileProviderCustomAction; performAction sends the socket pin/unpin op per item. A File Provider activation predicate cannot read our pinned state, so both actions always show — pin/unpin are idempotent, so that is harmless. A truly state-dependent menu would need a separate FinderSync extension.

  • The state on the wire. stat/enumerate carry a pinned flag (Pins::is_pinned) and a stale flag (content.is_stale — the cached copy’s etag no longer matches the server’s, i.e. PinnedStale). The item folds both into its metadataVersion, so a pin, unpin, or a file going stale is a real metadata change the system re-renders — without it, an unpin (especially of a folder) looked like a no-op.

  • The emblems. A pinned item returns an NSFileProviderDecorations badge: the system checkmark when current, the system warning when stale ("Offline Copy Outdated"). One badge, not both. The native download indicator only tells downloaded from online-only, not kept from merely-downloaded, which is why a custom decoration is needed at all. A pin whose bytes are not down yet — pinned-pending, the ordinary state of a file a directory pin has just started covering — gets no check: the promise is made and the copy is not here, which is precisely what the system’s own not-downloaded indicator already says. The item still declares downloadEagerlyAndKeepDownloaded, so the check appears when the copy lands, and the state is folded into the metadata version so that moment re-renders. A Team/Group folder root carries its own badge beside the offline one — the two say unrelated things, so they are not exclusive. Apple’s built-in decoration images have nothing that reads as "shared with a team", so that badge names an SF Symbol instead; both are system images, neither ships an asset.

  • Immediate feedback. A local pin/unpin emits no server change, so the working-set change channel would never see the acted item. performAction records the acted paths (and, for a folder, its descendants — a folder pin covers its whole subtree) in a shared list the working-set enumerator drains and reports, so every affected emblem updates at once.

  • The covered-file notice. Unpinning a file that a pinned folder still covers does nothing — the folder pin wins. Provider::unpin detects this and emits a localized Notice::PinnedByFolder ("kept offline by its folder"), so the no-op is explained rather than silent. It rides the notice pipeline, so every platform gets it.

One documented behavioural quirk, deliberately left as-is. macOS drives its own replica from the item’s content policy: a pinned item declares downloadEagerlyAndKeepDownloaded, which macOS honours by keeping the current version downloaded — so when the server moves on, the system tends to re-fetch by itself. That differs from the engine’s Linux-side refresh_pinned = ask default (notify, let the user run "Update now"; never spend bandwidth unasked). On macOS the practical result is that a pinned file trends toward staying current on its own, and the "outdated" warning emblem may therefore be short-lived. This is an accepted divergence, not a bug: the offline promise (the file is there, kept, not evictable — verified via fileproviderctl evaluate: isDownloaded=1, effective content policy downloadEagerlyAndKeepDownloaded) holds either way. The isKeepDownloaded=0 field and a transient cloud badge that clears on access are a known Finder display quirk decoupled from the real keep state — there is no settable item property for it, so it is left alone.

Who owns the local copy

This is the rule most likely to be violated by accident, because on Linux the answer is so obvious that it never has to be said:

The engine may offer a write buffer. It must never be the authority on whether an object is dirty.

Under FUSE the machine holds that authority: the buffer registry — open, dirty, base version, pending mtime — lives in wusel-fsm, and the engine no longer has a copy. Under Windows and macOS the operating system holds it, and we are informed after the fact. If both keep the flag, there are two truths about one state — and the symptom appears late and looks like data loss: an upload that overwrites a change the OS had already accounted for.

Stated as a boundary: the buffer is a service the engine offers a frontend, not a fact the engine knows about the world.

What the Provider facade has to lose

The facade this page was written against — list_dir(inode), lookup(parent, name), stat(inode), read(inode, offset, len) — was honest about the intent and wrong in its shape: it was FUSE spelled out. lookup(parent, name) and stat have no caller on Windows, and macOS addresses objects by opaque identifiers that are neither numbers nor paths.

Two changes followed, and both are done:

  1. inode: u64 becomes an opaque object identity. It appears sixteen times in provider.rs alone. Windows uses file identifiers, macOS uses NSFileProviderItemIdentifier strings. The identity must also be stable across a rename — which we already guarantee, because move_subtree keeps the row alive so open handles and a pending buffer survive.

  2. The facade speaks intents. read/hydrate_to stay as they are; the name-and-parent resolution becomes an enumeration concern rather than a per-callback one.

Both were removals of coupling. Neither commits us to Windows or macOS, and both are right even if no second frontend is ever written — which is the test a speculative abstraction has to pass and usually fails.

The engine’s own contract tests now drive the machine rather than a second set of entry points, which is what keeps the two from drifting apart while both look green — and porting them turned up seven real defects that no unit test would have found.

What we do now, and what we deliberately defer

Done, and as cheap while the machine was on paper as predicted:

  • the machine is keyed by an opaque ObjectId, never an inode;

  • its alphabet is engine intents — Fetch, Write, Stat, Lookup, Enumerate, Materialise, Publish, Remove, Move, SetAttr, State, Refresh, Relist — so a frontend maps its callbacks onto them and the machine never learns their names;

  • wusel-fsm depends on nothing, so no platform type can reach it by accident.

Adding four of those intents late made the argument better than the argument did: with no catch-all arm anywhere, the compiler named all eleven places that had to decide what they meant.

Still deferred, on purpose, until a second frontend actually exists:

  • the concrete trait shapes for completions and error mapping. A trait with exactly one implementor is a guess, not a design. We know where the seam goes; we do not yet know its exact form, and pretending otherwise produces the wrong abstraction with more confidence than a missing one.

That distinction is the difference between planning ahead and over-engineering: we remove coupling now, and we invent interfaces only when we have two callers to hold them to.

Two gates that make this checkable

In the spirit of the staged gates in Concurrency, the layering has to be mechanically verifiable, or it will erode:

  1. wusel-fsm builds and its tests pass with no frontend present, and the collision-policy tests read identically no matter which platform is imagined. It holds: 42 tests, none of which needs a mount, a server, a database or a thread, and cargo tree -p wusel-fsm is one line.

  2. wusel-core gains no further cfg(target_os) beyond the handful in keyring.rs and credentials.rs — the only places where secret storage is genuinely an OS matter.

Both fail loudly the moment platform knowledge is pushed into a shared layer.

The order, and which platform comes second

FUSE is finished first, completely, on its own merits. It is the platform we can test end to end today, and a half-finished frontend proves nothing about portability.

For the second, Windows is the easier candidate, on structure rather than taste:

  • it is range-based (FETCH_DATA), which is the shape our content path already has;

  • it is an ordinary process — no application extension, no sandbox, no bridge into another language, windows-rs and plain Win32;

  • its callback set maps almost one-to-one onto the intents above.

macOS asks for more that is structural rather than incidental: a File Provider extension must be an app extension inside a signed, notarized application bundle, sandboxed, with the Rust engine bridged into Swift or Objective-C. Its replicated model wants stable opaque identifiers and enumeration with sync anchors, and content transfer is whole-file — partial fetching exists only in recent releases and under conditions that would need checking before anything is built on it. None of that is prohibitive; all of it is packaging and lifecycle work that Windows simply does not ask for.

The honest counterweight: development happens on macOS, so macOS is the platform we can try most easily and Windows the one we can implement most easily. That tension is worth naming rather than resolving in advance — the decision belongs to whoever picks the work up, with this trade-off visible.

What this page does not commit us to

  • Building either frontend. It commits us to not making them impossible.

  • A FUSE-compatible shim elsewhere. If Wusel ships on another platform, it rides the native framework; that decision stands unchanged.

  • A timeline. This sits behind everything in Roadmap.