Skip to content

Project structure

sabine new my-app creates this layout:

  • Directorymy-app/
    • Directory.github/
      • Directoryworkflows/
        • release.yml builds and publishes releases from version tags
    • Directoryassets/
      • icon.svg the app icon, used for windows, launchers and installers
    • Directorysrc/
      • main.rs builds the window and registers bridge commands
    • Directoryui/
      • index.html
      • Directorysrc/
        • main.ts
        • style.css
        • bindings.ts generated by Sabine
      • Directorydist/ the built page, packaged with the app
        • …
    • Cargo.toml
    • Sabine.toml the app manifest
    • package.json
    • tsconfig.json
    • vite.config.ts

ui/src/bindings.ts and ui/dist/ appear once you run sabine dev and build the page. Commit the bindings with the rest of the page; they are part of its source. ui/dist/, node_modules/ and Cargo’s target/ are build output.

Nothing about this layout is fixed. The manifest says where the page lives, how it builds and which Cargo package is the app, so an existing Rust workspace or web project can keep its own structure.

Sabine.toml sits at the project root. The CLI reads it for development and packaging, and the app reads it at startup, from the project during development and from the bundle once installed. The template’s manifest:

[app]
id = "dev.sabine.my-app"
name = "my-app"
version = "0.1.0"
icon = "assets/icon.svg"
[web]
root = "."
dist = "ui/dist"
entry = "ui/dist/index.html"
dev_port = 5173
build = "bun run build"
[bindings]
path = "ui/src/bindings.ts"
Table What it holds
[app] Identity and metadata: id, name, version, icon, publisher and license details, document types and URL schemes, and how launchers list the app
[web] Where the page lives and how it builds: root, dist, entry or a production url, the development server, allowed origins and local file access
[window] The loading screen: splash and splash_delay_ms
[bindings] Where Sabine writes the page’s TypeScript bindings
[updates] Where the app’s own updates come from
[sandbox] What the app needs from a sandbox: network, folders, devices and session bus names
[resources] Files and folders shipped beside the app, such as a helper program

The id identifies the app everywhere: its browser profile, its data folders, its registration with the Sabine service and the desktop. It may contain lowercase letters, digits, dots and hyphens. Pick a reverse domain you control and keep it stable; changing it later makes a different app. Without version, bundles take the Cargo package’s version.

Rust settings take precedence over the manifest. SabineWindow::main applies the manifest first and then your builder, so a title set in Rust replaces [app] name as the window title. See the manifest reference for every key.

The template’s Cargo.toml:

[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
sabine = { git = "https://github.com/Lantharos/Sabine", tag = "v0.68", package = "sabine" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
specta = { version = "=2.0.0-rc.27", features = ["derive"] }
[profile.release]
lto = "fat"
codegen-units = 1
strip = true
[workspace]
  • sabine comes from the Sabine repository at a release tag. Keep it on the same release as the @lantharos/sabine package in package.json, and move both together when you update.
  • specta is pinned to the exact version Sabine uses to describe types in TypeScript. Your command and event types derive specta::Type from it, so it must match.
  • serde derives the conversion of requests and responses to and from JSON.
  • [profile.release] makes the release binary small: link-time optimization across all crates, one code generation unit, and no debug symbols.
  • The empty [workspace] table keeps the project its own workspace, so it builds the same inside another repository. Remove it when you add the app to an existing workspace.

To add Sabine to an existing project, add the same dependencies and release profile, and the page package:

Terminal window
bun add github:Lantharos/Sabine#v0.68

The same executable runs in two modes.

During sabine dev, the app runs in development: it loads the page from the development server and uses a separate identity, the app id with .dev appended, such as dev.sabine.my-app.dev. That identity gets its own browser profile, data folder and single-instance name, so a development run never touches the data of an installed copy, and both can run at once. Development runs skip what only belongs to an installed app: they check for no app updates, and register no URL schemes, browser extension hosts or login items.

A bundle runs in production. It loads the page packaged with it, or its production url, and uses the app id as it is. It never needs the source checkout.

Both modes read environment files from the project root, and from the page’s folder when it has its own:

Mode Files, later ones overriding earlier ones
Development .env, .env.local, .env.development, .env.development.local
Production .env, .env.local, .env.production, .env.production.local

Variables already set in the environment win over the files. The values reach cargo and the page’s build command; NODE_ENV and SABINE_ENV are set to development or production. Environment files are build inputs and are never installed with the app. Which values end up in the page is up to your frontend tooling, so keep secrets out of variables your bundler exposes. sabine bundle --env-file <path> adds more files to a build.

Rust code reads the mode with AppEnvironment::current().

What Linux Windows macOS
Your app’s files $XDG_DATA_HOME/<app id> %APPDATA%\<app id> ~/Library/Application Support/<app id>
Browser profile: cookies, local storage, IndexedDB, cache $XDG_CACHE_HOME/sabine/profiles/ %LOCALAPPDATA%\sabine\profiles\ ~/Library/Caches/sabine/profiles/
Remembered window size $XDG_STATE_HOME/<app id>/window.json %LOCALAPPDATA%\<app id>\window.json ~/Library/Application Support/<app id>/window.json
Sabine’s shared runtime, service and logs $XDG_DATA_HOME/sabine %LOCALAPPDATA%\Sabine ~/Library/Application Support/Sabine

Keep the files your app writes in its data folder. AppEnvironment::app_data_dir names it, with the .dev id during development, and it is the folder Sabine’s app sandbox lets the app write:

use std::path::PathBuf;
use sabine::AppEnvironment;
fn database_path() -> std::io::Result<PathBuf> {
let folder = AppEnvironment::current().app_data_dir("com.example.my-app");
std::fs::create_dir_all(&folder)?;
Ok(folder.join("notes.sqlite"))
}

The browser profile is Chromium’s, and Sabine manages it. Pages use it through the usual web storage APIs. Chromium does not encrypt local storage or IndexedDB, so keep secrets that need protecting from other programs in the system’s credential store from Rust instead.

Uninstalling an app keeps its browser profile unless sabine uninstall <app id> --purge is used, which also removes what Sabine kept for the app. The app’s own data folder stays; the Windows setup program asks whether to delete it when it uninstalls the app.