Quick Start
Enable in a mobile build
praisonai-mobile npm workspace already carries the Tauri config, so a mobile build needs no extra setup here.gen/apple and gen/android are committed to the repo, so no init step is needed for a first device build — see Platform Builds.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.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.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 + two commands
Four events go native → web, and two commands come web → native — a press answer and a standing back-declaration. 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.
visualViewport when the native keyboard-height event has not fired yet, and hands over to native the moment one arrives. On Android visualViewport reports nothing while enableEdgeToEdge() is on, so the handover is triggered by the Android window-insets bridge (NATIVE_INSETS_GLOBAL, which carries both channels) instead. The on_window_event handler emits lifecycle and safe-area-changed on device; the keyboard-height Tauri event is still not natively emitted anywhere. See Shell & Adapters → The three-source keyboard model.
keyboard-height Tauri event still does not fire — there is no Tauri window event for it, and no iOS keyboardWillShow/Hide listener has been added either. On iOS the shell therefore reads visualViewport for the keyboard height and would hand over the moment a native emitter lands. On Android that gap is now closed differently: the window-insets bridge delivers the keyboard height on the keyboard field of NATIVE_INSETS_GLOBAL — not through the keyboard-height event — because visualViewport reads nothing there. Every other event in the contract is live natively.src-tauri/capabilities/default.json grants what this seam needs: core:event:default, so the webview can subscribe via plugin:event|listen, plus opener:allow-open-url and opener:allow-default-urls so openExternal actually reaches the OS. Without the opener:* scope every http(s)/mailto/tel URL is still refused. 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.
platforms array is explicit because every permission here exists on all five — the desktop dev build must not fail on it. A mobile-only grant belongs in its own capability scoped to its platform.
Presence detection — both, not either
Before any of the events above can fire, the bridge has to decide whether the Tauri host is actually there.isPresent() in adapters/src/tauri/bridge.ts returns true only when the __TAURI_INTERNALS__ global carries both invoke and transformCallback.
&& / not / || — “both, not either”, explicitly. A half-present global read as present makes the shell call transformCallback inside listen, which throws, is caught, and turns every shell subscription silently into a no-op.
"a FULLY present Tauri global is treated as present -- the pair".Insets and keyboard
The shell writes safe-area insets to the#root element and the keyboard height to the composer’s own screen as CSS custom properties, and keeps them current for the life of the app.
main.ts writes --inset-* on #root (not on the chat .screen element) so sibling screens — Settings and Chats — read the same numbers. --inset-* sits alongside the pre-existing --safe-area-inset-* mirror: the layout consumes --inset-*, while the shell reads --safe-area-inset-* back through readInsets. The env(safe-area-inset-*) mirror is deliberately left untouched, so the pre-script paint is still inset on iOS.onInsetsChanged and onKeyboardHeightChanged are subscribed for the life of the app, not just at mount. Rotation, keyboard show/hide, and safe-area changes all reach the layout after the first frame — a shell adapter that fires onInsetsChanged only once looks correct on load and wrong on rotation.Coalescing rules for insets
coerceInsets distinguishes a partial payload from an empty one, and the Tauri shell dedupes across all four edges.
top pushes the composer up under the notch instead of clear of it. The “no inset is negative” case is now pinned against hollowing by the shell_negative_insets break mode — see Adapter Conformance.The Android window-insets bridge
On Android none of the web APIs can see the system bars or the software keyboard, so a native → web global (NATIVE_INSETS_GLOBAL) carries both. It is the Android-only source that feeds the shell’s insets and keyboard height.
Why it exists
MainActivity.onCreate calls enableEdgeToEdge(), which sets decorFitsSystemWindows = false. With that, the window is never resized by the system bars or the IME, so no web API observes them:
env(safe-area-inset-top) read 49px — the cutout at dpr 2.625 — while the 24 CSS-px status bar contributed 0. Rotated to landscape, that same 49 px moved to left and top became 0 even though the status bar was still along the top edge. With the IME shown and the textarea focused, every keyboard read was 0, so readKeyboardHeight returned 0 and the composer painted underneath the keyboard.
The values exist only in native WindowInsetsCompat. MainActivity reads them and pushes them across.
The seam
One string, pinned on both sides bytools/shell-seam.test.mjs:
evaluateJavascript calls window.<name> && window.<name>(...), so a name the shell does not install is a legal no-op, and the app lays out as though the phone had no bars and no keyboard.
The payload
toPx, because the payload crosses an evaluateJavascript string boundary and a NaN reaching a CSS length silently drops the whole declaration.
The animation callback
setOnApplyWindowInsetsListener fires once at each end of the IME transition, so on its own the composer teleports between endpoints. WindowInsetsAnimationCompat.Callback (registered with DISPATCH_MODE_CONTINUE_ON_SUBTREE) runs onProgress every frame in between, which is what makes the composer slide with the keyboard instead of jumping.
The retry-until-acknowledged loop
The very firstrequestApplyInsets evaluates window.<global> && window.<global>(...) against a document that has no global yet — a legal no-op — and Android has no reason to redispatch, so without a retry the app sat at zero insets until the user rotated the phone.
"true" — the acknowledgement that the global ran — then stops.
@JavascriptInterface: the WebView is Tauri’s, and interfaces added in onWebViewCreate only inject into documents loaded after the call, so the interface would miss the very first document. A density <= 0 guard sits on the divide that converts device pixels to CSS pixels — a NaN reaching a CSS length silently drops the whole declaration.The handover in the shell
WhenNATIVE_INSETS_GLOBAL fires, the shell publishes both insets and keyboard, retires the visualViewport listener (it reports nothing on Android anyway), and sets nativeInsetsSeen = true. From then on, a subsequent empty safe-area-changed payload is dropped rather than re-read from env() — re-reading would silently drop the status and navigation bars back to 0. The global is installed on view, not globalThis, so a test drives it with a fake window and no phone.
Back-gesture arbitration
Android presses back, Rust asks the webview whether it wants it, and if the webview says no — or cannot answer in time — Rust lets the system act. To decide the timeout case correctly, the webview also declares, out of band, whether it could take the next press.The in-repo tauri-plugin-back-gesture
The Android back callback is owned end-to-end by an in-repo Rust plugin, tauri-plugin-back-gesture, not by Tauri’s own AppPlugin.
Tauri’s AppPlugin already installs an OnBackPressedCallback, but it routes the press to JavaScript plugin listeners via Plugin.trigger — a completely separate channel that reaches neither Tauri’s event registry nor Rust. Wiring it that way would fire nothing, with no error anywhere. Arbitration also has to run in Rust: the webview’s answer is fire-and-forget (bridge.invoke swallows every rejection into null), so only Rust can time it out.
The press travels Kotlin → Channel → Rust → emit:
load(webView) — not init() — so it is always newer than Tauri’s own callback. Android’s OnBackPressedDispatcher consults the most recently added enabled callback first, and load runs once the WebView exists, after every plugin’s init, so this callback always wins. Registered in init it would win only when the plugin store happened to initialise it after Tauri’s.
The public Rust API, from src-tauri/plugins/back-gesture/src/lib.rs:
back_gesture_can_go_back is an app command in src-tauri/src/commands.rs (added to generate_handler! in lib.rs), not a plugin method — it only writes the Gate’s standing state, which Gate::timed_out reads. The plugin still exposes exactly fall_back, and the two-path choice between moveTaskToBack(true) and re-dispatch lives inside the plugin’s Kotlin defer().on_press never fires, and fall_back does nothing. iOS has no OS-level back, and an app that terminates itself is an App Review rejection that reads to the user as a crash.back-gesture is not the only in-repo plugin. src-tauri/plugins/secrets is the platform keychain: iOS / macOS via SecItem*, Android via EncryptedSharedPreferences, and a refusal on any host without a hardware store. See Native Secrets.declare_can_go_back is a dotted self-loop, not an edge between states: it sets out-of-band state (Gate::can_go_back, default false) that timed_out reads. It never changes whether a press is pending — only which way the watchdog resolves silence.
Four failure modes shape the Gate 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.
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. - Slow is not dead. The round trip is not bounded by anything this crate controls: the emit reaches the webview on the platform’s UI thread, and the handler runs on the thread that is painting. On an Android 15 emulator the same press was answered in 0.7 s once and 5.4 s the next time, both far past the watchdog. Treating that silence as “the app does not want this press” sent an app the user was actively using to the background — back on the Settings screen left the app entirely, while its own router had already popped back to the chat. So the webview declares, in advance and out of band, whether it can go back (
Gate::declare_can_go_back), and the watchdog only lets the platform act when the app has said it cannot.
answered(false) falls back even when the last declaration was true, and answered(true) is honoured regardless — the standing state is consulted by timed_out, never by answered.Gate returns an Action rather than performing it, so the decision is testable and the side effect lives at the edge.
src-tauri/src/commands.rs.
Who declares, and when
The standingsetCanGoBack declaration has one router-side caller and one hard rule for when it is false.
Live regions and screen-reader announcements
The DOM layer joins every polite announcement produced in one render pass into a single write, so an answer that finishes inside one tick still reaches the live region. The pureannounce() function may return several utterances at once; assigning them one at a time overwrote all but the last.
Each region is assigned once per pass, with every utterance of that politeness joined by a space. A short answer completing inside one interval produced ["The capital of France is Paris.", "Response complete"]; assigning per item left a screen-reader user hearing only “Response complete.” — exactly the failure announce.ts rule 4 exists to prevent, reintroduced where the pure function meets the DOM.
reconcile/applyOps mutate it every publish, so aria-live there would restart the reader on each token batch. Announcements go through the small polite/assertive regions below it instead.Where the empty-chat panel lives in the DOM
The empty-chat panel (Empty Chat) is a sibling of.transcript, never a child.
EMPTY_TITLE_ID ("empty-state-title") is a single constant shared by the heading’s id and the panel’s aria-labelledby, so the two cannot drift — see i18n & A11y → Empty-chat region.
Lifecycle mapping decision
Tauri surfaces suspend/resume/focus andShellPort declares three phases, so phase_for in src-tauri/src/shell/lifecycle.rs maps five window events 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 now emitted, from Focused(false) — a system dialog over the activity, or a window blur on the desktop dev build — except when suppressed after Suspended. active comes from both Resumed and focus gain, because a control-centre dismissal on iOS is didBecomeActive with no willEnterForeground before it.
The Tracker — why raw phases go through a filter
The lifecycle emitter runs each phase through Tracker in src-tauri/src/shell/lifecycle.rs before it emits, because the platforms deliver focus and suspension in an order that would otherwise announce inactive after background, or active twice. State lives in pub struct LifecycleState(pub Mutex<Tracker>) in shell/mod.rs, managed on the app.
android_home_press_announces_background_once_and_not_inactive_after_it and android_return_announces_active_once_for_resume_plus_focus.
Platform floors
The mobile build sets its platform minimums for the first time intauri.conf.json.
tauri-plugin-back-gesture sets its own lower floor in build.gradle.kts (minSdk = 24, compileSdk = 36) and requires AppCompatActivity. The app still requires API 26 — the higher floor wins. The plugin’s lower floor only means it could be reused in an app with a lower minimum; it does not change the app’s floor.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.
run()’s builder chain is split into configure() so tests build the same app on Tauri’s mock runtime. The chain a dev editing lib.rs must not drop is:.on_window_event(...) itself is covered by a source-string assertion in tests/wiring.rs, not a behavioural test, because Tauri’s MockRuntime accepts the callback and drops it — test/mock_runtime.rs never stores it. The test labels this explicitly. Wiring is otherwise covered by mutation-tested behavioural tests in src-tauri/tests/wiring.rs and tests/lifecycle.rs.Best Practices
Never rename an event string on one side only
Never rename an event string on one side only
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
0 → 340 teleports the composer instead of tracking the slide. Fire keyboard-height through the whole transition, not just at its endpoints.Dedupe insets across all four edges, including right
Dedupe insets across all four edges, including right
sameInsets must compare top, right, bottom, and left. Leaving right out makes a landscape notch appearing on the right invisible to the shell — the payload is deduped away as “no change” and content sits under the notch.Do not emit an unrecognised lifecycle phase
Do not emit an unrecognised lifecycle phase
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
Emitter::emit reaches. Plugin.trigger hits a separate channel and fails with no error.Do not use Plugin.trigger for back-gesture from a Tauri plugin either
Do not use Plugin.trigger for back-gesture from a Tauri plugin either
tauri-plugin-back-gesture, the press is routed through registerListener + a Rust-owned tauri::ipc::Channel → Emitter::emit, never Plugin.trigger, for the same reason: Plugin.trigger reaches JS plugin listeners, not Tauri’s event registry or Rust.Do not lower the answer-timeout below 250 ms
Do not lower the answer-timeout below 250 ms
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 false as the safe default for setCanGoBack
Keep false as the safe default for setCanGoBack
false was declared: a bundle that never loaded declared nothing, and back must still be able to leave the app. The router declares on attach, on every stack change, and false on detach; the crash screen declares false before repainting. Never default the standing state to true.Background a root activity — never re-dispatch it
Background a root activity — never re-dispatch it
defer() calls moveTaskToBack(true); re-dispatching the press ourselves walks the app-level path to finishAfterTransition(), which kills the process and turns the next return into a cold start with the transcript lost. Only re-dispatch above the root.Keep the mobile_entry_point attribute on run()
Keep the mobile_entry_point attribute on run()
run. The macro expands to the JNI/ObjC entry point on Android/iOS, and the CLI resolves it by that exact name.
