ShellPort is the seam between framework-free UI logic and the native shell.
Quick Start
1
Read the insets synchronously
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.
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:
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.
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".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.
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.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 fromvisualViewport 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.
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 inadapters/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.
Common Patterns
The live handler and the seed share one function, so a hide is never swallowed.Best Practices
Seed the snapshot, do not start at 0
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
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
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
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
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
Flush on background
iOS can kill a suspended app with no further callback, so anything unflushed when
onLifecycleChanged reports background is lost.Related
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.

