Native Shell (Tauri)
The Rust shell tells the webview four things a browser cannot know, and arbitrates the Android back gesture
Everything the mobile app does runs in the webview. The native shell exists to tell it four things a browser cannot know — the safe-area insets, the keyboard height, the lifecycle phase, and that the user pressed back — and to act on the one answer it sends back.
Quick Start
- Enable in a mobile build
The
praisonai-mobilenpm workspace already carries the Tauri config, so a mobile build needs no extra setup here. - Run the desktop dev binary
cargo tauri devrunssrc/main.rs, the desktop-only dev binary. iOS and Android enter through themobile_entry_pointinlib.rsinstead.bash cd src-tauri && cargo tauri dev - Run the shell tests
npm run test:rustruns the 14 Rust tests that pin the arbitration and the contract.bash npm run test:rust # → cd src-tauri && cargo testThe Node cross-language contract test lives at
tools/shell-seam.test.mjsand greps the same event strings out of both languages.
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 by src-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.
| Direction | Name (string literal) | Rust constant | Payload | Notes | ||
|---|---|---|---|---|---|---|
| Native → Web | safe-area-changed | EVT_SAFE_AREA | Insets object. A payload carrying even one edge is applied, not discarded. A payload with no edges at all = “re-read the CSS” (not “every inset is zero”). Emitted on Resized and ScaleFactorChanged with a deliberately empty payload — on iOS and desktop Tauri does not expose the insets, so the WebView re-reads the CSS env() variables it has already recomputed. On Android that re-read is suppressed once nativeInsetsSeen is true (see the Android window-insets bridge), because env() there is the display cutout only and re-reading it after a rotation would drop the status/navigation bars back to 0. | Consumed by coerceInsets in adapters/src/tauri/shell.ts | ||
| Native → Web | keyboard-height | EVT_KEYBOARD | Number (px) | 0 is a value, not an absence. Must fire through the show/hide transition, not only at endpoints — or the composer teleports while the keyboard slides | ||
| Native → Web | lifecycle | EVT_LIFECYCLE | `”active” \ | “inactive” \ | “background”` | Unrecognised phase is dropped by TS, never defaulted (defaulting to active would resume the render loop while the app is suspended) |
| Native → Web | back-gesture | EVT_BACK | (no payload) | The handler takes no argument | ||
| Web → Native | back_gesture_result | CMD_BACK_RESULT | { handled: boolean } | Answered via bridge.invoke; failures are swallowed — see arbitration below (no capability entry — app commands via invoke_handler are always reachable) | ||
| Web → Native | back_gesture_can_go_back | CMD_BACK_CAN_GO_BACK | { canGoBack: boolean } | Standing declaration, sent on route-stack change (not in reply to a press); read by the watchdog when an answer never arrives. Default is false, so a bundle that never loaded still lets back leave the app. See arbitration below |
On iOS and desktop the Tauri shell falls back to 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.
The capability file 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.
{
"$schema": "../gen/schemas/mobile-schema.json",
"identifier": "default",
"windows": ["main"],
"platforms": ["android", "iOS", "macOS", "windows", "linux"],
"permissions": [
"core:event:default",
"opener:allow-open-url",
"opener:allow-default-urls"
]
}The 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.
__TAURI_INTERNALS__ carries | isPresent() | Why |
|---|---|---|
both invoke and transformCallback | true | A Tauri version this bridge speaks |
| only one of the two functions | false | A half-present global is a version this bridge does not speak — treat as absent |
| neither | false | No host |
The check is && / 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.
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.
| Custom property | Written on | Source | When it updates |
|---|---|---|---|
--inset-top | #root | insets.top | mount + every onInsetsChanged |
--inset-bottom | #root | insets.bottom | mount + every onInsetsChanged |
--inset-left / --inset-right | #root | insets.left / insets.right | mount + every onInsetsChanged |
--keyboard-height | composer screen | keyboardHeightPx | mount + every onKeyboardHeightChanged |
Top comes from top and bottom from bottom — the two were swapped at one point, and a regression test now pins the mapping.
Coalescing rules for insets
coerceInsets distinguishes a partial payload from an empty one, and the Tauri shell dedupes across all four edges.
| Native emits | Shell does | Why |
|---|---|---|
| Every edge present, values changed | Apply all four, publish once | The normal path |
| Only the edges that changed (partial payload) | Apply the payload, publish — the guard returns null only when every edge is absent | An adapter that discards the whole event because a key is missing leaves the composer under the home indicator after a rotation |
| No edges at all (empty payload) | Re-read the CSS snapshot; do not zero the insets | Zeroing slides the composer under the home indicator |
| An identical four-edge payload | Do not republish (Tauri only) | Every frame of a rotation would otherwise relayout |
| An identical payload except for the right edge | Publish — the right edge counts | A landscape notch appearing on the right otherwise deduped away as “no change”, and content sat under it |
The Android window-insets bridge {#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:
| Web API | With the IME shown, edge-to-edge on |
|---|---|
env(safe-area-inset-*) | The display cutout only — not the status bar, not the navigation bar, never the keyboard |
window.innerHeight | The full viewport |
visualViewport.height | The full viewport |
navigator.virtualKeyboard.boundingRect | 0×0 |
Measured on an Android 15 emulator (Pixel-class, 1080×2400 @ 420 dpi): with a portrait cutout, 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 by tools/shell-seam.test.mjs:
| Side | Symbol | Value |
|---|---|---|
Kotlin (MainActivity.kt) | INSETS_GLOBAL | __praisonaiNativeInsets |
TypeScript (adapters/src/tauri/shell.ts) | NATIVE_INSETS_GLOBAL | __praisonaiNativeInsets |
A rename on either side is otherwise silent — 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
export interface NativeInsetsPayload {
readonly top?: unknown;
readonly right?: unknown;
readonly bottom?: unknown;
readonly left?: unknown;
readonly keyboard?: unknown;
}Every field is optional and every field is coerced through toPx, because the payload crosses an evaluateJavascript string boundary and a NaN reaching a CSS length silently drops the whole declaration.
| Field | What Kotlin sends |
|---|---|
top / right / bottom / left | getInsets(systemBars() or displayCutout()), folded into one “do not paint here” number per edge |
keyboard | getInsets(ime()).bottom |
The cutout is folded into the edges because on devices whose cutout is deeper than the status bar — every emulator AVD with a punch hole, and any such phone in landscape — the bars alone are too small.
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 first requestApplyInsets 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.
| Constant | Value | Meaning |
|---|---|---|
PUSH_RETRY_MS | 120 | Delay between pushes |
MAX_PUSH_ATTEMPTS | 125 | Ceiling on retries (~15 s total) |
The loop pushes until the injected JS returns "true" — the acknowledgement that the global ran — then stops.
The handover in the shell
When NATIVE_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:
BackGesturePlugin.kt OnBackPressedCallback, registered in `load` not `init`
so it is always newer than Tauri's, and the dispatcher
consults the most recently added callback first
-> trigger(EVENT) the base Plugin class's own `registerListener` command,
handed a Rust-owned tauri::ipc::Channel
-> Rust closure commands::on_back_pressed
-> Emitter::emit "back-gesture" — what the webview actually hears
-> back_gesture_result(handled), or a 400ms watchdog if silence — and the
watchdog reads the standing back_gesture_can_go_back
declaration instead of treating silence as "declined"
-> defer() at the task root, moveTaskToBack(true) — the task goes
behind the launcher and the process stays warm; above the
root, re-dispatch with the callback disabled so the SYSTEM
default runs beneath itTwo channels feed one gesture: a press that has already happened (answered in reply, or timed out) and a standing declaration sent ahead of any press whenever the route stack changes.
The Kotlin callback is registered in 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:
pub const PLUGIN_NAME: &str = "back-gesture";
pub enum Error { Platform(String) }
pub struct BackGesture<R: Runtime> { ... }
impl<R: Runtime> BackGesture<R> {
pub fn fall_back(&self) -> Result<(), Error>;
}
pub trait BackGestureExt<R: Runtime> {
fn back_gesture(&self) -> &BackGesture<R>;
}
pub fn init<R: Runtime>(
on_press: impl Fn(&AppHandle<R>) + Send + Sync + 'static,
) -> TauriPlugin<R>;| Constant | Value | Pinned by |
|---|---|---|
PLUGIN_NAME | "back-gesture" | src-tauri/tests/contract.rs |
Java package / PLUGIN_IDENTIFIER | ai.praison.mobile.backgesture | Kotlin package |
Kotlin event name / EVT_PRESSED | "pressed" | must match Rust |
The state diagram below is what the arbitration inside that plugin’s Rust closure does with each press.
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.
- 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.
The Gate returns an Action rather than performing it, so the decision is testable and the side effect lives at the edge.
| Method | Returns | Meaning | |
|---|---|---|---|
Gate::new() | Gate | Create in Idle, can_go_back = false | |
press() | `Action::Ask \ | Action::Drop` | Ask webview, or drop if already pending |
answered(handled: bool) | `Action::Ignore \ | Action::FallBack` | Ignore if late; fall back if handled=false |
timed_out() | `Action::FallBack \ | Action::Ignore` | Fall back once when the app has declared it cannot go back; ignore otherwise (or if a stray) |
declare_can_go_back(canGoBack: bool) | () | Set the standing answer; read by timed_out when a press is unanswered | |
can_go_back() | bool | Read back the last declaration (default false) | |
is_pending() | bool | Test hook |
The fallback itself differs per platform, from src-tauri/src/commands.rs.
| Platform | Fallback action | Rationale |
|---|---|---|
| Android | defer() branches on isTaskRoot — moveTaskToBack(true) at the root, else re-dispatch with our callback disabled | On Android 12+ the system-default path backgrounds a root activity, but only when the system dispatches the press. Re-dispatching it ourselves at the root walks the app-level path to finishAfterTransition(), killing the process. moveTaskToBack(true) sends the task behind the launcher and keeps the process warm; above the root, re-dispatch runs whatever sits beneath. |
| iOS | No-op | An iOS app must never terminate itself — App Review rejection, reads as a crash |
| Desktop (dev) | No-op | The desktop main.rs is a dev binary only |
Who declares, and when
The standing setCanGoBack declaration has one router-side caller and one hard rule for when it is false.
| Caller | Declares | Why |
|---|---|---|
attachBackGesture in ui/src/router.ts | backDecision(stack).consumed on attach, on every stack change (via router.subscribe), and false on detach | Same pure decision the press handler makes, so the declaration and the answer never disagree; false on detach because a torn-down view’s router speaks for nothing |
The crash handler in app/src/main.ts | false before repainting the fatal screen | A dead webview must not swallow the gesture |
The Tauri adapter in adapters/src/tauri/shell.ts | Sends on change; suppresses no-op re-sends but always sends the first value — even false | The first send overwrites whatever the previous page declared after a reload; a chat-to-chat move keeps the answer true and is deduped away |
The web adapter in adapters/src/web/shell.ts | Documented no-op | Browser popstate is answered synchronously in the same task — there is no bridge to race with |
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 pure announce() 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.
// main.ts — join once per region, never assign per item.
const polites = spoken.announcements.filter((a) => a.politeness !== "assertive");
if (polites.length > 0) polite.textContent = polites.map((a) => a.text).join(" ");Where the empty-chat panel lives in the DOM
The empty-chat panel (Empty Chat) is a sibling of .transcript, never a child.
| Rule | Source | What breaks if it regresses |
|---|---|---|
The panel is a sibling of .transcript, not a child | emptyPanel appended to the screen, not the transcript | Three code paths do transcript.textContent = "" (New chat, Open chat, delete-the-open-chat) and applyOps places rows by children[index]; a child panel would be displaced or silently deleted. |
The screen carries data-empty | screen.dataset["empty"] = view.kind in paintEmptyState | Written as "welcome" or "needs-key" matching the current EmptyStateKind. .screen[data-empty] .transcript { flex: 0 0 auto } stops both children of the flex column claiming flex: 1 and splitting the screen. |
The panel toggles via the hidden attribute | .empty-state[hidden] { display: none } | The rule overrides the display: flex layout, exactly as .screen[hidden] does. Never toggle via style.display. |
| Safe-area insets add to the gutter | .empty-state uses --inset-left / --inset-right | The insets are added to the gutter rather than replacing it; the topbar’s --inset-top handling is untouched. |
| Colours are tokens only | --ink, --soft, --accent | All three have dark-scheme values, so the panel follows the device theme with no rule of its own. A hardcoded colour would be invisible on the dark ground. |
The region id 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 and ShellPort declares three phases, so phase_for in src-tauri/src/shell/lifecycle.rs maps five window events between them.
Tauri WindowEvent | phase_for returns | Emitted after Tracker observes |
|---|---|---|
Suspended | "background" | Yes — first observation after any other phase |
Resumed | "active" | Yes — first observation after any other phase |
Focused(true) | "active" | Suppressed when it immediately follows Resumed (identical phase) |
Focused(false) | "inactive" | Suppressed when it immediately follows Suspended (background → inactive would lie about direction) |
| (anything else) | dropped | — |
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
) in shell/mod.rs, manage`d on the app.
| Suppressed sequence | Why |
|---|---|
Android: onPause → onWindowFocusChanged(false) | The focus-change would report the app as having come up to inactive while it is already backgrounded. Emit background once and drop the trailing inactive. |
Android: onResume → onWindowFocusChanged(true) | Both events say active; emit once. |
| Any repeated phase | The current phase is remembered; a duplicate is dropped. |
Pinned by 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 in tauri.conf.json.
| Platform | Minimum |
|---|---|
| iOS | 16.0 (bundle.iOS.minimumSystemVersion) |
| Android | API 26 (bundle.android.minSdkVersion) |
| Bundle identifier | ai.praison.mobile |
| Product name | PraisonAI |
These floors also drive the CSP, the WebView bundle target, and the store version story — see Shipping to iOS and Android.
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.
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
strip = "debuginfo"
# panic = "abort" -- deliberately NOT set (see above)The same reasoning keeps the 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
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
Emitting only 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
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
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
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 use Plugin.trigger for back-gesture from a Tauri plugin either
Even from inside the in-repo 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
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 false as the safe default for setCanGoBack
A shell that has never been told otherwise must behave as though 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
At the task root, 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()
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.