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_status folds 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 Notice variant for it (there is none today; the closest is ConnectionLost), 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.

  • Notification texts beyond two languages. Notice::localize has one arm per language and knows German plus the English fallback. That is enough to prove the seam and thin for the audience it exists for — notifications are the one place we speak the user’s language precisely because non-technical users read them. The official client runs everything through Qt’s tr() and a translation platform and ships dozens. The structure is already right (a Notice is data until a backend renders it), so this is either more arms or, once the count justifies the dependency, a real catalogue (Compared to the official Nextcloud client).

  • Server-side notifications as an opt-in channel. A share invitation or a Talk mention never reaches a Wusel user: we notify about what the engine did, and the server’s own Notifications app is not read. The signal already arrives — notify_push sends notify_notification on the websocket we already hold, and push.rs deliberately ignores it — so the missing piece is fetching the payload from OCS and routing it through Notice. Opt-in, and subject to the same bar as every other notice: it must be something the user has to act on.

  • Versioned documentation with a version selector. The published site now builds every version: main as "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 by build.sh site), not by antora.yml — a value there would override the mapping, which is precisely how the original release commits collided (they carry version: ~). Both tags were moved onto commits whose antora.yml omits the key, so v0.1.00.1.0 (its shipped docs) and v0.2.00.2.0 join the menu, and each vX.Y.Z from then on appears automatically. Local editing is unaffected — antora-playbook.yml still previews the working tree via build.sh / build.sh watch.

  • Full-text search over the documentation. @antora/lunr-extension builds a lunr index while the site is generated and ships the browser-side UI, so the search box in the header works with no search server and no external service. The published site indexes latest only — a hit never comes from a superseded release. The dropdown wears the site’s own palette because the UI bundle carries css/search.css, which is what makes the extension leave its default stylesheet out.

  • ⬜ 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_pinned is manual / ask / auto, where auto fetches only on an unmetered connection — NetworkManager reports it, and anything short of a clear "not metered" degrades to ask. 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; reqwest can 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: 0 optimisation 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 pin runs 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::Content carries the object, and the frontend turns it into inval_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_to copies 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_bytes hand 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 returns None unless 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++ KOverlayIconPlugin speaking the same status socket, pin/unpin via KDE ServiceMenus, a Baloo config exclude, and a KRunner/Milou search provider (Nemo/Thunar speak the same socket where cheap).

  • A 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. It is a normal application, launched for a task and closed, never a resident window that watches the sync: 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, and the daemon stays fully autonomous without it. This is the near-term, Linux increment of the Flutter management UI below — one codebase, minimal here — and the same guardrails govern both.

    Modular, not small — that is the lightweight claim. The engine runs without any of this: the GUI ships as its own optional package the daemon never links and never requires, so whoever wants the light install takes the core and FUSE alone and nothing here follows onto a headless box. "Lightweight against the official client" therefore means fewer parts you can leave out, not a smaller binary — a Flutter engine plus GTK is not that, and the claim must never be made on size.

    Two integration constraints are decided now, because getting them wrong is user-visible:

    • One application identity. There is a single at.itbh.Wusel.desktop and one grid icon. The GUI inherits the identity the search provider and libcloudproviders already reference rather than adding a second "Wusel" launcher; launching it opens the GUI, and opening the mount folder becomes a [Desktop Action]. On Wayland the window app_id must equal that basename, or the shell lists the running window as a separate entry however correct the .desktop is.

    • On Linux it talks to the daemon. The daemon owns the live state (SQLite, config, pins.json) and is its single writer, so the Linux GUI is an IPC client to it, not a second process linking the core behind its back. The in-process FFI path (flutter_rust_bridge) is for platforms where the app itself is the core-linker (mobile); see the Flutter item.

      Share options (create shares, public links with password/expiry via the OCS files_sharing API) wait for this GUI: a share dialog needs a window a context menu cannot host, and it is an engine capability first, a form second. The lighter file-manager actions do not wait on it — Copy internal link (<server>/f/<file_id>, from the file_id the socket already carries) and Open in Nextcloud are CLI verbs the Nautilus extension spawns, no GUI involved.

  • 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 ~/Wusel behaves 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 to xdg-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 frontend — macOS, experimental. The plan was to finish the Linux frontend first, then start a second one, with Portability reasoning Windows the structurally easier candidate. Demand reordered it: the internal need and user feedback for macOS outweighed both KDE and Windows, and the File Provider port proved straightforward — the portability groundwork held, so it was a port, not a rewrite. macOS is pulled ahead of, not instead of, the rest.

    • macOS — a native File Provider extension (not FUSE). What works today: files show up in Finder; the sync status (downloaded vs online-only) is drawn by macOS’s own File Provider indicators; an additional emblem marks items made available offline; and offline pin/unpin runs from the Finder context menu. It rides a signed app bundle with a sandboxed extension and a Swift bridge to the engine over the wusel-ipc socket. Experimental — the frontend is young; the engine underneath is the same one Linux uses.

    • Windows: the Cloud Filter API via windows-rs — native placeholders
      Explorer status on the official API (no FUSE-style shim), range-based hydration as our content path already has. Still the structurally easiest port; it comes later.

  • Frictionless install and uninstall (store-shipped frontends): a design rule for every packaged frontend — one step onto the machine, one step off it, with no manual system-extension dance and no leftover-file hunt. On macOS this is a deliberate contrast with the Nextcloud client, whose removal is a four-stage manual chore: disable the Finder extension, force-quit the background processes, trash the app, then hand-delete Application Support, Caches, Preferences, Logs and CloudStorage — and that last step ships with a data-loss warning.

    Wusel can do better, and say so honestly, because of two structural facts:

    • VFS-first means nothing local is precious. Everything Wusel writes on the client is a cache of server state (the SQLite metadata and hydrated blobs in the App Group container), so wiping it can never lose a file — the incumbent’s "delete CloudStorage, but not your real files" caveat simply does not apply.

    • We own the domain lifecycle. NSFileProviderManager.remove(domain) turns the extension off and tears down the mount in one call — the very step the Nextcloud client leaves to the user. The primitive already exists; the reset-domain recovery path uses it.

      So the deliverable is a first-class uninstall — a menu entry ("Remove Wusel…") and a wusel uninstall command — that in one action removes the File Provider domain (extension off, mount gone), unregisters the login item (SMAppService.unregister), stops serve, deletes the App Group container and logs, and offers to trash the app. Install is the mirror: open a signed, notarized .app and sign in — the app self-registers its domain and login item, no installer and no admin password.

      The claim this earns, and its honest limit, is worth stating plainly: advertise "easy on, easy off — and removing Wusel cannot touch your files," not "no system extension." Wusel runs a File Provider extension like any such client; the difference is that Wusel cleans up after itself where the Nextcloud client makes the user do it. The same rule governs the Windows and mobile builds, where the store sets the install bar and judges uninstall just as harshly.

  • 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_bridge lets a single Flutter codebase call wusel-core the way the CLI does — no re-implementation. One toolkit reaches Linux, macOS and Windows, and — only once a native VFS host exists for it — iOS and Android, which is the "lightweight alternative to the official app" a mobile user might want: a control surface over one shared core, not a second full sync client. The modularity rule carries across every platform: the UI is an optional package the VFS host never requires, so the light install is always the core alone.

    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 one wusel-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 later

    There are two communication paths, and only the first is uniform across the five operating systems:

    1. 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_bridge generates the Dart↔Rust glue — Rust async becomes a Dart Future, a Rust stream a Dart Stream, errors stay typed — and it is identical everywhere because it is just a linked library. Only the packaging differs: a .so/.dll/.dylib on the desktop, a per-ABI .so via cargo-ndk on Android, an .xcframework (device
      simulator, arm64) on iOS. This is the path the App↔core conversation runs on.

    2. 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.notifyChange on Android, and on iOS a shared App Group container plus NSFileProviderManager.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.