Skip to main content
ShellPort is the seam between framework-free UI logic and the native shell.

Quick Start

1

Read the insets synchronously

First paint places the composer above the home indicator, so insets are a synchronous snapshot, never a promise.
2

React to the keyboard

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

createWebShell seeds the snapshot at construction

keyboardHeightPx is seeded, not declared = 0.

What The Shell Provides

These are the capabilities a phone needs but a desktop window does not. 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.
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

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. 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.
The negative + positive pair: invokeStrict rejects where invoke resolves to null proves strictness has teeth, and a native reply of the wrong shape is an I/O failure, not an empty chat list proves the wrong-shape guard fires. Both are exercised against the same StoragePort conformance contract as the web adapter — see Adapter Conformance.

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.

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). 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 pair keeps the clamp from hiding a real keyboard. A clamp that returned 0 unconditionally would satisfy every overshoot case and remove the keyboard from the layout entirely. A real keyboard (small visualViewport.height, scale === 1) is still measured as a positive height and the composer must rise for it. Pinned by "a real keyboard is still measured -- the pair" and "pinch zoom is not a keyboard, and no viewport is not a keyboard".
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.
Pinch-zoom shrinks the visual viewport just like a keyboard does. readKeyboardHeight guards this with viewport.scale > 1. If you re-implement the shell for a different platform, replicate this guard, or a zoomed webview will push the composer up by the wrong amount.

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.
The allowlist lives in the port, not in one adapter, so every shell is held to it by the contract suite. Never add a blocklist instead — the set of dangerous schemes is open-ended.
Normalise the scheme before matching. openExternal("JAVASCRIPT:alert(1)") is the classic uppercase bypass — a case-sensitive allowlist waves it through because "JAVASCRIPT" is not literally in the list. The “an uppercase JAVASCRIPT: URL is refused” case is now pinned against hollowing by the shell_scheme_case_sensitive break mode — see Adapter Conformance.
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.
On device, openExternal now actually reaches the OS on iOS, thanks to tauri-plugin-opener. Previously the invoke rejected, the bridge swallowed it, and on iOS (no window.open) it returned true having done nothing. The capability grants opener:allow-open-url and opener:allow-default-urls — without the scope every http(s)/mailto/tel URL is still refused. No API change on the TypeScript side.
The Tauri bridge’s webOpen opens the URL in a fresh, severed context — and reports truthfully whether it did. 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 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. 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.
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): 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: 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.
Honouring disabled here is what stops a double tap sending two decisions. Skipping a disabled control to reach the row behind it re-introduces the exact bug the disabled state exists to prevent.

Common Patterns

The live handler and the seed share one function, so a hide is never swallowed.
Layout reads the synchronous snapshot at mount, then subscribes for changes.

Best Practices

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.
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.
Keep the clamp and the guard in a single readKeyboardHeight. Duplicating the subtraction in the seed and the handler lets the two drift apart.
The most recently registered handler gets first refusal. A Set has no defined order and ships a modal that closes the wrong screen.
parseFloat("") is NaN, and calc(100vh - NaNpx) silently blanks the screen. Every inset path coerces unparseable input to 0.
iOS can kill a suspended app with no further callback, so anything unflushed when onLifecycleChanged reports background is lost.

Mobile Architecture

How the shell is injected at boot.

Capabilities & Gaps

The keyboard snapshot as a closed gap.

Native Shell

The Tauri events that feed the shell on iOS and Android.

Storage & Secrets

Where chats and API keys live.

The Two Seams

How the UI-shell seam is enforced.
The other port every conformance suite pins.