Roadmap
Where Wusel stands, and where it is going. It is built incrementally — each step ends with something runnable. Today it is a working, VFS-first Nextcloud client on Linux with a full GNOME desktop integration, installable from a Fedora RPM.
Legend: ✅ done · ◐ works, refinement pending · ⬜ planned
What works today
Engine & CLI
-
✅ Cargo workspace (
wusel-core·wusel-fuse·wusel-desktop·wusel·wusel-mock); mise-pinned toolchain (Rust 1.97.1); the Linux/FUSE build and real-mount tests run in a podman container.wusel-coretests run natively on Linux and macOS. -
✅
wuselCLI:login,mount,service,pin/unpin/pins,accounts/account,desktop,cache,search-provider,doctor. -
✅
wusel doctor— a support-diagnostics command that collects system, daemon (per-thread kernel wait-channels), mount (/sys/fs/fusewaitingcount), configuration and connectivity, and asks the running daemon for its internal state over a per-user socket. Redacted by default (name-free engine view, no secrets, home/username masked); writes a.txtand.jsonbundle with-o. Built because talking a non-technical user throughcat /sys/fs/fuse/…is not viable, and commercial support needs a one-command bundle. See Troubleshooting. -
✅ Per-account configuration (
config.toml) and multiple named accounts, each with its own credentials, state, cache and mountpoint. Scoped logging viaRUST_LOG.
Authentication
-
✅ Nextcloud Login Flow v2 (browser-based, no plaintext password); the app password persists and is reused across restarts.
-
✅ Credentials in the OS keyring by default (freedesktop Secret Service — works with gnome-keyring, KWallet, KeePassXC), fail-soft to a
0600file; opt out with[auth] keyring = false. See Credential storage.
Concurrency (released in 0.2.0)
-
✅ The mount runs on a state machine: every callback becomes an engine intent; a deciding thread that performs no I/O hands steps to database readers, one writer, and network and file pools. One collision policy replaces the coordination that used to be written per operation. See Concurrency.
-
✅ Proven against a real Nextcloud (
mise run e2e-local): responsive during both a large download and a large upload over a throttled 3 Mbit link, write ordering preserved, one GET per hydration, and concurrent transfers overlapping. -
✅ Cancellation: a reader that dies mid-transfer is noticed through the
flushthe kernel delivers for it, and the transfer is given up rather than run out. Measured behaviour, not an assumption —interrupt_probe.rsstays in the tree as the regression guard.
The virtual filesystem (VFS-first)
-
✅ Directory tree from WebDAV PROPFIND into SQLite state;
getattr/lookup/readdiranswered from state, with real mtimes and read-only permissions reflected. Subdirectories load lazily; not a byte of content loads until read. -
✅ Live reads:
open/readstream from the server per range through theContentSourceseam; a reverse proxy that stripsRangeis handled transparently (the requested window is sliced from the full body). -
✅ Caching: whole-file blob cache, keyed by file id and ETag-validated, bounded by an LRU size budget + max age; concurrent reads coalesce; oversized and id-less files stay live.
-
✅ Pinning ("always keep offline"): path-based pins, hydrated proactively and exempt from eviction; driven from the CLI or straight from the file manager.
-
✅ Writing (write-back): a read-write mount — create / write / flush / unlink / rename / mkdir / truncate — with chunked upload for large files, conflict detection via
If-Match(a conflicted copy by default, opt-in 3-way text merge), read-only entries rejected, and mtime propagated. -
✅ Asynchronous write-back (the default):
closereturns once the change is durable locally and the upload runs in the background, so a slow server never blocks a save and a file manager copying many files does not stall. Durable and resumable across a crash; transient failures retry with backoff, permanent ones are parked and reported; an uploading / sync-error emblem shows where each change is.[sync] upload = synckeeps the synchronous behaviour. -
✅ Change detection:
notify_push(WebSocket) for instant invalidation, with TTL + ETag revalidation as the fallback.
Linux desktop integration (GNOME) — confirmed on Fedora/Nautilus
-
✅ Cloud-provider sidebar entry with live sync status (
libcloudproviders). -
✅ Per-file status emblems and a pin/unpin context menu — a native
libnautilus-extensionmodule reading theuser.wusel.statexattr — with live emblem refresh via a daemon → extension D-Bus push. -
✅ Localized desktop notifications for conflicts and upload failures.
-
✅ GNOME Shell search over Nextcloud Unified Search, opening results from the local mount.
-
✅ The mount is excluded from desktop indexers by default (synthetic marker files), so a crawler cannot trigger a hydrate-everything storm.
-
✅ Runs as a systemd user service (mounts at login, logs to the journal, no root); installable from a Fedora RPM (
packaging/rpm/).
Refinements (near-term)
-
⬜ Handle an authentication failure (401) as a first-class state — high priority. Today wusel has none:
error_for_statusfolds a 401 into a generic HTTP error, indistinguishable from a 500 or a dropped connection. The session is never marked invalid, the user is never told, and every later operation runs into the same 401 on its own. Our low, push-driven request cadence keeps that a trickle rather than the storm a busier client would make — but the architecture is the same gap.The aim is to be better than both official clients, which fail at opposite extremes. The iOS app has no central handler and retries blindly. The desktop client does treat 401 centrally, but as a terminal failure — several official and third-party clients "remove the local account entirely" on a 401, deleting cached state and forcing re-onboarding, which is destructive because a 401 does not distinguish a deleted user from one temporarily disabled (a known server-side ambiguity, nextcloud/server#57877).
So the target sits between them: detect a 401 centrally, mark the session invalid, pause the background loops instead of re-hitting it, and tell the user with an actionable notice ("your login is no longer valid — sign in again"). Never delete anything — a disabled account may be re-enabled, and the pins, cache and config must survive it. A
Noticevariant for it (there is none today; the closest isConnectionLost), and a re-auth path that resumes the loops once a fresh app password is in place. -
⬜ Restructure the documentation by audience — high priority, right after the current release. Testers report the docs are hard to navigate and, in particular, that it is not clear what to do after installing the RPM (the content exists — Installation covers login, the systemd service and, in More than one Nextcloud account, per-account
desktop install-provider— but testers do not find it). The fix is structural, not just more content: split into an end-user section (install, turn on, day-to-day use, troubleshooting), an administrator section where it actually differs from the end-user path (currently it mostly does not — call that out explicitly rather than inventing a distinction), and a developer section (architecture, building, contributing). Landing page and nav should route by audience from the top, not leave a flat page list to search. -
◐ The 3-way text merge is implemented and covered by mock end-to-end tests, but not yet verified against a live Nextcloud — treat it as experimental until it is.
-
◐ Emblem refresh announces a file arriving in the cache on every route since 0.2.0, but not a file leaving it — see Telling the desktop the truth below. A transient "syncing" emblem at the moment an open starts would also make the arrival immediate rather than merely prompt.
-
⬜ Proper gettext i18n (
.po/.mo) for the file-manager labels — currently a small built-in de/en table. -
✅ Versioned documentation with a version selector. The published site now builds every version:
mainas "latest" plus each release tag as its number, with the bottom-left menu to switch. The version is supplied by the site playbook (antora-playbook-site.yml, run bybuild.sh site), not byantora.yml— a value there would override the mapping, which is precisely how the original release commits collided (they carryversion: ~). Both tags were moved onto commits whoseantora.ymlomits the key, sov0.1.0→0.1.0(its shipped docs) andv0.2.0→0.2.0join the menu, and eachvX.Y.Zfrom then on appears automatically. Local editing is unaffected —antora-playbook.ymlstill previews the working tree viabuild.sh/build.sh watch. -
⬜ Re-crawl a pinned directory to pick up files added after the pin; prefetch policy and exclusion patterns.
-
✅ Serve a stale pinned file when the server is unreachable. It was a defect, not a missing feature: a pinned blob was served only while its ETag still matched, so once the server copy changed the read fell through to the network even though the complete file was on disk — and offline it simply failed, breaking the pin promise in exactly the situation someone pins for. A transport failure now falls back on the copy we have (a 404 does not: the file is genuinely gone). Serving it is announced rather than silent, once per file, because an application opening it and saving produces a conflicted copy the user never saw coming.
-
✅ Refresh pinned files when the server copy changes — staleness made visible instead of silently fetched. A fifth per-file state (
pinned-stale), an explicit "Update now" action (wusel update <path>and the context menu), and one aggregated notification per sync walk instead of one per file.[sync] refresh_pinnedismanual/ask/auto, whereautofetches only on an unmetered connection — NetworkManager reports it, and anything short of a clear "not metered" degrades toask. Refreshing on open is the one moment avoided: that is when someone is waiting and a poor connection hurts most. Whole files either way — Nextcloud has no delta transfer. Design in Concurrency, where the invalidation simply starts the same hydration flow a read would. -
⬜ Concurrent FUSE dispatch — the fix for interactive stalls, not for transfer speed. Today a blocking call serialises the whole mount: a running hydration delays the file manager’s emblem lookups behind it. The design — an I/O-free decision thread, a per-inode state machine, and the environment assumptions it refuses to make — has its own page: Concurrency. Not implemented; stages 1 and 2 there are worth doing on their own.
-
⬜ Stream hydration in one response. A whole-file fetch currently issues one range GET per chunk;
reqwestcan stream a single response to disk with the same bounded memory. Fewer round-trips, and the natural shape for an event-driven read path. Plausibly the larger throughput win of the two — which of them dominates is unmeasured, so profile before committing to either. -
⬜ Read-ahead/prefetch for sequential reads; optional sparse/range caching for huge, partially-read files (whole-file-per-file stays the model).
-
⬜ Advisory locking (WebDAV LOCK) to warn on the web-editor vs. local-editor case.
-
⬜ Native inotify for local changes (remote invalidation is already kernel-coherent).
-
⬜ Multi-account search; a propagating-root-ETag
Depth: 0optimisation to skip unchanged subtrees.
Telling the desktop the truth
Found by using 0.2.0 on a real Fedora/GNOME desktop, and done. All four had the same shape: the engine knew something had changed and told nobody, so what the user saw was right only by accident — the emblem correct on the next view reload, the file current after the attribute TTL expired. Correct by timeout is not correct.
-
✅ A file leaving the cache is announced. Eviction reports the file ids it dropped; a small thread on the engine side turns them into paths, because the cache layer has no state database and should not get one. Pinning and unpinning are announced by whoever performs them —
wusel pinruns in its own process while the daemon holds the mount, so the daemon’s channel cannot know. -
✅ The kernel is told when a file’s content changed on the server.
Invalidation::Contentcarries the object, and the frontend turns it intoinval_inode, dropping cached pages and attributes. Writing the test found a worse defect behind it: a cold listing asked for a background refresh it had just performed, and that second PROPFIND recorded the server’s state before the syncer ever compared — so a change made in between was invisible.This is not inotify. FUSE reverse-invalidation does not generate fsnotify events, so an editor watching the file is still not woken by itself — whether recent kernels have changed that is worth checking before promising anything. What is achievable is dependable reloading on request. -
✅ Errors say what failed. All 23 arms name the job and the cause, at
debug— some failures are ordinary, and warning about those would teach people to ignore the warnings. A test reads the source and refuses the shape that throws the error away, because it is a comfortable shape to write.
Editing an outdated offline copy
Under [sync] open_pinned = offline (and newest-unmetered on a metered
connection) an outdated offline copy is served, and it is read-only — see
Concurrency. Allowing the edit instead would let somebody
work on a train, and the conflict machinery to land it already exists. What is
missing is the part that makes it correct.
Changing the base version is not an addition to the feature, it is its precondition. An upload names the version its edit is based on, and the server accepts or refuses on that basis. Today the write buffer is filled from the server’s current version and records that ETag, so the statement is true. If the edit were allowed on the outdated copy without changing it, the upload would name a version the edit does not rest on — and the server would accept it, replacing the newer file with no conflict raised. The read-only rule exists to keep that state from arising at all; it is not caution about a defect, there is no defect to be cautious about.
So the work is four changes, and no new conflict handling — the 412 arises by itself once the stated base is the real one:
-
⬜ Fill the write buffer from the local blob rather than the server.
hydrate_tocopies the blob only when it is fresh and otherwise fetches live. -
⬜ Record the blob’s ETag as the buffer’s base, not the row’s. That needs a fact for "the version the user is actually looking at", which the read path now decides but does not report.
-
⬜ Let
cached_byteshand out the outdated blob as the three-way merge’s base. It is the right base — that is the version that was edited — but the method returnsNoneunless the blob is fresh, so today the result would always be a conflict copy rather than a merge. -
⬜ Lift the read-only rule for this case.
Then a text file merges against the version that was actually edited, and anything else becomes a conflict copy. Offline, the upload simply fails and the buffer waits, as it already does.
Open question before any of it: whether this should apply to every mode or
only to offline. Under newest-unmetered on a metered connection, saving
would download the server’s version for the merge — the very cost the setting
exists to avoid.
Later
-
⬜ KDE — the same feature set: the cloud folder via KIO, per-file overlays via a C++
KOverlayIconPluginreading the sameuser.wusel.statexattr, pin/unpin via KDE ServiceMenus, a Baloo config exclude, and a KRunner/Milou search provider (Nemo/Thunar read the same xattr where cheap). -
⬜ A small configuration UI on top of the CLI: an optional graphical helper to log in, add accounts and change options without the terminal — deliberately not a permanent system-tray applet. Status and notifications already live in the desktop’s own mechanisms (the file-manager sidebar and emblems, freedesktop notifications), so there is nothing to re-model in a tray.
-
⬜ Smart open (
wusel open <file>, surfaced as a file-manager context-menu action — not a double-click override): route a file to the right handler. Double-click stays the desktop’s own default (a file in~/Wuselbehaves like any other), because activation is chosen by MIME association, not per-mount; the collaborative path is therefore an explicit menu entry / CLI. Where Wusel has its own handler — an Office document opens in the instance’s collaborative editor (Collabora Online, OnlyOffice, EuroOffice) in a chrome-less WebView, without the surrounding Nextcloud web UI — it uses that; for everything else it hands the file to the OS opener — on Linux the XDG Desktop Portal (org.freedesktop.portal.OpenURI, the modern cross-desktop, sandbox-aware route we already reach over zbus), falling back toxdg-open. Opening is not editing: once it is open, the user decides what to do.This is the opinionated stance again. You already have a desktop you like, with the tools you like, so for anything Wusel has no special handler for, the OS’s own "open" is exactly right — no second desktop rendered inside a browser. Wusel only interposes where it adds something the desktop cannot: live collaborative editing, which is genuinely hard to get natively (Collabora’s desktop build never became a first-class citizen, and where native desktop co-editing is heading is unclear). A chrome-less editor emulates a "local office" that can still co-edit with colleagues — enough for Office files, for now. For plain text, the opt-in 3-way merge already gives a familiar, git-style middle ground.
-
⬜ A second platform (far-future experiment). The order is deliberate: the Linux frontend is finished first and completely — a half-built frontend proves nothing about portability — and only then does a second one start. What has to be true now so that second frontend costs a port rather than a rewrite is recorded in Frontends and portability, together with the reasoning that makes Windows the structurally easier candidate.
-
⬜ Windows: the Cloud Filter API via
windows-rs— native placeholders
Explorer status, on the official API (no FUSE-style shim). Range-based hydration, which is the shape our content path already has. -
⬜ macOS: a native File Provider extension — not FUSE. Additionally demands a signed, notarized app bundle with a sandboxed extension and a bridge into Swift, which is packaging work Windows does not ask for.
-
-
⬜ A Flutter management UI over the same core (far-future). This is the configuration UI point extended past the Linux desktop, and it is where the "maybe a UI one day" question meets our house toolkit.
flutter_rust_bridgelets a single Flutter codebase callwusel-corethe way the CLI does — no re-implementation.The line that must not blur: Flutter is the control surface, never the filesystem. It carries login, accounts, options, status and pin/conflict management; it cannot be the FUSE mount, the iOS File Provider extension, or the Android
DocumentsProvider, which are OS-mandated native hosts that link the same core. So the shape is two consumers of onewusel-core: the Flutter app, and the native VFS host beside it.Two consequences worth recording now. The near-term item keeps the desktop UI minimal because the desktop already supplies status and notifications (sidebar, emblems, freedesktop) — that reasoning inverts on mobile, where none of it exists, so the Flutter app must carry the chrome itself: minimal on desktop, fuller on mobile, one codebase. And on mobile the app and the native extension are separate processes, so the core’s state — SQLite, config,
pins.json— has to live in a shared container (iOS App Group, Android shared storage) or the two see different truths. On the Linux desktop the daemon owns that state and the UI talks to it instead.How the app reaches the core, so it is not re-invented laterThere are two communication paths, and only the first is uniform across the five operating systems:
-
In-process FFI — the universal path. Where the Flutter app links the core directly (login, accounts, settings, reading state), both live in one process and talk over the C ABI.
flutter_rust_bridgegenerates the Dart↔Rust glue — Rustasyncbecomes a DartFuture, a Rust stream a DartStream, errors stay typed — and it is identical everywhere because it is just a linked library. Only the packaging differs: a.so/.dll/.dylibon the desktop, a per-ABI.soviacargo-ndkon Android, an.xcframework(device
simulator, arm64) on iOS. This is the path the App↔core conversation runs on. -
Cross-process coordination — necessarily per-platform. Where a separate VFS host exists (the Linux daemon, the iOS File Provider extension, the Android
DocumentsProvider), two processes are involved and there is no single mechanism: a Unix socket or D-Bus on Linux,ContentResolver.notifyChangeon Android, and on iOS a shared App Group container plusNSFileProviderManager.signalEnumerator— iOS forbids arbitrary IPC between an app and its extension.
-
The design keeps path 2 small by making shared on-disk state the contract,
not a live protocol: pins.json, the config, the SQLite database. Each process
links the same core and applies it to the same files — the pin file’s mtime
reload we already ship is exactly this pattern — so all that is left to send is a
thin "wake up and re-read" signal, and only that signal is platform-specific.
Strictly after a mobile frontend exists (see the second-platform item and Frontends and portability); a management UI with no VFS host under it manages nothing.
Licensing
Wusel is licensed under Apache-2.0. It lets the free sources also ship as signed, commercial store builds (the distribution model), carries an explicit patent grant, and cleanly covers outside contributions (inbound = outbound). A copyleft licence would conflict with the Apple App Store / Microsoft Store terms and force direct-sale signed binaries only — so Apache-2.0 is what keeps the store path open. Wusel is an independent client and shares no code with Nextcloud’s own server (AGPL-3.0) or desktop client (GPL-2.0-or-later). Who develops, distributes and sells it is in Project, licence & distribution.