Quick Start
1
Enable in a mobile build
The
praisonai-mobile npm workspace already carries the Tauri config, so a mobile build needs no extra setup here.The iOS and Android targets initialise separately with
cargo tauri ios init / cargo tauri android init. Those generate gen/apple and gen/android and are not part of this shell β set them up before a first device build.2
Run the desktop dev binary
cargo tauri dev runs src/main.rs, the desktop-only dev binary. iOS and Android enter through the mobile_entry_point in lib.rs instead.3
Run the shell tests
npm run test:rust runs the 14 Rust tests that pin the arbitration and the contract.tools/shell-seam.test.mjs and greps the same event strings out of both languages.The same
cargo test and cargo clippy -- -D warnings run in CI on both ubuntu-22.04 and macos-15 via the shell job in .github/workflows/mobile.yml. Both platforms are covered deliberately: the crate is cfg-heavy β the back-gesture fallback is #[cfg(target_os = "android")] / ios / not(mobile) β and a cfg mistake compiles perfectly on whichever host you happened to try.The shell contract β four events + one command
Four events go native β web, one command comes web β native. Every string below is pinned bysrc-tauri/tests/contract.rs on the Rust side and tools/shell-seam.test.mjs on the TypeScript side β a rename on either side breaks the shell silently.
The events do not fire yet in a production build.
on_window_event in lib.rs is intentionally a stub in this shell (βno dead emit on desktopβ) β the contract and the arbitration are wired, the emit lines land once the mobile targets are initialised.src-tauri/capabilities/default.json grants the one permission this seam needs: core:event:default, so the webview can subscribe via plugin:event|listen. The back_gesture_result command is deliberately not listed β an app command registered through invoke_handler is always reachable and has no ACL entry to grant; naming one that does not exist fails tauri-build before the crate compiles.
Back-gesture arbitration
Android presses back, Rust asks the webview whether it wants it, and if the webview says no, Rust lets the system act. Three failure modes shape theGate in src-tauri/src/shell/back.rs.
- The answer may never come.
bridge.invokeon the TS side swallows every rejection intonull, so silence is indistinguishable from success. Without a watchdog (ANSWER_TIMEOUT_MS = 400), a bundle that failed to load leaves a back button that does nothing forever β worse than one that exits.
The floor of Lowering it under 250 ms stops the crate compiling (
ANSWER_TIMEOUT_MS is enforced at compile time in src-tauri/src/shell/back.rs:E0080) rather than failing a test someone could skip. Too short a timeout falls back while a slow handler is still deciding, sending the app to the background for a back press the userβs own UI was about to handle.- There is no correlation id. The webview sends
{ handled }and nothing else. Two presses close together produce two answers Rust cannot tell apart β the second could pop an activity the first decided to keep. Dropping while pending is the only correct option available on this side. - A late answer must not act twice. If the watchdog fires and the app has backgrounded, an answer arriving after must be ignored, not sent back again. It is the bug this design is most likely to ship, and has its own test in
src-tauri/tests/back_gesture.rs.
Gate returns an Action rather than performing it, so the decision is testable and the side effect lives at the edge.
The fallback itself differs per platform, from
src-tauri/src/commands.rs.
Lifecycle mapping decision
Tauri surfaces two states andShellPort declares three, so phase_for in src-tauri/src/shell/lifecycle.rs maps between them.
Suspended maps to background, not inactive, and that is deliberate. boot.ts only flushes on background, and on iOS the app can be killed while suspended with no further callback β so anything unflushed at that moment is lost. Mapping to inactive would mean the flush never runs and transcripts are lost on every backgrounding. The cost β a control-centre pull-down stopping the run loop β is the cheaper mistake.inactive is not currently emitted by this shell at all; reporting all three phases needs platform code (didEnterBackgroundNotification on iOS, ProcessLifecycleOwner on Android).
Platform floors
The mobile build sets its platform minimums for the first time intauri.conf.json.
Panic handling β why release does not set panic = "abort"
The release profile deliberately leaves panic = "abort" unset, unlike the desktop crate.
panic = "abort"is deliberately NOT set (unlike the desktop crate).mobile_entry_pointwraps the app incatch_unwindso a panic prints and aborts cleanly instead of unwinding across the JNI/ObjC boundary, which is undefined behaviour.abortturns a readable message into a bare SIGABRT β on a phone with no console, that is the difference between a crash you can read and one you cannot.
mobile_entry_point attribute on run(): the macro expands to the JNI symbol on Android and start_app on iOS, so renaming run breaks the entry point.
Best Practices
Never rename an event string on one side only
Never rename an event string on one side only
The seam fails silently β the webview simply stops receiving an event and lays out as though the phone had no notch, keyboard, or lifecycle. Both
contract.rs and shell-seam.test.mjs guard the five constants; run npm run test:rust and the Node cross-language test before landing any change to them.Emit keyboard-height continuously through show/hide
Emit keyboard-height continuously through show/hide
Emitting only
0 β 340 teleports the composer instead of tracking the slide. Fire keyboard-height through the whole transition, not just at its endpoints.Do not emit an unrecognised lifecycle phase
Do not emit an unrecognised lifecycle phase
TypeScript drops an unknown phase silently β better to add the phase on both sides than to hope a default kicks in. Defaulting to
active would resume the render loop on a suspended app.Do not use Plugin.trigger for shell events
Do not use Plugin.trigger for shell events
The TypeScript subscribes to Tauriβs event registry, which only
Emitter::emit reaches. Plugin.trigger hits a separate channel and fails with no error.Do not lower the answer-timeout below 250 ms
Do not lower the answer-timeout below 250 ms
The floor is a compile-time assertion in
shell::back, not a runtime test, so lowering it stops the crate compiling. Too short a timeout falls back while a slow handler is still deciding.Keep the mobile_entry_point attribute on run()
Keep the mobile_entry_point attribute on run()
Do not rename
run. The macro expands to the JNI/ObjC entry point on Android/iOS, and the CLI resolves it by that exact name.Related
Shell & Adapters
The TypeScript/web counterpart β the keyboard snapshot and pinch-zoom guard.
Architecture
Boot order and where the session join lives.
Overview
Retained chat and native navigation.
Engines
In-process vs remote engine.

