Architecture

Nextcloud’s official client keeps one Qt/C++ code base for Windows/macOS/Linux, so its Linux VFS arrives as an attachment: an external FUSE process (openvfs) dehydrates a directory the mirroring engine has already synced, and is driven over a socket (Compared to the official Nextcloud client). wusel turns this around: a platform-independent engine plus interchangeable, thin frontends, with the virtual filesystem as the starting point instead of a layer on top.

  1. wusel-fsm — the decider: occupancy, collision policy and operation scripts as plain data. No I/O and no dependencies at all.

  2. wusel-core — engine, no kernel: WebDAV, state, pins, content cache, syncer, and the substrate that carries the machine’s steps out. Tests natively on Linux and macOS.

  3. wusel-fuse — thin FUSE frontend (library), Linux (libfuse3).

  4. wusel-ipc — socket frontend: serves the engine’s intent protocol and the per-file status over a Unix-domain socket, for frontends that run out of process. Platform-independent.

  5. wusel-desktop — Linux/GNOME integration behind desktop::Desktop: localized freedesktop notifications, the cloud-provider sidebar entry (libcloudproviders over D-Bus), the FileChanged D-Bus signal for emblem refreshes, a GNOME Shell search provider and the opt-in notify hook. A no-op elsewhere.

  6. wusel — daemon/CLI binary, ties engine + frontends together (the product).

  7. wusel-mock — a mock Nextcloud server for the tests.

Outside the Rust workspace: the Nautilus extension under integration/nautilus/ (C, a libnautilus-extension module) draws the per-file emblems and the "Wusel" context menu from the socket’s status; the macOS frontend under macos/ (Swift, experimental) is an agent app that runs wusel serve and a File Provider extension that drives it over the socket.

Each OS gets its matching frontend (Linux → FUSE + libcloudproviders; macOS → File Provider extension, experimental), while the engine stays unchanged. That is the strategic advantage over the monolith.

Architecture overview

Frontend adapters and the Provider facade

Every OS gets its own thin frontend, and they all reach the engine the same way: by naming an intent and letting the machine decide what that costs.

wusel-fsm                    the decider: occupancy, collision policy, scripts
   Fetch · Write · Stat · Lookup · Enumerate · Materialise
   Publish · Remove · Move · SetAttr · State
   (internal: Relocate · Delete; modelled, not yet submitted: Refresh · Relist)

wusel-core::runtime          the substrate that carries the steps out
   N database readers · 1 writer · network pool · file pool

wusel-fuse          (Linux)   — FUSE callbacks       → intents   [exists]
wusel-ipc           (any)     — socket requests      → intents   [exists]
WuselFileProvider   (macOS)   — File Provider ext.   → socket    [experimental]
Cloud Filter host   (Windows) — Cloud Filter API     → socket    [planned]

Two words carry this, so they are worth stating plainly. An intent is what a call is meant to achieve, independent of the name an operating system gives it — which is why flush, fsync and release are one intent and not three. The substrate is what carries its steps out: the deciding thread, the database readers and writer, and the network and file pools.

The alphabet is what the engine does, not what one kernel interface calls it: flush, fsync and release are one intent because they are one operation, and unlink and rmdir likewise. A frontend maps its platform’s callbacks onto these and formats the replies; it carries no engine logic of its own.

That matters for more than tidiness. wusel-fsm depends on nothing — not on the engine, not on a database, not on an HTTP client, not on a FUSE binding — so "the deciding thread performs no I/O" is enforced by the compiler rather than by review, and a second frontend is a port rather than a rewrite. The reasoning is in Portability; the scripts themselves are in the operation scripts.

wusel-core keeps what was never per-request: the WebDAV client, the SQLite state, pins, the background syncer, and the conflict and reconcile logic the substrate calls. The macOS frontend is a separate, additive project in Swift that does not link the engine: it drives a wusel serve process over the wusel-ipc socket. A Windows frontend would do the same. Neither changes the engine, which is the whole point of the split.

FUSE ↔ async bridge

FUSE callbacks are synchronous/blocking, network I/O is async (tokio). The fuser session loop reads one request at a time on a single thread, so any call that blocks it — a PROPFIND, a content fetch — would stall every other operation queued behind it (tab-completion, stat). Two rules keep that from happening:

  • Reads serve only their range (see VFS-first) — never a whole multi-second download on the FUSE thread.

  • Revalidation is off-thread. A stale-but-listed directory is served from the cache at once, and the machine asks for a background re-list (Action::Refresh). It runs beside the machine as a detached ListRemote job in the network pool’s background tier, served after the work somebody is waiting for — the PROPFIND and a reconcile of the rows in one transaction — with nobody waiting for it. So a directory listing — or a background indexer walking the tree — never blocks interactive work. At most one refresh per directory runs at a time, none while that directory has other work running, and the backlog is capped, so a burst of accesses collapses to one PROPFIND. A listing is dropped if the machine started work on that directory while it was being taken — see Concurrency.

