Skip to main content
Four things must agree before a store accepts a build: the engine has to be reachable, the bundle has to parse on the oldest WebView you support, the version and build number have to be monotonic, and the crate has to type-check for the phone.

Quick Start

1

Build the webview and run cross-check

Build the webview, then initialise the platform projects so the crate can be checked for a phone.
gen/apple and gen/android are generated here and are not committed. The two schema files under gen/schemas/ (android-schema.json, mobile-schema.json) and src-tauri/Cargo.lock are.
2

Stamp the release

Write the version and a monotonic build number into tauri.conf.json before the store build runs.
This writes version, bundle.android.versionCode, and bundle.iOS.buildNumber.

The engine must be reachable — CSP

A webview enforces connect-src on fetch, and the engine’s baseUrl is a host the user sets — so a too-tight connect-src blocks every remote engine before a packet leaves. The exact CSP shipped in src-tauri/tauri.conf.json:
The old connect-src 'self' ipc: http://ipc.localhost permitted no engine at all — including the shipped loopback default http://127.0.0.1:8765. On a phone the engine is across a network, so https: is now permitted too.
script-src stays closed on purpose. A model can return arbitrary text and a tool result is attacker-shaped in the ordinary case; script-src 'self' is what stops that becoming code in the app’s origin. Widening connect-src must never widen script-src. Both halves are pinned by tools/tauri-conf.test.mjs — “the CSP still refuses remote SCRIPTS — the pair”.

The bundle must parse on the floor

The esbuild target is derived from the platform minimum the app declares, not chosen independently. minSdkVersion: 26 implies chrome58 because Android’s WebView updates through Play, but AOSP, Play-less, and long-offline devices keep whatever they shipped with — exactly the population a floor exists to protect. The bundle targets ["safari16", "chrome58"] and the table lives as ANDROID_WEBVIEW_FLOOR in tools/bundle.mjs. Lowering the floor cost 5.2 kB of the 400 kB budget, measured.
Bumping minSdkVersion in tauri.conf.json also bumps the bundle target. tools/bundle-target.test.mjs fails until both agree — it pins Chrome ↔ minSdkVersion, Safari ↔ the iOS minimum, and asserts the shipped bundle contains no ?., ??, ??=, ||=, or &&=.
Unresolved bare imports. The gate also asks, for every bare import in the bundled output, whether Node can resolve it from the mobile app entry (createRequire(resolve(entry))). A webview has no module resolver, so import "openai" in the shipped file dies at import time with the same blank screen as a static Node builtin. Two exemptions:
  • esbuild’s own metafile markers (RUNTIME_MARKERS) — not real packages.
  • Node builtins (fs, crypto, node:fs, …). Redundant in practice — createRequire resolves builtins too — but kept as intent so a builtin is never reported twice, since builtins are already classified elsewhere in the gate as fatal-or-lazy.
The resolver is anchored at the mobile app entry on purpose. A test that probes the gate with an entry file inside a temp directory sees everything as missing — there is no node_modules above a temp path — and passes for the wrong reason. The real app entry sits inside the package; the probe in tools/bundle.test.mjs does the same.

Version + monotonic build number

Semver alone cannot serve both stores, so tools/set-release-version.mjs writes a strictly increasing build number alongside the version. Play rejects a re-used versionCode; App Store Connect rejects a duplicate CFBundleVersion within the same CFBundleShortVersionString. A re-upload of the same version after a rejection needs a higher number — which is precisely when a release is most likely to need one.
CI passes github.run_number for the build number, because it only ever increases for a repository.
tauri.conf.json is the single source the bundler reads — deliberately not synced with package.json or Cargo.toml, matching the desktop package’s decision after #4527.

Cross-check in CI

Host cargo check never expands the mobile cfg, so a cross-check matrix in .github/workflows/mobile.yml runs cargo check --lib against real phone targets. The shell job runs on host targets, so #[cfg_attr(mobile, tauri::mobile_entry_point)] and the #[cfg(target_os = "android"/"ios")] arms in commands.rs expanded zero times. cargo check type-checks and macro-expands under the mobile cfg and needs no NDK or Xcode project — measured at 18 s for the Android target.

Best Practices

An engine that cannot be reached is a connect-src problem — widen that instead. script-src closed is what keeps model output from becoming code in the app’s origin.
The two are two numbers in two files that must agree. bundle-target.test.mjs fails until the WebView floor matches the declared minSdkVersion.
Use github.run_number, or another counter that only increases. A hand-picked number that repeats is rejected by the store on the second upload.
Commit gen/schemas/*.json and src-tauri/Cargo.lock. Do not commit gen/apple or gen/android — Tauri regenerates those on init.

Native Shell

The Tauri events, the back-gesture arbitration, and the platform floors.

Shell & Adapters

The two-source keyboard model and the trimmed-forward link rule.

Composer

The layout invariants that keep the composer clear of the keyboard.

Architecture

Boot order and where the shell is injected.