use std::path::Path;
use super::emit::Plan;
use super::{GenerateError, ensure_project_root, read_or_empty};
#[derive(Debug, Clone, Copy, Default)]
pub struct TauriMobileOptions {
pub offline_sync: bool,
}
pub fn plan_tauri_mobile(
project_root: &Path,
opts: TauriMobileOptions,
) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
let AppMeta {
package_name,
version: package_version,
lib_ident,
lib_src_path,
has_embed_assets,
} = read_app_meta(project_root)?;
let mut plan = Plan::new(project_root);
let tauri = project_root.join("src-tauri");
let autumn_web_dep = opts
.offline_sync
.then(|| shell_autumn_web_dep(project_root, &mut plan));
if !has_embed_assets {
plan.warn(
"the app's Cargo.toml declares no `embed-assets` feature — the mobile \
shell will link the app WITHOUT embedded assets, so static files \
(CSS, htmx, widgets JS) will 404 on a device. Add \
`embed-assets = [\"autumn-web/embed-assets\"]` under [features] and \
re-run with --force (see docs/guide/tauri-mobile-in-process.md)."
.to_owned(),
);
}
plan.create(
tauri.join("tauri.conf.json"),
render_mobile_tauri_conf(&package_name, &package_version),
);
plan.create(
tauri.join("Cargo.toml"),
render_mobile_cargo_toml(&package_name, has_embed_assets, autumn_web_dep.as_ref()),
);
plan.create(tauri.join("build.rs"), render_mobile_build_rs());
plan.create(
tauri.join("src").join("main.rs"),
render_mobile_main_rs(&package_name),
);
plan.create(
tauri.join("src").join("lib.rs"),
render_mobile_lib_rs(&package_name, &lib_ident, opts.offline_sync),
);
let icons_dir = tauri.join("icons");
let pwa_icon_src = project_root.join("static").join("icons").join("icon.svg");
if pwa_icon_src.is_file() {
let contents = std::fs::read_to_string(&pwa_icon_src).map_err(GenerateError::Io)?;
plan.create_if_absent(icons_dir.join("icon.svg"), contents);
} else {
plan.create_if_absent(icons_dir.join("icon.svg"), render_placeholder_icon_svg());
}
plan.create_bytes(icons_dir.join("32x32.png"), PLACEHOLDER_PNG);
plan.create_bytes(icons_dir.join("128x128.png"), PLACEHOLDER_PNG);
plan.create_bytes(icons_dir.join("128x128@2x.png"), PLACEHOLDER_PNG);
plan.create_bytes(icons_dir.join("icon.png"), PLACEHOLDER_PNG);
plan.create_bytes(icons_dir.join("icon.ico"), PLACEHOLDER_ICO);
plan.create_bytes(icons_dir.join("icon.icns"), PLACEHOLDER_ICNS);
plan.create(tauri.join(".gitignore"), render_mobile_gitignore());
plan_lib_extraction(
project_root,
&lib_ident,
&lib_src_path,
opts.offline_sync,
&mut plan,
);
if opts.offline_sync {
plan_app_offline_sync_feature(project_root, &mut plan);
}
Ok(plan)
}
pub fn ensure_no_other_mode_scaffold(project_root: &Path) -> Result<(), GenerateError> {
let tauri = project_root.join("src-tauri");
let first_existing = |markers: &[&str]| -> Option<String> {
markers
.iter()
.find(|m| tauri.join(m).is_file())
.map(|m| format!("src-tauri/{m}"))
};
if let Some(marker) = first_existing(&super::tauri::DESKTOP_MARKERS) {
return Err(GenerateError::Config(format!(
"src-tauri/ already contains a desktop (sidecar) Tauri scaffold \
({marker} exists); refusing to scaffold the mobile in-process \
shell on top of it — stale per-OS tauri.*.conf.json overlays \
would keep running the sidecar staging scripts on every `cargo \
tauri build`. Run `autumn destroy tauri` first, then re-run \
`autumn generate tauri-mobile`. --force only overwrites files \
within the same mode; it never mixes modes."
)));
}
if let Some(marker) = first_existing(&super::tauri::THIN_CLIENT_MARKERS) {
return Err(GenerateError::Config(format!(
"src-tauri/ already contains a mobile thin-client Tauri scaffold \
({marker} exists); refusing to scaffold the mobile in-process \
shell on top of it — Tauri loads every capability file under \
src-tauri/capabilities/, so the stale thin-client grants would \
fail validation against the in-process shell crate. Run `autumn \
destroy tauri --remote-url <URL>` (the URL the thin client was \
generated with) first, then re-run `autumn generate \
tauri-mobile`. --force only overwrites files within the same \
mode; it never mixes modes."
)));
}
Ok(())
}
pub fn render_mobile_prerequisites(opts: TauriMobileOptions) -> String {
let connect_step = if opts.offline_sync {
"3. Point the background sync engine at your remote deployment:\n\
edit src-tauri/src/lib.rs and set AUTUMN_SYNC__REMOTE_URL to the\n\
/sync mount of your deployed app (see the commented block in run()).\n\
Deploy the SAME app with AUTUMN_DATABASE__URL set so it serves the\n\
/sync endpoints. They are mounted FAIL CLOSED: set SYNC_TOKEN on the\n\
deployment (a long random secret) and deliver the same secret to\n\
devices as AUTUMN_SYNC__TOKEN, or /sync stays unmounted. The\n\
device itself needs no database connection; read\n\
docs/guide/tauri-mobile-offline-sync.md for the offline walkthrough.\n"
} else {
"3. Point the app at your remote Postgres database:\n\
edit src-tauri/src/lib.rs and set AUTUMN_DATABASE__URL (see the\n\
commented example in run()).\n"
};
format!(
"\
Required prerequisites for building the mobile app:\n\
\n\
1. Tauri CLI:\n\
cargo install tauri-cli --version '^2'\n\
\n\
2. Mobile toolchain:\n\
iOS: Xcode + iOS simulators (macOS only), then:\n\
cd src-tauri && cargo tauri ios init\n\
Android: Android Studio / SDK + NDK (set ANDROID_HOME, NDK_HOME), then:\n\
cd src-tauri && cargo tauri android init\n\
\n\
{connect_step}\
\n\
4. Develop or build:\n\
cd src-tauri && cargo tauri ios dev (or: cargo tauri ios build)\n\
cd src-tauri && cargo tauri android dev (or: cargo tauri android build)\n\
Note: Android release builds block cleartext HTTP (incl. 127.0.0.1) by\n\
default — permit loopback via a network_security_config.xml (see the\n\
docs page below).\n\
\n\
The server runs IN-PROCESS on a background thread — mobile sandboxes forbid\n\
sidecar processes, so this scaffold intentionally has no externalBin and no\n\
staging scripts. Read docs/guide/tauri-mobile-in-process.md for mobile\n\
sandboxing restrictions, remote-Postgres pool tuning for flaky networks,\n\
security notes, and App Store guideline compliance notes.\n\
\n\
Replace the placeholder icons before shipping:\n\
cd src-tauri && cargo tauri icon icons/icon.svg\n"
)
}
struct AppMeta {
package_name: String,
version: String,
lib_ident: String,
lib_src_path: String,
has_embed_assets: bool,
}
fn read_app_meta(project_root: &Path) -> Result<AppMeta, GenerateError> {
let cargo_path = project_root.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_path).map_err(GenerateError::Io)?;
let doc: toml::Value = toml::from_str(&content)
.map_err(|e| GenerateError::Config(format!("failed to parse Cargo.toml: {e}")))?;
let pkg = doc
.get("package")
.ok_or_else(|| GenerateError::Config("Cargo.toml missing [package].name".to_owned()))?;
let name = pkg
.get("name")
.and_then(|n| n.as_str())
.map(str::to_owned)
.ok_or_else(|| GenerateError::Config("Cargo.toml missing [package].name".to_owned()))?;
let version = match pkg.get("version") {
Some(toml::Value::String(s)) => s.clone(),
Some(toml::Value::Table(t))
if t.get("workspace").and_then(toml::Value::as_bool) == Some(true) =>
{
resolve_workspace_version(project_root).unwrap_or_else(|| "0.1.0".to_owned())
}
_ => "0.1.0".to_owned(),
};
let has_embed_assets = doc
.get("features")
.and_then(|f| f.get("embed-assets"))
.is_some();
let lib_ident = doc
.get("lib")
.and_then(|l| l.get("name"))
.and_then(|n| n.as_str())
.map_or_else(|| name.replace('-', "_"), str::to_owned);
let lib_src_path = doc
.get("lib")
.and_then(|l| l.get("path"))
.and_then(|p| p.as_str())
.map_or_else(|| "src/lib.rs".to_owned(), str::to_owned);
Ok(AppMeta {
package_name: name,
version,
lib_ident,
lib_src_path,
has_embed_assets,
})
}
fn resolve_workspace_version(project_root: &Path) -> Option<String> {
let root = effective_workspace_root(project_root);
let content = std::fs::read_to_string(root.join("Cargo.toml")).ok()?;
let doc = toml::from_str::<toml::Value>(&content).ok()?;
doc.get("workspace")?
.get("package")?
.get("version")?
.as_str()
.map(str::to_owned)
}
struct ShellAutumnWebDep {
dep_entry: String,
patch_entry: Option<String>,
}
fn lexical_relative(target: &Path, dir: &Path) -> Option<std::path::PathBuf> {
use std::path::Component;
fn normalize(p: &Path) -> Option<Vec<Component<'_>>> {
let mut out: Vec<Component<'_>> = Vec::new();
for component in p.components() {
match component {
Component::CurDir => {}
Component::ParentDir => match out.last() {
Some(Component::Normal(_)) => {
out.pop();
}
_ => return None,
},
other => out.push(other),
}
}
Some(out)
}
let target = normalize(target)?;
let dir = normalize(dir)?;
if target.first() != dir.first() {
return None;
}
let common = target
.iter()
.zip(dir.iter())
.take_while(|(a, b)| a == b)
.count();
let mut relative = std::path::PathBuf::new();
for _ in common..dir.len() {
relative.push("..");
}
for component in &target[common..] {
relative.push(component.as_os_str());
}
if relative.as_os_str().is_empty() {
relative.push(".");
}
Some(relative)
}
fn dep_source_from_table(
table: &toml::value::Table,
manifest_dir: &Path,
project_root: &Path,
) -> Option<String> {
if let Some(path) = table.get("path").and_then(toml::Value::as_str) {
let adjusted = if Path::new(path).has_root() {
path.replace('\\', "/")
} else if manifest_dir == project_root {
format!("../{}", path.replace('\\', "/"))
} else {
let target = manifest_dir.join(path);
let src_tauri = project_root.join("src-tauri");
lexical_relative(&target, &src_tauri)
.unwrap_or(target)
.display()
.to_string()
.replace('\\', "/")
};
return Some(format!("path = \"{adjusted}\""));
}
if let Some(git) = table.get("git").and_then(toml::Value::as_str) {
use std::fmt::Write as _;
let mut source = format!("git = \"{git}\"");
for key in ["rev", "branch", "tag"] {
if let Some(v) = table.get(key).and_then(toml::Value::as_str) {
let _ = write!(source, ", {key} = \"{v}\"");
}
}
return Some(source);
}
None
}
fn table_default_features(table: &toml::value::Table) -> Option<bool> {
["default-features", "default_features"]
.iter()
.find_map(|key| table.get(*key).and_then(toml::Value::as_bool))
}
fn resolve_app_autumn_web_value(
project_root: &Path,
) -> (Option<toml::Value>, std::path::PathBuf, Option<bool>) {
let parse = |dir: &Path| -> Option<toml::Value> {
toml::from_str(&read_or_empty(&dir.join("Cargo.toml"))).ok()
};
let doc = parse(project_root);
let dep_key = doc.as_ref().map_or_else(
|| "autumn-web".to_owned(),
|d| super::tauri::resolve_dep_key(project_root, d, "autumn-web"),
);
let dep = doc
.as_ref()
.and_then(|d| d.get("dependencies")?.get(&dep_key))
.cloned();
if let Some(toml::Value::Table(t)) = &dep
&& t.get("workspace").and_then(toml::Value::as_bool) == Some(true)
{
let member_default_features = table_default_features(t);
let root = effective_workspace_root(project_root);
if let Some(v) = parse(&root)
.as_ref()
.and_then(|doc| doc.get("workspace")?.get("dependencies")?.get(&dep_key))
.cloned()
{
return (Some(v), root, member_default_features);
}
return (None, project_root.to_path_buf(), member_default_features);
}
(dep, project_root.to_path_buf(), None)
}
fn mirror_autumn_web_patch(project_root: &Path, plan: &mut Plan) -> Option<String> {
let root = effective_workspace_root(project_root);
if root != project_root && autumn_web_patch_entry(project_root).is_some() {
plan.warn(format!(
"Cargo ignores [patch] tables outside the workspace root, so the \
[patch.crates-io] override of autumn-web in the app's own \
Cargo.toml was NOT mirrored into src-tauri/Cargo.toml (only the \
patch table of {} applies). Move the patch to the workspace \
root if the app should build against it.",
root.join("Cargo.toml").display(),
));
}
let (key, value) = autumn_web_patch_entry(&root)?;
let table = value.as_table();
let source = table.and_then(|t| dep_source_from_table(t, &root, project_root));
let Some(source) = source else {
plan.warn(
"the app's [patch.crates-io] override of autumn-web could not \
be mirrored into src-tauri/Cargo.toml (only path and git \
patches are supported). The shell declares its own \
[workspace], so the app's patch does NOT apply there — copy \
your [patch.crates-io] section into src-tauri/Cargo.toml \
yourself or the shell will build against the crates.io \
registry version of autumn-web."
.to_owned(),
);
return None;
};
let package_part = table
.and_then(|t| t.get("package"))
.and_then(toml::Value::as_str)
.map_or_else(String::new, |package| format!("package = \"{package}\", "));
Some(format!(
"\n[patch.crates-io]\n\
# Mirrors the app's [patch.crates-io] override of autumn-web: the shell\n\
# declares its own [workspace], so the app's patch would otherwise NOT\n\
# apply here and the shell would compile a different framework source.\n\
{key} = {{ {package_part}{source} }}\n"
))
}
fn autumn_web_patch_entry(dir: &Path) -> Option<(String, toml::Value)> {
toml::from_str::<toml::Value>(&read_or_empty(&dir.join("Cargo.toml")))
.ok()
.as_ref()
.and_then(|doc| doc.get("patch")?.get("crates-io")?.as_table())
.and_then(|table| {
table.iter().find(|(key, value)| {
key.as_str() == "autumn-web"
|| value
.as_table()
.and_then(|t| t.get("package"))
.and_then(toml::Value::as_str)
== Some("autumn-web")
})
})
.map(|(key, value)| (key.clone(), value.clone()))
}
pub fn effective_workspace_root(project_root: &Path) -> std::path::PathBuf {
let parse = |dir: &Path| -> Option<toml::Value> {
toml::from_str(&read_or_empty(&dir.join("Cargo.toml"))).ok()
};
let doc = parse(project_root);
if doc.as_ref().is_some_and(|d| d.get("workspace").is_some()) {
return project_root.to_path_buf();
}
if let Some(pointer) = doc
.as_ref()
.and_then(|d| d.get("package")?.get("workspace"))
.and_then(toml::Value::as_str)
{
return project_root.join(pointer);
}
let mut dir = project_root.parent();
while let Some(d) = dir {
if let Some(workspace) = parse(d)
.as_ref()
.and_then(|doc| doc.get("workspace").cloned())
{
let excluded = workspace
.get("exclude")
.and_then(toml::Value::as_array)
.is_some_and(|entries| {
let relative = lexical_relative(project_root, d);
entries.iter().filter_map(toml::Value::as_str).any(|entry| {
relative
.as_deref()
.is_some_and(|relative| relative.starts_with(Path::new(entry)))
})
});
if !excluded {
return d.to_path_buf();
}
}
dir = d.parent();
}
project_root.to_path_buf()
}
fn shell_autumn_web_dep(project_root: &Path, plan: &mut Plan) -> ShellAutumnWebDep {
let (dep_value, manifest_dir, member_default_features) =
resolve_app_autumn_web_value(project_root);
let resolved_default_features = match &dep_value {
Some(toml::Value::Table(t)) => table_default_features(t),
_ => None,
};
let (default_features_part, shell_features) =
if member_default_features.or(resolved_default_features) == Some(false) {
(
"default-features = false, ",
r#""offline-sync", "postgres""#,
)
} else {
("", r#""offline-sync""#)
};
if let Some(toml::Value::Table(t)) = &dep_value
&& let Some(source) = dep_source_from_table(t, &manifest_dir, project_root)
{
let version_part = t
.get("version")
.and_then(toml::Value::as_str)
.map_or_else(String::new, |v| format!("version = \"{v}\", "));
return ShellAutumnWebDep {
dep_entry: format!(
"autumn-web = {{ {version_part}{source}, \
{default_features_part}features = [{shell_features}] }}"
),
patch_entry: None,
};
}
let version = match &dep_value {
Some(toml::Value::String(s)) => Some(s.clone()),
Some(toml::Value::Table(t)) => t
.get("version")
.and_then(toml::Value::as_str)
.map(str::to_owned),
_ => None,
};
let registry_part = match &dep_value {
Some(toml::Value::Table(t)) => t
.get("registry")
.and_then(toml::Value::as_str)
.map_or_else(String::new, |registry| {
format!("registry = \"{registry}\", ")
}),
_ => String::new(),
};
let registry_entry = |req: &str| {
format!(
"autumn-web = {{ version = \"{req}\", \
{registry_part}{default_features_part}features = [{shell_features}] }}"
)
};
let Some(version) = version else {
plan.warn(format!(
"could not determine the app's autumn-web dependency source — the \
shell's src-tauri/Cargo.toml falls back to the crates.io registry \
(autumn-web = \"{}\"), which may be a DIFFERENT framework source \
than the app builds against (and may lack the offline-sync \
feature). Edit the autumn-web entry in src-tauri/Cargo.toml to \
match your app's source — path, git, or version (see \
docs/guide/tauri-mobile-offline-sync.md).",
env!("CARGO_PKG_VERSION"),
));
return ShellAutumnWebDep {
dep_entry: registry_entry(env!("CARGO_PKG_VERSION")),
patch_entry: None,
};
};
let patch_entry = mirror_autumn_web_patch(project_root, plan);
ShellAutumnWebDep {
dep_entry: registry_entry(&version),
patch_entry,
}
}
fn reachable_own_features(
features: &toml::value::Table,
start: &str,
) -> std::collections::HashSet<String> {
let mut visited = std::collections::HashSet::new();
let mut stack = vec![start.to_owned()];
while let Some(name) = stack.pop() {
if !visited.insert(name.clone()) {
continue;
}
if let Some(entries) = features.get(&name).and_then(toml::Value::as_array) {
for entry in entries.iter().filter_map(toml::Value::as_str) {
if !entry.contains('/') && !entry.contains(':') {
stack.push(entry.to_owned());
}
}
}
}
visited
}
fn offline_sync_enabled_by_default(features: &toml::value::Table) -> bool {
features.contains_key("offline-sync")
&& reachable_own_features(features, "default").contains("offline-sync")
}
fn offline_sync_feature_propagates(features: &toml::value::Table, dep_key: &str) -> bool {
let strong = format!("{dep_key}/offline-sync");
let weak = format!("{dep_key}?/offline-sync");
reachable_own_features(features, "offline-sync")
.iter()
.any(|name| {
features
.get(name)
.and_then(toml::Value::as_array)
.is_some_and(|entries| {
entries
.iter()
.filter_map(toml::Value::as_str)
.any(|entry| entry == strong || entry == weak)
})
})
}
#[allow(clippy::too_many_lines)] fn plan_app_offline_sync_feature(project_root: &Path, plan: &mut Plan) {
const FEATURES_ANCHOR: &str = "[features]\n";
const DEFAULT_ANCHOR: &str = "default = [\"flash\"]\n";
let cargo_path = project_root.join("Cargo.toml");
let content = read_or_empty(&cargo_path);
let doc = toml::from_str::<toml::Value>(&content).ok();
let dep_key = doc.as_ref().map_or_else(
|| "autumn-web".to_owned(),
|doc| super::tauri::resolve_dep_key(project_root, doc, "autumn-web"),
);
let feature_line = format!("offline-sync = [\"{dep_key}/offline-sync\"]\n");
let features = doc
.as_ref()
.and_then(|doc| doc.get("features"))
.and_then(toml::Value::as_table);
if let Some(features) = features.filter(|f| f.contains_key("offline-sync")) {
let mut edited = content;
let mut changed = false;
let default_ok = offline_sync_enabled_by_default(features);
let propagation_ok = if offline_sync_feature_propagates(features, &dep_key) {
true
} else {
const EMPTY_DECL: &str = "offline-sync = []\n";
if edited.contains(EMPTY_DECL) {
edited = edited.replacen(EMPTY_DECL, &feature_line, 1);
changed = true;
true
} else {
false
}
};
if !propagation_ok {
let default_note = if default_ok {
String::new()
} else {
" `default` was deliberately left untouched too — enabling \
the broken feature by default would break every plain \
`cargo build` — so after fixing the feature array, also \
add `offline-sync` to the `default` set (or run the \
server deployment with `--features offline-sync`)."
.to_owned()
};
plan.warn(format!(
"Cargo.toml declares an `offline-sync` feature, but it \
doesn't enable `{dep_key}/offline-sync` (directly or \
through another feature of this crate), so the sync \
code in serve() would compile against an autumn-web \
built WITHOUT its sync module — a guaranteed build \
failure. Add \"{dep_key}/offline-sync\" to your \
`offline-sync` feature array.{default_note} \
(see docs/guide/tauri-mobile-offline-sync.md)"
));
} else if !default_ok {
if edited.contains(DEFAULT_ANCHOR) {
edited = edited.replacen(
DEFAULT_ANCHOR,
"default = [\"flash\", \"offline-sync\"]\n",
1,
);
changed = true;
} else {
plan.warn(
"Cargo.toml declares an `offline-sync` feature, but `default` \
doesn't enable it and the default line doesn't match the \
stock scaffold — add `offline-sync` to the `default` feature \
set yourself (or run the server deployment with `--features \
offline-sync`), or the deployed app will never mount the \
/sync endpoints the device syncs against \
(see docs/guide/tauri-mobile-offline-sync.md)."
.to_owned(),
);
}
}
if changed {
plan.modify(cargo_path, edited);
}
return;
}
if !content.contains(FEATURES_ANCHOR) || !content.contains(DEFAULT_ANCHOR) {
plan.warn(format!(
"Cargo.toml doesn't match the stock scaffold layout — skipping the \
automatic offline-sync feature edit. Add \
`offline-sync = [\"{dep_key}/offline-sync\"]` under [features] \
yourself and include it in `default` (or build with \
`--features offline-sync`), so the server-side /sync endpoints \
compile (see docs/guide/tauri-mobile-offline-sync.md)."
));
return;
}
let edited = content
.replacen(
DEFAULT_ANCHOR,
"default = [\"flash\", \"offline-sync\"]\n",
1,
)
.replacen(
FEATURES_ANCHOR,
&format!(
"[features]\n\
# Offline sync (autumn generate tauri-mobile --offline-sync): local\n\
# SyncStore storage plus the server-side /sync router in serve(). In\n\
# the default set so plain `cargo run` server deployments mount /sync.\n\
{feature_line}"
),
1,
);
plan.modify(cargo_path, edited);
}
const MAIN_FN_ANCHOR: &str = "#[autumn_web::main]\nasync fn main() {";
const SERVE_FN_HEADER: &str = "\
/// Build and run the Autumn server.
///
/// Extracted from `main()` by `autumn generate tauri-mobile` so the Tauri
/// mobile shell (src-tauri/) can run the server in-process on a background
/// thread. The `main.rs` binary still calls this on desktop.
pub async fn serve() {";
const RUN_CALL_ANCHOR: &str = " app\n .run()\n .await;";
const SYNC_MOUNT_CALL: &str = r#" // Offline sync (issue #1508): mount the /sync endpoints when a database
// is configured; on a device (no database configured) this is a no-op —
// the app boots fully offline as a sync CLIENT (see src-tauri/src/lib.rs).
#[cfg(feature = "offline-sync")]
let app = mount_offline_sync(app).await;
"#;
const SYNC_MOUNT_HELPER: &str = r#"
/// Mount the offline-sync server endpoints (`POST /sync/push`, `GET /sync/pull`).
///
/// Added by `autumn generate tauri-mobile --offline-sync`. The endpoints
/// trust `device_id` as sent, and anyone who can reach them can read and
/// write every synced row — so the mount FAILS CLOSED: `/sync` is mounted
/// only when BOTH hold:
///
/// - the app's resolved configuration has a database URL: the REMOTE
/// deployment of this app (the one with Postgres) serves `/sync` for
/// every device, while the same code running in-process on a phone has
/// no database and syncs as a client instead (see
/// `src-tauri/src/lib.rs`);
/// - `SYNC_TOKEN` is set to a non-empty secret: `/sync` is then wrapped in
/// the bearer-token check in `require_sync_auth` below (devices send the
/// matching secret via `AUTUMN_SYNC__TOKEN` — see
/// `src-tauri/src/lib.rs`). Without it the endpoints are NOT mounted —
/// never exposed open — and a startup warning explains how to enable
/// them. Apps with their own authentication can swap the token check in
/// `require_sync_auth` for it; keep the replacement fail closed.
///
/// REQUIRED before shipping: serve these endpoints over HTTPS only — the
/// bearer token and every synced row travel in these requests. See
/// docs/guide/tauri-mobile-offline-sync.md.
///
/// MULTI-USER apps additionally need per-user data partitioning: the
/// `server::router` call below is SINGLE-TENANT — every authenticated
/// device reads and writes one shared "global" scope. Swap it for
/// `server::scoped_router` and have your auth middleware insert an
/// `autumn_web::sync::SyncScope` derived from the authenticated user (the
/// scope is derived server-side and never client-supplied) — see the
/// "Scope data per user" section of the guide.
#[cfg(feature = "offline-sync")]
async fn mount_offline_sync(app: autumn_web::app::AppBuilder) -> autumn_web::app::AppBuilder {
use std::sync::Arc;
use autumn_web::reexports::{axum, tokio};
use autumn_web::sync::{LwwResolver, PgSyncBackend, server};
// Diagnostics below use stderr: this helper runs BEFORE AppBuilder::run()
// installs the tracing subscriber, so tracing events here would be lost.
//
// The database URL is resolved through the SAME layered configuration
// the app itself boots with (autumn.toml, profile files, and the
// AUTUMN_DATABASE__URL / AUTUMN_DATABASE__PRIMARY_URL env overrides) —
// not from one raw env var. Caveat: a custom loader installed via
// `with_config_loader` is NOT consulted here; deployments that must
// serve /sync need their database URL visible to AutumnConfig::load().
let database_url = match autumn_web::config::AutumnConfig::load() {
Ok(config) => config.database.effective_primary_url().map(str::to_owned),
Err(e) => {
eprintln!("offline-sync: config load failed ({e}); /sync not mounted");
return app;
}
};
let Some(database_url) = database_url else {
eprintln!(
"offline-sync: no database is configured — running as a \
sync client only; the remote deployment serves /sync"
);
return app;
};
// FAIL CLOSED: a database is configured, so this process is the sync
// SERVER — but without a shared secret the endpoints would be open to
// anyone who can reach them. Refuse to mount rather than expose them.
if !std::env::var("SYNC_TOKEN").is_ok_and(|token| !token.is_empty()) {
eprintln!(
"offline-sync: SYNC_TOKEN is not set — /sync NOT mounted (fail \
closed). Set SYNC_TOKEN to a long random secret to serve /sync \
behind the generated bearer-token check (devices send the same \
secret via AUTUMN_SYNC__TOKEN), or replace the token check in \
require_sync_auth with your app's real authentication — see \
docs/guide/tauri-mobile-offline-sync.md."
);
return app;
}
let backend = Arc::new(PgSyncBackend::new(database_url));
// Idempotent DDL for the sync shadow tables. A temporarily unreachable
// database must not prevent the app from starting: log and continue —
// /sync requests fail until the schema exists (restart once the database
// is reachable, or run the DDL from a deploy step).
let schema_backend = Arc::clone(&backend);
match tokio::task::spawn_blocking(move || schema_backend.ensure_schema()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
eprintln!("offline-sync: could not ensure the sync schema (/sync will fail): {e}");
}
Err(e) => eprintln!("offline-sync: sync schema task failed: {e}"),
}
app.nest(
"/sync",
server::router(backend, Arc::new(LwwResolver))
.layer(axum::middleware::from_fn(require_sync_auth)),
)
}
/// Reject `/sync` requests that don't carry the expected bearer token
/// (`Authorization: Bearer <SYNC_TOKEN>`) — the generated single-tenant
/// default: every device of this deployment shares one secret. Swap the
/// token check for your app's real session/token validation if you have
/// one, and keep the replacement FAIL CLOSED (reject when the check cannot
/// be performed, never fall through to allowing the request).
#[cfg(feature = "offline-sync")]
async fn require_sync_auth(
request: autumn_web::reexports::axum::extract::Request,
next: autumn_web::reexports::axum::middleware::Next,
) -> Result<
autumn_web::reexports::axum::response::Response,
autumn_web::reexports::axum::http::StatusCode,
> {
use autumn_web::reexports::axum::http;
// Fail CLOSED when the server is misconfigured: with an unset/empty
// SYNC_TOKEN the expected value would be "" and a bare
// `Authorization: Bearer ` header would authenticate.
// (mount_offline_sync refuses to mount /sync in that case; this guard
// keeps the middleware safe even if it is reused elsewhere.)
let expected = std::env::var("SYNC_TOKEN").unwrap_or_default();
if expected.is_empty() {
return Err(http::StatusCode::INTERNAL_SERVER_ERROR);
}
let authorized = request
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
// Constant-time comparison: a plain `==` short-circuits on the
// first differing byte and leaks the secret through timing.
.is_some_and(|token| autumn_web::sync::server::constant_time_token_eq(token, &expected));
if authorized {
Ok(next.run(request).await)
} else {
Err(http::StatusCode::UNAUTHORIZED)
}
}
"#;
fn plan_lib_extraction(
project_root: &Path,
crate_ident: &str,
lib_src_path: &str,
offline_sync: bool,
plan: &mut Plan,
) {
let main_path = project_root.join("src").join("main.rs");
let lib_path = project_root.join(lib_src_path);
let main_rs = read_or_empty(&main_path);
if lib_path.exists() {
if main_rs.contains(&format!("{crate_ident}::serve()")) {
if offline_sync && !read_or_empty(&lib_path).contains("mount_offline_sync") {
plan.warn(format!(
"{lib_src_path} was extracted without --offline-sync — add \
the server-side sync mounting yourself: gate a \
`mount_offline_sync(app)` call before `.run()` behind \
`#[cfg(feature = \"offline-sync\")]` (the full helper is in \
docs/guide/tauri-mobile-offline-sync.md)."
));
}
return;
}
plan.warn(format!(
"{lib_src_path} already exists — skipping the automatic extraction \
of src/main.rs. The Tauri mobile shell calls \
`{crate_ident}::serve()`; expose your app as `pub async fn \
serve()` in {lib_src_path} by hand \
(see docs/guide/tauri-mobile-in-process.md)."
));
return;
}
if !main_rs.contains(MAIN_FN_ANCHOR) {
plan.warn(format!(
"src/main.rs doesn't match the stock scaffold layout — skipping the \
automatic extraction into {lib_src_path}. The Tauri mobile shell \
calls `{crate_ident}::serve()`; move your `main()` body into a \
`pub async fn serve()` in {lib_src_path} and call it from `main()` \
(see docs/guide/tauri-mobile-in-process.md for the exact steps)."
));
return;
}
let mut lib_rs = main_rs.replace(MAIN_FN_ANCHOR, SERVE_FN_HEADER);
if offline_sync {
if lib_rs.contains(RUN_CALL_ANCHOR) {
lib_rs = lib_rs.replacen(
RUN_CALL_ANCHOR,
&format!("{SYNC_MOUNT_CALL}{RUN_CALL_ANCHOR}"),
1,
);
lib_rs.push_str(SYNC_MOUNT_HELPER);
} else {
plan.warn(
"src/main.rs doesn't end in the stock `app.run().await` shape — \
skipping the automatic /sync mounting in the extracted \
src/lib.rs. Add it yourself before `.run()` (the full helper \
is in docs/guide/tauri-mobile-offline-sync.md)."
.to_owned(),
);
}
}
plan.create_if_absent(lib_path, lib_rs);
plan.modify(main_path, render_thin_app_main_rs(crate_ident));
}
fn render_thin_app_main_rs(crate_ident: &str) -> String {
format!(
"//! Thin binary entry point — the app itself lives in `src/lib.rs` so the\n\
//! Tauri mobile shell (`src-tauri/`) can run the server in-process.\n\
//! Generated by `autumn generate tauri-mobile`.\n\
\n\
#[autumn_web::main]\n\
async fn main() {{\n\
\x20 {crate_ident}::serve().await;\n\
}}\n"
)
}
fn render_mobile_tauri_conf(package_name: &str, version: &str) -> String {
let identifier = super::tauri::derive_mobile_identifier(package_name);
let title: String = package_name
.split(['-', '_'])
.map(|word| {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |c| {
c.to_uppercase().to_string() + chars.as_str()
})
})
.collect::<Vec<_>>()
.join(" ");
format!(
r#"{{
"$schema": "https://schema.tauri.app/config/2",
"productName": "{title}",
"version": "{version}",
"identifier": "{identifier}",
"bundle": {{
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.ico",
"icons/icon.icns"
]
}},
"app": {{
"security": {{
"csp": null
}}
}}
}}
"#
)
}
fn render_mobile_cargo_toml(
package_name: &str,
has_embed_assets: bool,
autumn_web_dep: Option<&ShellAutumnWebDep>,
) -> String {
let shell_name = format!("{package_name}-mobile");
let app_dep = if has_embed_assets {
format!(
"# The `embed-assets` feature compiles the app's static/ tree (CSS, htmx,\n\
# widgets JS) into the binary — on a device there is no on-disk static/\n\
# directory to serve from.\n\
{package_name} = {{ path = \"..\", features = [\"embed-assets\"] }}"
)
} else {
format!("{package_name} = {{ path = \"..\" }}")
};
let sync_dep = autumn_web_dep.map_or_else(String::new, |dep| {
format!(
"\n# Offline sync (issue #1508): the shell opens the local SyncStore and runs\n\
# the background SyncEngine (autumn_web::sync). The dependency mirrors the\n\
# app crate's own autumn-web source so cargo unifies both dependency edges.\n\
{}\n",
dep.dep_entry
)
});
let patch_section = autumn_web_dep
.and_then(|dep| dep.patch_entry.as_deref())
.unwrap_or("");
format!(
r#"[package]
name = "{shell_name}"
version = "0.0.1"
edition = "2021"
# Standalone workspace so this crate is independent from the autumn app workspace —
# no change to the root Cargo.toml is needed.
[workspace]
[lib]
# Mobile platforms link the app as a library rather than running a binary:
# staticlib — iOS (linked into the generated Xcode project)
# cdylib — Android (loaded by the generated Android activity)
# rlib — lets src/main.rs run the same shell on desktop (cargo tauri dev)
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = {{ version = "2", features = [] }}
[dependencies]
tauri = {{ version = "2", features = [] }}
# Runtime for the in-process server thread (see src/lib.rs).
tokio = {{ version = "1", features = ["rt-multi-thread"] }}
getrandom = {{ version = "0.4", features = ["std"] }}
# The autumn app itself — the server runs IN-PROCESS on a background thread
# (mobile sandboxes forbid sidecar processes), so the shell links the app
# crate directly instead of bundling a server binary.
{app_dep}
{sync_dep}
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true
{patch_section}"#
)
}
fn render_mobile_build_rs() -> String {
"fn main() {\n tauri_build::build()\n}\n".to_owned()
}
fn render_mobile_main_rs(package_name: &str) -> String {
let lib_name = package_name.replace('-', "_") + "_mobile";
format!(
"#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\
\n\
// Desktop-dev entry point for the mobile shell; on iOS/Android the\n\
// platform loads the library through `tauri::mobile_entry_point`.\n\
fn main() {{\n\
\x20 {lib_name}::run();\n\
}}\n"
)
}
const SYNC_LIB_DOC: &str = "\
//!
//! OFFLINE SYNC (--offline-sync): app data lives in a local SyncStore-backed
//! `SQLite` database inside the app sandbox, and a background SyncEngine
//! reconciles it with the remote deployment's /sync endpoints whenever the
//! network allows — the device itself needs NO direct database connection.
//! See docs/guide/tauri-mobile-offline-sync.md.
";
const SYNC_ENV_BLOCK: &str = r#" // ── Offline sync (issue #1508) ──────────────────────────────────────────
// Local-first data: app state lives in a SyncStore-backed SQLite file in
// the app sandbox (AUTUMN_SYNC__DB_PATH, exported in setup() once the
// sandbox path is known) and a background SyncEngine syncs it with your
// remote Autumn deployment. Point the engine at the remote /sync mount:
// std::env::set_var(
// "AUTUMN_SYNC__REMOTE_URL",
// "https://app.example.com/sync",
// );
// ... and give it the deployment's SYNC_TOKEN so requests authenticate
// (sent as `Authorization: Bearer …`; the generated server mount serves
// /sync ONLY behind that check). Never hard-code a real secret in
// source — deliver it at runtime from your login/auth flow:
// std::env::set_var("AUTUMN_SYNC__TOKEN", sync_token);
// With offline sync the device needs NO direct database connection: leave
// AUTUMN_DATABASE__URL unset and the in-process server boots without
// Postgres — the app works fully offline and converges with the remote in
// the background. Full guide: docs/guide/tauri-mobile-offline-sync.md.
// ────────────────────────────────────────────────────────────────────────
"#;
const SYNC_SETUP_BLOCK: &str = r#" // Offline sync: local-first app data lives in a SyncStore-backed SQLite
// file inside the sandbox. AUTUMN_SYNC__DB_PATH is exported so the app's
// routes reach the SAME file the background engine syncs (see
// start_background_sync below). In your routes, open the store ONCE
// (e.g. behind a OnceLock) and clone it per use — clones share one
// connection; repeated SyncStore::open calls pay setup every time.
let sync_db = data_root.join("sync.db");
std::env::set_var("AUTUMN_SYNC__DB_PATH", sync_db.to_string_lossy().as_ref());
"#;
#[allow(clippy::too_many_lines)]
fn render_mobile_lib_rs(package_name: &str, crate_ident: &str, offline_sync: bool) -> String {
let sync_doc = if offline_sync { SYNC_LIB_DOC } else { "" };
let sync_env_block = if offline_sync { SYNC_ENV_BLOCK } else { "" };
let sync_setup = if offline_sync { SYNC_SETUP_BLOCK } else { "" };
let sync_thread_line = if offline_sync {
" start_background_sync(&runtime, sync_db);\n"
} else {
""
};
let tauri_run_tail = if offline_sync {
format!(
r#" tauri::Builder::default()
.setup(move |app| setup(app, port))
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app_handle, event| {{
// Connectivity-regain trigger: mobile OSes freeze the process
// (and its timers) in the background, and connectivity usually
// returns together with the foreground. On resume, kick one
// immediate sync pass instead of waiting out the background
// interval/backoff.
if let tauri::RunEvent::Resumed = event {{
if let Some((handle, engine)) = SYNC_KICK.get() {{
let engine = engine.clone();
handle.spawn(async move {{
if let Err(e) = engine.sync_once().await {{
eprintln!(
"[{package_name}] Resume sync failed (next background pass retries): {{e}}"
);
}}
}});
}}
}}
}});"#
)
} else {
r#" tauri::Builder::default()
.setup(move |app| setup(app, port))
.run(tauri::generate_context!())
.expect("error while running tauri application");"#
.to_owned()
};
let sync_helpers = if offline_sync {
format!(
r#"
/// Handle to the background sync engine, set once from the server thread so
/// the tauri run-loop callback can trigger an immediate sync pass on
/// RunEvent::Resumed. Never set when AUTUMN_SYNC__REMOTE_URL is unset
/// (offline-only mode).
static SYNC_KICK: std::sync::OnceLock<(tokio::runtime::Handle, autumn_web::sync::SyncEngine)> =
std::sync::OnceLock::new();
/// Open the local sync store and spawn the background sync engine on the
/// server runtime (issue #1508). The app keeps working fully offline: local
/// reads and writes always hit the SyncStore, and when
/// AUTUMN_SYNC__REMOTE_URL is configured the engine pushes/pulls every 30 s
/// (exponential backoff while the remote is unreachable), so the app
/// converges automatically when connectivity returns. See
/// docs/guide/tauri-mobile-offline-sync.md.
///
/// MULTI-ACCOUNT apps: this one store file serves the whole installation.
/// If the same device can re-authenticate as a different account, bind the
/// store to the signed-in account with `store.set_identity(user_key)` on
/// every login/logout BEFORE syncing — on an account change it resets the
/// cached rows, pending outbox, and cursor so the next account never sees
/// (or skips past) the previous account's data. Pair it with the server's
/// `scoped_router` — see the guide's "Scope data per user" section.
fn start_background_sync(runtime: &tokio::runtime::Runtime, sync_db: std::path::PathBuf) {{
let store = match autumn_web::sync::SyncStore::open(&sync_db) {{
Ok(store) => store,
Err(e) => {{
eprintln!("[{package_name}] Failed to open the offline sync store: {{e}}");
return;
}}
}};
let Ok(remote_url) = std::env::var("AUTUMN_SYNC__REMOTE_URL") else {{
eprintln!(
"[{package_name}] AUTUMN_SYNC__REMOTE_URL is not set — running \
offline-only (local SyncStore, no background sync)."
);
return;
}};
let mut config = autumn_web::sync::SyncConfig::new(remote_url);
// The generated server mount serves /sync only behind its SYNC_TOKEN
// bearer check — send the matching secret (set in run(), ideally
// delivered by your login/auth flow rather than hard-coded).
match std::env::var("AUTUMN_SYNC__TOKEN") {{
Ok(token) if !token.is_empty() => config.bearer_token = Some(token),
_ => eprintln!(
"[{package_name}] AUTUMN_SYNC__TOKEN is not set — syncing without \
credentials. The generated server mount requires its SYNC_TOKEN \
and will answer 401 until the matching secret is set here (or \
until you wire your own auth end to end)."
),
}}
let engine = autumn_web::sync::SyncEngine::new(store, config);
// spawn_background must be entered from inside the runtime; the returned
// JoinHandle detaches on drop (dropping never cancels the task).
let _sync_task =
runtime.block_on(async {{ engine.spawn_background(std::time::Duration::from_secs(30)) }});
let _ = SYNC_KICK.set((runtime.handle().clone(), engine));
}}
"#
)
} else {
String::new()
};
format!(
r#"//! Tauri mobile shell for {package_name} — in-process backend + remote Postgres.
//!
//! Mobile sandboxes (iOS and Android) forbid spawning child processes, so the
//! desktop sidecar model cannot work here. Instead the Autumn Axum server runs
//! on a background thread INSIDE this app process:
//!
//! 1. Bind loopback:0 to find a free ephemeral port.
//! 2. Configure the server through environment variables at the top of
//! run(), before tauri::Builder starts any platform threads.
//! 3. std::thread::spawn a dedicated tokio runtime that block_on()s
//! {crate_ident}::serve() — the app entry extracted into src/lib.rs by
//! `autumn generate tauri-mobile`.
//! 4. Poll GET /health in a background thread until the server is ready,
//! then open the webview at http://127.0.0.1:<port>.
//!
//! The database is a REMOTE Postgres reached over the device network. The env
//! defaults below (small pool, short connect timeout) are tuned for flaky
//! mobile networks — see docs/guide/tauri-mobile-in-process.md for the full
//! rationale, reconnection semantics, security notes, and App Store
//! compliance notes.
{sync_doc}
use std::net::TcpListener;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {{
// 1. Find a free loopback port: bind :0, read the assigned port, then drop
// the listener so the in-process server can bind that same address.
// (The port is briefly unbound until the server thread claims it — see
// the security section of docs/guide/tauri-mobile-in-process.md.)
let port = {{
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind a loopback port");
listener
.local_addr()
.expect("failed to read the loopback port")
.port()
}};
// 2. Configure the server via environment variables. The server runs in
// THIS process, so std::env::set_var is the whole config story — no
// staged config files (an autumn.toml in the app repo is NOT read by
// this shell). This block runs at the top of run(), BEFORE
// tauri::Builder starts platform runtimes: set_var is only sound while
// no foreign threads may call getenv concurrently. Only the values
// that need the app sandbox path are deferred to setup().
std::env::set_var("AUTUMN_SERVER__HOST", "127.0.0.1");
std::env::set_var("AUTUMN_SERVER__PORT", port.to_string());
// ── Remote Postgres over a mobile network ───────────────────────────────
// Point the app at your remote database (require TLS in production):
// std::env::set_var(
// "AUTUMN_DATABASE__URL",
// "postgres://app_user:PASSWORD@db.example.com:5432/app?sslmode=require",
// );
//
// Conservative pool defaults for flaky mobile networks: a phone gets one
// user's traffic, so a large server-style pool only holds open sockets
// that die on every network transition (Wi-Fi ⇄ cellular, backgrounding).
// The deadpool-backed pool re-checks connections on checkout and
// re-establishes broken ones automatically, so a small pool recovers
// faster and wastes fewer half-dead connections.
std::env::set_var("AUTUMN_DATABASE__POOL_SIZE", "2");
// Fail fast on NEW connection attempts when the radio is off or asleep.
// (5 is also the framework default — pinned here so the mobile posture
// survives future framework default changes.)
std::env::set_var("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "5");
// ────────────────────────────────────────────────────────────────────────
{sync_env_block} // The webview loads the app over plain HTTP on loopback; Secure cookies
// would be silently dropped by the webview, breaking sessions and CSRF.
// Loopback never leaves the device — but other apps ON the device can
// reach it too (see the security section of the docs page).
std::env::set_var("AUTUMN_SESSION__SECURE", "false");
// Loopback-only server: accept Host: 127.0.0.1 from the webview even when
// production config pins trusted hosts to a public domain.
std::env::set_var("AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS", "127.0.0.1,localhost");
// Local blob storage backend rooted in the app sandbox; the root path is
// set in setup() once the sandbox data dir is known.
std::env::set_var("AUTUMN_STORAGE__BACKEND", "local");
std::env::set_var("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true");
// No load balancer drains connections to an in-process loopback server;
// skip the prestop grace so shutdown hooks run immediately.
std::env::set_var("AUTUMN_SERVER__PRESTOP_GRACE_SECS", "0");
// Profile selection: dev config during `cargo tauri [ios|android] dev`,
// prod config in release builds. Set explicitly — running serve()
// in-process bypasses the #[autumn_web::main] entry point that would
// otherwise bake the release/debug signal into profile detection, so
// without this a release build would silently run the dev profile.
if cfg!(debug_assertions) {{
std::env::set_var("AUTUMN_ENV", "dev");
}} else {{
std::env::set_var("AUTUMN_ENV", "prod");
}}
// Clear inherited one-off mode flags and desktop-only settings: any of
// these would make AppBuilder::run() exit before binding the HTTP port
// (build-static/dump-routes/task modes), bind a Unix socket the health
// probe can't reach, or attach a foreign managed-Postgres cluster.
for var in [
"AUTUMN_PROFILE",
"AUTUMN_BUILD_STATIC",
"AUTUMN_DUMP_ROUTES",
"AUTUMN_LIST_TASKS",
"AUTUMN_RUN_TASK",
"AUTUMN_SERVER__UNIX_SOCKET",
"AUTUMN_SERVE_FORCE_UNIX_SOCKET",
"AUTUMN_MANAGED_PG_ATTACH_URL",
] {{
std::env::remove_var(var);
}}
{tauri_run_tail}
}}
fn setup(app: &mut tauri::App, port: u16) -> Result<(), Box<dyn std::error::Error>> {{
// 3. Per-app data directories (inside the mobile app sandbox). These two
// set_var calls are the only ones that must wait for the tauri App
// handle (the sandbox path is not known before the Builder runs); they
// still execute before the server thread reads the environment.
let data_root = app.path().app_data_dir()?;
std::fs::create_dir_all(&data_root)?;
// Local blob storage in blobs/ (the only writable filesystem location).
let blobs_dir = data_root.join("blobs");
std::fs::create_dir_all(&blobs_dir)?;
std::env::set_var(
"AUTUMN_STORAGE__LOCAL__ROOT",
blobs_dir.to_string_lossy().as_ref(),
);
{sync_setup} // Per-install signing secret: autumn requires one in prod mode. Generate
// 32 random bytes on first launch and persist them so sessions survive
// restarts.
let signing_secret = load_or_generate_signing_secret(&data_root)?;
std::env::set_var("AUTUMN_SECURITY__SIGNING_SECRET", &signing_secret);
// 4. Spawn the Autumn server on a background thread with its own tokio
// runtime. tauri's setup() is synchronous and must return quickly, so
// the server gets a dedicated OS thread; block_on parks that thread on
// the server future for the lifetime of the app. Mobile OSes reclaim
// the whole process on exit — there is no separate child to clean up.
std::thread::spawn(move || {{
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build tokio runtime for the in-process autumn server");
{sync_thread_line} runtime.block_on({crate_ident}::serve());
}});
// 5. Poll for server readiness in a background thread so setup() returns
// immediately and the Tauri event loop starts (blocking here can
// trigger the mobile OS's app-startup watchdog). We probe GET /health —
// the cheap readiness endpoint autumn always registers; any HTTP
// response proves the server is up and routing.
let handle = app.handle().clone();
std::thread::spawn(move || {{
let addr = std::net::SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)),
port,
);
let poll_timeout = std::time::Duration::from_millis(200);
let mut ready = false;
// 300 × 200 ms = 60 s. No local Postgres cluster to initialise here
// (the DB is remote), so startup is fast; 60 s leaves headroom for
// slow first-launch migrations without hanging a broken install
// forever behind a blank screen.
for _ in 0..300 {{
if let Ok(mut stream) = std::net::TcpStream::connect_timeout(&addr, poll_timeout) {{
let _ = stream.set_read_timeout(Some(poll_timeout));
use std::io::{{Read, Write}};
let req = "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
if stream.write_all(req.as_bytes()).is_ok() {{
let mut buf = [0u8; 8];
// Any valid HTTP response (200, 404, …) means the server is
// up and routing — accept the `HTTP/` prefix regardless of
// status.
if stream.read(&mut buf).is_ok() && buf.starts_with(b"HTTP/") {{
ready = true;
break;
}}
}}
}}
std::thread::sleep(poll_timeout);
}}
if !ready {{
eprintln!("[{package_name}] Server did not become ready within 60 s.");
show_startup_error(
&handle,
"The app's built-in server did not start in time. This is \
usually a configuration problem — for example an unreachable \
or misconfigured AUTUMN_DATABASE__URL. Check the device logs \
for details.",
);
return;
}}
if let Err(e) = tauri::WebviewWindowBuilder::new(
&handle,
"main",
tauri::WebviewUrl::External(
format!("http://127.0.0.1:{{port}}").parse().unwrap(),
),
)
.title("{package_name}")
.build()
{{
eprintln!("[{package_name}] Failed to open window: {{e}}");
show_startup_error(&handle, "The app window could not be opened.");
}}
}});
Ok(())
}}
{sync_helpers}
/// Surface a startup failure in a visible window instead of a silent exit or
/// an endless blank screen. Falls back to exiting when even the error window
/// cannot be built. Note: some framework-level startup failures (for example
/// a database bootstrap error) call std::process::exit directly from the
/// server thread and terminate the app before this can render — see the
/// troubleshooting notes in docs/guide/tauri-mobile-in-process.md.
fn show_startup_error(handle: &tauri::AppHandle, message: &str) {{
let html = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"></head>\
<body style=\"font-family:system-ui;margin:2rem\">\
<h1>{package_name} could not start</h1>\
<p>{{message}}</p></body></html>"
);
let opened = format!("data:text/html,{{}}", percent_encode(&html))
.parse()
.ok()
.and_then(|url| {{
tauri::WebviewWindowBuilder::new(handle, "startup-error", tauri::WebviewUrl::External(url))
.title("{package_name}")
.build()
.ok()
}})
.is_some();
if !opened {{
handle.exit(1);
}}
}}
/// Minimal percent-encoding for embedding the error page in a data: URL
/// (avoids pulling an extra dependency into the shell crate).
fn percent_encode(input: &str) -> String {{
input
.bytes()
.map(|b| match b {{
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {{
char::from(b).to_string()
}}
_ => format!("%{{b:02X}}"),
}})
.collect()
}}
/// Generate a 32-byte random signing secret on first launch, persist it to
/// `{{data_root}}/signing_secret.txt`, and return it as a hex string.
/// Autumn requires a signing secret in prod mode to sign session tokens;
/// without one the release build aborts before binding the HTTP port.
fn load_or_generate_signing_secret(
data_root: &std::path::Path,
) -> Result<String, Box<dyn std::error::Error>> {{
let path = data_root.join("signing_secret.txt");
if let Ok(s) = std::fs::read_to_string(&path) {{
let s = s.trim().to_owned();
if s.len() >= 32 {{
return Ok(s);
}}
}}
let mut bytes = [0u8; 32];
// Propagate RNG failure — an all-zero secret would be trivially guessable.
getrandom::fill(&mut bytes)?;
let hex: String = bytes.iter().map(|b| format!("{{b:02x}}")).collect();
// The app sandbox is private to this app on iOS/Android, but restrict
// permissions anyway where the platform supports it.
#[cfg(unix)]
{{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true).create(true).truncate(true)
.mode(0o600)
.open(&path)
.and_then(|mut f| f.write_all(hex.as_bytes()))?;
}}
#[cfg(not(unix))]
{{
std::fs::write(&path, &hex)?;
}}
Ok(hex)
}}
"#
)
}
pub fn render_placeholder_icon_svg() -> String {
concat!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\">\n",
" <!-- Placeholder app icon. Replace with your own, then run:\n",
" cargo tauri icon static/icons/icon.svg -->\n",
" <rect width=\"512\" height=\"512\" rx=\"64\" fill=\"#4F7942\"/>\n",
" <text x=\"256\" y=\"370\" font-size=\"280\" text-anchor=\"middle\"",
" font-family=\"system-ui\">🍂</text>\n",
"</svg>\n",
)
.to_owned()
}
fn render_mobile_gitignore() -> String {
"/target\n/gen\n".to_owned()
}
pub const PLACEHOLDER_PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf0, 0xaf, 0x74, 0xfa, 0x0f, 0x00, 0x04, 0x2f, 0x02, 0x0a, 0x5e, 0x60, 0x4a, 0x2d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ];
pub const PLACEHOLDER_ICO: &[u8] = &[
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x20, 0x00, 0x46, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf0, 0xaf, 0x74, 0xfa,
0x0f, 0x00, 0x04, 0x2f, 0x02, 0x0a, 0x5e, 0x60, 0x4a, 0x2d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
pub const PLACEHOLDER_ICNS: &[u8] = &[
0x69, 0x63, 0x6e, 0x73, 0x00, 0x00, 0x00, 0x56, 0x69, 0x63, 0x70, 0x36, 0x00, 0x00, 0x00, 0x4e, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf0, 0xaf, 0x74, 0xfa,
0x0f, 0x00, 0x04, 0x2f, 0x02, 0x0a, 0x5e, 0x60, 0x4a, 0x2d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::super::emit::Action;
use super::*;
fn app_src_action<'p>(plan: &'p Plan, name: &str) -> Option<&'p Action> {
plan.actions.iter().find(|a| {
a.path().ends_with(format!("src/{name}"))
&& !a.path().to_string_lossy().contains("src-tauri")
})
}
fn app_dir(name: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion = \"0.2.3\"\n\n\
[features]\nembed-assets = [\"autumn-web/embed-assets\"]\n"
),
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
tmp
}
fn stock_main_rs() -> String {
"use autumn_web::prelude::*;\n\n#[autumn_web::main]\nasync fn main() {\n \
let app = autumn_web::app()\n .routes(routes![]);\n\n \
app\n .run()\n .await;\n}\n"
.to_owned()
}
#[test]
fn conf_has_identifier_product_name_and_no_external_bin() {
let conf = render_mobile_tauri_conf("my-app", "1.2.3");
let parsed: serde_json::Value = serde_json::from_str(&conf).unwrap();
assert_eq!(parsed["identifier"], "com.example.myapp");
assert_eq!(parsed["productName"], "My App");
assert_eq!(parsed["version"], "1.2.3");
assert!(parsed["bundle"].get("externalBin").is_none());
assert!(parsed["bundle"].get("resources").is_none());
}
#[test]
fn conf_identifier_is_android_and_ios_safe() {
let conf = render_mobile_tauri_conf("my_app", "0.1.0");
let parsed: serde_json::Value = serde_json::from_str(&conf).unwrap();
assert_eq!(parsed["identifier"], "com.example.myapp");
assert_eq!(parsed["productName"], "My App");
let conf = render_mobile_tauri_conf("my-kebab_mix", "0.1.0");
let parsed: serde_json::Value = serde_json::from_str(&conf).unwrap();
assert_eq!(parsed["identifier"], "com.example.mykebabmix");
}
#[test]
fn cargo_toml_is_mobile_library_without_sidecar_plugin() {
let toml_src = render_mobile_cargo_toml("my-app", true, None);
assert!(toml_src.contains(r#"crate-type = ["staticlib", "cdylib", "rlib"]"#));
assert!(
toml_src.contains(r#"my-app = { path = "..", features = ["embed-assets"] }"#),
"shell must build the app with embedded assets (no static/ dir on device)"
);
assert!(toml_src.contains(r#"name = "my-app-mobile""#));
assert!(!toml_src.contains("tauri-plugin-shell"));
toml::from_str::<toml::Value>(&toml_src).expect("generated Cargo.toml must parse");
}
#[test]
fn cargo_toml_omits_embed_assets_when_app_lacks_the_feature() {
let toml_src = render_mobile_cargo_toml("my-app", false, None);
assert!(toml_src.contains(r#"my-app = { path = ".." }"#));
assert!(!toml_src.contains("embed-assets"));
toml::from_str::<toml::Value>(&toml_src).expect("generated Cargo.toml must parse");
}
#[test]
fn plan_warns_when_app_has_no_embed_assets_feature() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
assert!(
plan.warnings.iter().any(|w| w.contains("embed-assets")),
"must warn when the app declares no embed-assets feature, got {:?}",
plan.warnings
);
}
#[test]
fn lib_rs_spawns_in_process_server_with_pool_defaults() {
let lib = render_mobile_lib_rs("my-app", "my_app", false);
assert!(lib.contains("tauri::Builder::default()"));
assert!(lib.contains(".setup("));
assert!(lib.contains("std::thread::spawn"));
assert!(lib.contains("block_on(my_app::serve())"));
assert!(lib.contains("tauri::mobile_entry_point"));
assert!(lib.contains(r#"set_var("AUTUMN_DATABASE__POOL_SIZE", "2")"#));
assert!(lib.contains(r#"set_var("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "5")"#));
assert!(lib.contains("AUTUMN_DATABASE__URL"));
assert!(!lib.contains(".sidecar("));
assert!(!lib.contains("tauri_plugin_shell"));
}
#[test]
fn lib_rs_pins_prod_profile_in_release_builds() {
let lib = render_mobile_lib_rs("my-app", "my_app", false);
assert!(lib.contains(r#"set_var("AUTUMN_ENV", "prod")"#));
assert!(lib.contains(r#"set_var("AUTUMN_ENV", "dev")"#));
assert!(
!lib.contains(r#"remove_var("AUTUMN_ENV")"#),
"release builds must pin prod explicitly, not unset AUTUMN_ENV"
);
}
#[test]
fn lib_rs_sets_env_before_tauri_builder_starts() {
let lib = render_mobile_lib_rs("my-app", "my_app", false);
let env_pos = lib
.find(r#"set_var("AUTUMN_ENV""#)
.expect("template must set AUTUMN_ENV");
let builder_pos = lib
.find("tauri::Builder::default()")
.expect("template must build a tauri app");
assert!(
env_pos < builder_pos,
"the env-var block must run before tauri::Builder::default()"
);
assert!(lib.contains(r#"set_var("AUTUMN_STORAGE__BACKEND", "local")"#));
}
#[test]
fn lib_rs_surfaces_startup_failure_in_a_visible_error_page() {
let lib = render_mobile_lib_rs("my-app", "my_app", false);
assert!(
lib.contains("fn show_startup_error("),
"startup failures must surface a visible error, not a silent exit"
);
assert!(lib.contains("data:text/html"));
assert!(lib.contains("did not become ready"));
}
#[test]
fn mobile_main_rs_calls_shell_lib() {
let main = render_mobile_main_rs("my-app");
assert!(main.contains("my_app_mobile::run();"));
}
#[test]
fn lib_extraction_rewrites_stock_main_and_creates_lib() {
let tmp = app_dir("my-app");
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
let lib_action =
app_src_action(&plan, "lib.rs").expect("plan must create the app src/lib.rs");
let Action::CreateIfAbsent { contents, .. } = lib_action else {
panic!("app lib.rs must be a CreateIfAbsent action, got {lib_action:?}");
};
assert!(contents.contains("pub async fn serve()"));
assert!(!contents.contains("async fn main()"));
let main_action =
app_src_action(&plan, "main.rs").expect("plan must rewrite the app src/main.rs");
let Action::Modify { contents, .. } = main_action else {
panic!("app main.rs must be a Modify action, got {main_action:?}");
};
assert!(contents.contains("my_app::serve().await;"));
assert!(plan.warnings.is_empty(), "no warnings on the happy path");
}
#[test]
fn lib_extraction_skips_customised_main_with_docs_warning() {
let tmp = app_dir("my-app");
std::fs::write(
tmp.path().join("src/main.rs"),
"#[tokio::main]\nasync fn main() {}\n",
)
.unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
assert!(
app_src_action(&plan, "lib.rs").is_none(),
"no app lib.rs may be planned when the anchor is missing"
);
assert!(
plan.warnings
.iter()
.any(|w| w.contains("tauri-mobile-in-process")),
"warning must point at the docs page, got {:?}",
plan.warnings
);
}
#[test]
fn lib_extraction_is_silent_when_already_extracted() {
let tmp = app_dir("my-app");
std::fs::write(
tmp.path().join("src/main.rs"),
render_thin_app_main_rs("my_app"),
)
.unwrap();
std::fs::write(tmp.path().join("src/lib.rs"), "pub async fn serve() {}\n").unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
assert!(plan.warnings.is_empty());
assert!(app_src_action(&plan, "main.rs").is_none());
}
#[test]
fn lib_extraction_warns_when_foreign_lib_rs_exists() {
let tmp = app_dir("my-app");
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
std::fs::write(tmp.path().join("src/lib.rs"), "pub fn helper() {}\n").unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
assert!(
plan.warnings
.iter()
.any(|w| w.contains("src/lib.rs already exists")),
"must warn about the pre-existing lib.rs, got {:?}",
plan.warnings
);
}
#[test]
fn plan_emits_no_sidecar_artifacts() {
let tmp = app_dir("my-app");
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
for action in &plan.actions {
let p = action.path().to_string_lossy().replace('\\', "/");
assert!(
!p.contains("stage-sidecar"),
"mobile plan must not stage sidecars: {p}"
);
}
}
#[test]
fn read_app_meta_resolves_workspace_version() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n[workspace.package]\nversion = \"9.9.9\"\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(app.join("src")).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"app\"\nversion.workspace = true\n",
)
.unwrap();
let meta = read_app_meta(&app).unwrap();
assert_eq!(meta.package_name, "app");
assert_eq!(meta.version, "9.9.9");
assert!(
!meta.has_embed_assets,
"no [features] table means no embed-assets feature"
);
}
#[test]
fn read_app_meta_resolves_workspace_version_through_a_pointer_root() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(
ws.join("Cargo.toml"),
"[workspace]\nmembers = []\n[workspace.package]\nversion = \"2.3.4\"\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(app.join("src")).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"app\"\nversion.workspace = true\nworkspace = \"../ws\"\n",
)
.unwrap();
let meta = read_app_meta(&app).unwrap();
assert_eq!(
meta.version, "2.3.4",
"the POINTED root's [workspace.package] version must win"
);
}
#[test]
fn read_app_meta_version_falls_back_without_any_workspace() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion.workspace = true\n",
)
.unwrap();
let meta = read_app_meta(tmp.path()).unwrap();
assert_eq!(
meta.version, "0.1.0",
"no resolvable workspace version keeps the documented fallback"
);
}
#[test]
fn read_app_meta_defaults_lib_ident_to_underscored_package_name() {
let tmp = app_dir("my-app");
let meta = read_app_meta(tmp.path()).unwrap();
assert_eq!(meta.lib_ident, "my_app");
}
#[test]
fn read_app_meta_honors_custom_lib_name() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[lib]\nname = \"custom_lib\"\n",
)
.unwrap();
let meta = read_app_meta(tmp.path()).unwrap();
assert_eq!(
meta.lib_ident, "custom_lib",
"[lib].name must win over the dash-to-underscore package name"
);
}
#[test]
fn read_app_meta_honors_custom_lib_path() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[lib]\npath = \"src/app_lib.rs\"\n",
)
.unwrap();
let meta = read_app_meta(tmp.path()).unwrap();
assert_eq!(
meta.lib_src_path, "src/app_lib.rs",
"[lib].path must win over the default src/lib.rs"
);
let default = app_dir("my-app");
assert_eq!(
read_app_meta(default.path()).unwrap().lib_src_path,
"src/lib.rs"
);
}
#[test]
fn plan_extracts_serve_into_custom_lib_path() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[lib]\npath = \"src/app_lib.rs\"\n\n\
[features]\nembed-assets = [\"autumn-web/embed-assets\"]\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
let lib_action =
app_src_action(&plan, "app_lib.rs").expect("plan must create the custom-path lib file");
let Action::CreateIfAbsent { contents, .. } = lib_action else {
panic!("custom lib file must be a CreateIfAbsent action, got {lib_action:?}");
};
assert!(contents.contains("pub async fn serve()"));
assert!(
app_src_action(&plan, "lib.rs").is_none(),
"no stray src/lib.rs may be planned when [lib].path points elsewhere"
);
let main_action =
app_src_action(&plan, "main.rs").expect("plan must rewrite the app src/main.rs");
let Action::Modify { contents, .. } = main_action else {
panic!("app main.rs must be a Modify action, got {main_action:?}");
};
assert!(contents.contains("my_app::serve().await;"));
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
#[test]
fn plan_warns_when_custom_lib_path_file_already_exists() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[lib]\npath = \"src/app_lib.rs\"\n\n\
[features]\nembed-assets = [\"autumn-web/embed-assets\"]\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
std::fs::write(tmp.path().join("src/app_lib.rs"), "pub fn helper() {}\n").unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
assert!(
plan.warnings
.iter()
.any(|w| w.contains("src/app_lib.rs already exists")),
"the graceful fallback must name the ACTUAL lib target file, got {:?}",
plan.warnings
);
assert!(
app_src_action(&plan, "main.rs").is_none(),
"main.rs must be left untouched on the fallback path"
);
}
#[test]
fn plan_uses_custom_lib_name_for_all_serve_call_sites() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[lib]\nname = \"custom_lib\"\n\n\
[features]\nembed-assets = [\"autumn-web/embed-assets\"]\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
let shell_lib = plan
.actions
.iter()
.find(|a| {
a.path()
.to_string_lossy()
.replace('\\', "/")
.ends_with("src-tauri/src/lib.rs")
})
.expect("plan must create the shell src/lib.rs");
let Action::Create { contents, .. } = shell_lib else {
panic!("shell lib.rs must be a Create action, got {shell_lib:?}");
};
assert!(
contents.contains("block_on(custom_lib::serve())"),
"shell lib.rs must call the custom [lib] name"
);
assert!(
!contents.contains("my_app::serve()"),
"shell lib.rs must not call the dash-to-underscore package name"
);
let main_action =
app_src_action(&plan, "main.rs").expect("plan must rewrite the app src/main.rs");
let Action::Modify { contents, .. } = main_action else {
panic!("app main.rs must be a Modify action, got {main_action:?}");
};
assert!(
contents.contains("custom_lib::serve().await;"),
"thin main.rs must call the custom [lib] name"
);
}
#[test]
fn extraction_reruns_silently_with_custom_lib_name() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[lib]\nname = \"custom_lib\"\n\n\
[features]\nembed-assets = [\"autumn-web/embed-assets\"]\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(
tmp.path().join("src/main.rs"),
render_thin_app_main_rs("custom_lib"),
)
.unwrap();
std::fs::write(tmp.path().join("src/lib.rs"), "pub async fn serve() {}\n").unwrap();
let plan = plan_tauri_mobile(tmp.path(), TauriMobileOptions::default()).unwrap();
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
assert!(app_src_action(&plan, "main.rs").is_none());
}
#[test]
fn guard_refuses_desktop_leftovers() {
let tmp = app_dir("my-app");
let tauri = tmp.path().join("src-tauri");
std::fs::create_dir_all(&tauri).unwrap();
std::fs::write(tauri.join("stage-sidecar.sh"), "#!/bin/sh\n").unwrap();
let err = ensure_no_other_mode_scaffold(tmp.path())
.unwrap_err()
.to_string();
assert!(err.contains("desktop (sidecar)"), "got: {err}");
assert!(err.contains("src-tauri/stage-sidecar.sh"), "got: {err}");
assert!(err.contains("autumn destroy tauri"), "got: {err}");
}
#[test]
fn guard_refuses_thin_client_leftovers() {
let tmp = app_dir("my-app");
let caps = tmp.path().join("src-tauri").join("capabilities");
std::fs::create_dir_all(&caps).unwrap();
std::fs::write(caps.join("remote-app.json"), "{}\n").unwrap();
let err = ensure_no_other_mode_scaffold(tmp.path())
.unwrap_err()
.to_string();
assert!(err.contains("thin-client"), "got: {err}");
assert!(
err.contains("src-tauri/capabilities/remote-app.json"),
"got: {err}"
);
assert!(
err.contains("autumn destroy tauri --remote-url"),
"got: {err}"
);
}
#[test]
fn guard_allows_clean_tree_and_same_mode_regenerate() {
let clean = app_dir("my-app");
assert!(ensure_no_other_mode_scaffold(clean.path()).is_ok());
let mobile = app_dir("my-app");
let tauri = mobile.path().join("src-tauri");
std::fs::create_dir_all(tauri.join("src")).unwrap();
std::fs::write(
tauri.join("tauri.conf.json"),
render_mobile_tauri_conf("my-app", "0.1.0"),
)
.unwrap();
std::fs::write(
tauri.join("Cargo.toml"),
render_mobile_cargo_toml("my-app", true, None),
)
.unwrap();
std::fs::write(
tauri.join("src").join("lib.rs"),
render_mobile_lib_rs("my-app", "my_app", false),
)
.unwrap();
assert!(
ensure_no_other_mode_scaffold(mobile.path()).is_ok(),
"same-mode regenerate must not be blocked by the guard"
);
}
#[test]
fn prerequisites_mention_mobile_toolchains_and_docs() {
let prereqs = render_mobile_prerequisites(TauriMobileOptions::default());
assert!(prereqs.contains("tauri-cli"));
assert!(prereqs.contains("ios"));
assert!(prereqs.contains("android"));
assert!(prereqs.contains("tauri-mobile-in-process"));
assert!(
!prereqs.contains("AUTUMN_SYNC__REMOTE_URL"),
"without --offline-sync the prerequisites must not mention sync"
);
}
const OFFLINE: TauriMobileOptions = TauriMobileOptions { offline_sync: true };
fn stock_app_dir(name: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n\n\
[features]\ndefault = [\"flash\"]\nflash = [\"autumn-web/flash\"]\n\
embed-assets = [\"autumn-web/embed-assets\"]\n"
),
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
tmp
}
#[test]
fn offline_cargo_toml_adds_matching_autumn_web_dependency() {
let mut plan = Plan::new(Path::new("."));
let tmp = stock_app_dir("my-app");
let dep = shell_autumn_web_dep(tmp.path(), &mut plan);
let toml_src = render_mobile_cargo_toml("my-app", true, Some(&dep));
assert!(
toml_src.contains(r#"autumn-web = { version = "0.9.1", features = ["offline-sync"] }"#),
"the shell must depend on autumn-web (offline-sync) at the app's requirement"
);
assert!(
!toml_src.contains("[patch.crates-io]"),
"an unpatched registry dep must not grow a patch section"
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
toml::from_str::<toml::Value>(&toml_src).expect("generated Cargo.toml must parse");
let no_sync = render_mobile_cargo_toml("my-app", true, None);
assert!(!no_sync.contains("offline-sync"));
assert!(!no_sync.contains("autumn-web ="));
}
fn shell_dep_for(deps: &str, extra: &str) -> (ShellAutumnWebDep, Vec<String>) {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
format!(
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\n{deps}\n{extra}"
),
)
.unwrap();
let mut plan = Plan::new(tmp.path());
let dep = shell_autumn_web_dep(tmp.path(), &mut plan);
(dep, plan.warnings)
}
#[test]
fn lexical_relative_handles_ancestors_dotdots_and_foreign_roots() {
use std::path::Path;
assert_eq!(
lexical_relative(
Path::new("/ws/vendor/autumn"),
Path::new("/ws/app/src-tauri")
),
Some(std::path::PathBuf::from("../../vendor/autumn"))
);
assert_eq!(
lexical_relative(
Path::new("/ws/vendor/../autumn/./core"),
Path::new("/ws/app/src-tauri")
),
Some(std::path::PathBuf::from("../../autumn/core"))
);
assert_eq!(
lexical_relative(Path::new("/ws/app"), Path::new("/ws/app")),
Some(std::path::PathBuf::from("."))
);
assert_eq!(
lexical_relative(
Path::new("/ws/app/src-tauri/gen"),
Path::new("/ws/app/src-tauri")
),
Some(std::path::PathBuf::from("gen"))
);
assert_eq!(
lexical_relative(Path::new("/../etc"), Path::new("/ws")),
None
);
}
#[test]
fn offline_shell_dep_mirrors_path_source_relative_to_src_tauri() {
let (dep, warnings) = shell_dep_for(
"autumn-web = { path = \"../autumn\", version = \"0.9.1\" }",
"",
);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { version = "0.9.1", path = "../../autumn", features = ["offline-sync"] }"#,
"a relative path dep must be recomputed for src-tauri/ (one level deeper)"
);
assert!(dep.patch_entry.is_none());
assert!(warnings.is_empty(), "got {warnings:?}");
}
#[test]
fn offline_shell_dep_normalizes_backslash_relative_paths() {
let (dep, warnings) = shell_dep_for(r"autumn-web = { path = 'vendor\autumn' }", "");
assert_eq!(
dep.dep_entry,
r#"autumn-web = { path = "../vendor/autumn", features = ["offline-sync"] }"#,
"the direct-relative branch must normalize separators"
);
assert!(warnings.is_empty(), "got {warnings:?}");
let toml_src = render_mobile_cargo_toml("my-app", true, Some(&dep));
toml::from_str::<toml::Value>(&toml_src).expect("generated Cargo.toml must parse");
let (dep, warnings) = shell_dep_for(
"autumn-web = \"0.9.1\"",
"\n[patch.crates-io]\nautumn-web = { path = 'vendor\\autumn' }\n",
);
let patch = dep
.patch_entry
.clone()
.expect("the backslashed patch must still be mirrored");
assert!(
patch.contains(r#"autumn-web = { path = "../vendor/autumn" }"#),
"the patch mirror must normalize separators, got: {patch}"
);
assert!(warnings.is_empty(), "got {warnings:?}");
let toml_src = render_mobile_cargo_toml("my-app", true, Some(&dep));
toml::from_str::<toml::Value>(&toml_src).expect("generated Cargo.toml must parse");
let (dep, warnings) = shell_dep_for("autumn-web = { path = \"vendor/autumn\" }", "");
assert_eq!(
dep.dep_entry,
r#"autumn-web = { path = "../vendor/autumn", features = ["offline-sync"] }"#,
);
assert!(warnings.is_empty(), "got {warnings:?}");
}
#[test]
fn offline_shell_dep_mirrors_absolute_path_source() {
let (dep, warnings) = shell_dep_for("autumn-web = { path = \"/src/autumn/autumn\" }", "");
assert_eq!(
dep.dep_entry,
r#"autumn-web = { path = "/src/autumn/autumn", features = ["offline-sync"] }"#,
);
assert!(warnings.is_empty(), "got {warnings:?}");
}
#[test]
fn offline_shell_dep_mirrors_git_source_with_rev() {
let (dep, warnings) = shell_dep_for(
"autumn-web = { git = \"https://github.com/autumn-foundation/autumn\", rev = \"abc123\" }",
"",
);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { git = "https://github.com/autumn-foundation/autumn", rev = "abc123", features = ["offline-sync"] }"#,
);
assert!(warnings.is_empty(), "got {warnings:?}");
}
#[test]
fn offline_shell_dep_preserves_default_features_false() {
let (dep, warnings) = shell_dep_for(
"autumn-web = { version = \"0.9.1\", default-features = false, \
features = [\"maud\"] }",
"",
);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { version = "0.9.1", default-features = false, features = ["offline-sync", "postgres"] }"#,
);
assert!(warnings.is_empty(), "got {warnings:?}");
let (dep, _) = shell_dep_for(
"autumn-web = { path = \"../autumn\", default_features = false }",
"",
);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { path = "../../autumn", default-features = false, features = ["offline-sync", "postgres"] }"#,
);
let (dep, _) = shell_dep_for("autumn-web = { version = \"0.9.1\" }", "");
assert!(
!dep.dep_entry.contains("default-features"),
"got: {}",
dep.dep_entry
);
}
#[test]
fn offline_shell_dep_resolves_renamed_dependency_by_package() {
let (dep, warnings) = shell_dep_for(
"autumn = { package = \"autumn-web\", version = \"0.9.1\" }",
"",
);
assert_eq!(
dep.dep_entry, r#"autumn-web = { version = "0.9.1", features = ["offline-sync"] }"#,
"a renamed registry dep must still resolve (not fall back)"
);
assert!(warnings.is_empty(), "got {warnings:?}");
let (dep, warnings) = shell_dep_for(
"autumn = { package = \"autumn-web\", path = \"../autumn\" }",
"",
);
assert_eq!(
dep.dep_entry, r#"autumn-web = { path = "../../autumn", features = ["offline-sync"] }"#,
"a renamed path dep must mirror its source"
);
assert!(warnings.is_empty(), "got {warnings:?}");
}
#[test]
fn offline_feature_edit_targets_the_actual_dependency_key() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn = { package = \"autumn-web\", version = \"0.9.1\" }\n\n\
[features]\ndefault = [\"flash\"]\nflash = [\"autumn/flash\"]\n\
embed-assets = [\"autumn/embed-assets\"]\n",
)
.unwrap();
let mut plan = Plan::new(tmp.path());
plan_app_offline_sync_feature(tmp.path(), &mut plan);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
let edited = plan
.actions
.iter()
.find_map(|a| match a {
Action::Modify { path, contents } if path.ends_with("Cargo.toml") => {
Some(contents.clone())
}
_ => None,
})
.expect("the feature edit must be planned");
assert!(
edited.contains(r#"offline-sync = ["autumn/offline-sync"]"#),
"the feature must reference the RENAMED dependency key, got:\n{edited}"
);
assert!(
!edited.contains(r#"["autumn-web/offline-sync"]"#),
"the package name is not a valid feature dependency here"
);
}
#[test]
fn offline_shell_dep_mirrors_crates_io_patch_into_shell_manifest() {
let (dep, warnings) = shell_dep_for(
"autumn-web = \"0.9.1\"",
"\n[patch.crates-io]\nautumn-web = { path = \"/src/autumn/autumn\" }\n",
);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { version = "0.9.1", features = ["offline-sync"] }"#,
);
let patch = dep
.patch_entry
.clone()
.expect("the crates-io patch must be mirrored");
assert!(patch.contains("[patch.crates-io]"), "got: {patch}");
assert!(
patch.contains(r#"autumn-web = { path = "/src/autumn/autumn" }"#),
"got: {patch}"
);
assert!(warnings.is_empty(), "got {warnings:?}");
let toml_src = render_mobile_cargo_toml("my-app", true, Some(&dep));
assert!(toml_src.contains("[patch.crates-io]"));
toml::from_str::<toml::Value>(&toml_src).expect("generated Cargo.toml must parse");
}
#[test]
fn offline_shell_dep_default_features_across_workspace_inheritance() {
fn workspace_dep(workspace_entry: &str, member_extra: &str) -> ShellAutumnWebDep {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
format!(
"[workspace]\nmembers = [\"app\"]\n\n\
[workspace.dependencies]\nautumn-web = {workspace_entry}\n"
),
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
format!(
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = {{ workspace = true{member_extra} }}\n"
),
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
dep
}
let dep = workspace_dep(r#"{ version = "0.9.1" }"#, ", default-features = false");
assert_eq!(
dep.dep_entry,
r#"autumn-web = { version = "0.9.1", default-features = false, features = ["offline-sync", "postgres"] }"#,
);
let dep = workspace_dep(r#"{ version = "0.9.1" }"#, ", default_features = false");
assert!(
dep.dep_entry.contains("default-features = false"),
"got: {}",
dep.dep_entry
);
let dep = workspace_dep(r#"{ version = "0.9.1", default-features = false }"#, "");
assert!(
dep.dep_entry.contains("default-features = false"),
"workspace-level opt-out must apply, got: {}",
dep.dep_entry
);
let dep = workspace_dep(
r#"{ version = "0.9.1", default-features = false }"#,
", default-features = true",
);
assert!(
!dep.dep_entry.contains("default-features"),
"a member re-enable must win over the workspace opt-out, got: {}",
dep.dep_entry
);
let dep = workspace_dep(r#"{ version = "0.9.1" }"#, "");
assert!(
!dep.dep_entry.contains("default-features"),
"got: {}",
dep.dep_entry
);
}
#[test]
fn offline_feature_edit_requires_default_enablement() {
fn manifest(features: &str) -> (TempDir, std::path::PathBuf) {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("Cargo.toml");
std::fs::write(
&path,
format!(
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n\n\
[features]\n{features}"
),
)
.unwrap();
(tmp, path)
}
fn planned_edit(tmp: &TempDir) -> (Option<String>, Vec<String>) {
let mut plan = Plan::new(tmp.path());
plan_app_offline_sync_feature(tmp.path(), &mut plan);
let edit = plan.actions.iter().find_map(|a| match a {
Action::Modify { contents, .. } => Some(contents.clone()),
_ => None,
});
(edit, plan.warnings)
}
let (tmp, _) = manifest(
"default = [\"flash\", \"offline-sync\"]\nflash = []\n\
offline-sync = [\"autumn-web/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(edit.is_none(), "wired manifests must stay untouched");
assert!(warnings.is_empty(), "got {warnings:?}");
let (tmp, _) = manifest(
"default = [\"flash\", \"full\"]\nflash = []\n\
full = [\"offline-sync\"]\n\
offline-sync = [\"autumn-web/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(
edit.is_none(),
"transitive default enablement counts as wired"
);
assert!(warnings.is_empty(), "got {warnings:?}");
let (tmp, _) = manifest(
"default = [\"flash\", \"autumn-web/offline-sync\"]\nflash = []\n\
offline-sync = [\"autumn-web/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(
edit.is_some() || !warnings.is_empty(),
"a dep-feature default entry must not count as wired"
);
let (tmp, _) = manifest(
"default = [\"flash\"]\nflash = []\n\
offline-sync = [\"autumn-web/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
let edited = edit.expect("the stock default line must gain the feature");
assert!(warnings.is_empty(), "got {warnings:?}");
assert!(
edited.contains(r#"default = ["flash", "offline-sync"]"#),
"got:\n{edited}"
);
assert_eq!(
edited.matches("offline-sync = [").count(),
1,
"the existing feature declaration must not be duplicated:\n{edited}"
);
let (tmp, _) = manifest(
"default = [\"flash\", \"extra\"]\nflash = []\nextra = []\n\
offline-sync = [\"autumn-web/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(
edit.is_none(),
"a customised default line is never rewritten"
);
assert!(
warnings.iter().any(|w| w.contains(
"`default` \
doesn't enable it"
) || w.contains("doesn't enable it")),
"got {warnings:?}"
);
}
#[test]
fn offline_feature_detection_survives_spacing_and_layout_variants() {
fn manifest(features: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
format!(
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n\n\
[features]\n{features}"
),
)
.unwrap();
tmp
}
fn planned_edit(tmp: &TempDir) -> (Option<String>, Vec<String>) {
let mut plan = Plan::new(tmp.path());
plan_app_offline_sync_feature(tmp.path(), &mut plan);
let edit = plan.actions.iter().find_map(|a| match a {
Action::Modify { contents, .. } => Some(contents.clone()),
_ => None,
});
(edit, plan.warnings)
}
let tmp = manifest(
"default = [\"flash\", \"offline-sync\"]\nflash = []\n\
offline-sync=[\"autumn-web/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(
edit.is_none(),
"`offline-sync=[…]` (no spaces) must be detected as already \
defined, got:\n{edit:?}"
);
assert!(warnings.is_empty(), "got {warnings:?}");
let tmp = manifest(
"default = [\"flash\"]\nflash = []\n\
offline-sync=[\"autumn-web/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
let edited = edit.expect("the stock default line must gain the feature");
assert!(warnings.is_empty(), "got {warnings:?}");
assert!(
edited.contains(r#"default = ["flash", "offline-sync"]"#),
"got:\n{edited}"
);
toml::from_str::<toml::Value>(&edited)
.expect("the edited manifest must stay valid TOML (no duplicate keys)");
assert_eq!(
edited.matches("offline-sync=[").count() + edited.matches("offline-sync = [").count(),
1,
"the existing declaration must not be duplicated:\n{edited}"
);
let tmp = manifest(
"default = [\"flash\"]\nflash = []\n\
offline-sync = [\n \"autumn-web/offline-sync\",\n]\n",
);
let (edit, warnings) = planned_edit(&tmp);
let edited = edit.expect("the stock default line must gain the feature");
assert!(warnings.is_empty(), "got {warnings:?}");
assert!(
edited.contains(r#"default = ["flash", "offline-sync"]"#),
"got:\n{edited}"
);
toml::from_str::<toml::Value>(&edited)
.expect("the edited manifest must stay valid TOML (no duplicate keys)");
let tmp = manifest("default = [\"flash\"]\nflash = []\n");
let (edit, warnings) = planned_edit(&tmp);
let edited = edit.expect("an absent feature must still be inserted");
assert!(warnings.is_empty(), "got {warnings:?}");
assert!(
edited.contains(r#"offline-sync = ["autumn-web/offline-sync"]"#),
"got:\n{edited}"
);
assert!(
edited.contains(r#"default = ["flash", "offline-sync"]"#),
"got:\n{edited}"
);
}
#[test]
fn offline_shell_dep_mirrors_alternate_registry_selection() {
let (dep, warnings) = shell_dep_for(
"autumn-web = { version = \"0.9.1\", registry = \"internal\" }",
"",
);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { version = "0.9.1", registry = "internal", features = ["offline-sync"] }"#,
);
assert!(warnings.is_empty(), "got {warnings:?}");
let (dep, _) = shell_dep_for(
"autumn-web = { version = \"0.9.1\", registry = \"internal\", default-features = false }",
"",
);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { version = "0.9.1", registry = "internal", default-features = false, features = ["offline-sync", "postgres"] }"#,
);
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n\n\
[workspace.dependencies]\n\
autumn-web = { version = \"0.9.1\", registry = \"internal\" }\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = { workspace = true }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { version = "0.9.1", registry = "internal", features = ["offline-sync"] }"#,
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
let (dep, _) = shell_dep_for("autumn-web = { version = \"0.9.1\" }", "");
assert_eq!(
dep.dep_entry,
r#"autumn-web = { version = "0.9.1", features = ["offline-sync"] }"#,
);
}
#[test]
fn offline_shell_dep_mirrors_renamed_crates_io_patch() {
let (dep, warnings) = shell_dep_for(
"autumn-web = \"0.9.1\"",
"\n[patch.crates-io]\naw_local = { package = \"autumn-web\", path = \"/src/autumn/autumn\" }\n",
);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { version = "0.9.1", features = ["offline-sync"] }"#,
);
let patch = dep
.patch_entry
.clone()
.expect("the renamed patch must be mirrored, not silently dropped");
assert!(
patch.contains(r#"aw_local = { package = "autumn-web", path = "/src/autumn/autumn" }"#),
"the entry must be mirrored whole (key + package + source), got: {patch}"
);
assert!(warnings.is_empty(), "got {warnings:?}");
let toml_src = render_mobile_cargo_toml("my-app", true, Some(&dep));
toml::from_str::<toml::Value>(&toml_src).expect("generated Cargo.toml must parse");
}
#[test]
fn offline_shell_dep_mirrors_renamed_patch_from_ancestor_workspace() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n\n\
[patch.crates-io]\naw_local = { package = \"autumn-web\", path = \"vendor/autumn\" }\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
let patch = dep
.patch_entry
.expect("the ancestor workspace's renamed patch must be mirrored");
assert!(
patch
.contains(r#"aw_local = { package = "autumn-web", path = "../../vendor/autumn" }"#),
"the mirrored patch path must be relative to src-tauri/, got: {patch}"
);
assert!(
!patch.contains(&tmp.path().display().to_string()),
"no machine-specific absolute prefix may leak into the manifest"
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
#[test]
fn offline_member_local_patch_is_skipped_with_a_warning() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/ignored/autumn\" }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
assert!(
dep.patch_entry.is_none(),
"a member-local patch cargo ignores must not be mirrored, got {:?}",
dep.patch_entry
);
assert!(
plan.warnings
.iter()
.any(|w| w.contains("outside the workspace root")),
"the skip must be explained, got {:?}",
plan.warnings
);
}
#[test]
fn offline_root_patch_wins_over_a_stale_member_local_one() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/effective/autumn\" }\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/stale/autumn\" }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
let patch = dep
.patch_entry
.expect("the workspace root's patch must be mirrored");
assert!(
patch.contains(r#"autumn-web = { path = "/src/effective/autumn" }"#),
"the EFFECTIVE (root) patch must win, got: {patch}"
);
assert!(
!patch.contains("/src/stale/autumn"),
"the ignored member-local patch must not leak in: {patch}"
);
}
#[test]
fn offline_excluded_member_uses_its_own_patch_table() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = []\nexclude = [\"app\"]\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/standalone/autumn\" }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
let patch = dep
.patch_entry
.expect("an excluded (standalone) app's own patch must be mirrored");
assert!(
patch.contains(r#"autumn-web = { path = "/src/standalone/autumn" }"#),
"got: {patch}"
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
#[test]
fn offline_exclude_entries_match_as_directory_prefixes() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = []\nexclude = [\"examples\"]\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/ancestor/autumn\" }\n",
)
.unwrap();
let app = tmp.path().join("examples").join("mobile");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/own/autumn\" }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
let patch = dep
.patch_entry
.expect("the prefix-excluded (standalone) app's own patch must be mirrored");
assert!(
patch.contains(r#"autumn-web = { path = "/src/own/autumn" }"#),
"got: {patch}"
);
assert!(
!patch.contains("/src/ancestor/autumn"),
"the excluding ancestor's patch must not leak in: {patch}"
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
#[test]
fn offline_exclude_prefix_match_respects_component_boundaries() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"examples-extra/mobile\"]\nexclude = [\"examples\"]\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/ancestor/autumn\" }\n",
)
.unwrap();
let app = tmp.path().join("examples-extra").join("mobile");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/member-local/autumn\" }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
let patch = dep
.patch_entry
.expect("the ancestor root's patch must be mirrored for a real member");
assert!(
patch.contains(r#"autumn-web = { path = "/src/ancestor/autumn" }"#),
"examples-extra must not be swallowed by the `examples` exclude \
entry, got: {patch}"
);
assert!(
!patch.contains("/src/member-local/autumn"),
"the ignored member-local patch must not leak in: {patch}"
);
assert!(
plan.warnings
.iter()
.any(|w| w.contains("Cargo ignores [patch] tables outside the workspace root")),
"the member-local patch skip must warn, got {:?}",
plan.warnings
);
}
#[test]
fn offline_exclusion_by_an_intermediate_ancestor_walks_to_the_outer_root() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"mid/app\"]\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/outer/autumn\" }\n",
)
.unwrap();
let mid = tmp.path().join("mid");
std::fs::create_dir_all(&mid).unwrap();
std::fs::write(
mid.join("Cargo.toml"),
"[workspace]\nmembers = []\nexclude = [\"app\"]\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/intermediate/autumn\" }\n",
)
.unwrap();
let app = mid.join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
let patch = dep
.patch_entry
.expect("the OUTER root's patch must be mirrored past the excluding mid");
assert!(
patch.contains(r#"autumn-web = { path = "/src/outer/autumn" }"#),
"got: {patch}"
);
assert!(
!patch.contains("/src/intermediate/autumn"),
"the excluding intermediate's patch must not leak in: {patch}"
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
#[test]
fn offline_exclusion_with_no_outer_workspace_is_still_standalone() {
let tmp = TempDir::new().unwrap();
let mid = tmp.path().join("mid");
std::fs::create_dir_all(&mid).unwrap();
std::fs::write(
mid.join("Cargo.toml"),
"[workspace]\nmembers = []\nexclude = [\"app\"]\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/intermediate/autumn\" }\n",
)
.unwrap();
let app = mid.join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = \"0.9.1\"\n\n\
[patch.crates-io]\nautumn-web = { path = \"/src/standalone/autumn\" }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
let patch = dep
.patch_entry
.expect("the excluded (standalone) app's own patch must be mirrored");
assert!(
patch.contains(r#"autumn-web = { path = "/src/standalone/autumn" }"#),
"got: {patch}"
);
assert!(
!patch.contains("/src/intermediate/autumn"),
"the excluding ancestor's patch must not leak in: {patch}"
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
#[test]
fn offline_shell_dep_resolves_workspace_inherited_path_dep() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n\n\
[workspace.dependencies]\nautumn-web = { path = \"vendor/autumn\" }\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\nautumn-web = { workspace = true }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { path = "../../vendor/autumn", features = ["offline-sync"] }"#,
);
assert!(
!dep.dep_entry.contains(&tmp.path().display().to_string()),
"no machine-specific absolute prefix may leak into the manifest"
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
#[test]
fn offline_shell_dep_follows_package_workspace_pointer_for_inheritance() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(
ws.join("Cargo.toml"),
"[workspace]\nmembers = []\n\n\
[workspace.dependencies]\nautumn-web = { path = \"vendor/autumn\" }\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\nworkspace = \"../ws\"\n\n\
[dependencies]\nautumn-web = { workspace = true }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { path = "../../ws/vendor/autumn", features = ["offline-sync"] }"#,
);
assert!(
!dep.dep_entry.contains(&tmp.path().display().to_string()),
"no machine-specific absolute prefix may leak into the manifest"
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
#[test]
fn offline_shell_dep_pointer_skips_a_nearer_excluding_workspace() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"mid/app\"]\n\n\
[workspace.dependencies]\nautumn-web = { path = \"framework/autumn\" }\n",
)
.unwrap();
let mid = tmp.path().join("mid");
std::fs::create_dir_all(&mid).unwrap();
std::fs::write(
mid.join("Cargo.toml"),
"[workspace]\nmembers = []\nexclude = [\"app\"]\n\n\
[workspace.dependencies]\nautumn-web = { path = \"wrong/autumn\" }\n",
)
.unwrap();
let app = mid.join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\nworkspace = \"../..\"\n\n\
[dependencies]\nautumn-web = { workspace = true }\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { path = "../../../framework/autumn", features = ["offline-sync"] }"#,
"the POINTED root's entry must win over the nearer excluding root's"
);
assert!(
!dep.dep_entry.contains("wrong"),
"the skipped nearer entry must not leak in, got: {}",
dep.dep_entry
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
#[test]
fn offline_pointer_root_resolves_renamed_inherited_dep_key() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(
ws.join("Cargo.toml"),
"[workspace]\nmembers = []\n\n\
[workspace.dependencies]\n\
autumn = { package = \"autumn-web\", path = \"vendor/autumn\" }\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\nworkspace = \"../ws\"\n\n\
[dependencies]\nautumn = { workspace = true }\n\n\
[features]\ndefault = [\"flash\"]\nflash = []\n",
)
.unwrap();
let mut plan = Plan::new(&app);
let dep = shell_autumn_web_dep(&app, &mut plan);
assert_eq!(
dep.dep_entry,
r#"autumn-web = { path = "../../ws/vendor/autumn", features = ["offline-sync"] }"#,
"the renamed inherited dep must resolve through the pointed root"
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
let mut plan = Plan::new(&app);
plan_app_offline_sync_feature(&app, &mut plan);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
let edited = plan
.actions
.iter()
.find_map(|a| match a {
Action::Modify { contents, .. } => Some(contents.clone()),
_ => None,
})
.expect("the feature edit must be planned");
assert!(
edited.contains(r#"offline-sync = ["autumn/offline-sync"]"#),
"the feature must reference the RENAMED dependency key, got:\n{edited}"
);
assert!(
!edited.contains(r#"["autumn-web/offline-sync"]"#),
"the package name is not a valid feature dependency here, got:\n{edited}"
);
}
#[test]
#[allow(clippy::too_many_lines)] fn offline_feature_wired_check_requires_dep_propagation() {
fn manifest(deps: &str, features: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
format!(
"[package]\nname = \"my-app\"\nversion = \"0.2.3\"\n\n\
[dependencies]\n{deps}\n\n\
[features]\n{features}"
),
)
.unwrap();
tmp
}
fn planned_edit(tmp: &TempDir) -> (Option<String>, Vec<String>) {
let mut plan = Plan::new(tmp.path());
plan_app_offline_sync_feature(tmp.path(), &mut plan);
let edit = plan.actions.iter().find_map(|a| match a {
Action::Modify { contents, .. } => Some(contents.clone()),
_ => None,
});
(edit, plan.warnings)
}
const STOCK_DEP: &str = "autumn-web = \"0.9.1\"";
let tmp = manifest(
STOCK_DEP,
"default = [\"flash\", \"offline-sync\"]\nflash = []\noffline-sync = []\n",
);
let (edit, warnings) = planned_edit(&tmp);
let edited = edit.expect("an empty offline-sync declaration must be patched");
assert!(warnings.is_empty(), "got {warnings:?}");
assert!(
edited.contains(r#"offline-sync = ["autumn-web/offline-sync"]"#),
"got:\n{edited}"
);
assert!(
edited.contains(r#"default = ["flash", "offline-sync"]"#),
"the already-correct default line must stay untouched:\n{edited}"
);
toml::from_str::<toml::Value>(&edited).expect("edited manifest must stay valid TOML");
let tmp = manifest(
STOCK_DEP,
"default = [\"flash\"]\nflash = []\noffline-sync = []\n",
);
let (edit, warnings) = planned_edit(&tmp);
let edited = edit.expect("both fixes must be planned");
assert!(warnings.is_empty(), "got {warnings:?}");
assert!(
edited.contains(r#"offline-sync = ["autumn-web/offline-sync"]"#)
&& edited.contains(r#"default = ["flash", "offline-sync"]"#),
"got:\n{edited}"
);
toml::from_str::<toml::Value>(&edited).expect("edited manifest must stay valid TOML");
let tmp = manifest(
STOCK_DEP,
"default = [\"flash\", \"offline-sync\"]\nflash = []\n\
offline-sync = [\"flash\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(edit.is_none(), "a customised array is never rewritten");
assert!(
warnings
.iter()
.any(|w| w.contains("autumn-web/offline-sync")),
"the warning must name the missing dep entry, got {warnings:?}"
);
let tmp = manifest(
STOCK_DEP,
"default = [\"flash\", \"offline-sync\"]\nflash = []\n\
offline-sync = [\"sync-impl\"]\n\
sync-impl = [\"autumn-web/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(edit.is_none(), "transitive propagation counts as wired");
assert!(warnings.is_empty(), "got {warnings:?}");
let tmp = manifest(
STOCK_DEP,
"default = [\"flash\", \"offline-sync\"]\nflash = []\n\
offline-sync = [\"autumn-web?/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(edit.is_none(), "the weak form counts as propagating");
assert!(warnings.is_empty(), "got {warnings:?}");
let tmp = manifest(
"autumn = { package = \"autumn-web\", version = \"0.9.1\" }",
"default = [\"flash\", \"offline-sync\"]\nflash = []\n\
offline-sync = [\"autumn/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(edit.is_none(), "the alias entry counts as propagating");
assert!(warnings.is_empty(), "got {warnings:?}");
let tmp = manifest(
"autumn = { package = \"autumn-web\", version = \"0.9.1\" }",
"default = [\"flash\", \"offline-sync\"]\nflash = []\n\
offline-sync = [\"autumn-web/offline-sync\"]\n",
);
let (_edit, warnings) = planned_edit(&tmp);
assert!(
warnings.iter().any(|w| w.contains("autumn/offline-sync")),
"a package-name entry does not reach the renamed dep's feature, \
got {warnings:?}"
);
let tmp = manifest(
STOCK_DEP,
"default = [\"flash\"]\nflash = []\n\
offline-sync = [\"flash\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
assert!(
edit.is_none(),
"a broken feature must never be added to `default`, got:\n{edit:?}"
);
assert_eq!(warnings.len(), 1, "one warning covers both: {warnings:?}");
assert!(
warnings[0].contains("autumn-web/offline-sync")
&& warnings[0].contains("`default` was deliberately left untouched"),
"the warning must cover the feature fix AND the skipped default \
edit, got {warnings:?}"
);
let tmp = manifest(
STOCK_DEP,
"default = [\"flash\"]\nflash = []\n\
offline-sync = [\"autumn-web/offline-sync\"]\n",
);
let (edit, warnings) = planned_edit(&tmp);
let edited = edit.expect("a propagating feature must still be defaulted");
assert!(warnings.is_empty(), "got {warnings:?}");
assert!(
edited.contains(r#"default = ["flash", "offline-sync"]"#),
"got:\n{edited}"
);
}
#[test]
fn offline_shell_dep_warns_and_falls_back_when_source_is_unrepresentable() {
let (dep, warnings) = shell_dep_for("serde = \"1\"", "");
assert_eq!(
dep.dep_entry,
format!(
r#"autumn-web = {{ version = "{}", features = ["offline-sync"] }}"#,
env!("CARGO_PKG_VERSION"),
),
);
assert!(
warnings
.iter()
.any(|w| w.contains("could not determine the app's autumn-web dependency source")),
"got {warnings:?}"
);
}
#[test]
fn offline_shell_dep_warns_when_patch_is_unrepresentable() {
let (dep, warnings) = shell_dep_for(
"autumn-web = \"0.9.1\"",
"\n[patch.crates-io]\nautumn-web = { version = \"0.9.2\" }\n",
);
assert!(dep.patch_entry.is_none());
assert!(
warnings
.iter()
.any(|w| w.contains("[patch.crates-io] override of autumn-web could not")),
"got {warnings:?}"
);
}
#[test]
fn offline_lib_rs_wires_store_engine_and_resume_trigger() {
let lib = render_mobile_lib_rs("my-app", "my_app", true);
assert!(lib.contains(r#"let sync_db = data_root.join("sync.db");"#));
assert!(lib.contains(r#""AUTUMN_SYNC__DB_PATH""#));
assert!(lib.contains("autumn_web::sync::SyncStore::open(&sync_db)"));
assert!(lib.contains(r#"std::env::var("AUTUMN_SYNC__REMOTE_URL")"#));
assert!(lib.contains("autumn_web::sync::SyncConfig::new(remote_url)"));
assert!(lib.contains(".spawn_background(std::time::Duration::from_secs(30))"));
assert!(lib.contains("start_background_sync(&runtime, sync_db);"));
assert!(lib.contains(".build(tauri::generate_context!())"));
assert!(lib.contains("tauri::RunEvent::Resumed"));
assert!(lib.contains("engine.sync_once().await"));
assert!(lib.contains("docs/guide/tauri-mobile-offline-sync.md"));
assert!(!lib.contains("\n std::env::set_var(\"AUTUMN_DATABASE__URL\""));
}
#[test]
fn offline_lib_rs_sends_the_sync_bearer_token() {
let lib = render_mobile_lib_rs("my-app", "my_app", true);
assert!(lib.contains(r#"std::env::var("AUTUMN_SYNC__TOKEN")"#));
assert!(lib.contains("config.bearer_token = Some(token)"));
assert!(
lib.contains("AUTUMN_SYNC__TOKEN is not set"),
"a missing client token must be called out at startup"
);
assert!(lib.contains(r#"// std::env::set_var("AUTUMN_SYNC__TOKEN", sync_token);"#));
assert!(!render_mobile_lib_rs("my-app", "my_app", false).contains("AUTUMN_SYNC__TOKEN"));
}
#[test]
fn default_lib_rs_matches_the_golden_snapshot() {
let generated = render_mobile_lib_rs("golden-app", "golden_app", false);
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/golden/tauri_mobile_default_lib.rs.golden"
);
if std::env::var("UPDATE_GOLDEN").is_ok() {
std::fs::write(path, &generated).expect("write golden");
}
let golden = std::fs::read_to_string(path).expect("golden snapshot file");
assert_eq!(
generated, golden,
"the DEFAULT (no --offline-sync) shell lib.rs emission changed. If \
this is intentional, regenerate the snapshot with \
`UPDATE_GOLDEN=1 cargo test -p autumn-cli default_lib_rs_matches` \
and mention the template change in the commit"
);
}
#[test]
fn lib_rs_without_offline_flag_has_no_sync_wiring() {
let lib = render_mobile_lib_rs("my-app", "my_app", false);
assert!(!lib.contains("AUTUMN_SYNC"));
assert!(!lib.contains("sync_once"));
assert!(!lib.contains("SYNC_KICK"));
assert!(
lib.contains(".run(tauri::generate_context!())"),
"the default shell must keep the simple run(context) tail"
);
}
#[test]
fn offline_plan_edits_app_cargo_toml_features() {
let tmp = stock_app_dir("my-app");
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let plan = plan_tauri_mobile(tmp.path(), OFFLINE).unwrap();
let cargo_action = plan
.actions
.iter()
.find(|a| {
a.path().ends_with("Cargo.toml")
&& !a.path().to_string_lossy().contains("src-tauri")
})
.expect("plan must edit the app Cargo.toml");
let Action::Modify { contents, .. } = cargo_action else {
panic!("app Cargo.toml must be a Modify action, got {cargo_action:?}");
};
assert!(contents.contains(r#"offline-sync = ["autumn-web/offline-sync"]"#));
assert!(contents.contains(r#"default = ["flash", "offline-sync"]"#));
toml::from_str::<toml::Value>(contents).expect("edited Cargo.toml must stay valid TOML");
}
#[test]
fn offline_plan_skips_cargo_toml_edit_when_already_wired() {
let tmp = stock_app_dir("my-app");
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let cargo_path = tmp.path().join("Cargo.toml");
let wired = read_or_empty(&cargo_path).replace(
"default = [\"flash\"]",
"default = [\"flash\", \"offline-sync\"]",
) + "offline-sync = [\"autumn-web/offline-sync\"]\n";
std::fs::write(&cargo_path, wired).unwrap();
let plan = plan_tauri_mobile(tmp.path(), OFFLINE).unwrap();
assert!(
!plan.actions.iter().any(|a| a.path().ends_with("Cargo.toml")
&& !a.path().to_string_lossy().contains("src-tauri")),
"an already-wired Cargo.toml must not be edited again"
);
assert!(plan.warnings.is_empty(), "re-runs must stay silent");
}
#[test]
fn offline_plan_warns_on_customised_cargo_toml_features() {
let tmp = app_dir("my-app");
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let plan = plan_tauri_mobile(tmp.path(), OFFLINE).unwrap();
assert!(
plan.warnings
.iter()
.any(|w| w.contains("offline-sync") && w.contains("[features]")),
"must warn with manual steps on a customised Cargo.toml, got {:?}",
plan.warnings
);
}
#[test]
fn offline_extraction_mounts_sync_router_in_serve() {
let tmp = stock_app_dir("my-app");
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let plan = plan_tauri_mobile(tmp.path(), OFFLINE).unwrap();
let lib_action =
app_src_action(&plan, "lib.rs").expect("plan must create the app src/lib.rs");
let Action::CreateIfAbsent { contents, .. } = lib_action else {
panic!("app lib.rs must be a CreateIfAbsent action, got {lib_action:?}");
};
assert!(contents.contains("let app = mount_offline_sync(app).await;"));
assert!(contents.contains("async fn mount_offline_sync"));
assert!(contents.contains("PgSyncBackend::new(database_url)"));
assert!(contents.contains("ensure_schema()"));
assert!(
contents.contains(".layer(axum::middleware::from_fn(require_sync_auth)),"),
"the sync router must be mounted behind the generated auth middleware"
);
let mount = contents.find("mount_offline_sync(app)").unwrap();
let run = contents.find(".run()").unwrap();
assert!(mount < run, "sync mounting must happen before .run()");
}
#[test]
fn offline_extraction_mounts_sync_fail_closed_behind_sync_token() {
let tmp = stock_app_dir("my-app");
std::fs::write(tmp.path().join("src/main.rs"), stock_main_rs()).unwrap();
let plan = plan_tauri_mobile(tmp.path(), OFFLINE).unwrap();
let lib_action =
app_src_action(&plan, "lib.rs").expect("plan must create the app src/lib.rs");
let Action::CreateIfAbsent { contents, .. } = lib_action else {
panic!("app lib.rs must be a CreateIfAbsent action, got {lib_action:?}");
};
assert!(
contents.contains(r#"std::env::var("SYNC_TOKEN")"#),
"the mount must be gated on SYNC_TOKEN"
);
assert!(
contents.contains("/sync NOT mounted"),
"the skipped mount must be explained loudly at startup"
);
assert!(contents.contains("async fn require_sync_auth"));
assert!(contents.contains("StatusCode::INTERNAL_SERVER_ERROR"));
assert!(contents.contains("StatusCode::UNAUTHORIZED"));
assert!(
contents.contains("autumn_web::sync::server::constant_time_token_eq"),
"the token comparison must be constant-time"
);
assert!(
!contents.contains(r#".nest("/sync", server::router(backend, Arc::new(LwwResolver)))"#),
"an unauthenticated /sync mount must not be emitted"
);
}
#[test]
fn offline_prerequisites_swap_in_the_sync_step() {
let prereqs = render_mobile_prerequisites(OFFLINE);
assert!(prereqs.contains("AUTUMN_SYNC__REMOTE_URL"));
assert!(prereqs.contains("tauri-mobile-offline-sync"));
assert!(
!prereqs.contains("set AUTUMN_DATABASE__URL (see the"),
"the direct-Postgres step must be replaced, not duplicated"
);
}
#[test]
fn shell_autumn_web_dep_reads_string_and_table_version_deps() {
let tmp = stock_app_dir("my-app");
let mut plan = Plan::new(tmp.path());
assert_eq!(
shell_autumn_web_dep(tmp.path(), &mut plan).dep_entry,
r#"autumn-web = { version = "0.9.1", features = ["offline-sync"] }"#
);
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\
autumn-web = { version = \"1.2.3\", features = [\"mail\"] }\n",
)
.unwrap();
assert_eq!(
shell_autumn_web_dep(tmp.path(), &mut plan).dep_entry,
r#"autumn-web = { version = "1.2.3", features = ["offline-sync"] }"#
);
assert!(plan.warnings.is_empty(), "got {:?}", plan.warnings);
}
}