DocsThe ecosystem
Native packaging
Optional Windows and Android packaging for the application you already have. Your Rust backend is compiled for the platform, the generated router answers on a loopback socket, and the operating system's WebView loads it — so pages, components, rpcs, uploads, sockets and sessions all work, because none of them are involved.
What a native package is
The application you already have, running inside an installed program. The same generated Axum router, with the same auth guard, the same CSRF layer and the same static fallback, listening on a loopback port. WebView2 on Windows, Android System WebView on Android.
There is nothing to convert and no compatibility subset. Layouts, error boundaries, loading regions, #[rpc], streaming rpcs, uploads, #[socket], sessions and PulsePoint bindings behave exactly as they do on the web, because packaging does not touch any of them.
Why it is a separate package
So that "optional" is a fact rather than a claim. A Rahti project is a web application and stays one: nothing in the native packages is compiled, downloaded or resolved by a project that has not asked for it — not Tauri, not an Android SDK, not the runtime crate. You install the tool when you want a package, and a project that never does is unaffected.
# The packaging tool.
cargo install cargo-rahti-native
# The CLI it delegates to. A separate install because it has to match the
# `tauri` version in your native/Cargo.toml, which is yours to move.
cargo install tauri-cli --version "^2" --lockedAndroid additionally needs the SDK, the NDK, a JDK and four Rust targets. doctor names each missing one and the command that supplies it, and installs nothing itself. See Official packages for how these two crates are versioned.
The commands
cargo rahti native init --identifier com.example.myapp --windows --android
cargo rahti native doctor
cargo rahti native dev --target windows
cargo rahti native build --target windows
cargo rahti native build --target android --format apk
cargo rahti native build --target android --format aab| Command | What it does |
|---|---|
| init | Writes rahti.native.json, its schema file, and the application-owned Tauri shell under native/. Same hash-ledger rule as upgrade: an unedited generated file is regenerated, an edited one is left alone and reported, and --force takes it back. |
| doctor | Checks only what the named target needs — every configured target when none is named — and installs nothing. Each failure says what is missing, why it is needed, and the command that supplies it. --release adds the signing checks. |
| dev | Runs doctor's checks, then delegates to cargo tauri dev from inside native/ and stays running, watching your application as well as the generated shell. See Development reload. |
| build | The same, for a package. Prints the absolute path of every artifact it produced. |
cargo rahti native … is forwarded by cargo-rahti to the installed helper; cargo rahti-native … reaches it directly through cargo's own subcommand dispatch. When the helper is not installed, cargo rahti native prints the line that installs it and installs nothing.
Development reload
dev does not hand off and forget. It watches the application as well as the shell, and what a save costs you depends on what changed and on which target is running.
| Change | Windows | Android |
|---|---|---|
src/, Cargo.toml, Cargo.lock, build.rs, rahti.config.json, .env | Rebuild and restart the native application. | Rebuild, reinstall and restart it on the selected device. |
public/*.css | Swap the stylesheet in the running WebView. | Rebuild, reinstall and restart with new embedded assets. |
Another file under public/ | Reload the running WebView. | Rebuild, reinstall and restart with new embedded assets. |
Rust is compiled into the application, so this is restart-based live reload and not state-preserving hot replacement. Windows asset edits are the fast case because a debug process can serve the checkout directly. Android cannot read a directory on your development machine — its assets have to cross the package boundary — so even a CSS edit costs an incremental Android build and a redeployment.
Your application exposes its startup
The native shell starts the same application the web binary starts, by calling one function in your library.
// src/lib.rs — the one function both hosts call.
//
// `src/main.rs` calls it and binds the public listener; the native shell
// calls it and binds a loopback one. Connecting a database, applying
// migrations and installing the auth policy happen here, once.
pub async fn initialize_application() -> Result<ApplicationRuntime, StartupError>The split exists because Android has no main: the operating system loads a library and calls into it. A project whose startup is still inside fn main() cannot be packaged, and doctor says so with the command that fixes it — projects scaffolded by a current cargo rahti new already have the split.
rahti.native.json
Written by init, beside rahti.config.json, with a schema file next to it. Editing this file and re-running init is how the shell learns a new window size, version or target.
{
"$schema": "./rahti.native.schema.json",
"schema": 1,
"productName": "My App",
"identifier": "com.example.myapp",
"version": "0.1.0",
"targets": ["windows", "android"],
"window": { "title": "My App", "width": 1200, "height": 800 },
"android": { "minSdk": 24 },
"database": { "mode": "sqlite-local" },
"security": { "loopbackToken": true }
}It holds names, sizes and identifiers. It holds no credentials, and there is no field to put one in: signing is configured through the environment, and the session key is generated on the device.
The identifier is validated to Android's rule, which is the strict one: at least two segments, each a legal Java identifier, no hyphens. com.example.my-app is a perfectly good Windows bundle identity and stops a Gradle build several minutes in, so it is refused before anything is written.
native/ is yours
The shell is a separate cargo package, deliberately outside your workspace (exclude = ["native"] in the project's Cargo.toml). It is the only package in a Rahti project that depends on Tauri, so cargo check --workspace, cargo test --workspace and cargo build never resolve a native dependency.
The generated icons are the Rahti mark, at every size Windows and Android ask for, so a first build looks like an application rather than a blank square. It is still not your mark:
# The generated set is the Rahti mark, at every size Windows and Android
# ask for. Replace it with yours from one square PNG.
cargo tauri icon path/to/icon.pngWhere a packaged application keeps things
Installed applications cannot assume the repository root is the working directory, and cannot write to their installation directory at all.
| Kind | Windows | Android |
|---|---|---|
| data | %LOCALAPPDATA%\<identifier> | internal files directory |
| config | %APPDATA%\<identifier> | <files>/config |
| cache | %LOCALAPPDATA%\<identifier>\cache | cache directory |
The database, uploads, logs and the session key are data. Spilled upload parts and scratch files are cache, because the operating system may delete a cache directory when the device is short of space and a spilled part exists for one request. RAHTI_PUBLIC_DIR and RAHTI_SPILL_DIR are set to the resolved paths before the router is built.
Static assets are embedded
Your public/ is compiled into the executable and written out to application storage on first launch. That is not the obvious design, and the obvious one does not work: Tauri's bundler copies declared resources into an Android package as zip entries rather than files, and resource_dir() on Android returns the string asset://localhost/ — a URI, not a directory. An application built that way starts, binds its port, opens its window, and 404s every stylesheet and the whole PulsePoint runtime.
Embedding sidesteps all of it, and one code path then serves both platforms. It costs the size of public/ in the binary. An upgrade replaces the staged tree whole — a stale pp-reactive-v2.min.js beside a current main.js fails in ways nothing explains — and touches nothing else. A debug build serves your project's own public/ directly instead, so a stylesheet edit is visible without a rebuild.
The database
database.mode decides, and the default is sqlite-local: a SQLite file in application storage, reached through an absolute DATABASE_URL created on first launch. A project on PostgreSQL or MySQL sets remote, and its DATABASE_URL is left exactly as it is — rewriting it to a local file would start the application against an empty database that looked like a working one.
The session key
rahti::auth signs its session cookie with AUTH_SECRET. A packaged application has no .env to read one from, and must not have one: a key shipped inside an installer is a key every installation can forge every other installation's sessions with. Going without is not an option either — rahti::auth invents one per process, so everyone is signed out at every restart.
So the key is generated on the device at first launch, per installation.
- Windows: encrypted with DPAPI before it is written. The blob is readable by that user account on that machine and by nobody else.
- Android: in the internal files directory, which is the platform's own per-application sandbox.
A key file that cannot be decrypted — a restored profile, a recreated account — is replaced rather than fatal. The user is signed out; the application opens.
Security
Everything on the Security page still applies — a packaged application is the same application. What changes is the consequence of getting it wrong, and three boundaries exist only here.
Loopback is not private
127.0.0.1 is unreachable from the network and reachable by every process on the machine. A packaged Rahti application listening there is a signed-in session, an upload endpoint and a database, offered to all of them.
- the listener binds
127.0.0.1and there is no API to bind anything else; - the port is assigned by the operating system;
security.loopbackToken(on by default) mints a token per launch. The shell opens the application with it, the gate turns it into anHttpOnly; SameSite=Strictcookie and redirects to the clean URL, and anything arriving without it gets a bodyless 403.
A cookie rather than a header is the whole design: a header can be attached to fetch and to nothing else, so a header scheme would allow rpcs and refuse the document. It is not authentication and does not replace CSRF — Rahti's own layers still answer "is there a session" and "did this call come from a page of ours".
The native command bridge
In a browser, an XSS is a stolen session. In a native shell it is a stolen session and whatever the page can reach through the bridge. So the bridge is an allowlist, and it is five entries.
| Command | What it does |
|---|---|
| platform | Which operating system. |
| app_version | The installed version. |
| app_data_dir | Where this installation keeps its data, for showing the user. |
| open_external | Hands an http, https or mailto URL to the user's own browser. |
| choose_file | Opens the platform picker and returns what the user chose. |
Nothing runs a program. Nothing reads a path the page names — choose_file returns what a human selected, and that difference is the security model. open_external refuses file:, javascript: and data:: on Windows a file: URL opens whatever the extension is associated with, including executables.
Adding a command means three places, on purpose: a #[tauri::command] in native/src/lib.rs, an entry in rahti_native::commands(), and a permission in native/capabilities/default.json. Assume the page calling it has been compromised, because it has to be safe in that case too.
html! {
<button onclick={openDocs()}>"Documentation"</button>
<script>
const native = window.rahtiNative;
function openDocs() {
const url = "https://example.com/docs";
// The same page has to work as a website: `window.rahtiNative`
// is undefined in a browser, so every use is a capability
// check with a web fallback.
if (native && native.has("open-external")) {
native.invoke("open_external", { url });
} else {
window.open(url, "_blank", "noopener");
}
}
</script>
}Content-Security-Policy
Served by the embedded server as a response header, because that is the only policy a browser applies to a page on a loopback origin. The default permits no external source of anything — and permits 'unsafe-eval', which is not an oversight: PulsePoint builds a render function with new Function when it compiles a reactive block. Remove the directive and the server-rendered page still appears with every binding on it dead. Narrowing script-src further is not a tightening, it is turning the browser runtime off.
A release package also sets RAHTI_DEV=0 rather than inheriting it, so a developer with it exported cannot start a shipped application with its diagnostics endpoint and reload stream live.
Android and cleartext
Android refuses cleartext HTTP by default, and Tauri's generated project sets usesCleartextTraffic="false" for release builds — right for Tauri, which serves through an asset protocol, and wrong for Rahti, which serves itself over http://127.0.0.1. So a network security config is written into the generated Android project and the manifest is pointed at it: cleartext stays refused for every host except the loopback addresses the embedded server binds.
Signing
Nothing about signing is in any committed file, and native/.gitignore refuses keystores and certificates. Both platforms read it from the environment of the build.
# Read from the environment of the build. Nothing about signing is in a
# committed file, and native/.gitignore refuses keystores and certificates.
RAHTI_NATIVE_WINDOWS_CERTIFICATE
RAHTI_NATIVE_WINDOWS_CERTIFICATE_PASSWORD
RAHTI_NATIVE_ANDROID_KEYSTORE
RAHTI_NATIVE_ANDROID_KEYSTORE_PASSWORD
RAHTI_NATIVE_ANDROID_KEY_ALIAS
RAHTI_NATIVE_ANDROID_KEY_PASSWORDdoctor --release says which are missing, and build --target android --debug produces a package that installs without any of them, which is what testing wants.
What native packaging does not give you
Background services, exact alarms, home-screen widgets, biometrics, push notifications, system tray, global shortcuts, registry access and Windows Hello are not included and are not automatic. Each needs an optional Tauri plugin or platform code added to native/ — which is yours — and each is a native capability to be designed with the allowlist rules above in mind.
