1import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
2import {
3 makeMcpAppHost,
4 McpAppHostRefusal,
5 mcpAppStyleVariables,
6 mcpResourceBytes,
7 type McpAppCallToolResult,
8 type McpAppHost,
9 type McpAppHostContext,
10} from "@t3tools/client-runtime/mcp-apps";
11import { scopeThreadRef } from "@t3tools/client-runtime/environment";
12import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
13import { CommandId, MessageId } from "@t3tools/contracts";
14import {
15 mcpAppAllowAttribute,
16 mcpAppFileName,
17 mcpAppReferencesEqual,
18 type McpAppReference,
19} from "@t3tools/shared/mcpApp";
20import * as Predicate from "effect/Predicate";
21import Constants from "expo-constants";
22import { useNavigation } from "@react-navigation/native";
23import { useEffect, useMemo, useRef, useState } from "react";
24import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
25import { useSafeAreaInsets } from "react-native-safe-area-context";
26import { WebView, type WebViewMessageEvent } from "react-native-webview";
27
28import { AppText as Text } from "../../components/AppText";
29import { shareGeneratedAttachment } from "../../lib/attachmentDownload";
30import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme";
31import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
32import { uuidv4 } from "../../lib/uuid";
33import { useAssetUrlState } from "../../state/assets";
34import { useThreadShell } from "../../state/entities";
35import { mcpAppEnvironment } from "../../state/mcpApps";
36import { orchestrationEnvironment } from "../../state/orchestration";
37import { useEnvironmentQuery } from "../../state/query";
38import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
39import { useAtomCommand } from "../../state/use-atom-command";
40import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
41import { MCP_APP_INLINE_MAX_HEIGHT, mcpAppInlineHeight } from "./mcpAppSizing";
42
43const ROW_BOTTOM_MARGIN = 8;
44
45// The WebView loads a tiny outer page that hosts the app in a real
46// opaque-origin iframe, as web does, so the app's `window.parent` and the
47// `event.source` of host replies are a real window, which the MCP Apps SDK
48// requires. The outer page only relays: app → React Native, and host
49// replies (injected as `__t3McpAppReceive(...)`) → app.
50//
51// Android exposes `ReactNativeWebView` to every frame, so a frame nested in
52// the app could post to React Native directly. The outer page therefore wraps
53// what it relays with a secret only it holds, and React Native drops anything
54// else. The bridge belongs to the captured document: once the app frame loads
55// a second time (it navigated itself), the outer page stops relaying and says
56// so, rather than letting a page T3 never served pose as the app. This is not
57// a confidentiality boundary: a frame can always navigate itself, so the app
58// could carry anything it read out in a URL either way.
59function outerDocument(src: string, allow: string, secret: string) {
60 const attribute = (value: string) =>
61 value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
62 return `<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">
63<style>html,body{margin:0;height:100%;background:transparent}iframe{border:0;display:block;width:100%;height:100%}</style></head>
64<body><iframe id="app" sandbox="allow-scripts allow-forms" allow="${attribute(allow)}"></iframe>
65<script>(function(){var frame=document.getElementById("app"),secret=${JSON.stringify(secret)},loads=0,live=true;
66var send=function(m){window.ReactNativeWebView.postMessage(JSON.stringify({secret:secret,message:m}));};
67frame.addEventListener("load",function(){loads+=1;if(loads>1&&live){live=false;send({t3:"navigated"});}});
68window.addEventListener("message",function(e){if(live&&e.source===frame.contentWindow)send(e.data);});
69window.__t3McpAppReceive=function(m){live&&frame.contentWindow&&frame.contentWindow.postMessage(m,"*");};
70frame.src=${JSON.stringify(src).replace(/</g, "\\u003c")};})();</script></body></html>`;
71}
72
73const commandFailure = (result: {
74 readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"];
75}) => {
76 const error = squashAtomCommandFailure(result);
77 return new McpAppHostRefusal(
78 error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.",
79 );
80};
81
82const confirm = (title: string, message: string, action: string) =>
83 new Promise<boolean>((resolve) => {
84 Alert.alert(
85 title,
86 message,
87 [
88 { text: "Cancel", style: "cancel", onPress: () => resolve(false) },
89 { text: action, onPress: () => resolve(true) },
90 ],
91 // Android dismisses an open alert when the next one shows; a dismissed
92 // request is declined rather than left holding an in-flight slot.
93 { cancelable: true, onDismiss: () => resolve(false) },
94 );
95 });
96
97/** Largest file an app may hand the user through `ui/download-file`. */
98const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
99
100/**
101 * A captured MCP App, hosted in a WebView over the MCP Apps bridge: inline as
102 * a content-sized row of the thread feed, or full screen in its own modal screen. A
103 * WebView cannot move between the two without reloading, so each is its own
104 * view of the app: the inline one is torn down before full screen opens, and
105 * comes back when it closes.
106 */
107export function ThreadMcpApp(props: {
108 readonly environmentId: EnvironmentId;
109 /** The thread that produced the app, which its requests run against. */
110 readonly threadId: ThreadId;
111 /** The thread on screen, which approved app messages are sent to. */
112 readonly conversationThreadId: ThreadId;
113 readonly itemId: TurnItemId;
114 readonly revision: string;
115 readonly app: McpAppReference;
116 readonly width: number;
117 /** Full screen fills its screen; inline follows the app's reported height. */
118 readonly displayMode?: "inline" | "fullscreen";
119 /** Full screen only: leaves it, back to the inline row. */
120 readonly onExitFullscreen?: () => void;
121 /** Full screen only: the screen's height, which the app is told it fills. */
122 readonly height?: number;
123}) {
124 // One reference per app: a new object with the same content (a refetch, a
125 // rerender) must not rebuild the host of a document that is already live.
126 const [height, setHeight] = useState(MCP_APP_INLINE_MAX_HEIGHT);
127 const [app, setApp] = useState(props.app);
128 if (!mcpAppReferencesEqual(app, props.app)) {
129 setApp(props.app);
130 setHeight(MCP_APP_INLINE_MAX_HEIGHT);
131 }
132 const fullscreen = props.displayMode === "fullscreen";
133 const navigation = useNavigation();
134 const insets = useSafeAreaInsets();
135 // The app asked to be closed; the row falls back to a note that brings it back.
136 const [closed, setClosed] = useState(false);
137 // Full screen is a separate view of the app, so the inline one steps aside
138 // (and is torn down) while it is open, and comes back when the thread is
139 // shown again.
140 const [presentedFullscreen, setPresentedFullscreen] = useState(false);
141 // Counts the documents this row has shown on purpose (back from full screen,
142 // or reopened after closing); each gets its own view and host.
143 const [documentKey, setDocumentKeyState] = useState(0);
144
145 const { themeId, themeAppearance, themeVariables, systemColorsActive } =
146 useAppearancePreferences();
147 const theme = useMemo(
148 () =>
149 mobileHtmlRenderTheme({
150 themeId,
151 appearance: themeAppearance,
152 variables: themeVariables,
153 systemColors: systemColorsActive,
154 platform: Platform.OS,
155 }),
156 [themeId, themeAppearance, themeVariables, systemColorsActive],
157 );
158 const resource = useMemo(
159 () => ({
160 _tag: "attachment" as const,
161 attachmentId: app.attachmentId,
162 fileName: mcpAppFileName(app),
163 mimeType: "text/html",
164 disposition: "inline" as const,
165 renderIntent: "mcp-app" as const,
166 }),
167 [app],
168 );
169 const asset = useAssetUrlState(props.environmentId, resource);
170 // The view keeps its first URL: a re-minted one would reload the app.
171 const [uri, setUri] = useState<string | null>(null);
172 if (uri === null && asset._tag === "Success") setUri(asset.url);
173 const [loaded, setLoaded] = useState(false);
174 const [navigatedAway, setNavigatedAway] = useState(false);
175 // Bumped when the OS reclaims the web process: a fresh view, document and
176 // host, so the reloaded app is initialized and replayed again. Once only, so
177 // an app that keeps crashing its process is not reloaded forever.
178 const [generation, setGeneration] = useState(0);
179 const [crashed, setCrashed] = useState(false);
180 const restart = () => {
181 if (generation > 0) {
182 setCrashed(true);
183 return;
184 }
185 setLoaded(false);
186 setHeight(MCP_APP_INLINE_MAX_HEIGHT);
187 // The first URL's token may have expired by now; the asset query keeps
188 // a refreshed one, which this new view can load.
189 if (asset._tag === "Success") setUri(asset.url);
190 setGeneration(1);
191 };
192 // Minted per view, so only this view's outer page can speak for its app.
193 const [secret] = useState(uuidv4);
194 // A new document starts loading, is not yet one that navigated away, and
195 // loads the asset query's current URL: the first one's token may be gone.
196 const setDocumentKey = (next: (value: number) => number) => {
197 setLoaded(false);
198 setHeight(MCP_APP_INLINE_MAX_HEIGHT);
199 setNavigatedAway(false);
200 if (asset._tag === "Success") setUri(asset.url);
201 setDocumentKeyState(next);
202 };
203 // Back from full screen: the thread shows a fresh inline view of the app.
204 // The listener reads the latest helper, whose URL comes from this render.
205 const openNewDocument = useRef(setDocumentKey);
206 useEffect(() => {
207 openNewDocument.current = setDocumentKey;
208 });
209 useEffect(() => {
210 if (!presentedFullscreen) return;
211 return navigation.addListener("focus", () => {
212 openNewDocument.current((value) => value + 1);
213 setPresentedFullscreen(false);
214 });
215 }, [navigation, presentedFullscreen]);
216
217 // The feed omits tool input and output; the app needs both.
218 const detail = useEnvironmentQuery(
219 orchestrationEnvironment.turnItem({
220 environmentId: props.environmentId,
221 input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision },
222 }),
223 );
224 const storedItem = detail.data?.item;
225 const toolCall = useMemo(() => {
226 if (storedItem?.type !== "dynamic_tool") return undefined;
227 const output = storedItem.output as { readonly result?: unknown } | undefined;
228 return {
229 arguments: storedItem.input,
230 result: output?.result as McpAppCallToolResult | undefined,
231 };
232 }, [storedItem]);
233
234 const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false });
235 const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false });
236 const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false });
237 const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, {
238 reportFailure: false,
239 });
240 // The tool's definition, which the app receives as `toolInfo` at initialize.
241 const [toolDefinition, setToolDefinition] = useState<unknown>(undefined);
242 // Read by the host on every message, so it always sees current values
243 // without being rebuilt (which would drop the app's session).
244 // The approval or question the agent waits on renders on this thread, and a
245 // full-screen app would cover it.
246 const conversation = useThreadShell(
247 scopeThreadRef(props.environmentId, props.conversationThreadId),
248 );
249 const awaitingUser =
250 conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true;
251 const live = {
252 awaitingUser,
253 theme,
254 props,
255 callTool,
256 toolInfo,
257 readResource,
258 updateModelContext,
259 insets,
260 toolDefinition,
261 navigation,
262 };
263 const latest = useRef(live);
264 useEffect(() => {
265 latest.current = live;
266 });
267 useEffect(() => {
268 let cancelled = false;
269 void latest.current
270 .toolInfo({
271 environmentId: props.environmentId,
272 input: { threadId: props.threadId, itemId: props.itemId, name: app.tool },
273 })
274 .then((info) => {
275 if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) {
276 setToolDefinition(info.value.tool);
277 }
278 });
279 return () => {
280 cancelled = true;
281 };
282 }, [props.environmentId, props.threadId, props.itemId, app.tool]);
283 const webView = useRef<WebView<object>>(null);
284 const hostRef = useRef<McpAppHost | null>(null);
285
286 // One host per loaded document.
287 useEffect(() => {
288 if (uri === null) return;
289 const scope = () => {
290 const { environmentId, threadId, itemId } = latest.current.props;
291 return { environmentId, input: { threadId, itemId } };
292 };
293 const hostContext = (): McpAppHostContext => {
294 const current = latest.current;
295 const isFullscreen = current.props.displayMode === "fullscreen";
296 return {
297 theme: current.theme.appearance,
298 styles: { variables: mcpAppStyleVariables(current.theme.variables) },
299 displayMode: isFullscreen ? "fullscreen" : "inline",
300 availableDisplayModes: ["inline", "fullscreen"],
301 containerDimensions: isFullscreen
302 ? {
303 width: current.props.width,
304 height: current.props.height ?? MCP_APP_INLINE_MAX_HEIGHT,
305 }
306 : { width: current.props.width, maxHeight: MCP_APP_INLINE_MAX_HEIGHT },
307 platform: "mobile",
308 locale: Intl.DateTimeFormat().resolvedOptions().locale,
309 timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
310 userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`,
311 deviceCapabilities: { touch: true, hover: false },
312 // The full-screen modal keeps the top and bottom insets for its
313 // header and the home indicator; only the sides reach the app.
314 safeAreaInsets: isFullscreen
315 ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left }
316 : { top: 0, right: 0, bottom: 0, left: 0 },
317 ...(current.toolDefinition === undefined
318 ? {}
319 : { toolInfo: { tool: current.toolDefinition } }),
320 };
321 };
322 const next = makeMcpAppHost({
323 app,
324 hostVersion: Constants.expoConfig?.version ?? "0.0.0",
325 post: (message) =>
326 webView.current?.injectJavaScript(
327 `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`,
328 ),
329 hostContext,
330 callTool: async ({ name, arguments: args }) => {
331 const { environmentId, input } = scope();
332 const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } });
333 if (info._tag !== "Success") throw commandFailure(info);
334 if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool.");
335 if (
336 !info.value.readOnly &&
337 !(await confirm(
338 `Allow ${app.server} to run ${info.value.title ?? name}?`,
339 JSON.stringify(args, null, 2),
340 "Allow",
341 ))
342 ) {
343 throw new McpAppHostRefusal("Declined by the user.");
344 }
345 const result = await latest.current.callTool({
346 environmentId,
347 input: { ...input, name, arguments: args },
348 });
349 if (result._tag !== "Success") throw commandFailure(result);
350 return result.value;
351 },
352 readResource: async ({ uri: resourceUri }) => {
353 const { environmentId, input } = scope();
354 const result = await latest.current.readResource({
355 environmentId,
356 input: { ...input, uri: resourceUri },
357 });
358 if (result._tag !== "Success") throw commandFailure(result);
359 return result.value;
360 },
361 openLink: async (url) => {
362 // A WebView cannot tell whether the reader just tapped the app, so it
363 // asks, rather than letting an app leave T3 on a timer.
364 if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) {
365 throw new McpAppHostRefusal("Declined by the user.");
366 }
367 if (!(await tryOpenExternalUrl(url, "mcp-app"))) {
368 throw new McpAppHostRefusal("The link could not be opened.");
369 }
370 },
371 sendMessage: async (text) => {
372 if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) {
373 throw new McpAppHostRefusal("Declined by the user.");
374 }
375 // Through the outbox like a typed message, so it survives a dropped
376 // connection; the thread's own settings fill in when it sends.
377 await enqueueThreadOutboxMessage({
378 environmentId: latest.current.props.environmentId,
379 threadId: latest.current.props.conversationThreadId,
380 messageId: MessageId.make(uuidv4()),
381 commandId: CommandId.make(uuidv4()),
382 text,
383 attachments: [],
384 dispatchMode: "queue",
385 createdAt: new Date().toISOString(),
386 });
387 },
388 updateModelContext: async (context) => {
389 const { environmentId, input } = scope();
390 const result = await latest.current.updateModelContext({
391 environmentId,
392 input: {
393 ...input,
394 ...context,
395 conversationThreadId: latest.current.props.conversationThreadId,
396 },
397 });
398 if (result._tag !== "Success") throw commandFailure(result);
399 },
400 requestDisplayMode: async (mode) => {
401 const current = latest.current.props;
402 // Full screen would cover the approval or question the agent waits
403 // on, or a screen the user moved to.
404 if (
405 mode === "fullscreen" &&
406 (latest.current.awaitingUser || !latest.current.navigation.isFocused())
407 ) {
408 return current.displayMode ?? "inline";
409 }
410 if (mode === "fullscreen" && current.displayMode !== "fullscreen") {
411 // The inline view is torn down by the switch (this row unmounts
412 // while the modal covers it); the modal opens a fresh view.
413 // The inline view steps aside for the modal; it gets its teardown first.
414 await hostRef.current?.teardown();
415 // The wait may have let an approval arrive, or the user moved to
416 // another screen; either way the view reloads inline.
417 if (latest.current.awaitingUser || !latest.current.navigation.isFocused()) {
418 openNewDocument.current((value) => value + 1);
419 return "inline";
420 }
421 setPresentedFullscreen(true);
422 latest.current.navigation.navigate("ThreadMcpApp", {
423 environmentId: String(current.environmentId),
424 threadId: String(current.threadId),
425 conversationThreadId: String(current.conversationThreadId),
426 itemId: String(current.itemId),
427 revision: current.revision,
428 });
429 return "fullscreen";
430 }
431 if (mode === "inline" && current.displayMode === "fullscreen") {
432 await hostRef.current?.teardown();
433 current.onExitFullscreen?.();
434 return "inline";
435 }
436 return current.displayMode ?? "inline";
437 },
438 downloadFile: async (files) => {
439 const names = files.map((file) => file.name).join(", ");
440 if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) {
441 throw new McpAppHostRefusal("Declined by the user.");
442 }
443 for (const file of files) {
444 // A linked file is read from the app's own server, like its other reads.
445 let bytes: Uint8Array | undefined;
446 let mimeType = file.mimeType ?? "application/octet-stream";
447 if (file._tag === "embedded") {
448 bytes = file.bytes;
449 } else {
450 const { environmentId, input } = scope();
451 const read = await latest.current.readResource({
452 environmentId,
453 input: { ...input, uri: file.uri },
454 });
455 if (read._tag !== "Success") throw commandFailure(read);
456 const content = read.value.contents[0];
457 bytes = mcpResourceBytes(content);
458 const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType;
459 if (typeof declared === "string") mimeType = declared;
460 }
461 if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`);
462 if (bytes.byteLength > MAX_DOWNLOAD_BYTES) {
463 throw new McpAppHostRefusal(`${file.name} is too large to save.`);
464 }
465 const shared = await shareGeneratedAttachment({
466 bytes,
467 attachment: { name: file.name, mimeType },
468 signal: new AbortController().signal,
469 });
470 if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device.");
471 }
472 },
473 onRequestTeardown: () => {
474 const current = latest.current.props;
475 if (current.displayMode === "fullscreen") {
476 void hostRef.current?.teardown().then(() => current.onExitFullscreen?.());
477 } else {
478 void hostRef.current?.teardown().then(() => setClosed(true));
479 }
480 },
481 onSizeChanged: (size) => {
482 if (latest.current.props.displayMode === "fullscreen") return;
483 const nextHeight = mcpAppInlineHeight(size.height);
484 if (nextHeight !== undefined) setHeight(nextHeight);
485 },
486 });
487 hostRef.current = next;
488 return () => {
489 // An unmount cannot wait: the request goes out before the view does.
490 void next.teardown();
491 hostRef.current = null;
492 };
493 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A restarted view needs a new host.
494 }, [uri, app, generation, documentKey]);
495
496 // The host reads the context through `latest`; these only say when to resend.
497 useEffect(() => {
498 hostRef.current?.updateHostContext();
499 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend.
500 }, [theme, props.width, props.height, insets, toolDefinition]);
501 // A new document gets a new host, which needs the call again.
502 useEffect(() => {
503 if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall);
504 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call.
505 }, [toolCall, uri, generation, documentKey]);
506
507 const source = useMemo(
508 () =>
509 uri === null
510 ? null
511 : { html: outerDocument(uri, mcpAppAllowAttribute(app.permissions), secret) },
512 [uri, app.permissions, secret],
513 );
514
515 if (presentedFullscreen) {
516 return (
517 <View
518 style={{ height, marginBottom: ROW_BOTTOM_MARGIN }}
519 className="items-center justify-center rounded-lg border border-border"
520 >
521 <Text className="text-sm text-foreground-muted">
522 The {app.server} app is open full screen
523 </Text>
524 </View>
525 );
526 }
527
528 if (closed) {
529 return (
530 <View
531 style={{ height, marginBottom: ROW_BOTTOM_MARGIN }}
532 className="items-center justify-center gap-2 rounded-lg border border-border"
533 >
534 <Text className="text-sm text-foreground-muted">The {app.server} app was closed</Text>
535 <Pressable
536 accessibilityRole="button"
537 onPress={() => {
538 setDocumentKey((value) => value + 1);
539 setClosed(false);
540 }}
541 >
542 <Text className="text-sm text-foreground">Show app</Text>
543 </Pressable>
544 </View>
545 );
546 }
547
548 return (
549 <View style={fullscreen ? { flex: 1 } : { height, marginBottom: ROW_BOTTOM_MARGIN }}>
550 {navigatedAway ? (
551 <View className="flex-1 items-center justify-center">
552 <Text className="text-sm text-foreground-muted">
553 The {app.server} app left its page and was stopped
554 </Text>
555 </View>
556 ) : uri !== null && !crashed ? (
557 <WebView<object>
558 key={`${documentKey}:${generation}`}
559 ref={webView}
560 onContentProcessDidTerminate={restart}
561 onRenderProcessGone={restart}
562 source={source!}
563 accessibilityLabel={`${app.server} app`}
564 style={{ flex: 1, backgroundColor: "transparent" }}
565 nestedScrollEnabled
566 originWhitelist={["*"]}
567 // Only the outer page loads at the top; the app opens links through
568 // the bridge. Android reports every navigation as top-frame, frames
569 // the app nests included, so it has no gate to apply: there the
570 // outer page never navigates, and the app frame's sandbox already
571 // forbids top navigation and popups.
572 onShouldStartLoadWithRequest={(request) =>
573 Platform.OS === "android" ||
574 request.isTopFrame === false ||
575 request.url === "about:blank"
576 }
577 setSupportMultipleWindows={false}
578 onLoadEnd={() => setLoaded(true)}
579 // The outer page is inline HTML, so these report a broken view, not
580 // an app page; the row shows the failure instead of a blank box.
581 onError={() => setCrashed(true)}
582 onHttpError={() => setCrashed(true)}
583 onMessage={(event: WebViewMessageEvent) => {
584 let envelope: unknown;
585 try {
586 envelope = JSON.parse(event.nativeEvent.data);
587 } catch {
588 return; // Not JSON: not a bridge message.
589 }
590 if (!Predicate.isObject(envelope) || envelope.secret !== secret) return;
591 const message = envelope.message;
592 if (Predicate.isObject(message) && message.t3 === "navigated") {
593 hostRef.current?.dispose();
594 setNavigatedAway(true);
595 return;
596 }
597 hostRef.current?.receive(message);
598 }}
599 />
600 ) : asset._tag === "Failure" || crashed ? (
601 <View className="flex-1 items-center justify-center">
602 <Text className="text-sm text-foreground-muted">Unable to load the {app.server} app</Text>
603 </View>
604 ) : null}
605 {uri !== null && !loaded && !crashed && !navigatedAway ? (
606 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
607 <ActivityIndicator />
608 </View>
609 ) : null}
610 </View>
611 );
612}