Shell & Adapters
One shell interface, two adapters, and the phone capabilities a desktop window never needs.
ShellPort is the seam between framework-free UI logic and the native shell.
import { createTauriShell } from "praisonai-mobile/adapters/tauri";
const shell = createTauriShell();
shell.haptic("selection");
await shell.share({ text: "My agent's answer", title: "PraisonAI" });Quick Start
- Read the insets synchronously
ts const { top, bottom } = shell.insets;First paint places the composer above the home indicator, so insets are a synchronous snapshot, never a promise.
- React to the keyboard
ts const off = shell.onKeyboardHeightChanged((px) => layout(px));keyboardHeightPxis also a synchronous snapshot, so a warm resume with the keyboard already up lays out correctly on mount. - readKeyboardHeight is the single source of truth
One exported function computes the keyboard height, with the clamp and the zoom guard in one place.
ts export function readKeyboardHeight(view: Window): number { const viewport = view.visualViewport; if (viewport === null || viewport === undefined) return 0; if (viewport.scale > 1) return 0; return Math.max(0, view.innerHeight - viewport.height - viewport.offsetTop); } - createWebShell seeds the snapshot at construction
keyboardHeightPxis seeded, not declared= 0.ts export function createWebShell(view: Window = window): WebShell { let keyboardHeightPx = readKeyboardHeight(view); // seeded, not 0 // ... the live handler calls the SAME readKeyboardHeight(view) }
What The Shell Provides
These are the capabilities a phone needs but a desktop window does not.
| Capability | Member | Note |
|---|---|---|
| Safe area | insets, onInsetsChanged | Synchronous snapshot + change events. |
| Keyboard | keyboardHeightPx, onKeyboardHeightChanged | 0 when hidden; fires through the transition. |
| Lifecycle | onLifecycleChanged | Background must stop the run loop and flush. |
| Back gesture | onBackGesture, setCanGoBack | Handlers are a stack; first to return true wins. setCanGoBack declares out of band whether the next press would be consumed — the native side reads it when a press answer is too slow. See Native Shell → Back-gesture arbitration. |
| Haptics | haptic(kind) | selection, impact, success, warning, error. |
| Sharing | share(payload) | Hand text/URL to the OS share sheet. |
| Secure links | openExternal(url) | Allowlisted schemes only, and the trimmed form is what reaches the OS. |
The web adapter’s back-gesture handler sits on popstate. When any subscriber returns true (consumed), the shell re-pushes the current URL: view.history.pushState(null, "", view.location.href). That restores the entry the browser just popped, so the app stays where it is and the next OS back gesture goes one screen up as the user expects — not out of the app. Using replaceState instead would replace the browser’s entry, and the next back would exit. When every subscriber returns false (declined), the shell does not touch history: the browser is allowed to navigate away, so the OS gesture is never trapped inside the app. Pinned by "the web shell re-pushes history after consuming a back gesture" and its pair "a back gesture the app DECLINES does not touch history".
setCanGoBack — the standing back-declaration
setCanGoBack is the app’s standing answer to “would the next back press be consumed”, sent out of band so the native side already has it when a press arrives.
setCanGoBack(canGoBack: boolean): voidCalled by the router on attach, on every stack change, and with false on detach. Android’s answer to a press has to cross a thread boundary and, on a slow device, may arrive seconds after the 400 ms watchdog — so the native side reads this declaration instead of reading silence as “declined”. false is the safe default: a bundle that never loaded declared nothing, and back must still be able to leave the app.
The web adapter implements it as a documented no-op — popstate is answered synchronously in the same task, so there is no bridge to race with. The Tauri adapter sends back_gesture_can_go_back on change, suppressing no-op re-sends but always sending the first value (even false) so a reload cannot leave a stale true behind. See Native Shell → Back-gesture arbitration.
The Two Adapters
| Adapter | Where | Backs |
|---|---|---|
web | adapters/src/web | A plain browser window; secrets are process memory. |
tauri | adapters/src/tauri | The native shell; keychain-backed secrets. |
Only adapters/src/tauri may import @tauri-apps/*, and inside it only bridge.ts touches them. tools/depgraph.mjs enforces this, so a React Native port is one directory rather than an audit.
invoke vs invokeStrict
bridge.ts (adapters/src/tauri/bridge.ts) exports two ways for TypeScript to talk to Rust — the same call, two rejection policies — because a lost round-trip means different things to a haptic tap and to a chat write.
| Function | On rejection | Right for |
|---|---|---|
invoke(command, args) | Swallows it and resolves to null | A haptic tap, a UI ping — dropping a lost round-trip silently is fine |
invokeStrict(command, args) | Re-throws it | A chat write — a lost round-trip is an I/O failure, not an absence |
The storage adapter is built on invokeStrict (createTauriStorage({ invoke: bridge.invokeStrict })), and it cannot use invoke:
nullisStoragePort.read‘s word for “no such key”. An adapter built oninvokewould present a failing disk as an empty chat list — the conversations look deleted rather than unreadable, and the next save writes over them.repository.tsalready distinguishesmissingfromunreadable, but only if something down here still knows the difference.- A reply that is not a string or null — a boolean, a number, an object — is likewise an I/O failure, not an absence.
invokeStrict‘s caller throws on a wrong-shaped reply (asStringOrNull/asStringArrayinstorage.ts), so a native host that renamed a command or changed its reply shape fails loudly. - Both, on purpose.
invokestays as it is;invokeStrictis added alongside it. Storage moves toinvokeStrict; the haptic tap does not need to.
How It Works
The seed at construction and the live handler read through the same function, so the clamp-at-0 and the pinch-zoom guard cannot drift between the first frame and every frame after it.
| Behaviour | Result | Why |
|---|---|---|
visualViewport missing | 0 | A desktop browser with no software keyboard reports nothing. |
visualViewport.scale > 1 | 0 | Pinch-zoom shrinks the viewport exactly as a keyboard does; a software keyboard never changes scale. |
| otherwise | Math.max(0, innerHeight − height − offsetTop) | The gap between the layout and visual viewports, clamped so either overshoot condition below cannot make it negative. |
Why the clamp
The clamp is not decorative — it guards two independent ways the raw formula can go negative, and a negative keyboard height pushes the composer off the bottom of the screen (documented in-source).
| Overshoot condition | What makes it happen |
|---|---|
visualViewport.height > innerHeight | iOS rubber-band scroll |
offsetTop > 0 (non-zero on its own) | A scrolled visual viewport — the page is zoomed, or the URL bar has collapsed |
Either condition alone can drive innerHeight − height − offsetTop negative, so Math.max(0, …) is what keeps the composer visible. Pinned by "an overscrolled viewport reports NO keyboard, never a negative one" and "a scrolled-down visual viewport reports NO keyboard, never a negative one".
The keyboardHeightPx snapshot is seeded at construction, so a component mounting during a warm resume, or with a hardware or floating keyboard already up, lays out correctly on its first frame.
Secure External Links
openExternal must reject any scheme the allowlist rejects. In a webview, openExternal("javascript:...") is script execution in the app’s own origin — and the URL routinely comes from a model or a tool result.
export const OPENABLE_SCHEMES = ["http", "https", "mailto", "tel", "sms", "geo", "maps"];Case-folding cuts both ways. RFC 3986 says URI schemes are case-insensitive, so a model that emits HTTPS://example.com, Mailto:a@b, or TEL:+15551234 is producing perfectly legal URIs — and the user tapping them must reach the OS handler. isOpenableExternally lowercases the scheme before the allowlist match, so an uppercased legal scheme opens and an uppercased dangerous scheme is still refused. The port fails closed, so dropping the case-fold would leave a dead link rather than a hole — but a dead link with no error is still the app appearing broken. Pinned by "a scheme the model typed in capitals is still openable" in core/src/ports/shell.test.ts, which asserts HTTPS://, Mailto:, TEL:+… open and JAVASCRIPT:, FILE: do not.
openExternal forwards the trimmed URL the allowlist validated, not the raw input. isOpenableExternally(url) trims before reading the scheme, so validating one form and forwarding another is the shape of a scheme-confusion bypass — doesNotReject proves only that a call did not throw, never that it did the right thing, and for a security boundary that gap is the whole question. The web adapter (view.open(url.trim(), …)), the Tauri bridge, and the fake shell all forward the trimmed form.
The Tauri bridge’s webOpen opens the URL in a fresh, severed context — and reports truthfully whether it did.
| Invariant | Why |
|---|---|
open(url, …) is actually called | Dropping the call and keeping return true reports success having opened nothing — a link that does nothing while the app believes it worked. |
target === "_blank" | "_self" replaces the running app with whatever the link points to. |
features contains noopener and noreferrer | Otherwise the opened page keeps a live window.opener back into the app, and the referrer leaks. |
openExternal returns false when there is no window to open with | Returning true unconditionally is the defect above; returning false unconditionally would make every real link look broken. The pair is what makes either mistake detectable. |
Pinned by "the tauri bridge opens links in a NEW context, with the opener severed" and "openExternal reports FAILURE when there is nothing to open with".
The native counterpart
On desktop and web-only builds, readKeyboardHeight(view) is the entire source of the keyboard height. On iOS and Android the safe-area insets, lifecycle, and back-press come from the Native Shell instead — Tauri events the webview subscribes to by string. On Android the keyboard height and insets additionally arrive through the window-insets bridge, because the web APIs read nothing there.
The sources are complementary, not alternatives. The web shell’s keyboardHeightPx seed still runs at construction so the first frame lays out correctly; a native source (the keyboard-height event on iOS, the insets bridge on Android) feeds every update after it.
The three-source keyboard model {#the-three-source-keyboard-model}
The Tauri shell has three possible sources for the keyboard height, and exactly one writer at a time. It seeds from visualViewport and subscribes to it live, then hands over to whichever native source arrives first.
| # | Source | Platform |
|---|---|---|
| 1 | readKeyboardHeight(view) from visualViewport | iOS + desktop |
| 2 | The native keyboard-height Tauri event (EVT_KEYBOARD) | Not natively emitted anywhere yet — reserved for a future iOS/desktop emitter |
| 3 | The Android NATIVE_INSETS_GLOBAL bridge, keyboard field | Android |
Precedence and handover. Any native source — either the keyboard-height event or the Android insets bridge — is authoritative and retires the visualViewport listener the moment its first payload arrives, so there is one live writer at a time and no race across an IME animation. Once nativeInsetsSeen is true, a subsequent empty safe-area-changed payload is dropped rather than re-read from env(). See Native Shell → The Android window-insets bridge.
createTauriShell takes a view?: Window param so the path runs in tests without a phone. It seeds keyboardHeightPx from readKeyboardHeight(view), the same reader the web shell uses, and subscribes to visualViewport‘s resize and scroll. On Android visualViewport reads 0 while enableEdgeToEdge() is on, so the seed is 0 there and the insets bridge is the real source.
import { createTauriShell } from "praisonai-mobile/adapters/tauri";
import { createFakeWindow } from "praisonai-mobile/adapters/web/fake-window";
const window = createFakeWindow();
const shell = createTauriShell({ view: window.window });
window.setKeyboardHeight(320);
// shell.keyboardHeightPx === 320On iOS this was a hard 0 whose only writer was the native keyboard-height event — still not emitted natively — so the visualViewport seed is what makes the composer track the slide there. On Android visualViewport reports nothing either, and because app.css pins #root to position: fixed; inset: 0 the layout viewport does not shrink, so the Android bridge’s keyboard field is the only source. Either way, one native source retires the viewport listener on its first payload.
The retry loop that lands the first Android push runs on the native side (see Native Shell → The retry-until-acknowledged loop):
The fake window now models visualViewport.removeEventListener, so a test using createFakeWindow() can exercise an adapter that unsubscribes — previously a listener could be added and never removed, and any adapter that unsubscribed threw. The paths are pinned by adapters/src/conformance/contracts.test.ts — “the TAURI shell reports the keyboard height, without any native event” and “a native keyboard height takes over from the viewport reading” — by adapters/src/tauri/native-insets.test.ts (the Android bridge, driven with a fake Window that has no visualViewport), and by tools/shell-seam.test.mjs (the Kotlin ↔ TypeScript global name). See Adapter Conformance → Keyboard height.
Executable Specification
Conformance tests in adapters/src/conformance/contracts.test.ts pin the snapshot, the guard, and the trimmed-forward invariant:
| Test | Asserts |
|---|---|
| keyboard already up at construction | seeds a non-zero height on the first frame |
| no keyboard | reports 0 |
| zoom at construction | a page opened pinch-zoomed is not mistaken for a keyboard |
| zoom after construction | the live path applies the same guard, not just the seed |
| a padded URL reaches the OS trimmed, or not at all | the OS receives "https://ok.example", never " https://ok.example " — and a padded javascript: / data: is still rejected |
| (Tauri-only) the tauri shell forwards the URL it validated, not the one it was given | the bridge is handed the trimmed URL |
| a consumed back gesture re-pushes the current URL | the app stays where it is, and the next back goes one screen up — not out of the app |
| an unconsumed back gesture does not touch history | the OS gesture is allowed to leave the app — pushing unconditionally would trap the user on the page |
The harness surfaces what each shell forwarded through forwarded(), so these cases read the exact string handed to the OS rather than only whether the call settled. The SecretsPort, StoragePort, and TimePort contracts sit alongside the shell contract in the same file and are pinned by a spawned fixture — see Adapter Conformance.
What a tap means
A tap lands on whatever is under the finger — a label or an icon inside a button, not the button. intentFrom climbs the ancestor chain and returns the innermost actionable intent.
import { intentFrom } from "praisonai-mobile/app/intents";
const intent = intentFrom(chain); // chain = tapped element then its ancestors| Rule | Behaviour |
|---|---|
| A disabled control terminates the walk | It is not skipped so the outer row’s intent can fire. A tap on a greyed-out approval button must not fire a second send from the enclosing row. |
| An enabled control resolves through its ancestors | The terminate-on-disabled rule does not disable the whole app — a tap on an icon inside an enabled button still resolves the button’s intent. |
delete-chat with an empty or missing chatId is refused | Returns null rather than defaulting to "" and deleting whichever chat, if any, is keyed "". |
Common Patterns
The live handler and the seed share one function, so a hide is never swallowed.
const publishKeyboard = () => {
const height = readKeyboardHeight(view);
keyboardHeightPx = height;
for (const cb of keyboardSubs) cb(height);
};
viewport.addEventListener("resize", publishKeyboard);
viewport.addEventListener("scroll", publishKeyboard);Layout reads the synchronous snapshot at mount, then subscribes for changes.
screen.style.setProperty("--keyboard-height", `${platform.shell.keyboardHeightPx}px`);
platform.shell.onKeyboardHeightChanged(applyGeometry);Best Practices
Seed the snapshot, do not start at 0
A property declared = 0 and only updated by an event reproduces the exact bug it was added to fix: one frame at the wrong height, then a jump. Seed from readKeyboardHeight(view) at construction.
Guard pinch-zoom in every shell you write
scale > 1 separates zoom from a keyboard. A shell that omits the guard reports a phantom keyboard the moment the user zooms — most visibly at construction, on a page opened already zoomed.
Read the height through one function
Keep the clamp and the guard in a single readKeyboardHeight. Duplicating the subtraction in the seed and the handler lets the two drift apart.
Store back handlers in an array
The most recently registered handler gets first refusal. A Set has no defined order and ships a modal that closes the wrong screen.
Never let insets become NaN
parseFloat("") is NaN, and calc(100vh - NaNpx) silently blanks the screen. Every inset path coerces unparseable input to 0.
Flush on background
iOS can kill a suspended app with no further callback, so anything unflushed when onLifecycleChanged reports background is lost.
Related
How the shell is injected at boot.
Capabilities & GapsThe keyboard snapshot as a closed gap.
Native ShellThe Tauri events that feed the shell on iOS and Android.
Storage & SecretsWhere chats and API keys live.
The Two SeamsHow the UI-shell seam is enforced.
CardThe other port every conformance suite pins.