Building the kutup desktop app
The desktop app is a Tauri 2 native shell wrapping the same React frontend that powers the web. Build it locally with:
pnpm -C frontend installpnpm tauri:buildArtifacts land in src-tauri/target/release/bundle/:
| Host OS | Bundle types produced |
|---|---|
| Linux | .deb, .AppImage, .rpm |
| macOS | .dmg, .app.tar.gz |
| Windows | .msi, .exe (NSIS) |
The build cross-compiles only to the host OS — to ship all platforms, run
the build on each, or push a desktop-v* tag to run the Release Desktop
GitHub Actions workflow (.github/workflows/release-desktop.yml), which
builds Linux / macOS / Windows in a matrix and drafts a GitHub Release with
all the installers.
The dedicated iOS / Android apps are separate work in progress and are not
release-ready; see mobile-build.md. The mobile crate types
and scripts retained here are an experimental Tauri wrapper path.
The bundled executable is named kutup-client (mainBinaryName in
tauri.conf.json), not kutup — the plain kutup name belongs to the CLI
(crates/kutup-cli), and a .deb shipping /usr/bin/kutup would clash with it.
The Cargo crate is still kutup; only the produced binary is renamed. The
bundle identifier is dev.kutup.client — reserved across the product,
including the mobile apps under development; it is also the OS-keychain service
name (src-tauri/src/lib.rs) and the suffix of the desktop app-data dir
($APPDATA/dev.kutup.client/).
Cutting a release
Section titled “Cutting a release”Releases are tag-triggered; the tag is the source of truth for the
version (the desktop workflow writes it into tauri.conf.json before the
build).
| What | CLI (v* → cargo matrix) |
Desktop (desktop-v* → tauri-action) |
|---|---|---|
| Stable | git tag v0.1.0 && git push origin v0.1.0 |
git tag desktop-v0.1.0 && git push origin desktop-v0.1.0 |
| Prerelease | git tag v0.1.0-alpha.1 … |
git tag desktop-v0.1.0-alpha.1 … |
A -alpha.N / -beta.N / -rc.N segment makes GitHub flag the release
“Pre-release” (so it’s excluded from “Latest release”) and, for the
desktop app, gets baked into the installer version
(Kutup_0.1.0-alpha.1_amd64.deb, …). Both workflows create a draft
release — review it on GitHub, then publish. (This is just the GitHub
prerelease flag; a real alpha auto-update channel would need
tauri-plugin-updater configured first — not done yet.)
OnlyOffice is excluded from the desktop bundle
Section titled “OnlyOffice is excluded from the desktop bundle”tauri.conf.json’s beforeBuildCommand runs pnpm -C frontend build:tauri, which builds dist/ and then deletes dist/onlyoffice/.
That directory is the ~2.6 GB OnlyOffice document-editor SDK (copied from
frontend/public/onlyoffice/dist/). tauri::generate_context!() embeds
all of frontendDist into the binary as a static byte array — embedding
2.6 GB OOMs rustc on any machine, so it has to go.
Consequence: the desktop app cannot open .docx / .xlsx / .pptx
files in v1. Everything else (Excalidraw, CodeMirror text/code editors,
file browse / upload / download, sharing, admin) works. The follow-up is
to point the OnlyOffice iframe at ${serverUrl}/onlyoffice/... instead of
the relative /onlyoffice/... so the desktop app streams the SDK from the
user’s kutup server — at which point it can be re-enabled without bloating
the binary. (OfficeEditor.tsx + the hardcoded paths in
frontend/public/onlyoffice/inner.html / x2t.html are the touch points.)
Memory note for the build itself
Section titled “Memory note for the build itself”The kutup shell crate is built at opt-level = 1
([profile.release.package.kutup] in src-tauri/Cargo.toml) — rustc’s
peak memory const-evaluating the embedded bundle + the
Builder::default()…run() monomorphizations exceeds ~4 GB at the default
opt-level = 3. opt-level=1 roughly halves that; the shell crate is thin
glue so the perf cost is irrelevant. Deps stay at opt-level=3. The [lib] crate-type is ["staticlib", "cdylib", "rlib"] — rlib for the desktop
binary, staticlib/cdylib for Tauri Mobile’s iOS/Android FFI tooling
(cargo only emits the ones a given build requests, so a desktop build still
produces just rlib).
Linux prerequisites
Section titled “Linux prerequisites”sudo apt install -y \ libwebkit2gtk-4.1-dev libssl-dev libgtk-3-0 libgtk-3-dev \ libayatana-appindicator3-dev librsvg2-devThe runtime .deb and .AppImage only depend on the user-space libs
declared in src-tauri/Cargo.toml (libwebkit2gtk-4.1-0, libgtk-3-0).
Server URL prompt
Section titled “Server URL prompt”On first launch the app shows a server-picker screen (Nextcloud / Mastodon
style). The user enters a kutup backend URL, the app probes
GET ${url}/api/health, and on success persists the choice via the
Tauri Store plugin at $APPDATA/dev.kutup.client/kutup.dat.
URL normalization:
- bare hosts get
https://prepended http://is refused except forlocalhost,127.0.0.1,::1,*.local- trailing slash is stripped
- malformed URLs are rejected before the probe fires
Self-signed certificate caveat
Section titled “Self-signed certificate caveat”Kutup’s local development stack runs at https://localhost:38443 with a
self-signed cert. The Tauri webview rejects this by default — there is no
“continue anyway” UX baked in.
Three workable paths during development:
- Real cert via tunnel. Run cloudflared or ngrok and point the desktop app at the tunnel URL (which has a trusted cert).
- OS trust store. Generate the dev cert with
mkcert(auto-trusts on macOS / Windows / Linux when the root CA is installed) and run the backend behind that. - Native backend over localhost HTTP. Run
kutup-serverdirectly onhttp://localhost:3000and point the server picker there. The bundled Compose edge cannot be made plaintext by changing the scheme on port 38443; port 38080 redirects back to its self-signed HTTPS endpoint.
For production deploys the server should serve TLS via a real certificate (Let’s Encrypt, Cloudflare, etc.) and the desktop app then “just works”.
OS keychain (restart persistence)
Section titled “OS keychain (restart persistence)”After a successful login the app stashes the access token + master key + private key in the OS keychain so the next launch restores the session silently. Profile data + server URL live in the Tauri Store-plugin file.
Per-platform backends:
| OS | Backend |
|---|---|
| Linux | libsecret (gnome-keyring / KWallet) |
| macOS | macOS Keychain |
| Windows | Windows Credential Manager |
The Linux backend requires a Secret Service daemon at runtime. Headless
sessions without gnome-keyring or similar surface a one-time toast
“Stay-signed-in is unavailable on this machine…” and fall back to
re-login on every launch — the app keeps working, the convenience just
drops out.
Install on Debian / Ubuntu:
sudo apt install -y gnome-keyring libsecret-1-0Cross-origin Tauri requests need explicit origins, since withCredentials: true (used for the refresh-cookie path) is incompatible with the wildcard
AllowOrigins: "*". The backend reads an env-driven allowlist:
# default — covers the dev stack + Tauri's custom protocol originsALLOWED_ORIGINS="https://localhost:38443,tauri://localhost,http://tauri.localhost"Add your production frontend domain (e.g.
https://kutup.example.com) to the comma-separated list when deploying.
Smoke test
Section titled “Smoke test”After a fresh .AppImage:
- Launch → expect the server-picker screen.
- Enter your backend URL → expect a redirect to
/login. - Sign in → expect
/drive. - Quit + relaunch → expect to land directly on
/drive(vault restored). - Click “Sign out” → quit + relaunch → expect
/login(vault wiped). - Click “Switch server” → expect the picker again with the input cleared.