Architecture
Guiding idea
Nextcloud’s official client avoids a Linux VFS because a single Qt/C++ code base
is maintained for Windows/macOS/Linux, and Linux would need its own,
platform-specific path (FUSE). wusel turns this around: a platform-independent
engine plus interchangeable, thin frontends.
-
wusel-core— engine, no kernel. Tests natively on Linux and macOS. -
wusel-fuse— thin FUSE frontend (library), Linux (libfuse3). -
wusel-desktop— Linux/GNOME integration behinddesktop::Desktop: localized freedesktop notifications, the Nautilus cloud-provider sidebar plus per-file emblems and a pin/unpin menu (libcloudproviders+ a nativelibnautilus-extensionmodule), and a GNOME Shell search provider. A no-op elsewhere; macOS/Windows would be their own crates. -
wusel— daemon/CLI binary, ties engine + frontends together (the product).
Each OS gets its matching frontend (Linux → FUSE + libcloudproviders; macOS → later File Provider Extension), while the engine stays unchanged. That is the strategic advantage over the monolith.
Platform support
Wusel runs on Linux, where the mount is a FUSE filesystem (libfuse3). That is the only supported platform today.
macOS and Windows are not supported yet. They have no FUSE, so the FUSE
frontend is cfg-gated out and the daemon returns a clear error instead of a
broken mount. Much later, and only as experiments, they will get their own
native frontends built on each OS’s official API — a File Provider Extension
on macOS, a Cloud Filter frontend on Windows — each an adapter over the same
Provider facade. We deliberately avoid a FUSE-compatible shim on those
platforms: if Wusel ships there, it rides the native framework, not an emulated
FUSE. This is far down the roadmap (see Roadmap) — but what
has to be true now for those frontends to stay affordable later is recorded in
Frontends and portability.
Testing vs. product
Keep these apart:
-
Testing the mount works on any host that runs podman — macOS or Windows included, where podman uses a Linux VM whose kernel has FUSE. The mount then lives inside that Linux VM (a dev/test path); it is not a host drive.
-
Using Wusel as a real drive is Linux-only today; the native macOS/Windows frontends above are future work. podman does not provide a host drive.
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 · 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-fileprovider (macOS) — File Provider ext. → intents [planned] wusel-cfapi (Windows) — Cloud Filter API → intents [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 Frontends and 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 and Windows frontends remain separate, additive
projects with their own OS glue (Swift + a C ABI on macOS; windows-rs on
Windows) — neither changes the engine, which is the whole point of the split.
VFS-first / on-demand hydration
Under FUSE the "virtual" behavior comes for free: the kernel routes every
open/read through our handler, so "online-only" is just "we don’t have the
bytes until a read asks for them" — no placeholder API. The native frontends
must build the same effect explicitly (macOS File Provider materializing dataless
items, Windows Cloud Filter placeholders + hydration callbacks):
State (provider::FileState) |
Meaning |
|---|---|
|
No local copy; metadata only (from PROPFIND). The default. |
|
A whole, ETag-fresh copy sits in the blob cache — evictable. |
|
Kept offline on purpose (the file, an ancestor directory, or the root is pinned); exempt from eviction. The pin list lives in |
|
Pinned, but the copy we keep no longer matches the server’s. Still served when the server is unreachable — an outdated copy beats an error. |
|
A local edit not yet uploaded (an open write buffer, or a deferred create). |
This is the only per-file state model, and it is derived, not stored:
Provider::file_state answers from the write buffers, the pins table and the
blob cache’s ETag sidecars — SQLite holds no state column. The same four values
are what the OS integrations see, as the user.wusel.state xattr
(File-manager integration documents the contract).
Flow: we answer getattr/readdir from the SQLite state (without content).
Only open/read triggers the hydration (a range GET for the bytes asked for,
plus background hydration of the whole file).
Hydration: serve the range live, cache the whole file in the background
Opening a file caches it — the classic on-demand cache, bounded by an LRU size budget. But the download is decoupled from the read, on two axes:
-
The read is served live, per range, and never blocks. A
read(offset, len)returns exactly that range (a WebDAV range GET, escalating to chunked readahead for a sequential run). The FUSE session loop is single-threaded — it processes one request at a time — so a read must never do a whole-file download inline: a 4 KiB peek at a 2 GiB file would freezestat/tab-completion for everyone for the length of the transfer. That was a real regression. Reads now cost only the bytes asked for, immediately.This holds even when the server never answers with
206.Rangeis only an optional optimisation (RFC 9110 §14.2): a reverse proxy or cache sitting between us and Nextcloud may strip the header, and the server then returns200with the whole body. We detect the non-206status, slice the requested window out of the full response locally, and warn once. The OS still sees an ordinary partial read succeed — the only cost is bandwidth: until the proxy is fixed, each partial read transfers the whole file. -
The whole file is hydrated into the cache in the background. A read of an uncached file also requests hydration; a dedicated worker — with its own client and runtime, so it never contends with the FUSE thread — pulls the rest and publishes an evictable blob. The file then reads local and its per-file emblem flips online-only → cached. Hydration is deduplicated (the storm of reads that opening a file produces enqueues one) and bounded by the LRU budget.
Pinning is the stronger, explicit promise: keep the file offline permanently, exempt from eviction (the green-check emblem). A local write keeps the just-uploaded copy hot too. So the per-file states the OS backends surface are online-only → cached (used, evictable) → pinned (kept) — see File-manager integration.
because opening caches, a desktop indexer or thumbnailer (Tracker, Baloo,
Spotlight) that reads files would trigger background hydration of everything it
touches — a traffic storm (the LRU budget bounds disk, not network). So
exclude_from_indexers is on by default: the FUSE root exposes synthetic,
local-only .trackerignore/.nomedia markers (never uploaded) that GNOME
Tracker honours to skip the whole tree. Opt back in with [desktop]
exclude_from_indexers = false. KDE Baloo ignores markers (needs a config
exclude); the native Cloud Filter / File Provider backends can signal "don’t
hydrate for indexing", FUSE cannot. The long-term answer is a desktop search
provider that queries Nextcloud directly — no local indexing at all.
|
Content delivery: ContentSource + cache decorator
File contents flow through a single encapsulated abstraction, so that caching can be added later transparently, without touching the FUSE layer:
FUSE read() ──► dyn ContentSource
├── LiveWebDav (reads live via range GET)
└── CachingSource (decorator: cache hit? otherwise to Live)
CachingSource implements the same trait and wraps LiveWebDav
(classic decorator pattern) — the caller notices nothing of it.
Pinning ("always keep offline")
Pins are path-based and live in each account’s state DB (pins table), not
per-inode — so a pin survives reconciliation, a directory pin covers its whole
subtree (including entries not yet listed), and pinning the root ("") is
the legacy "download everything" of the old desktop client.
Pinning a path hydrates it now (a directory recursively) and marks each blob with
a .pin sidecar next to the cached file. Eviction skips .pin-marked blobs
entirely — they neither get evicted nor count against the size budget — and a pin
overrides the oversized-bypass, so even a file larger than the budget is kept.
wusel pin | unpin | pins drive this and need no mount (they write straight
into the cache); each account is independent.
Performance principles
WebDAV is the same protocol Nextcloud’s official client uses for transfer
(including chunked upload for large files). The sluggishness people feel with a
GVfs / davfs2 WebDAV mount is not the protocol — it is metadata chattiness
(a PROPFIND per stat), no caching, and per-request PHP overhead. wusel
avoids exactly that:
-
No PROPFIND in the FUSE hot path.
getattr/readdir/lookupare answered from the SQLite metadata cache. PROPFIND runs only to fill or revalidate the cache (see Cache coherence below), never per file operation. -
Read only what is asked for. A read serves just its byte range live; it never downloads the whole file (see Hydration: serve the range live, cache the whole file in the background). A pinned, cached or just-written file is served whole from the blob cache instead — but the blob is only used while its ETag sidecar still matches the ETag in SQLite. Once the sync walk learns the server copy changed, the blob counts as stale and the next read goes to the network again (re-hydrating as it goes); proactive re-hydration of pinned paths is an open roadmap item, see Refinements.
-
Reuse connections. The
reqwestclient keeps a persistent connection pool with HTTP keep-alive and HTTP/2 multiplexing — no TLS handshake per operation. -
Stream and prefetch. Range GET for partial/streaming reads; read-ahead for sequential access.
-
Push over poll.
notify_push(WebSocket) drives change detection instead of repeated PROPFIND polling.
The bottleneck isn’t the bytes of a single GET (which saturates the link); it is round-trip latency on metadata. The cache-first VFS removes it.
Authentication: Login Flow v2
Browser-based, without ever seeing the real user password:
-
POST /index.php/login/v2→loginURL + poll token (auth::begin) -
User confirms in the browser
-
Poll until the app password arrives (
auth::poll)
The app password is revocable server-side (it is not the account password), so its blast radius is limited by design.
Credential storage: keyring with a file fallback
By default the app password goes into the OS keyring — via the freedesktop
Secret Service, so any provider works: gnome-keyring on GNOME, KWallet on KDE
Plasma, or e.g. KeePassXC (the pure-Rust async-secret-service backend talks
org.freedesktop.secrets, tied to no single desktop). The 0600 file
(credentials.json) is the fail-soft fallback and the opt-out ([auth] keyring =
false, or wusel login --keyring false). The non-obvious question is how the
background service reads the keyring back with nobody typing a password at
start:
-
At graphical login, PAM (
pam_gnome_keyring/pam_kwallet) unlocks the login keyring using the login password the user just entered. -
Wusel runs as a systemd user service (
wusel@.service), so it lives inside that session and reaches the Secret Service over the session D-Bus. The collection is already unlocked → it reads the secret with no prompt. (A system/root service could not: it has no access to the user keyring. Choosing a user service is the precondition, not an accident.)
This has a hard limit: headless / SSH-only / enable-linger / autologin without
a PAM unlock never unlock the keyring, and a background service cannot prompt
anyone — so the secret is unreachable there. This is inherent to the Secret
Service model, not a bug (the reference client hits it too).
Therefore the design is try the keyring, else fall back to the 0600 file:
-
Store the secret in the auto-unlocked login collection; read via the Secret Service when it is available and unlocked.
-
When it is absent or locked (servers, headless), keep using the
0600file. Because the secret is a revocable app password, the file fallback is acceptable hardening-wise — it is a "better by default on the desktop", not a hard gate.
How that fallback is tested
The rule above is only worth as much as the evidence for it, and its interesting
half — what happens when the keyring misbehaves — cannot be staged on a real
machine. You cannot ask a running Secret Service to be absent, to be locked on
demand, or (the case store actually guards against) to accept a write and then
not have it. Tests that simply used the machine’s keyring inherited its state
instead: green in a container, red on an unlocked desktop, and a statement about
neither.
So the keyring sits behind the keyring::Secrets trait, with exactly one
production implementation (keyring::Os). Both halves are then covered, and
both run on every machine, in every run:
-
The decisions —
credentials::store_with/load_withtake the store as a parameter, and the tests pass an in-memory keyring that is absent, locked, or forgetful. No D-Bus is reachable from those tests at all, so the host’s keyring is not merely irrelevant, it is unobservable. -
The backend — one contract test states what a
Secretsimplementation promises (notably: "no such entry" isOk(None), not an error) and is run against the fake and againstOs.mise run testprovides what the latter needs:scripts/test.shstarts a private D-Bus session with an empty, unlocked gnome-keyring under a temporaryXDG_DATA_HOME.
Deliberately not #[ignore]: a test that only runs when someone remembers to
ask for it does not run. Just as deliberately not skipped-when-absent: a green
run must mean the thing was checked. Without the tools the suite stops and names
the package to install.
The throwaway session has a second benefit. No test can reach the developer’s
real login keyring even by accident — which is not hypothetical: the credential
tests used to write a dummy secret over the wusel entry of the default
account, because a test and the product naturally pick the same account key.
Change detection
-
Primary (implemented):
notify_push(Nextcloud high-performance backend) via WebSocket — the endpoint is discovered through OCS capabilities, the client authenticates with login + app password, and eachnotify_filestamps a shared invalidation timestamp. Affected directories are re-listed (and their content cache re-validated) on next access. Runs on its own thread; seewusel_core::push. -
Fallback (implemented): TTL revalidation — PROPFIND + ETag reconciliation when a listing is older than
sync.revalidate_secs, and whenevernotify_pushis unavailable. Used automatically if the server lacks the app.
TLS & certificates
All network I/O — WebDAV, OCS, Login Flow v2, and the notify_push WebSocket —
rides a single reqwest client (the WebSocket via reqwest-websocket), so TLS
is configured in exactly one place (wusel_core::tls). rustls with the ring
provider is the only TLS stack; no OpenSSL.
Trust policy, safest to loosest:
-
Default: the OS trust store (
rustls-tls-native-roots) — the same certificates a browser orcurltrusts. Public and enterprise CAs installed system-wide work without configuration. -
Private CA / self-signed: set
tls.ca_certto a PEM file; those certificates are trusted in addition to the OS store. The clean path for self-hosters. -
tls.insecure = true: disables verification entirely. A testing-only escape hatch; the daemon logs a prominent warning at start. Never a fallback — a failed handshake never silently downgrades.
Compatibility & stability
wusel targets Nextcloud specifically, not "any WebDAV server". The read/data
plane is close to standard WebDAV and is the most stable; several load-bearing
pieces are Nextcloud extensions. What holds it together is not "it’s all
standard" — it is that these are the same APIs the official desktop/mobile
clients use, so Nextcloud keeps them backward-compatible, plus we degrade
gracefully when an extension is absent or changes (losing convenience, not
correctness).
| Touchpoint | Origin | Stability |
|---|---|---|
PROPFIND + multistatus, GET + |
WebDAV / HTTP standard |
very stable |
Standard props ( |
WebDAV standard |
very stable |
DAV path |
Nextcloud / ownCloud |
stable (long-standing) |
Login Flow v2 ( |
Nextcloud API |
stable, but not WebDAV |
|
ownCloud / Nextcloud extension |
stable convention |
OCS capabilities ( |
Nextcloud API |
stable |
notify_push WebSocket |
Nextcloud app |
most fluid; optional |
Graceful degradation, by design:
-
no
oc:fileid→ the file is served live, uncached (no crash); -
no
oc:permissions→ assumed writable, so the mount does not lock the user out of a file the server would in fact accept; a write the server does not allow is still rejected by the server itself; -
no notify_push / no OCS answer → TTL revalidation;
-
an unparsable date →
mtime0.
Diagnostics: login probes the OCS capabilities and reports the server version
(or warns "is this a Nextcloud instance?"); the mount logs the version on
connect (wusel_core::capabilities::fetch). The PROPFIND parser matches on the
local element name, so namespace-prefix changes do not break it.
The write path adds the most Nextcloud-specific and change-prone surface —
chunked upload NG (/remote.php/dav/uploads/…) — so it is
inherently less "standard" than reading.
Sync state model (ETag-based)
There is one ETag per node — the etag column in SQLite, holding the last
ETag the server reported for it. There is no separate "local" or "last known"
ETag, and no stored sync-state enum: each of the three questions is answered
where it is actually cheap to answer.
| Question | How it is answered |
|---|---|
Do we have a local edit? |
By the presence of a write buffer for that inode ( |
Did the server copy change? |
The reconcile walk compares the stored |
Did both change? |
Not computed at all — it is asked. The upload carries a precondition, and the
server answers |
Deriving the states this way avoids the classic check-then-write race: nothing we
computed a moment ago can go stale between the decision and the PUT.
Conflicts never cause a silent overwrite — see Conflict handling below.
existing databases still carry hydration and sync columns from an
earlier design. They are unused legacy — kept only so an old state DB opens
unchanged — and nothing maps to them.
|
Conflict handling
The official client treats any double-edit as a whole-file collision with an opaque prompt. Wusel does not, and two principles hold across everything below:
-
Access is never blocked. No modal "what to do with the difference?" on open — you open the file and see content.
-
No data loss. Both versions always survive; a conflict never resolves into a silent overwrite.
Detection (implemented)
Uploads carry a precondition, so a server-side change under us is rejected with
412 — the conflict signal (no check-then-write race). Which precondition depends
on what we know about the target (webdav::Precondition):
-
A create (no file id yet — the file has never existed server-side):
If-None-Match: *, so losing the race against a same-named file created elsewhere yields a conflict instead of clobbering it. -
A known base version:
If-Match: "<etag>"— the ordinary case. -
The file exists but its ETag is unknown (the server answered an upload without an
ETagheader, or a listing carried nogetetag): no precondition. A condition we know to be false protects nothing and would turn every single save into a conflicted copy; the exposure is one save until the next PROPFIND restores the ETag, and a genuine concurrent change is still caught by the sync walk.
Resolution (implemented)
-
Default (like the reference client): keep the server version at the original path and save the local edit beside it as
… (conflicted copy <unix>).ext. Lossless, works for any type including binaries. A desktop notification tells the user a copy was made — otherwise the rename is invisible. -
Opt-in
[sync] text_merge = true: a 3-way merge (diffy) before falling back to that copy. Off by default, since it deviates from the reference client’s behaviour.
The three-way merge
Because a merge needs the content of the common ancestor and not just its ETag,
the cached blob of the version the edit started from serves as the base — and
when text_merge is on, a write seeds that base into the cache even if the read
would not otherwise have cached it. The merge then runs base (last known) vs.
ours (the write buffer) vs. theirs (the current server copy):
-
All three must decode as UTF-8; binary content is never merged. Line-based text is the whole scope — Markdown, source, config, CSV and the like — with no per-format special-casing.
-
Non-overlapping edits merge cleanly and upload automatically, conditioned on the exact version that was merged as "theirs", so a third change landing between the merge’s read and its write is caught rather than overwritten.
-
A same-line clash, non-UTF-8 content, or a missing base falls back to the conflicted copy above.
The merge is covered by mock end-to-end tests but not yet verified against a live Nextcloud — treat it as experimental (Roadmap).
Intended direction (not implemented)
Everything from here on is design intent, not current behaviour:
-
Descriptive copy names. Both versions survive today, but under the reference client’s opaque
foo (conflicted copy <unix>).txt. The intent is a name that says who changed it and when, plus a plain-language note. -
In-file git-style markers. Meet developers in their tools: a real clash would appear as
<<<<<<</=======/>>>>>>>in a clearly named copy, resolvable in any merge editor — no proprietary UI. Today a clash simply yields the conflicted copy, with no markers. -
Advisory locking. Nextcloud supports WebDAV LOCK / file locking. When the web editor (Text app) holds a lock, Wusel would see it and warn before a local write — addressing the web-editor vs. local-editor case at the source.
Conflict avoidance is already partly real, though: notify_push plus fast
reconciliation keep the window in which two people unknowingly diverge small.
Cache coherence
The SQLite metadata cache must not drift from the server. Guiding rule: the server ETag is the truth, SQLite is only the speed. Reads may be served slightly stale within a small freshness window by design; the system is eventually consistent, not strongly consistent.
Propagating ETags
In Nextcloud a directory’s ETag changes whenever anything beneath it changes — the change propagates up the tree. This makes "is anything stale?" cheap:
-
One PROPFIND
Depth: 0on a directory → compare its ETag. -
Unchanged → the entire subtree is guaranteed unchanged. Done, no further requests.
-
Changed → descend one level (
Depth: 1), compare child ETags, and recurse only into the changed subtrees.
This turns cache validation from O(all files) into O(changed paths).
This is implemented by the background syncer (provider::sync_loop): on a
push it walks the cached tree from the root, and at each level descends only into
the child directories whose stored ETag differs from the fresh listing —
reaching the directory that actually changed in O(changed paths) and reconciling
it. Crucially, notify_push is path-less (it says something changed, not
what), so this ETag walk is how we find where without re-listing everything and
without the event telling us. The syncer owns its own state connection and HTTP
client, so it runs entirely off the FUSE thread; changed entries it finds are
pushed to the kernel (see Invalidating the kernel’s FUSE cache).
Reconcile triggers
-
Push:
notify_pushfires → the syncer walks the tree by propagated ETags and reconciles whatever moved (a delete/add nobody happened to re-list). -
Poll fallback: periodically check the root ETag (
Depth: 0) — cheap thanks to propagation — whennotify_pushis unavailable. -
On access: if an already-listed directory’s cache entry is stale, revalidate it in the background (see FUSE ↔ async bridge) and serve the cached listing immediately — the slow PROPFIND never blocks the access. Only a directory that was never listed is loaded synchronously (there is nothing to serve yet).
-
On reconnect/startup: one root ETag check to catch anything missed while offline.
Reconcile algorithm (per directory)
PROPFIND Depth: 1, then per child compare local vs. server ETag:
-
same → skip.
-
different + file → update the stored metadata (ETag, size, mtime). A cached blob thereby stops matching its ETag sidecar, so it is ignored from then on and the next read re-hydrates from the server.
-
different + directory → mark the subtree for a lazy re-check.
-
local but gone on the server → deleted remotely → remove from SQLite + blob cache.
-
on the server but not local → new → insert.
Keying on Nextcloud’s oc:fileid (stable across renames) lets us tell a rename
from a delete + create, so a rename does not needlessly discard a hydrated blob.
Own writes never self-desync
Every PUT/MKCOL/MOVE/DELETE response carries the new ETag (OC-ETag/ETag).
We adopt it into SQLite atomically, so our own write never looks "changed" and
never triggers a needless re-download.
Writing: deferred create + write buffer
Writes land in a per-file scratch buffer beside the cache and upload on flush (a plain PUT, or chunked upload for large files). Two properties matter for the churn a live filesystem otherwise generates:
-
Deferred create.
createcontacts the server for nothing — noPUTempty, no PROPFIND. It adds a local node (nooc:fileidyet) and an empty, dirty scratch; the file is materialised on the first flush. A file created and deleted before any flush — an editor probe (vimwrites a4913file to test writability), a temp file — therefore never touches the server. On flush the upload gives the node its server identity (a reconcile of the parent picks up theoc:fileid), and reconcile preserves not-yet-flushed local nodes (aNULLfile id) instead of deleting them. -
Reads see the buffer. While a scratch is open, reads are served from it, so in-progress edits are coherent and a freshly created file is readable before its first flush.
-
Ignored files stay local. Ephemeral editor/OS files (vim swap, LibreOffice/MS Office lock, backup and temp files —
[sync] ignore_patterns) are never uploaded: no PUT on flush, no DELETE on remove. They build on the deferred-create machinery (a local node, its buffer is the file) and, like the reference client’s exclude list, keep the server free of throwaway churn. An ignored temp renamed onto a real name is promoted: its buffer uploads under the new name — exactly the atomic-save pattern of office suites.
A failed upload keeps the buffer for a later retry (never silent data loss), and our own writes adopt the returned ETag so they never look "changed" (above).
Invalidating the kernel’s FUSE cache
FUSE caches our getattr/readdir answers for the attribute/entry TTL we return
(1s). A short TTL means the kernel re-asks quickly — but a file manager sitting
in a directory does not re-readdir on its own, so a server-side change would
linger in its view until the user refreshes.
So the syncer, when its ETag walk finds an added or removed entry, sends it to
the frontend, which calls the fuser notify API (Notifier::inval_entry,
enabled via the abi-7-12 feature) to drop that entry from the kernel’s cache.
The file then appears/disappears in the file manager live, without a manual
refresh. The notification runs on its own thread (the Notifier is obtained from
Session::notifier()), so it never blocks the FUSE request loop.
All tree updates run inside a SQLite transaction (WAL), so the FUSE layer never sees a half-updated tree.
FUSE ↔ async bridge
FUSE callbacks are synchronous/blocking, network I/O is async (tokio). The
fuser session loop processes one request at a time on a single thread, so any
call that blocks it — a PROPFIND, a content fetch — stalls every other
operation queued behind it (tab-completion, stat). Two rules keep that from
happening:
-
Reads serve only their range (see Hydration: serve the range live, cache the whole file in the background) — never a whole multi-second download on the FUSE thread.
-
Revalidation is off-thread. A stale-but-listed directory is refreshed by a background revalidator: a dedicated thread does only the slow PROPFIND and hands the listing back through a channel; the provider applies it (a fast local SQLite reconcile) on the FUSE thread at the next call. So a directory listing — or a background indexer walking the tree — never blocks interactive work. All SQLite access stays on the one thread (single connection, no locks); the worker touches only the network. A pending revalidation per directory is de-duplicated, so a burst of accesses collapses to one PROPFIND.
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. The paragraph above describes 0.1.0, the mitigated single-threaded loop.
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.
File watching (inotify)
Tools (editors, LSP servers, build watchers, file managers) watch files via the kernel’s inotify API. Wusel supports this, with one honest distinction:
-
Local changes (through the mount) work natively: a write through the mount goes through the VFS, so the kernel generates the inotify events itself.
-
Remote changes (another client on the server) are the known FUSE limit: cache invalidation alone does not emit an inotify event. What we do keep coherent is the kernel’s cache — the syncer pushes added and removed entries through the one notify call we use,
Notifier::inval_entry(the kernel’snotify_inval_entry; see Invalidating the kernel’s FUSE cache), and the short attribute/entry TTL makes the kernel re-ask for everything else. A re-stat or re-read therefore reflects reality immediately, but a watcher blocked on an inotify event is not woken by it.
We do not promise perfect remote inotify delivery — local watches are first-class, remote coherence is immediate.
User-facing notifications
We deliberately ship no application window for sync activity or conflicts — unlike the reference client. Everything the user needs is surfaced through the OS’s own channels, along two lines:
Continuous per-file status and conflicts → the file-manager / cloud framework. Not notifications (that would be spam), but the native per-file state and conflict UI:
-
Linux:
libcloudproviders(D-Bus) reports provider and sync status to Nautilus; per-file emblems via file-manager plugins. (Nautilus-centric, and its status channel has been historically fragile.) -
Windows:
CfReportSyncStatusreports sync-root status; the sync root and its state appear natively in File Explorer. Cloud Filter even shows hydration toasts itself. -
macOS: the File Provider surfaces state as Finder badges and errors as
NSFileProviderErrorcodes — e.g. an auth error becomes a Finder "Sign in" ribbon; conflicts are merged by the system (FailOnConflict→ re-modifyItem).
Actionable warnings only → native notifications. A VFS should be invisible, so the bar is deliberately high: a transient toast fires only when one of two things is true — otherwise it is the "sync finished" spam users rightly hate.
-
The user must act, and would learn it nowhere else (the mount is a background service; nobody reads the journal).
-
Data is silently at risk — the user believes something is saved when it is not.
Never for routine success (a file read, uploaded, "synced"), progress, cache eviction, or revalidation. The cases that clear the bar, most important first:
-
A conflicted copy was made (data at risk). The user edited
X, believes it saved, but their version is nowX (conflicted copy …)whileXshows the server’s — invisible without a nudge. -
An upload cannot complete after retries (data at risk): quota exceeded, permission revoked, a persistent server error. The edit lives only in the local scratch and is lost on unmount/restart.
-
Connection or auth is lost (must act): the app password was revoked, a TLS error, the server is unreachable — the "Nextcloud folder" is then silently stale.
-
(minor) the keyring is locked at service start, so the mount cannot begin.
These are largely the structured events the engine already emits as warnings
(the conflicted copy in resolve_conflict, "upload failed — keeping the buffer"
in flush); the Notifier just routes them to a channel, de-duplicated to one
notification per incident.
Good news, only as resolution. Routine success is never announced, but the
recovery of a problem the user was told about is — e.g. ConnectionRestored
after a ConnectionLost. (A successful login is not notified: it happens in
the terminal, where the user already sees the confirmation.) Every notice carries
a Severity (Success / Warning / Error) so the backend renders good vs bad
distinctly — on Linux, a freedesktop urgency hint (critical stays on screen)
plus a distinct standard icon (dialog-information / dialog-warning /
dialog-error). The icon goes through the image-path hint, not just the
app_icon argument: GNOME Shell honours app_icon only when it can resolve the
sending app, so an unpackaged CLI ("wusel") would otherwise fall back to one
generic icon for every severity. The severity→urgency/icon mapping is the
backend’s job; the engine stays platform-independent.
-
Linux:
org.freedesktop.Notifications(D-Bus) — works from the daemon today, no application identity required.wusel desktop notify [info|warning|error]fires a test notice straight through this path, to verify it end to end. -
macOS / Windows:
UNUserNotificationCenter/ toast notifications need an app identity (bundle / AUMID), so they arrive with the packaged frontend layer, not the bare CLI.
Engine shape — one swappable seam (desktop::Desktop). Both message kinds go
through a single trait in wusel-core, Desktop, with two methods: notify(&Notice)
(rare, actionable) and set_status(Status) (continuous idle/syncing/error). The
engine calls these on its hot path and knows nothing of any OS UI. wusel-core holds only the trait, the message enums, and a
no-op NullDesktop — no platform code (the platform-independence rule). The
frontend injects a backend once via Provider::set_desktop(Arc<dyn Desktop>):
-
Linux daemon → a D-Bus backend:
org.freedesktop.Notificationsfor notices,libcloudprovidersfor status. (Implemented behind the trait, not inwusel-core.) -
macOS / Windows (later) → their native frameworks (File Provider status
UNUserNotificationCenter; Cloud FilterCfReportSyncStatus+ toasts).
Because the whole surface is one injected trait, swapping the Linux module for a
macOS/Windows one is a drop-in — the engine is untouched. And because the default
is NullDesktop, a headless box, an unsupported desktop, or a Linux without the
API simply gets nothing: desktop integration can never affect whether the
filesystem works. The notices are largely events the engine already emits as
warnings (conflicted copy, upload-failed), now also routed through notify.
Localization. A Notice carries structured data, not a finished sentence, so
the notification is translated at render time (Notice::localize(locale),
locale from ui_locale() — LC_ALL/LC_MESSAGES/LANG). This is the one
place we speak the user’s language: OS notifications reach non-technical users,
many of whom do not read English. Everything else — logs, CLI output, terminal
errors — stays English. Adding a language is one match arm; a background systemd
service that does not inherit LANG just falls back to English (safe).
"Can we reach the server?" — one shared answer (health::Reachability)
Of those cases, the connection is gone is the one the user cannot diagnose at
all. It does not present as an error: the file manager simply stops drawing the
folder and the application stops opening its document, so the mount looks
hung, and a user who reads it that way starts killing the daemon. Meanwhile
the engine knew — it logged [connect] … dns error per failed request and
carried on. Nobody reads the journal.
The difficulty is that reachability is not a property of any one operation: a
listing, a content read, an upload and the notify_push discovery each learn it
separately, and each would notify separately. So the answer lives in one
place, health::Reachability in wusel-core, and every request reports its
outcome to it — WebDavClient has a single private send for exactly this
reason, so a new call site cannot forget. Three rules keep thousands of events
down to one notification:
-
Only transport failures count (
Error::is_transport): no answer at all — DNS, connect, TLS, timeout, a dropped connection. The distinction is structural, not textual:From<reqwest::Error>keeps a status code asHttpStatusand everything without one asHttp, and "no status" is precisely "nobody answered". A server that answers with a 500 is reachable; that is a different problem, with a different message. -
A blip is not an outage. The first failure only starts a clock; the notice fires when failures are still arriving ten seconds later. (The WebDAV client already retries a dropped keep-alive connection internally, so a lone failure reaching this layer is rare to begin with.)
-
One notice per incident, and the first success both clears the state and — only if the user was told — announces the recovery.
The heartbeat. An idle mount issues no requests, and the interesting moment during an outage is precisely the one nobody is asking about: the recovery. The notify_push listener is the only component that keeps talking to the server on its own, so both of its retry loops report reachability — endpoint discovery while the socket has never come up, and the reconnect loop once it has. A mount nobody is touching therefore still learns within about half a minute that the server went away, and that it is back. Making discovery retry at all also fixes a defect of its own: it used to happen exactly once, so a daemon that started before the network had DNS ran without push until the next restart.
File-manager integration (libcloudproviders)
The Linux half of Desktop::set_status, in wusel-desktop. We speak the
org.freedesktop.CloudProviders D-Bus protocol directly via zbus — no C
libcloudproviders library, no second D-Bus stack (same one zbus already in the
tree via the keyring). A file manager (GNOME/Nautilus best) then shows the mount
with a live sync status.
The daemon’s desktop worker, on one session-bus connection (shared with notifications):
-
Owns a per-account bus name —
org.freedesktop.CloudProviders.wusel.<account>(the account name sanitised to a valid D-Bus element) — and exports, under a matching object path, the three objects the spec’s collector expects: anorg.freedesktop.DBus.ObjectManager, aProviderobject (Name), and anAccountobject (Name,Path= the mountpoint,Icon= an icon name,Status= the account status enum,StatusDetails). -
Maps our
Status(idle/syncing/error) to the wire enum (1/2/3) and, on each change, emitsPropertiesChangedfor the account’sStatus, so the sidebar updates without polling. The status lives in a shared atomic the property getter reads live.
It is fail-soft and per-account: no session bus, or a name/export failure, just disables the file-manager status (notifications and the filesystem are unaffected); each account gets its own bus name and object path, so several mounts coexist.
Discovery: the registration file must be system-installed
The collector reads a .desktop file (Implements=org.freedesktop.CloudProviders
plus a [org.freedesktop.CloudProviders] group giving BusName + ObjectPath)
to find the provider. The catch, straight from the source
(cloudproviderscollector.c): it scans only g_get_system_data_dirs() — i.e.
$XDG_DATA_DIRS (/usr/local/share, /usr/share, Flatpak exports) — and never
g_get_user_data_dir() (~/.local/share). A file dropped in the user’s data home
is therefore never seen, however valid it is. This is why the official Nextcloud
client ships its .desktop in /usr/share/applications (package-installed).
So the running daemon does not write this file (it would be useless in the user’s home). Delivery is separate:
-
the package installs the default account’s file into a system data dir;
-
wusel desktop install-provider [--account NAME]writes it there on demand (needs root — it targets a system dir), anduninstall-providerremoves it.
Because the collector matches every implementing .desktop, one provider per
account (our per-account bus name) is exactly right: N accounts = N files = N
sidebar entries, no coordinating daemon.
One binary, one package — no GNOME-vs-KDE split
libcloudproviders is the GNOME/Nautilus + GTK integration; KDE Dolphin
does not consume it (it uses KIO, and would want a Dolphin plugin for per-file
overlays). That does not fork the build: the daemon simply owns its bus name and
exports the objects — on a desktop with no consumer this is inert and harmless
(fail-soft). A future KDE backend is another Desktop-behind-the-seam module in
the same binary, chosen at runtime by who actually connects, not by a build flag.
Per-file-manager extensions (below) ship as inert, optional pieces pulled in by
whichever file manager is present (weak deps), never as separate distro builds.
Still open (own frontends, reading the same daemon state): per-file emblems via
file-manager plugins — Nautilus-Python, Dolphin KVersionControlPlugin,
Nemo/Thunar.
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 (credentials, 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.)
Distribution & packaging
Not an architecture concern: distribution, licence and the open-core split — including the packaging tooling — live on their own page, Project, licence & distribution.