| 1 | import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; |
| 2 | import { |
| 3 | makeMcpAppHost, |
| 4 | McpAppHostRefusal, |
| 5 | mcpAppStyleVariables, |
| 6 | mcpResourceBytes, |
| 7 | type McpAppCallToolResult, |
| 8 | type McpAppHost, |
| 9 | type McpAppHostContext, |
| 10 | } from "@t3tools/client-runtime/mcp-apps"; |
| 11 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 12 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 13 | import { CommandId, MessageId } from "@t3tools/contracts"; |
| 14 | import { |
| 15 | mcpAppAllowAttribute, |
| 16 | mcpAppFileName, |
| 17 | mcpAppReferencesEqual, |
| 18 | type McpAppReference, |
| 19 | } from "@t3tools/shared/mcpApp"; |
| 20 | import * as Predicate from "effect/Predicate"; |
| 21 | import Constants from "expo-constants"; |
| 22 | import { useNavigation } from "@react-navigation/native"; |
| 23 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 24 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; |
| 25 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 26 | import { WebView, type WebViewMessageEvent } from "react-native-webview"; |
| 27 | |
| 28 | import { AppText as Text } from "../../components/AppText"; |
| 29 | import { shareGeneratedAttachment } from "../../lib/attachmentDownload"; |
| 30 | import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; |
| 31 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 32 | import { uuidv4 } from "../../lib/uuid"; |
| 33 | import { useAssetUrlState } from "../../state/assets"; |
| 34 | import { useThreadShell } from "../../state/entities"; |
| 35 | import { mcpAppEnvironment } from "../../state/mcpApps"; |
| 36 | import { orchestrationEnvironment } from "../../state/orchestration"; |
| 37 | import { useEnvironmentQuery } from "../../state/query"; |
| 38 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; |
| 39 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 40 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 41 | import { MCP_APP_INLINE_MAX_HEIGHT, mcpAppInlineHeight } from "./mcpAppSizing"; |
| 42 | |
| 43 | const 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. |
| 59 | function 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; |
| 66 | var send=function(m){window.ReactNativeWebView.postMessage(JSON.stringify({secret:secret,message:m}));}; |
| 67 | frame.addEventListener("load",function(){loads+=1;if(loads>1&&live){live=false;send({t3:"navigated"});}}); |
| 68 | window.addEventListener("message",function(e){if(live&&e.source===frame.contentWindow)send(e.data);}); |
| 69 | window.__t3McpAppReceive=function(m){live&&frame.contentWindow&&frame.contentWindow.postMessage(m,"*");}; |
| 70 | frame.src=${JSON.stringify(src).replace(/</g, "\\u003c")};})();</script></body></html>`; |
| 71 | } |
| 72 | |
| 73 | const 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 | |
| 82 | const 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`. */ |
| 98 | const 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 | */ |
| 107 | export 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 | } |