@ggui-ai/mcp-apps-react-native 0.0.1-placeholder → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * UiFeedback — a small, dismissable rating affordance for the render
3
+ * shell chrome ("did this generated UI work for you?").
4
+ *
5
+ * Platform delta (vs `ggui-react/src/components/UiFeedback.tsx`):
6
+ * - DOM elements (`div` / `button` / `form` / `input`) become RN
7
+ * primitives (`View` / `Pressable` / `Text` / `TextInput`).
8
+ * - Inline styles with `--ggui-*` CSS-variable hooks become
9
+ * `StyleSheet.create` constants carrying the same neutral palette
10
+ * the web copy uses as its variable fallbacks (RN has no CSS
11
+ * variable layer).
12
+ * - `data-ggui-ui-feedback*` styling/test hooks become `testID`s
13
+ * (`ggui-ui-feedback`, `ggui-ui-feedback-verdict-<verdict>`, …).
14
+ * - The comment `<form>` submit becomes a Send `Pressable` plus the
15
+ * `TextInput`'s `onSubmitEditing` (keyboard return key).
16
+ * - `role` / `aria-label` become `accessibilityLabel` /
17
+ * `accessibilityRole` props.
18
+ * The exported surface is identical to the web copy — pinned by the
19
+ * twin-parity gate (`DOCUMENTED_DELTA_TWINS`).
20
+ *
21
+ * Three verdicts — Love / Dislike / Other (free-text comment) — plus a
22
+ * dismiss control. Entirely host-driven: the component renders NOTHING
23
+ * unless the host passes `onUiFeedback`, so deployments that don't
24
+ * collect feedback never show a dead affordance. Where the payload
25
+ * goes is the host's choice — a logger, an analytics client, a support
26
+ * inbox; this component only builds the typed {@link UiFeedbackPayload}
27
+ * and hands it over.
28
+ *
29
+ * Zero wire surface: feedback never crosses the agent ↔ UI contract.
30
+ * It is host-app chrome (Data vs Behavior — the agent cannot observe
31
+ * it from the wire, so it is not protocol data), which is why the
32
+ * payload leaves through a host callback rather than a contract spec.
33
+ *
34
+ * Two mount surfaces exist for this affordance (ggui#244):
35
+ *
36
+ * 1. THIS component — chrome in the host's own tree, next to a
37
+ * render surface the host controls; the payload leaves through
38
+ * `onUiFeedback`.
39
+ * 2. The in-iframe twin the `@ggui-ai/iframe-runtime` boot path
40
+ * mounts inside a served render iframe whenever a parent window
41
+ * exists (`window.parent !== window` — top-level tabs get
42
+ * neither surface); its payload leaves as a `ui-feedback`
43
+ * observability event on the `ggui:observe` postMessage seam,
44
+ * surfaced to hosts via `<McpAppIframe onObserve>`.
45
+ *
46
+ * Hosts wire exactly ONE surface — either pass `onUiFeedback` here
47
+ * and ignore the `ui-feedback` observe arm, or handle the observe arm
48
+ * and omit `onUiFeedback`. Wiring both shows the user two affordances
49
+ * for one render. The render-nothing-without-a-sink default makes the
50
+ * choice safe: omitting `onUiFeedback` fully disables this surface.
51
+ */
52
+ import { useCallback, useState } from 'react';
53
+ import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
54
+
55
+ /** The three feedback verdicts the affordance can emit. */
56
+ export type UiFeedbackVerdict = 'love' | 'dislike' | 'other';
57
+
58
+ /**
59
+ * Payload handed to {@link UiFeedbackProps.onUiFeedback}. Context
60
+ * fields are present exactly when the host supplied them as props;
61
+ * `comment` is present only for `verdict: 'other'` with a non-empty
62
+ * trimmed comment.
63
+ */
64
+ export interface UiFeedbackPayload {
65
+ verdict: UiFeedbackVerdict;
66
+ comment?: string;
67
+ /** GguiSession id of the render the feedback is about. */
68
+ sessionId?: string;
69
+ /** Tool that produced the render (e.g. `ggui_render`). */
70
+ toolName?: string;
71
+ }
72
+
73
+ export interface UiFeedbackProps {
74
+ /**
75
+ * Feedback sink. Absent = the affordance renders nothing at all —
76
+ * zero-config hosts never show a dead control.
77
+ */
78
+ onUiFeedback?: (feedback: UiFeedbackPayload) => void;
79
+ /** GguiSession id to stamp onto every emitted payload. */
80
+ sessionId?: string;
81
+ /** Producing tool name to stamp onto every emitted payload. */
82
+ toolName?: string;
83
+ }
84
+
85
+ type Phase = 'idle' | 'comment' | 'sent' | 'dismissed';
86
+
87
+ export function UiFeedback({ onUiFeedback, sessionId, toolName }: UiFeedbackProps) {
88
+ const [phase, setPhase] = useState<Phase>('idle');
89
+ const [comment, setComment] = useState('');
90
+
91
+ const emit = useCallback(
92
+ (verdict: UiFeedbackVerdict, commentText?: string) => {
93
+ if (!onUiFeedback) return;
94
+ const trimmed = commentText?.trim();
95
+ onUiFeedback({
96
+ verdict,
97
+ ...(trimmed !== undefined && trimmed.length > 0 ? { comment: trimmed } : {}),
98
+ ...(sessionId !== undefined ? { sessionId } : {}),
99
+ ...(toolName !== undefined ? { toolName } : {}),
100
+ });
101
+ setPhase('sent');
102
+ },
103
+ [onUiFeedback, sessionId, toolName],
104
+ );
105
+
106
+ const onCommentSubmit = useCallback(() => {
107
+ emit('other', comment);
108
+ }, [emit, comment]);
109
+
110
+ // Hidden entirely: no sink wired, or the user dismissed it.
111
+ if (!onUiFeedback || phase === 'dismissed') return null;
112
+
113
+ return (
114
+ <View
115
+ testID="ggui-ui-feedback"
116
+ accessibilityLabel="Share feedback on this UI"
117
+ style={styles.root}
118
+ >
119
+ {phase === 'sent' ? (
120
+ <Text testID="ggui-ui-feedback-thanks" style={styles.thanksText}>
121
+ Thanks for the feedback
122
+ </Text>
123
+ ) : phase === 'comment' ? (
124
+ <View style={styles.commentForm} testID="ggui-ui-feedback-comment-form">
125
+ <TextInput
126
+ value={comment}
127
+ onChangeText={setComment}
128
+ onSubmitEditing={onCommentSubmit}
129
+ placeholder="What happened?"
130
+ accessibilityLabel="Feedback comment"
131
+ testID="ggui-ui-feedback-comment"
132
+ style={styles.commentInput}
133
+ autoFocus
134
+ />
135
+ <Pressable
136
+ accessibilityRole="button"
137
+ style={styles.verdictButton}
138
+ testID="ggui-ui-feedback-send"
139
+ onPress={onCommentSubmit}
140
+ >
141
+ <Text style={styles.verdictText}>Send</Text>
142
+ </Pressable>
143
+ </View>
144
+ ) : (
145
+ <>
146
+ <Pressable
147
+ accessibilityRole="button"
148
+ style={styles.verdictButton}
149
+ testID="ggui-ui-feedback-verdict-love"
150
+ onPress={() => emit('love')}
151
+ >
152
+ <Text style={styles.verdictText}>Love</Text>
153
+ </Pressable>
154
+ <Pressable
155
+ accessibilityRole="button"
156
+ style={styles.verdictButton}
157
+ testID="ggui-ui-feedback-verdict-dislike"
158
+ onPress={() => emit('dislike')}
159
+ >
160
+ <Text style={styles.verdictText}>Dislike</Text>
161
+ </Pressable>
162
+ <Pressable
163
+ accessibilityRole="button"
164
+ style={styles.verdictButton}
165
+ testID="ggui-ui-feedback-verdict-other"
166
+ onPress={() => setPhase('comment')}
167
+ >
168
+ <Text style={styles.verdictText}>Other…</Text>
169
+ </Pressable>
170
+ </>
171
+ )}
172
+ <Pressable
173
+ accessibilityRole="button"
174
+ accessibilityLabel="Dismiss feedback"
175
+ style={styles.dismissButton}
176
+ testID="ggui-ui-feedback-dismiss"
177
+ onPress={() => setPhase('dismissed')}
178
+ >
179
+ <Text style={styles.dismissText}>{'×'}</Text>
180
+ </Pressable>
181
+ </View>
182
+ );
183
+ }
184
+
185
+ // Neutral palette mirrors the web copy's CSS-variable fallbacks.
186
+ const styles = StyleSheet.create({
187
+ root: {
188
+ flexDirection: 'row',
189
+ alignItems: 'center',
190
+ alignSelf: 'flex-start',
191
+ gap: 4,
192
+ },
193
+ thanksText: {
194
+ fontSize: 12,
195
+ color: '#6b7280',
196
+ },
197
+ verdictButton: {
198
+ backgroundColor: '#f9fafb',
199
+ borderWidth: 1,
200
+ borderColor: '#e5e7eb',
201
+ borderRadius: 4,
202
+ paddingVertical: 2,
203
+ paddingHorizontal: 8,
204
+ },
205
+ verdictText: {
206
+ fontSize: 12,
207
+ color: '#4b5563',
208
+ },
209
+ dismissButton: {
210
+ paddingVertical: 2,
211
+ paddingHorizontal: 4,
212
+ },
213
+ dismissText: {
214
+ fontSize: 12,
215
+ color: '#9ca3af',
216
+ },
217
+ commentForm: {
218
+ flexDirection: 'row',
219
+ alignItems: 'center',
220
+ gap: 4,
221
+ },
222
+ commentInput: {
223
+ fontSize: 12,
224
+ paddingVertical: 2,
225
+ paddingHorizontal: 6,
226
+ minWidth: 140,
227
+ borderWidth: 1,
228
+ borderColor: '#e5e7eb',
229
+ borderRadius: 4,
230
+ backgroundColor: '#ffffff',
231
+ color: '#1f2937',
232
+ },
233
+ });
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Generic MCP-Apps host-role bridge helpers — the transport-agnostic
3
+ * JSON-RPC dispatcher plus the native WebView shim builders.
4
+ *
5
+ * Extracted from the (now-deleted) `McpAppsGguiSessionRenderer` component
6
+ * so the spec-canonical `<McpAppIframe>` can still reuse the bridge
7
+ * surface without dragging in the render-bound legacy renderer.
8
+ *
9
+ * Both transports — the web iframe postMessage bridge and the native
10
+ * `react-native-webview` `onMessage`/`injectJavaScript` bridge —
11
+ * compute responses to embedded views' JSON-RPC requests via the
12
+ * single {@link handleHostBridgeRequest} switch. Keeping one dispatcher
13
+ * keeps the protocol surface identical across platforms; the
14
+ * transports differ, the methods do not.
15
+ *
16
+ * Adapter boundary rule — ENFORCED:
17
+ * - Lifecycle messages from the embedded view do NOT mutate outer
18
+ * ggui render state, actionSpec, streamSpec, or any core contract.
19
+ * - Everything the view can do reduces to (a) self-render, and
20
+ * (b) call source-server tools through the ggui server's
21
+ * `/mcp-apps/tools-call` proxy. No side channels.
22
+ */
23
+
24
+ import { Linking, Platform } from 'react-native';
25
+ import type { McpAppsGguiSession } from '@ggui-ai/protocol/integrations/mcp-apps';
26
+ import {
27
+ LATEST_PROTOCOL_VERSION,
28
+ McpUiUpdateModelContextRequestSchema,
29
+ type McpUiInitializeResult,
30
+ type McpUiUpdateModelContextRequest,
31
+ } from '@modelcontextprotocol/ext-apps';
32
+
33
+ interface JsonRpcRequest {
34
+ jsonrpc?: '2.0';
35
+ id?: number | string;
36
+ method?: string;
37
+ params?: Record<string, unknown>;
38
+ }
39
+
40
+ interface JsonRpcResponse {
41
+ jsonrpc: '2.0';
42
+ id: number | string;
43
+ result?: Record<string, unknown>;
44
+ error?: { code: number; message: string };
45
+ }
46
+
47
+ interface JsonRpcNotification {
48
+ jsonrpc: '2.0';
49
+ method: string;
50
+ params?: Record<string, unknown>;
51
+ }
52
+
53
+ export interface HostBridgeContext {
54
+ readonly sessionId: string;
55
+ readonly render: McpAppsGguiSession;
56
+ readonly toolsCallUrl: string;
57
+ readonly locale?: string;
58
+ readonly containerDimensions?: McpAppsGguiSession['containerDimensions'];
59
+ /**
60
+ * Optional `ui/update-model-context` sink — see the McpAppIframe
61
+ * prop of the same name. Absent ⇒ the honest `method_not_supported`.
62
+ */
63
+ readonly onUpdateModelContext?: (
64
+ params: McpUiUpdateModelContextRequest['params'],
65
+ ) => Promise<void> | void;
66
+ }
67
+
68
+ /**
69
+ * Shared host-role method dispatcher. Returns a JSON-RPC response or
70
+ * `null` when the request is a malformed / untrusted frame the host
71
+ * should drop silently (no response).
72
+ *
73
+ * Exported for test coverage; production callers come through the web
74
+ * iframe listener or the native `onMessage` handler in
75
+ * {@link McpAppIframe}.
76
+ */
77
+ export async function handleHostBridgeRequest(
78
+ req: JsonRpcRequest,
79
+ ctx: HostBridgeContext,
80
+ ): Promise<JsonRpcResponse | null> {
81
+ if (!req || typeof req !== 'object') return null;
82
+ if (req.jsonrpc !== '2.0' || typeof req.method !== 'string') return null;
83
+ // Notifications (no `id`) get no response — they're fire-and-forget.
84
+ const id = req.id;
85
+
86
+ switch (req.method) {
87
+ case 'ping': {
88
+ return {
89
+ jsonrpc: '2.0',
90
+ id: id ?? 0,
91
+ result: { pong: true },
92
+ };
93
+ }
94
+ case 'ui/initialize': {
95
+ // Spec-canonical `McpUiInitializeResult` — the ext-apps
96
+ // `App.connect` inside the embedded page zod-requires
97
+ // `protocolVersion` + `hostInfo` + `hostCapabilities` +
98
+ // `hostContext`; the pre-App draft shape fails that gate and
99
+ // kills the mount before the renderer boots. Adapter boundary —
100
+ // `hostContext` carries `{locale, containerDimensions}` ONLY,
101
+ // NO outer render state leaks here (ggui theming rides the
102
+ // `ai.ggui/render.theme` slice, never this handshake).
103
+ const requestedRaw = req.params?.protocolVersion;
104
+ const requested =
105
+ typeof requestedRaw === 'string' && requestedRaw.length > 0
106
+ ? requestedRaw
107
+ : LATEST_PROTOCOL_VERSION;
108
+ const result: McpUiInitializeResult = {
109
+ protocolVersion: requested,
110
+ // Diagnostic-only; no build-stamp machinery in this tsc-built
111
+ // package — see the McpAppIframe dispatcher's identical note.
112
+ hostInfo: { name: 'ggui-react-native', version: 'unstamped' },
113
+ hostCapabilities: {},
114
+ hostContext: {
115
+ locale:
116
+ ctx.locale ??
117
+ (typeof navigator !== 'undefined' ? navigator.language : 'en-US'),
118
+ containerDimensions: ctx.containerDimensions ?? {},
119
+ },
120
+ };
121
+ return { jsonrpc: '2.0', id: id ?? 0, result };
122
+ }
123
+ case 'ui/open-link': {
124
+ // iframe asks the host to open a URL out-of-band (system browser
125
+ // on native, new tab on web). ENFORCEMENT stays at the proxy —
126
+ // this method does NOT call any MCP tool and does NOT carry
127
+ // credentials; it's a platform-level link hand-off.
128
+ const url = typeof req.params?.url === 'string' ? req.params.url : '';
129
+ // Validate scheme. Anything other than http(s) is rejected so an
130
+ // iframe can't trick the host into opening `file://`, `javascript:`,
131
+ // arbitrary URI schemes, etc.
132
+ if (!/^https?:\/\//i.test(url)) {
133
+ return {
134
+ jsonrpc: '2.0',
135
+ id: id ?? 0,
136
+ error: { code: -32602, message: 'ui/open-link requires http(s) url' },
137
+ };
138
+ }
139
+ try {
140
+ if (Platform.OS === 'web') {
141
+ if (typeof window !== 'undefined') {
142
+ window.open(url, '_blank', 'noopener,noreferrer');
143
+ }
144
+ } else {
145
+ await Linking.openURL(url);
146
+ }
147
+ return { jsonrpc: '2.0', id: id ?? 0, result: { opened: true } };
148
+ } catch (err) {
149
+ return {
150
+ jsonrpc: '2.0',
151
+ id: id ?? 0,
152
+ error: { code: -32000, message: `open_link_failed: ${String(err)}` },
153
+ };
154
+ }
155
+ }
156
+ case 'tools/call': {
157
+ const tool = typeof req.params?.name === 'string' ? req.params.name : '';
158
+ const args =
159
+ req.params?.arguments && typeof req.params.arguments === 'object'
160
+ ? (req.params.arguments as Record<string, unknown>)
161
+ : {};
162
+ if (!tool) {
163
+ return {
164
+ jsonrpc: '2.0',
165
+ id: id ?? 0,
166
+ error: { code: -32602, message: 'tools/call requires params.name' },
167
+ };
168
+ }
169
+ try {
170
+ const resp = await fetch(ctx.toolsCallUrl, {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({
174
+ session: ctx.sessionId,
175
+ tool,
176
+ arguments: args,
177
+ }),
178
+ });
179
+ if (!resp.ok) {
180
+ const body = (await resp.json().catch(() => ({}))) as {
181
+ error?: string;
182
+ };
183
+ return {
184
+ jsonrpc: '2.0',
185
+ id: id ?? 0,
186
+ error: {
187
+ code: resp.status === 403 ? -32003 : -32000,
188
+ message: body.error ?? `proxy_error_${resp.status}`,
189
+ },
190
+ };
191
+ }
192
+ const result = (await resp.json()) as Record<string, unknown>;
193
+ return { jsonrpc: '2.0', id: id ?? 0, result };
194
+ } catch (err) {
195
+ return {
196
+ jsonrpc: '2.0',
197
+ id: id ?? 0,
198
+ error: {
199
+ code: -32000,
200
+ message: `proxy_fetch_failed: ${String(err)}`,
201
+ },
202
+ };
203
+ }
204
+ }
205
+ case 'ui/update-model-context': {
206
+ // Forwarded to the caller's sink when wired; without one the
207
+ // honest answer stays `method_not_supported` — acking context
208
+ // we did not carry anywhere would mislead the app
209
+ // (first-integrator report, ggui#425 fourth finding).
210
+ if (ctx.onUpdateModelContext === undefined) {
211
+ return {
212
+ jsonrpc: '2.0',
213
+ id: id ?? 0,
214
+ error: { code: -32601, message: 'method_not_supported' },
215
+ };
216
+ }
217
+ const parsed = McpUiUpdateModelContextRequestSchema.safeParse({
218
+ method: 'ui/update-model-context',
219
+ params: req.params,
220
+ });
221
+ if (!parsed.success) {
222
+ return {
223
+ jsonrpc: '2.0',
224
+ id: id ?? 0,
225
+ error: { code: -32602, message: 'invalid ui/update-model-context params' },
226
+ };
227
+ }
228
+ await ctx.onUpdateModelContext(parsed.data.params);
229
+ return { jsonrpc: '2.0', id: id ?? 0, result: {} };
230
+ }
231
+ default: {
232
+ return {
233
+ jsonrpc: '2.0',
234
+ id: id ?? 0,
235
+ error: { code: -32601, message: 'method_not_supported' },
236
+ };
237
+ }
238
+ }
239
+ }
240
+
241
+ // =============================================================================
242
+ // Native WebView bridge
243
+ // =============================================================================
244
+ //
245
+ // The injected shim aliases `window.parent.postMessage` onto
246
+ // `window.ReactNativeWebView.postMessage` so MCP Apps pages authored
247
+ // against the iframe-host contract work unchanged in a top-level
248
+ // WebView (where `window.parent === window`).
249
+ //
250
+ // Wire format — wrap the original payload so the RN side can
251
+ // distinguish MCP Apps traffic from other message events the embedded
252
+ // page might legitimately generate.
253
+ export const NATIVE_BRIDGE_ENVELOPE_KEY = '__ggui_mcp_apps';
254
+
255
+ /** Script injected BEFORE the page's own scripts run. */
256
+ export function buildInjectedBridgeScript(): string {
257
+ // The `true;` trailer is required by react-native-webview on iOS for
258
+ // injected strings; without it iOS's JS runtime returns an `undefined`
259
+ // result that confuses the loader.
260
+ return `
261
+ (function() {
262
+ try {
263
+ if (window.__gguiMcpAppsBridge) return;
264
+ window.__gguiMcpAppsBridge = { version: 1 };
265
+
266
+ function forward(msg) {
267
+ try {
268
+ if (window.ReactNativeWebView && typeof window.ReactNativeWebView.postMessage === 'function') {
269
+ window.ReactNativeWebView.postMessage(JSON.stringify({
270
+ ${JSON.stringify(NATIVE_BRIDGE_ENVELOPE_KEY)}: true,
271
+ payload: msg,
272
+ }));
273
+ }
274
+ } catch (_e) {}
275
+ }
276
+
277
+ // Intercept page → host posts.
278
+ //
279
+ // MCP Apps hosted as iframes use \`window.parent.postMessage(msg, '*')\`.
280
+ // Inside react-native-webview \`window.parent === window\`, so calls
281
+ // hit \`window.postMessage\`. Override it to forward to the RN bridge
282
+ // instead of firing a same-window message event.
283
+ var origPostMessage = window.postMessage;
284
+ window.postMessage = function(data /*, targetOrigin */) {
285
+ forward(data);
286
+ };
287
+ // ONE stable parent proxy — first-integrator device finding
288
+ // (ggui#425): a getter that mints a fresh object per access breaks
289
+ // every transport that checks \`event.source === window.parent\`
290
+ // (two reads can never be equal).
291
+ var parentProxy = { postMessage: forward };
292
+ // Explicit \`parent.postMessage\` aliasing — some engines reify
293
+ // \`window.parent\` lazily, so overriding it on \`window\` is the
294
+ // canonical safety net.
295
+ try {
296
+ Object.defineProperty(window, 'parent', {
297
+ configurable: true,
298
+ get: function() {
299
+ return parentProxy;
300
+ },
301
+ });
302
+ } catch (_e) {
303
+ // If \`parent\` is non-configurable on this engine, leave the
304
+ // \`window.postMessage\` override in place — it covers the
305
+ // \`window.parent === window\` loopback for us.
306
+ }
307
+
308
+ // Host → page delivery channel. WebKit rejects a synthesized
309
+ // \`MessageEvent\` whose \`source\` is a plain object (must be a
310
+ // WindowProxy / MessagePort / null) — the TypeError was swallowed
311
+ // and no host response ever reached the renderer (first-integrator
312
+ // device finding, ggui#425). Sidestep event-construction legality
313
+ // entirely: capture \`message\` listeners at document-start (this
314
+ // script runs via injectedJavaScriptBeforeContentLoaded, so the
315
+ // wrappers are in place before any page script registers) and
316
+ // deliver by direct invocation with an event-shaped object whose
317
+ // \`source\` IS the stable parent proxy.
318
+ var messageListeners = [];
319
+ var origAdd = window.addEventListener ? window.addEventListener.bind(window) : null;
320
+ window.addEventListener = function(type, fn, opts) {
321
+ if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);
322
+ if (origAdd) return origAdd(type, fn, opts);
323
+ };
324
+ var origRemove = window.removeEventListener ? window.removeEventListener.bind(window) : null;
325
+ window.removeEventListener = function(type, fn, opts) {
326
+ if (type === 'message') {
327
+ var i = messageListeners.indexOf(fn);
328
+ if (i !== -1) messageListeners.splice(i, 1);
329
+ }
330
+ if (origRemove) return origRemove(type, fn, opts);
331
+ };
332
+ window.__gguiDeliver = function(data) {
333
+ var ev = { type: 'message', data: data, source: parentProxy, origin: '' };
334
+ var snapshot = messageListeners.slice();
335
+ for (var i = 0; i < snapshot.length; i++) {
336
+ try { snapshot[i](ev); } catch (_e) {}
337
+ }
338
+ if (typeof window.onmessage === 'function') {
339
+ try { window.onmessage(ev); } catch (_e) {}
340
+ }
341
+ };
342
+
343
+ // Mark that the shim loaded; tests and diagnostics may read this.
344
+ window.__gguiMcpAppsBridge.ready = true;
345
+ } catch (_e) {}
346
+ })();
347
+ true;
348
+ `;
349
+ }
350
+
351
+ /**
352
+ * Build a script that delivers a host → WebView JSON-RPC message to the
353
+ * embedded page. Escaped for safe injection — every caller-controlled
354
+ * string lands inside a JSON.parse'd literal, never as a JS identifier.
355
+ *
356
+ * Delivery goes through the injected bridge's `window.__gguiDeliver`
357
+ * (direct listener invocation with `source` = the stable parent proxy)
358
+ * rather than a synthesized `MessageEvent`: WebKit rejects a
359
+ * `MessageEvent` whose `source` is a plain object, and the resulting
360
+ * TypeError was silently swallowed — no host response ever reached the
361
+ * renderer (first-integrator device finding, ggui#425). The
362
+ * `dispatchEvent` fallback below only runs when the bridge failed to
363
+ * inject; it omits `source` (null is legal everywhere) as a
364
+ * best-effort path for transports that don't check event provenance.
365
+ */
366
+ export function buildDeliveryScript(
367
+ message: JsonRpcResponse | JsonRpcNotification,
368
+ ): string {
369
+ // JSON.stringify produces valid JS when re-embedded as the argument
370
+ // to JSON.parse. This is the canonical safe-injection pattern for
371
+ // WebView.injectJavaScript — no string concatenation of untrusted
372
+ // fields into executable JS.
373
+ const json = JSON.stringify(JSON.stringify(message));
374
+ return `
375
+ (function() {
376
+ try {
377
+ var data = JSON.parse(${json});
378
+ if (typeof window.__gguiDeliver === 'function') {
379
+ window.__gguiDeliver(data);
380
+ } else {
381
+ var ev = new MessageEvent('message', { data: data });
382
+ window.dispatchEvent(ev);
383
+ }
384
+ } catch (_e) {}
385
+ })();
386
+ true;
387
+ `;
388
+ }