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.

ts
import { createTauriShell } from "praisonai-mobile/adapters/tauri";

const shell = createTauriShell();
shell.haptic("selection");
await shell.share({ text: "My agent's answer", title: "PraisonAI" });
Diagram

🪟 ui

🔌 ShellPort

🌐 web adapter

📱 tauri adapter

Diagram

readKeyboardHeight(view)

yes

no

zoom

keyboard

🪟 visualViewport

missing?

return 0

scale > 1?

innerHeight − height − offsetTop

Quick Start

  1. 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.

  2. React to the keyboard
    ts
    const off = shell.onKeyboardHeightChanged((px) => layout(px));

    keyboardHeightPx is also a synchronous snapshot, so a warm resume with the keyboard already up lays out correctly on mount.

  3. 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);
    }
  4. createWebShell seeds the snapshot at construction

    keyboardHeightPx is 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.

CapabilityMemberNote
Safe areainsets, onInsetsChangedSynchronous snapshot + change events.
KeyboardkeyboardHeightPx, onKeyboardHeightChanged0 when hidden; fires through the transition.
LifecycleonLifecycleChangedBackground must stop the run loop and flush.
Back gestureonBackGesture, setCanGoBackHandlers 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.
Hapticshaptic(kind)selection, impact, success, warning, error.
Sharingshare(payload)Hand text/URL to the OS share sheet.
Secure linksopenExternal(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.

ts
setCanGoBack(canGoBack: boolean): void

Called 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

AdapterWhereBacks
webadapters/src/webA plain browser window; secrets are process memory.
tauriadapters/src/tauriThe 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.

FunctionOn rejectionRight for
invoke(command, args)Swallows it and resolves to nullA haptic tap, a UI ping — dropping a lost round-trip silently is fine
invokeStrict(command, args)Re-throws itA 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:

  • null is StoragePort.read‘s word for “no such key”. An adapter built on invoke would present a failing disk as an empty chat list — the conversations look deleted rather than unreadable, and the next save writes over them. repository.ts already distinguishes missing from unreadable, 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 / asStringArray in storage.ts), so a native host that renamed a command or changed its reply shape fails loudly.
  • Both, on purpose. invoke stays as it is; invokeStrict is added alongside it. Storage moves to invokeStrict; 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.

BehaviourResultWhy
visualViewport missing0A desktop browser with no software keyboard reports nothing.
visualViewport.scale > 10Pinch-zoom shrinks the viewport exactly as a keyboard does; a software keyboard never changes scale.
otherwiseMath.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 conditionWhat makes it happen
visualViewport.height > innerHeightiOS 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.

ts
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.

InvariantWhy
open(url, …) is actually calledDropping 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 noreferrerOtherwise 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 withReturning 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.

#SourcePlatform
1readKeyboardHeight(view) from visualViewportiOS + desktop
2The native keyboard-height Tauri event (EVT_KEYBOARD)Not natively emitted anywhere yet — reserved for a future iOS/desktop emitter
3The Android NATIVE_INSETS_GLOBAL bridge, keyboard fieldAndroid

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.

ts
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 === 320
Diagram
subscribersvisualViewportTauriShell__praisonaiNativeInsetsMainActivity (Android)subscribersvisualViewportTauriShell__praisonaiNativeInsetsMainActivity (Android)viewport is the only writernative is authoritative nowresize / scroll → readKeyboardHeightWindowInsets dispatch + every IME frame{ top, right, bottom, left, keyboard }unsubscribepublishInsets + publishKeyboard

On 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):

Diagram
WebView documentMainActivityWebView documentMainActivitywait PUSH_RETRY_MS (120 ms)loop stops (≤ MAX_PUSH_ATTEMPTS = 125, ~15 s)evaluateJavascript push (page not booted)“false” — no global yet, a legal no-opretry push“true” — global ran, insets applied

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:

TestAsserts
keyboard already up at constructionseeds a non-zero height on the first frame
no keyboardreports 0
zoom at constructiona page opened pinch-zoomed is not mistaken for a keyboard
zoom after constructionthe live path applies the same guard, not just the seed
a padded URL reaches the OS trimmed, or not at allthe 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 giventhe bridge is handed the trimmed URL
a consumed back gesture re-pushes the current URLthe 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 historythe 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.

ts
import { intentFrom } from "praisonai-mobile/app/intents";

const intent = intentFrom(chain); // chain = tapped element then its ancestors
RuleBehaviour
A disabled control terminates the walkIt 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 ancestorsThe 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 refusedReturns 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.

ts
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.

ts
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