use rahti_native::NativeConfig;
use crate::project::Project;
pub struct File {
pub path: String,
pub contents: String,
}
impl File {
fn new(path: impl Into<String>, contents: impl Into<String>) -> Self {
File {
path: path.into(),
contents: contents.into(),
}
}
}
pub fn files(project: &Project, config: &NativeConfig) -> Vec<File> {
vec![
File::new("native/Cargo.toml", cargo_toml(project, config)),
File::new("native/build.rs", BUILD_RS.to_string()),
File::new("native/tauri.conf.json", tauri_conf(config)),
File::new("native/capabilities/default.json", capabilities()),
File::new("native/permissions/rahti-native.json", permissions()),
File::new("native/src/lib.rs", lib_rs(project, config)),
File::new("native/src/main.rs", main_rs(project)),
File::new("native/dist/index.html", DIST_INDEX.to_string()),
File::new("native/.gitignore", GITIGNORE.to_string()),
File::new("native/README.md", readme(config)),
]
}
fn cargo_toml(project: &Project, config: &NativeConfig) -> String {
let package = format!("{}-native", project.package);
let lib = format!("{}_native_lib", project.lib);
format!(
r#"# The native shell. Generated by `cargo rahti native init`, and yours from
# here: the dependencies below are the ones the generated shell uses, and
# anything else a native capability needs is added by you.
#
# Deliberately outside the project's workspace. This is the only package in a
# Rahti project that depends on Tauri, and the project root excludes this
# directory so that a web build never resolves one.
[package]
name = "{package}"
version = "{version}"
edition = "2024"
publish = false
# Its own workspace, so `cargo` inside native/ does not walk up into the
# application's and try to make this a member of it.
[workspace]
# `cdylib` is what Android loads — a packaged Android application is a Java
# activity that dlopens this library. `staticlib` and `rlib` are what iOS and
# the desktop binary want. All three, because the same source is all three.
[lib]
name = "{lib}"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = {{ version = "2", features = [] }}
[dependencies]
tauri = {{ version = "2", features = [] }}
# The two plugins the generated commands use. `opener` hands a URL to the
# user's own browser; `dialog` opens the platform file picker. Both are
# official Tauri plugins, and neither can open anything the user did not
# choose.
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
# The application's `public/`, compiled into the binary. Android's package
# resources are zip entries and Tauri reports their directory as the URI
# `asset://localhost/`, which is not a path a file server can open — so the
# assets travel in the executable and are written to internal storage on first
# launch. See `rahti_native::stage_embedded_assets`.
include_dir = "0.7"
# The application itself. The shell links its library and calls
# `initialize_application`, which is the same function `src/main.rs` calls —
# so the web binary and the package cannot drift apart.
{app_dep}
# The platform-neutral half of native support: packaged paths, the loopback
# server, the session key, the capability allowlist.
{native_dep}
serde = {{ version = "1", features = ["derive"] }}
tokio = {{ version = "1", features = ["rt-multi-thread"] }}
"#,
version = config.version,
app_dep = format!("{} = {{ path = \"..\" }}", project.package),
native_dep = native_dependency(config),
)
}
fn native_dependency(config: &NativeConfig) -> String {
let Some(local) = &config.local else {
return format!("rahti-native = {{ version = \"{}\" }}", NATIVE_VERSION);
};
let checkout = local.trim_end_matches('/');
let path = if std::path::Path::new(checkout).is_absolute() {
format!("{checkout}/crates/rahti-native")
} else if checkout == "." {
"../crates/rahti-native".to_string()
} else {
format!("../{checkout}/crates/rahti-native")
};
format!("rahti-native = {{ path = \"{path}\" }}")
}
const NATIVE_VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_RS: &str = r#"// Generated by cargo-rahti-native — do not edit.
//
// Tauri's build script. It reads tauri.conf.json, generates the permission
// schemas under gen/, and on Windows embeds the icon and the manifest into the
// executable.
fn main() {
tauri_build::build()
}
"#;
fn tauri_conf(config: &NativeConfig) -> String {
let icons = ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"]
.map(|i| format!(" \"{i}\""))
.join(",\n");
format!(
r#"{{
"$schema": "https://schema.tauri.app/config/2",
"productName": {product},
"version": {version},
"identifier": {identifier},
"build": {{
"frontendDist": "./dist"
}},
"app": {{
"withGlobalTauri": true,
"windows": [],
"security": {{
"csp": {csp}
}}
}},
"bundle": {{
"active": true,
"targets": ["nsis", "msi"],
"icon": [
{icons}
],
"android": {{
"minSdkVersion": {min_sdk}
}}
}}
}}
"#,
product = json(&config.product_name),
version = json(&config.version),
identifier = json(&config.identifier),
csp = json(&config.security.csp),
min_sdk = config.android.min_sdk,
)
}
fn permissions() -> String {
let allowed: Vec<String> = rahti_native::commands()
.iter()
.map(|command| format!(" \"{}\"", command.name))
.collect();
format!(
r#"{{
"permission": [
{{
"identifier": "allow-rahti-native-commands",
"description": "The native commands Rahti exposes to the application's own pages. Every entry is also on the Rust allowlist in `rahti_native::commands()`; a command needs both.",
"commands": {{
"allow": [
{allowed}
],
"deny": []
}}
}}
]
}}
"#,
allowed = allowed.join(
",
"
)
)
}
fn capabilities() -> String {
r#"{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "What the application's own pages may ask the operating system for. Every entry here is a decision: an XSS in a page reaches everything on this list.",
"windows": ["main"],
"remote": {
"urls": ["http://127.0.0.1:*"]
},
"permissions": [
"core:default",
"opener:allow-open-url",
"dialog:allow-open",
"allow-rahti-native-commands"
]
}
"#
.to_string()
}
fn lib_rs(project: &Project, config: &NativeConfig) -> String {
let cookie = match &config.auth.cookie_name {
Some(name) => format!("Some({})", json(name)),
None => "None".to_string(),
};
format!(
r#"//! The native shell.
//!
//! Generated by `cargo rahti native init`. It starts the same application the
//! web binary starts, serves it on a loopback socket, and opens a WebView on
//! it.
//!
//! **The WebView is a WebView.** What it draws is HTML rendered by the
//! application's own Rust, laid out by the system web engine — WebView2 on
//! Windows, Android System WebView on Android. They are not native operating
//! system controls and this shell does not turn them into any.
//!
//! ## The order of a launch
//!
//! Every line below is in the order it is for a reason:
//!
//! 1. resolve where this installation keeps its files;
//! 2. stage the packaged assets into internal storage;
//! 3. put the paths, the session key and the release flags in the environment
//! — before anything reads them;
//! 4. **bind** the loopback socket, so the port is real;
//! 5. build the application's router;
//! 6. **serve** it;
//! 7. only then create the window.
//!
//! Steps 4 and 7 in that order are the startup race, and the reason the WebView
//! never opens on a port nothing is listening to.
use std::time::Duration;
use rahti_native::{{AppPaths, DatabaseMode, EmbeddedServer, LaunchToken, Platform, RunningServer}};
use tauri::{{Manager, WebviewUrl, WebviewWindowBuilder}};
/// Written from rahti.native.json. Regenerate with `cargo rahti native init`.
const IDENTIFIER: &str = {identifier};
const VERSION: &str = {version};
const WINDOW_TITLE: &str = {title};
const WINDOW_WIDTH: f64 = {width}.0;
const WINDOW_HEIGHT: f64 = {height}.0;
const WINDOW_RESIZABLE: bool = {resizable};
const CONTENT_SECURITY_POLICY: &str = {csp};
/// The application's `public/`, compiled into this binary.
///
/// Not read from disk at runtime, and that is the whole point. Android's
/// package resources are entries in a zip, and Tauri reports their location as
/// the URI `asset://localhost/` — not a directory, not something a file server
/// can open. A shell that pointed `ServeDir` at it would start, bind, open a
/// window, and 404 every stylesheet and the browser runtime.
///
/// So the bytes travel inside the executable and are written to application
/// storage on first launch. One code path, both platforms.
static PUBLIC: include_dir::Dir<'_> =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/../public");
/// The project's own `public/` on the machine that built this.
///
/// Used by a development build only, so a stylesheet edit is visible without a
/// rebuild — `rahti`'s watcher is looking at that directory. A release package
/// never consults it, and therefore never depends on a path from the build
/// machine.
const PROJECT_PUBLIC: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../public");
/// The project's `AUTH_COOKIE_NAME`. A name, never the key — the key is
/// generated on this device at first launch and never leaves it.
const AUTH_COOKIE_NAME: Option<&str> = {cookie};
/// Whether this application's database runs inside the package.
const DATABASE_MODE: DatabaseMode = DatabaseMode::{database_mode};
/// Refuse requests to the embedded server that did not come from this launch.
///
/// `127.0.0.1` is unreachable from the network and reachable by every process
/// on the machine. From `security.loopbackToken` in rahti.native.json.
const LOOPBACK_TOKEN: bool = {loopback_token};
/// Held for the life of the process so the server can be stopped when the last
/// window closes.
struct Server(std::sync::Mutex<Option<RunningServer>>);
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {{
// A release package states its development flags rather than inheriting
// them: an installed application inherits the environment of whoever
// launched it.
rahti_native::harden_release();
rahti_native::install_csp(CONTENT_SECURITY_POLICY);
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
platform,
app_version,
app_data_dir,
open_external,
choose_file
])
.setup(|app| {{
match start(app.handle()) {{
Ok(server) => {{
let url = server.base_url();
app.manage(Server(std::sync::Mutex::new(Some(server))));
open_window(app.handle(), &url)?;
Ok(())
}}
// A window with nothing in it explains nothing. Startup
// returns its failure rather than exiting on it precisely so
// that this line can exist.
Err(message) => Err(message.into()),
}}
}})
.build(tauri::generate_context!())
.expect("the native shell could not be built")
.run(|app, event| {{
if let tauri::RunEvent::ExitRequested {{ .. }} = event {{
stop(app);
}}
}});
}}
/// Steps 1 to 6.
fn start(app: &tauri::AppHandle) -> Result<RunningServer, String> {{
// Tauri knows where the operating system put this installation's
// directories — on Android only the Java side does, which is why they are
// asked for rather than derived.
let resolver = app.path();
let data = resolver.app_local_data_dir().map_err(describe)?;
let cache = resolver.app_cache_dir().map_err(describe)?;
let resources = resolver.resource_dir().map_err(describe)?;
let paths = AppPaths::from_host(IDENTIFIER, data, cache, resources).map_err(describe)?;
paths.prepare().map_err(describe)?;
// A development build serves the project's own `public/` directly, so a
// stylesheet edit shows up without a rebuild. Anything else writes the
// embedded copy into internal storage, once per version, replaced whole on
// an upgrade so a stale framework asset cannot outlive the version that
// shipped it.
let source = std::path::Path::new(PROJECT_PUBLIC);
let public = if cfg!(debug_assertions) && source.is_dir() {{
source.to_path_buf()
}} else {{
let assets = embedded_assets();
rahti_native::stage_embedded_assets(&assets, &paths.public(), VERSION)
.map_err(describe)?;
paths.public()
}};
// Before the router is built, because it reads RAHTI_PUBLIC_DIR as it goes
// up and RAHTI_SPILL_DIR on the first upload that spills.
paths.apply_environment(public.as_ref());
// Only when the database is one that can live inside the package. A
// project on PostgreSQL keeps its own DATABASE_URL: rewriting it to a
// local SQLite file would start the application against an empty database
// that looked like a working one.
if matches!(DATABASE_MODE, DatabaseMode::SqliteLocal) {{
paths.apply_sqlite_database_url();
}}
// Generated on this device at first launch, kept in protected application
// storage, and restored before the auth policy is built — a key that
// changed per launch would sign every user out at every restart.
rahti_native::install_session_secret(&paths, AUTH_COOKIE_NAME).map_err(describe)?;
tauri::async_runtime::block_on(async {{
// Bound before anything is told to go there.
let server = EmbeddedServer::bind().await.map_err(describe)?;
let application = {app_lib}::initialize_application()
.await
.map_err(|e| format!("the application could not start ({{}}): {{e}}", e.step))?;
// The gate and the security headers, in that order — see
// `rahti_native::secure`. One call, so the shell names no axum type
// and cannot get the layer order backwards.
let router = rahti_native::secure(application.router, LOOPBACK_TOKEN);
let running = server.serve(router);
running.wait_until_ready().await.map_err(describe)?;
Ok(running)
}})
}}
/// Step 7.
fn open_window(app: &tauri::AppHandle, base_url: &str) -> tauri::Result<()> {{
// The launch URL carries this launch's token once. The gate turns it into
// a cookie and redirects to the clean URL, so it is not left in the
// address bar or in a `Referer`.
let url = LaunchToken::launch_url(base_url);
let url: tauri::Url = url.parse().expect("a loopback URL");
let handle = app.clone();
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
.title(WINDOW_TITLE)
.inner_size(WINDOW_WIDTH, WINDOW_HEIGHT)
.resizable(WINDOW_RESIZABLE)
// The bridge, injected before any page script runs. It attaches
// `pp.native` to the PulsePoint runtime when the bundle publishes it;
// the runtime bundle itself is untouched.
.initialization_script(&rahti_native::bridge_script(
Platform::current().name(),
VERSION,
))
// A link to somewhere else opens in the user's own browser, and this
// window stays where it is. A privileged WebView that navigated to an
// external page would be running somebody else's HTML with the native
// bridge attached to it.
//
// Returning `false` cancels the navigation, so anything that is
// neither this server nor an ordinary web URL simply does not happen.
.on_navigation(move |url| {{
let target = url.to_string();
if target.starts_with("http://127.0.0.1:") {{
return true;
}}
if rahti_native::is_external_url(&target) {{
let _ =
tauri_plugin_opener::OpenerExt::opener(&handle).open_url(target, None::<&str>);
}}
false
}})
.build()?;
Ok(())
}}
/// Stop the server when the application exits.
///
/// Rahti's shutdown broadcast ends the long-lived responses first — the dev
/// event stream, every open WebSocket — because a graceful shutdown would
/// otherwise wait for connections that were never going to close. Ctrl+C is
/// not involved: a packaged GUI never receives one, and Android's lifecycle
/// has nothing like it.
fn stop(app: &tauri::AppHandle) {{
let Some(state) = app.try_state::<Server>() else {{
return;
}};
let Some(server) = state.0.lock().ok().and_then(|mut held| held.take()) else {{
return;
}};
tauri::async_runtime::block_on(async move {{
if let Err(e) = server.shutdown(Duration::from_secs(5)).await {{
eprintln!("rahti native: {{e}}");
}}
}});
}}
/// Every embedded file, flattened with `/` separators.
///
/// `include_dir` already normalizes to forward slashes on Windows, but the
/// paths become directories under application storage either way, so it is
/// stated rather than assumed.
fn embedded_assets() -> Vec<rahti_native::EmbeddedAsset<'static>> {{
fn walk<'a>(
dir: &'a include_dir::Dir<'a>,
out: &mut Vec<rahti_native::EmbeddedAsset<'a>>,
) {{
for file in dir.files() {{
out.push(rahti_native::EmbeddedAsset {{
path: file.path().to_str().unwrap_or_default(),
bytes: file.contents(),
}});
}}
for child in dir.dirs() {{
walk(child, out);
}}
}}
let mut out = Vec::new();
walk(&PUBLIC, &mut out);
out
}}
fn describe(error: impl std::fmt::Display) -> String {{
error.to_string()
}}
// ------------------------------------------------------------- commands
//
// The whole native surface, and it is short on purpose. In a browser an XSS is
// a stolen session; here it is a stolen session and everything below. Nothing
// runs a program, and nothing reads a path the page names — `choose_file`
// returns what the *user* picked, which is the difference the security model
// rests on.
//
// A command has to be in `rahti_native::commands()` and in
// `capabilities/default.json` before it works. Both, on purpose.
#[tauri::command]
fn platform() -> &'static str {{
Platform::current().name()
}}
#[tauri::command]
fn app_version() -> &'static str {{
VERSION
}}
/// Where this installation keeps its data — for showing the user, in a
/// settings page or a support message.
#[tauri::command]
fn app_data_dir(app: tauri::AppHandle) -> Result<String, String> {{
app.path()
.app_local_data_dir()
.map(|dir| dir.display().to_string())
.map_err(describe)
}}
/// Hand a URL to the user's own browser or mail client.
///
/// Refused unless it is `http`, `https` or `mailto`. A `file:` URL opens
/// whatever the shell associates with the extension, which on Windows includes
/// executables; `javascript:` and `data:` are ways back into a privileged
/// context.
#[tauri::command]
fn open_external(app: tauri::AppHandle, url: String) -> Result<(), String> {{
if !rahti_native::is_external_url(&url) {{
return Err("that is not a URL this application will open".to_string());
}}
tauri_plugin_opener::OpenerExt::opener(&app)
.open_url(url, None::<&str>)
.map_err(describe)
}}
/// Open the platform file picker and return what the user chose.
///
/// The page cannot name a path, cannot list a directory, and learns nothing
/// unless a human picked it. An ordinary `<input type="file">` still works and
/// is the right choice on any page that does not need a path back.
#[tauri::command]
async fn choose_file(app: tauri::AppHandle, extensions: Option<Vec<String>>) -> Option<String> {{
use tauri_plugin_dialog::DialogExt;
let mut dialog = app.dialog().file();
if let Some(extensions) = &extensions {{
let filters: Vec<&str> = extensions.iter().map(String::as_str).collect();
dialog = dialog.add_filter("Files", &filters);
}}
dialog.blocking_pick_file().map(|path| path.to_string())
}}
"#,
identifier = json(&config.identifier),
version = json(&config.version),
title = json(&config.window.title),
width = config.window.width,
height = config.window.height,
resizable = config.window.resizable,
csp = json(&config.security.csp),
cookie = cookie,
database_mode = match config.database.mode {
rahti_native::DatabaseMode::SqliteLocal => "SqliteLocal",
rahti_native::DatabaseMode::Remote => "Remote",
},
loopback_token = config.security.loopback_token,
app_lib = project.lib,
)
}
const MAIN_RS: &str = r#"// Generated by cargo-rahti-native — do not edit.
//
// The desktop binary. Everything is in the library beside it, because Android
// has no `main`: the operating system loads the library and calls the entry
// point `#[tauri::mobile_entry_point]` generates. One `run`, two hosts.
// No console window behind the application on Windows, in a release build.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
APP_LIB::run()
}
"#;
const DIST_INDEX: &str = r#"<!doctype html>
<meta charset="utf-8">
<title>Starting…</title>
<p>Starting…</p>
"#;
const GITIGNORE: &str = r#"# Build output.
/target
/dist/.vite
# Development diagnostics. A `dev` run has this directory as its working
# directory, so the application writes its log here rather than at the project
# root — where the project's own .gitignore is anchored and would not catch it.
# Never commit it and never ship it: it carries rendered values, error
# messages and URLs.
/.rahti
# Generated by tauri-build on every build.
/gen
# Android and iOS projects, generated by `cargo tauri android init`. They are
# regenerated rather than reviewed, and the Android one carries absolute paths
# from the machine that created it.
/gen/android
/gen/apple
# Never commit a keystore or a signing certificate. Release signing is
# configured through the environment — see native/README.md.
*.keystore
*.jks
*.p12
*.pfx
key.properties
keystore.properties
"#;
fn readme(config: &NativeConfig) -> String {
format!(
r#"# {product} — native package
Generated by `cargo rahti native init`. **These files are yours now.** Rahti
will not replace one you have edited unless you name it:
```text
cargo rahti native init --force
```
## What is here
| Path | What it is |
| --- | --- |
| `tauri.conf.json` | The identifier, the version, the bundle targets, the icons. |
| `capabilities/default.json` | What the application's pages may ask the operating system for. |
| `src/lib.rs` | The shell: paths, server, window, and the five native commands. |
| `src/main.rs` | The desktop binary. Android calls the library instead. |
| `icons/` | **Placeholders.** Replace them before you ship anything. |
| `dist/` | A page that is never shown; Tauri's bundler requires a frontend directory. |
## Icons
The generated icons are the Rahti mark, at every size Windows and Android ask
for. That is a real icon set rather than a placeholder, and it is still not
yours — replace it. Tauri's own tool takes a single square PNG and writes every
size both platforms want:
```bash
cargo tauri icon path/to/icon.png
```
## Release signing
Nothing about signing is in any committed file, and nothing should be. Both
platforms read it from the environment of the build.
**Windows** — a PFX certificate, base64 encoded:
```text
RAHTI_NATIVE_WINDOWS_CERTIFICATE
RAHTI_NATIVE_WINDOWS_CERTIFICATE_PASSWORD
```
**Android** — a keystore and its alias:
```text
RAHTI_NATIVE_ANDROID_KEYSTORE
RAHTI_NATIVE_ANDROID_KEYSTORE_PASSWORD
RAHTI_NATIVE_ANDROID_KEY_ALIAS
RAHTI_NATIVE_ANDROID_KEY_PASSWORD
```
On Android these are wired into the generated Gradle project for you — Tauri
reads no signing key from the environment there, so a keystore passed and
assumed would produce an unsigned release. The details land in
`gen/android/keystore.properties`, which is ignored.
`cargo rahti native doctor --release --target android` says which of them are
missing. `cargo rahti native build --target android --debug` produces a package
that installs without any of them, which is what testing wants.
## Adding a native capability
Three places, and all three on purpose:
1. a `#[tauri::command]` in `src/lib.rs`, and in `generate_handler!`;
2. an entry in `rahti_native::commands()`, which is what `pp.native.invoke`
checks against;
3. a permission in `capabilities/default.json`, which is what Tauri enforces.
Before you add one, assume the page calling it has been compromised, because
the command has to be safe in that case too. That is why there is no command
that runs a program and none that reads a path the page names.
## The identifier
`{identifier}` is in `tauri.conf.json`, in `rahti.native.json` and in
`src/lib.rs`. Changing it makes a different application: on Android it installs
beside the old one rather than over it, and on both platforms the new one
starts with an empty data directory and a new session key.
"#,
product = config.product_name,
identifier = config.identifier,
)
}
pub fn main_rs(project: &Project) -> String {
MAIN_RS.replace("APP_LIB", &format!("{}_native_lib", project.lib))
}
fn json(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}