ESC
Type to search...
S
Soli Docs

Desktop Applications

Package an app as a single executable that carries its own database. The user double-clicks it; it starts a private database, opens their browser, and serves the app locally. No installer, no separate database, no configuration.

soli desktop build ./myapp --app-id com.example.myapp

That produces one file. Run it and the app opens.

Launch with a path or custom-scheme URL so the first screen after the launch token is not always /:

./myapp --open /pings/3
./myapp myapp://host/pings/3
SOLI_DESKTOP_OPEN=/dashboard ./myapp

After the one-shot session exchange, the browser redirects to that path. soli desktop build writes register-protocol.sh, register-protocol.ps1, and a .desktop file next to the artifact.

./register-protocol.sh
soli desktop register-protocol --exe ./myapp --scheme myapp --name "My App"

In-app Updater.check() works when the artifact was built with --update-url / --update-key.

A native window instead of a browser

With a soli built with --features eui-desktop, an app that has EUI components can be packaged to open one of them in its own window — no browser, no HTML:

soli desktop build ./myapp --app-id com.example.myapp --eui dashboard

dashboard is a component name given to router_eui. The artifact still carries the runtime, the encrypted app and its database; at launch the server runs on a thread and the embedded EUI client draws the component on the GPU in a window titled after the app. The loopback gate is armed with a session only that client holds, presented as a cookie on every request, so no other local process can reach the app. The EUI publisher key is generated per install in the app's state directory — a bundle never carries the developer's config/eui_publisher.pkcs8, whatever the build. Capabilities the manifest asks for are granted: the person installed the app.

--eui cannot be combined with --target yet, because a runtime for another target is fetched prebuilt without the window. SOLI_DESKTOP_NO_WINDOW=1 prints the session URL and cookie and serves without opening anything. The same build also answers soli eui <wss://host/_eui/session/app> to open any EUI application in a window.

What the artifact contains

[ soli runtime ]
[ manifest        — app identity, versions, checksums ]
[ app.sole        — your application, encrypted ]
[ solidb          — the database binary, compressed ]
[ ref_*.ndjson    — read-only reference data (optional) ]

Your application source is always encrypted. There is no unencrypted desktop build, because that would ship your source in the clear to every user.

Requirements

The application must be unlockable at launch, which means a key. Set either SOLI_BUNDLE_KEY (the key itself) or SOLI_BUNDLE_AUTH_URL (a key server that returns it) at both build time and run time — the same resolution chain soli build --encrypt uses. See Deployment.

There is no offline fallback

The key is never written to disk, so an app that cannot reach its key server does not start. That is the trade for being able to revoke an installation.

Options

FlagMeaning
--app-id <id>Reverse-DNS identity, e.g. com.example.myapp. Required. Determines where the user's data lives, so changing it between releases orphans their existing data.
--name <name>Display name. Defaults to the source directory's name.
--output <path>Artifact path. Defaults to <app> or <app>-<target>.
--target <t>Cross-build: linux-amd64, linux-arm64, darwin-amd64, darwin-arm64, windows-amd64.
--solidb <path>Embed a locally built database binary instead of downloading the published release.
--solidb-version <v>Database release to download. Pinned by default.
--no-dbEmbed and start no database: for an app whose state lives in memory or behind an API. The artifact loses the 18 MB database binary and the launch loses its database process.
--db-url <url>Embed no database; at launch the app's model layer and session store point at this solidb address. Credentials come from the app's .env or the environment, as under soli serve.
--seed <dir>Directory of <collection>.ndjson reference data.
--protectCompile the app to a binary AST, stripping source and comments.

Shipping reference data

Read-only data your app needs — a country list, a price table — can travel with the artifact. Each seed/<name>.ndjson is one JSON document per line. It is imported at first launch, and re-imported only when the content changes.

soli desktop build ./myapp --app-id com.example.myapp --seed ./seed

Seed collections must be named ref_*

The build fails otherwise, and the reason matters: importing replaces a collection wholesale. Without the prefix, shipping a users.ndjson would silently destroy the user's own users data on their next launch. The prefix keeps the two namespaces disjoint by construction rather than by review.

seed/
  ref_countries.ndjson     ✓
  ref_currencies.ndjson    ✓
  users.ndjson             ✗ build fails — could collide with your model

Where user data lives

Data (persists)Cache (safe to delete)
Linux$XDG_DATA_HOME/<app-id>/db$XDG_CACHE_HOME/<app-id>/bin
macOS~/Library/Application Support/<app-id>/db~/Library/Caches/<app-id>/bin
Windows%LOCALAPPDATA%\<app-id>\db%LOCALAPPDATA%\<app-id>\cache\bin

The database binary is cached and reused across launches, re-verified against its checksum each time. Deleting the cache costs one extraction, not data. A second launch of the same app is refused while the first is running — two servers over one database directory would fail deep inside the storage engine with an unhelpful error.

Protecting data at rest

The database files sit in the user's own directory. Mark sensitive fields so they are encrypted with the key fetched at launch:

