Roadmap
What Wusel already does is described throughout this site — start at the overview, or at Platform support for what is tested where. This page is only about what is not done yet, and in which order.
The licence question is settled and lives on its own page: Licence and what it means for you.
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 Install a package.
Legend: ✅ done · ◐ works, refinement pending · ⬜ planned
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 — Install a package 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 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 Portability); a management UI with no VFS host under it manages nothing.