Only the first listing of a directory is synchronous, because there is nothing cached to serve yet.

The single dispatch thread was a ceiling, and it has been lifted

Both rules above were mitigations: they kept the known slow operations off the one thread, while the thread itself stayed a hard serialisation point. Anything that did block it blocked everything behind it — a hydration delayed the file manager’s per-file emblem lookups, and a cp from the mount and an ls elsewhere took turns for no reason.

That ceiling is gone as of 0.2.0. Every callback is now an intent handed to a state machine that decides and performs no I/O, with database readers, a writer, and network and file pools underneath it: see Concurrency for the design and the operation scripts for what each operation does. In 0.1.0 the background revalidator handed its listing back to the FUSE thread, which applied it over the one SQLite connection.

Directory streams see a stable listing

Answering readdir straight from SQLite per chunk would collide with exactly that background revalidation. A directory is not delivered in one reply: the kernel asks for it in chunks identified by an offset into the listing. If a revalidation lands between two chunks and the second chunk is computed from the new listing, the offsets no longer mean the same thing — entries get skipped or duplicated, and ls silently lies.

The FUSE frontend therefore takes a snapshot per traversal: the listing is built when a stream starts and every continuation chunk is served from that same snapshot, so one ls is always internally consistent. The snapshot is not taken at opendir but at the stream’s start, which is also where POSIX rewinddir lands (glibc implements it as a seek to offset 0 on the same descriptor, with no second opendir). That keeps the other half of the contract: a rewound stream must see the directory’s current state, so a long-lived directory handle — a file manager, a watcher, an indexer — is never stuck with a listing from the moment it opened.

A snapshot is a copy of the directory’s names, and a process may hold arbitrarily many directory handles, so the number kept at once is capped. Beyond the cap a stream simply re-lists per chunk again — still correct, just without the intra-stream stability — rather than letting memory grow unbounded.

Running as a service, and multiple accounts

Simple users should not have to babysit a terminal. The daemon runs as a systemd user service — not system-wide: it lives in the user’s session, reads credentials from ~/.config, needs no root, and its FUSE mount is naturally bound to login. Logs go to the journal (journalctl --user -u wusel); tracing writes to stderr, which systemd captures. The daemon owns the mountpoint (creates it if missing) and unmounts cleanly on SIGTERM, so the unit stays minimal.

Multiple accounts. Many users have more than one Nextcloud — personal plus work, or one per client. Accounts are optional named profiles: the implicit default account uses the base dirs directly (a single-account user sees no profile machinery at all), while each named account is opt-in and fully isolated under an accounts/<name>/ subdirectory:

  • default: ~/.config/wusel/ · ~/.local/state/wusel/state.sqlite · ~/.cache/wusel/blobs

  • named work: ~/.config/wusel/accounts/work/ · …/state/wusel/accounts/work/… · …/cache/wusel/accounts/work/…

The CLI carries an optional --account <name> (default default, so the single-account case is unchanged); wusel accounts lists them and wusel account remove <name> deletes a named profile (configuration, credentials including the keyring entry, pins, state, cache — the server is untouched). Named accounts map directly onto a templated systemd unit so N instances run side by side:

# /usr/lib/systemd/user/wusel@.service  (shipped by the package)
[Unit]
Description=Wusel — virtual Nextcloud filesystem (%i)
StartLimitIntervalSec=60
StartLimitBurst=3

[Service]
Type=simple
ExecStart=/usr/bin/wusel mount --account %i
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

The absent sandboxing is deliberate. The unit carries none of the systemd hardening directives one would normally reach for — ProtectSystem, LockPersonality, RestrictRealtime, ProtectKernelModules and friends — because every one of them implies NoNewPrivileges=yes, and an unprivileged FUSE mount goes through the setuid fusermount3 helper. Under NoNewPrivileges the setuid bit is ignored and the mount dies with fusermount3: Operation not permitted. So the hardening is not missing by oversight; it is incompatible with the one thing the service exists to do. What limits the blast radius instead is that this is a user service holding a revocable app password, not a root daemon. Both shipped unit files carry this rationale as a comment, so nobody "fixes" it later.

Two accounts must never share or nest a mountpoint (Linux would over-mount and hide the first), so mount refuses to start where its target equals, contains, or sits inside another active mount (wusel_core::mount::find_conflict against /proc/self/mountinfo). Mounting the same server + user under two accounts is allowed but warned: two read-write mounts of one account edit the same server files through two independent caches and write buffers, so they can conflict with each other exactly as two different machines would.

Flow for a plain user: install the package (pulls fuse3), wusel login --account work <url> (confirm in the browser), then wusel --account work service enable — a convenience subcommand that writes/enables the unit so no one touches systemctl. The account then mounts at every login. The service is enabled only after a successful login, so it never crash-loops without credentials. (The template unit also serves the single default account, as wusel@default.)