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 as a package (see Install a package), plus an experimental macOS frontend.
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 covered by mock end-to-end tests and by the nightly end-to-end run against a real Nextcloud (a genuine 412, merged).
-
✅ Seed the 3-way merge base when a write starts. Filling the write buffer keeps the version it started from as a cache blob, so a conflict on a file that was never read or pinned merges too. Only a file larger than the whole cache budget still has no base.
-
⬜ A transient "downloading" emblem while an open hydrates a file. Arrival in and departure from the cache are announced (see Telling the desktop the truth below); there is no state for the fetch in between, so the emblem changes only once it is done.
-
⬜ Proper gettext i18n (
.po/.mo) for the file-manager labels — currently a small built-in de/en table. -
⬜ Notification texts beyond two languages.
Notice::localizehas 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’str()and a translation platform and ships dozens. The structure is already right (aNoticeis 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_pushsendsnotify_notificationon the websocket we already hold, andpush.rsdeliberately ignores it — so the missing piece is fetching the payload from OCS and routing it throughNotice. 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:
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. -
✅ Full-text search over the documentation.
@antora/lunr-extensionbuilds 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 indexeslatestonly — a hit never comes from a superseded release. The dropdown wears the site’s own palette because the UI bundle carriescss/search.css, which is what makes the extension leave its default stylesheet out. -
✅ Periodic polling when
notify_pushis missing. While the server has nonotify_pushor the connection is down, the sync walk runs every[sync] poll_secsseconds (default 300,0= off); every push (re)authentication also runs it once, to catch up on what was missed. See Keeping in sync. -
⬜ 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 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. Theasknotification carries no button; the update runs from the menu orwusel update. 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. Underautothe sync walk fetches the stale copies itself, through the same cache path a pin uses; the reasoning is in Concurrency. -
✅ Concurrent FUSE dispatch (0.2.0) — the fix for interactive stalls, not for transfer speed. FUSE callbacks run on several threads (
[mount] dispatch_threads), so a running hydration no longer delays the file manager’s lookups behind it. The design — an I/O-free decision core, a per-object state machine, and the environment assumptions it refuses to make — has its own page: Concurrency. -
⬜ Cancel a running hydration. Abandoning a flow (a reader killed while its read waits) stops it at the next step boundary; the transfer already under way runs to completion. Aborting it mid-transfer is designed in Concurrency but not built.
-
✅ Stream hydration in one response. Filling the read cache and filling a write buffer are each one streamed GET instead of one range GET per chunk.
-
⬜ Schema version negotiation on the status socket. The protocol carries a version constant (
wire::SCHEMA), but no client sends it and the daemon checks nothing. Needed before two frontends of different versions meet a breaking change — see the status protocol. -
⬜ 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.
-
⬜ Multi-account search.
-
✅ A
Depth: 0check of the root ETag before a sync walk: an unchanged tree costs that one small request instead of aDepth: 1listing of the root.
Telling the desktop the truth
Found by using 0.2.0 on a real Fedora/GNOME desktop. Two are done; the kernel invalidation is built but switched off, and a server-side add or remove still does not reach an open window. They 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 — built, and currently disabled.
Invalidation::Contentcarries the object and the frontend used to turn it intoinval_inode(and an entry change intoinval_entry). Both calls are switched off inwusel-fuseas the suspected cause of a freeze in which Nautilus shows a blank folder view; until that is settled, astator re-read sees a server-side change after the kernel’s attribute/entry timeout rather than at once. The emblem refresh over D-Bus (FileChanged) is unaffected. Re-enabling the calls is open. Writing the test found a worse defect behind it, which stays fixed: 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. -
⬜ A file added or removed on the server appears in an open window. Today it shows only after a reload, although the engine knows of it within moments of the push. Neither half of what exists can carry it: a file manager learns of a folder’s changes from inotify on the directory, and no FUSE notification makes the kernel send that —
notify_deleteraisesIN_DELETE_SELFon the file, neverIN_DELETEon the directory. The Nautilus extension API re-reads a file the window already shows, but has no call for one that appeared or went. Two ways are open, to be tried in that order:-
nautilus_file_info_create_for_urifor an added path — whether Nautilus then puts the file into an open view has to be tried; it covers additions in Nautilus only. -
Real VFS operations by the daemon inside the directory, which the kernel reports to every watcher — a file manager of any desktop, an editor, a build watcher. It needs a design of its own: the daemon would call into its own mount, and the kernel would briefly hold the wrong inode for the name.
-
-
✅ Errors say what failed. Every arm names 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
-
⬜ OIDC bearer tokens instead of an app password (wusel#40). Where Nextcloud delegates login to an external identity provider (
user_oidc), wusel could obtain access and refresh tokens from that provider itself — the Device Authorization Grant (RFC 8628) suits a daemon — and send the access token asAuthorization: Bearerto WebDAV and OCS. Nextcloud accepts that once Check Bearer token on API and WebDav requests is enabled for the provider; the user must already exist, and the token’s audience must match the provider’s client ID. The gain is central token lifetime and revocation at the identity provider; today an app password outlives a user disabled there. Not scheduled: it needs the 401 state above and an auth layer behind the WebDAV client, which still hard-codes Basic auth at every call. -
⬜ KDE — the same feature set: the cloud folder via KIO, per-file overlays via a C++
KOverlayIconPluginspeaking 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.desktopand one grid icon. The GUI inherits the identity the search provider andlibcloudprovidersalready 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 windowapp_idmust equal that basename, or the shell lists the running window as a separate entry however correct the.desktopis. -
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_sharingAPI) 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 thefile_idthe 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~/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 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; additional decorations mark items made available offline and offline copies that are out of date; and the Finder context menu offers keep offline / stop keeping offline, "Update Now", "Open in Nextcloud", "Open Folder in Nextcloud" and "Copy Internal Link", each only where it fits the selection. 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,LogsandCloudStorage— 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; thereset-domainrecovery path uses it.So the deliverable is a first-class uninstall — a menu entry ("Remove Wusel…") and a
wusel uninstallcommand — that in one action removes the File Provider domain (extension off, mount gone), unregisters the login item (SMAppService.unregister), stopsserve, deletes the App Group container and logs, and offers to trash the app. Install is the mirror: open a signed, notarized.appand 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_bridgelets a single Flutter codebase callwusel-corethe 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 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.