class Customer < Model
  encrypts :tax_id, :bank_account
end

Encrypted fields cannot be queried by value — each write uses a fresh nonce, so the ciphertext differs every time. Encrypt what must stay confidential, not what you filter on. Fields you do not mark are stored in plaintext and strings will find them.

What this protects against, and what it does not

Worth being precise, because “encrypted desktop app” promises more than any local software can deliver.

It does protect against: a copied artifact being useful to someone without an authorized key; casual inspection of your source; other local processes driving the app's API or its database; and it lets you revoke an installation.

It does not protect against the machine's owner

The key reaches the process environment at launch, and on their own machine a user can read it — from /proc/<pid>/environ, from a debugger, from process memory. Assume a motivated user recovers it. Once they have, it decrypts that data offline, forever.

Treat this as licensing-grade protection: it stops copying and enables revocation. It is not confidentiality against your user. If you hold data whose disclosure to that user would be a breach, keep it on your server behind an authenticated API and cache only derived, non-sensitive results locally.

The local port

The app binds 127.0.0.1 on a port the OS assigns, so it never appears on the network and never collides with another app.

Loopback keeps the network out but not the machine — every process running as that user can reach the port. So the browser is launched with a single-use token that it exchanges once for a session cookie; everything else gets 403. The token expires in 60 seconds and a wrong guess burns it.

One limitation to know: cookies are not port-scoped, so another local server in the same browser profile could read the session cookie. Closing that needs loopback HTTPS with a per-launch certificate, which means touching the user's trust store. What the gate does close is a non-browser local process driving your API.

Embedding in a native shell

The artifact opens the app in a chrome-less browser window. If you wrap it in a native shell of your own — a Cocoa/WebView app, an Electron-style container — you want your window, not that one. Set SOLI_DESKTOP_NO_WINDOW=1 and the server opens nothing:

SOLI_DESKTOP_NO_WINDOW=1 ./myapp

The launch URL is still printed, on its own indented line, and your shell needs it: it carries the single-use token described above, so pointing a web view at http://127.0.0.1:<port>/ directly gets a 403. Read the child's stdout, take the first http://127.0.0.1: line, and load that.

Send SIGTERM when your window closes so the database and the decrypted tree are cleaned up in order — see Stopping.

Once your shell owns the window it can also own notifications: see Native Bridge, which lets server-side Soli raise a real OS notification in your shell. A web view has no Push API and no Notification API, so that bridge is the only way an embedded app gets one.

Cross-building

--target downloads a published runtime and database for that platform and verifies both against their checksums. It does not compile anything, so you can build every target from one machine:

soli desktop build ./myapp --app-id com.example.myapp --target darwin-arm64
soli desktop build ./myapp --app-id com.example.myapp --target windows-amd64

Two signing notes, both of which bite at distribution rather than build time:

  • macOS artifacts built elsewhere are unsigned and Apple Silicon refuses to run them. Re-sign on a Mac with codesign --force -s -. Use codesign specifically: the payload rides inside __LINKEDIT, and a signer that regenerates that segment (such as rcodesign) discards it, leaving a binary that no longer contains the app.
  • Windows artifacts are unsigned, so SmartScreen shows “Windows protected your PC” with Run anyway hidden behind More info. Sign the finished file — never before packaging, which would invalidate the signature: signtool sign /fd sha256 /tr <timestamp-url> /td sha256 myapp.exe.

Stopping

Closing the terminal, or SIGTERM, shuts the app down in order: the decrypted application tree is removed first, then the database is closed cleanly. A clean close matters — an abrupt kill makes the storage engine replay its write-ahead log on the next launch, which is slow and looks like corruption. A hard kill -9 skips this; the leftover directory is swept at the next launch.

Size

A typical artifact is 70–80 MB, mostly the database binary (stored compressed, roughly a third of its size). It contains everything: runtime, application, database and reference data.

Measured on 2026-09-07 for the EUI counter-app example, x86-64 Linux:

runtimeartifact
soli as installed (no EUI)66 MB—
--features eui-desktop, default features78 MB96 MB
--no-default-features --features eui-desktop61 MB80 MB
--features eui-desktop, default features, --no-db78 MB76 MB

The EUI window costs 12 MB. Dropping a browser does not make the artifact small: the runtime and the database dominate, and the EUI project's own target of a ~15 MB desktop artifact needs a runtime built for it — the interpreter without the server features an offline app never uses. That is open work; these numbers are the honest starting point.

Staying current

A desktop artifact is a frozen binary — to ship a fix you'd otherwise ask every user to re-download it by hand. Build it with --update-url and it can check a release channel you control and replace itself with a newer, cryptographically signed build:

soli desktop build ./my_app --app-id com.example.myapp \
  --update-url https://updates.example.com/my_app \
  --update-key <p256-pubkey>

The running app then supports --check-update / --update, or drives the flow from Soli via Updater.check() / Updater.apply(). See Auto-Update (OTA) for signing keys, publishing a manifest, and the security model.