@ggui-ai/mcp-apps-react-native 0.0.1-placeholder → 0.9.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.
- package/LICENSE +201 -0
- package/README.md +58 -4
- package/package.json +84 -4
- package/src/McpAppIframe/McpAppIframe.tsx +368 -0
- package/src/McpAppIframe/dispatch.ts +418 -0
- package/src/McpAppIframe/index.ts +15 -0
- package/src/McpAppIframe/types.ts +227 -0
- package/src/chat-helpers/index.ts +12 -0
- package/src/chat-helpers/message-groups.ts +124 -0
- package/src/chat-helpers/render.ts +65 -0
- package/src/chat-helpers/useRafThrottled.ts +34 -0
- package/src/components/ErrorBoundary.tsx +96 -0
- package/src/components/GguiProvider.tsx +188 -0
- package/src/components/UiFeedback.tsx +233 -0
- package/src/components/mcp-apps-bridge.ts +388 -0
- package/src/context/GguiContext.ts +121 -0
- package/src/hooks/useAppState.ts +24 -0
- package/src/index.ts +150 -0
- package/src/invoke/index.ts +8 -0
- package/src/invoke/sse-parse.ts +79 -0
- package/src/invoke/useInvoke.ts +502 -0
- package/src/test-setup.ts +85 -0
- package/src/theme/ThemeProvider.tsx +129 -0
- package/src/theme/index.ts +29 -0
- package/src/theme/tokens.ts +201 -0
- package/src/theme/types.ts +94 -0
- package/src/types/react-test-renderer.d.ts +50 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ContentBlock, InvokeTurn } from '@ggui-ai/protocol';
|
|
2
|
+
import type { ConversationMessage } from '../invoke/useInvoke';
|
|
3
|
+
import { extractRenderFromToolResult } from './render';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A "content group" is one durable-renderable unit from an invoke message:
|
|
7
|
+
* - a contiguous run of text blocks (one text bubble)
|
|
8
|
+
* - one tool_use + its paired tool_result (one card bubble)
|
|
9
|
+
*
|
|
10
|
+
* Integrators persist content groups to their storage and reconstruct
|
|
11
|
+
* ConversationMessages from them on thread reopen.
|
|
12
|
+
*/
|
|
13
|
+
export interface ContentGroup {
|
|
14
|
+
/** Deterministic from `${invokeMessageId}-${startBlockIdx}`. Key for idempotency. */
|
|
15
|
+
key: string;
|
|
16
|
+
kind: 'text' | 'card' | 'other';
|
|
17
|
+
authorRole: 'user' | 'agent';
|
|
18
|
+
blocks: ContentBlock[];
|
|
19
|
+
/** For kind='card' only — a frozen GguiSession extracted from the tool_result. */
|
|
20
|
+
cardSnapshot: unknown | null;
|
|
21
|
+
/** Human-readable ~160-char preview (for chat-list lastMessagePreview). */
|
|
22
|
+
textPreview: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Split a finalized invoke ConversationMessage into ContentGroups.
|
|
27
|
+
* Returns [] when the message is still streaming (nothing durable yet).
|
|
28
|
+
*/
|
|
29
|
+
export function invokeMessageToContentGroups(message: ConversationMessage): ContentGroup[] {
|
|
30
|
+
if (message.isStreaming) return [];
|
|
31
|
+
const authorRole: 'user' | 'agent' = message.role === 'user' ? 'user' : 'agent';
|
|
32
|
+
const groups: ContentGroup[] = [];
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < message.content.length) {
|
|
35
|
+
const b = message.content[i]!;
|
|
36
|
+
if (b.type === 'text') {
|
|
37
|
+
const startIdx = i;
|
|
38
|
+
const textBlocks: ContentBlock[] = [b];
|
|
39
|
+
let j = i + 1;
|
|
40
|
+
while (j < message.content.length && message.content[j]!.type === 'text') {
|
|
41
|
+
textBlocks.push(message.content[j]!);
|
|
42
|
+
j++;
|
|
43
|
+
}
|
|
44
|
+
const preview = textBlocks
|
|
45
|
+
.map((t) => (t.type === 'text' ? t.text : ''))
|
|
46
|
+
.join(' ')
|
|
47
|
+
.slice(0, 160);
|
|
48
|
+
groups.push({
|
|
49
|
+
key: `${message.id}-${startIdx}`,
|
|
50
|
+
kind: 'text',
|
|
51
|
+
authorRole,
|
|
52
|
+
blocks: textBlocks,
|
|
53
|
+
cardSnapshot: null,
|
|
54
|
+
textPreview: preview,
|
|
55
|
+
});
|
|
56
|
+
i = j;
|
|
57
|
+
} else if (b.type === 'tool_use') {
|
|
58
|
+
const paired = message.content.find(
|
|
59
|
+
(x) => x.type === 'tool_result' && x.tool_use_id === b.id,
|
|
60
|
+
);
|
|
61
|
+
const resultBlock = paired ?? null;
|
|
62
|
+
const cardSnapshot = resultBlock ? extractRenderFromToolResult(resultBlock) : null;
|
|
63
|
+
const blocks: ContentBlock[] = resultBlock ? [b, resultBlock] : [b];
|
|
64
|
+
// A render-bearing result IS a card, whatever the tool was named —
|
|
65
|
+
// agent frameworks surface renders under their own tool names
|
|
66
|
+
// (`render_card`, `mcp__cards__update_card`, …) with the payload
|
|
67
|
+
// recognized by extractRenderFromToolResult. The name arm keeps
|
|
68
|
+
// ggui's own render tools folding as cards even when the result
|
|
69
|
+
// block is absent (e.g. still in flight).
|
|
70
|
+
groups.push({
|
|
71
|
+
key: `${message.id}-${i}`,
|
|
72
|
+
kind:
|
|
73
|
+
cardSnapshot !== null || b.name === 'ggui_render' || b.name === 'ggui_update'
|
|
74
|
+
? 'card'
|
|
75
|
+
: 'other',
|
|
76
|
+
authorRole,
|
|
77
|
+
blocks,
|
|
78
|
+
cardSnapshot,
|
|
79
|
+
textPreview: '[UI card]',
|
|
80
|
+
});
|
|
81
|
+
i++;
|
|
82
|
+
} else {
|
|
83
|
+
// Standalone tool_result (already absorbed) + other kinds — skip.
|
|
84
|
+
i++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return groups;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Inverse: reassemble ConversationMessages from persisted content groups.
|
|
92
|
+
* Groups sharing the same `${invokeMessageId}` prefix collapse back into
|
|
93
|
+
* a single message — used on thread reopen to seed useInvoke.
|
|
94
|
+
*/
|
|
95
|
+
export function contentGroupsToConversationMessages(
|
|
96
|
+
groups: ContentGroup[],
|
|
97
|
+
): ConversationMessage[] {
|
|
98
|
+
const byInvokeMessageId = new Map<string, ConversationMessage>();
|
|
99
|
+
for (const g of groups) {
|
|
100
|
+
const invokeMessageId = g.key.split('-').slice(0, -1).join('-') || g.key;
|
|
101
|
+
const role: 'user' | 'assistant' = g.authorRole === 'user' ? 'user' : 'assistant';
|
|
102
|
+
const existing = byInvokeMessageId.get(invokeMessageId);
|
|
103
|
+
if (existing) {
|
|
104
|
+
existing.content.push(...g.blocks);
|
|
105
|
+
} else {
|
|
106
|
+
byInvokeMessageId.set(invokeMessageId, {
|
|
107
|
+
id: invokeMessageId,
|
|
108
|
+
role,
|
|
109
|
+
content: [...g.blocks],
|
|
110
|
+
isStreaming: false,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return Array.from(byInvokeMessageId.values());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Build wire history from in-memory messages, stripping streaming turns. */
|
|
118
|
+
export function conversationMessagesToInvokeHistory(
|
|
119
|
+
messages: ConversationMessage[],
|
|
120
|
+
): InvokeTurn[] {
|
|
121
|
+
return messages
|
|
122
|
+
.filter((m) => !(m.role === 'assistant' && m.isStreaming))
|
|
123
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
124
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ContentBlock } from '@ggui-ai/protocol';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pull a durable render payload out of a `tool_result` block's
|
|
5
|
+
* `content` payload. Tolerant of shapes:
|
|
6
|
+
* - Direct: { id, componentCode, props, ... } (GguiSession itself)
|
|
7
|
+
* - Wrapped: { render: {...} }
|
|
8
|
+
* - Nested: { result: { render: {...} } }
|
|
9
|
+
* - Nested direct: { result: { id, componentCode, ... } }
|
|
10
|
+
* - MCP-Apps bootstrap: { _meta: { ui: { resourceUri } }, ... }
|
|
11
|
+
* (SEP-1865) — a tool result whose `_meta.ui.resourceUri` is set IS
|
|
12
|
+
* a render; the result itself is the durable card payload (it
|
|
13
|
+
* carries `structuredContent` plus the full `_meta.ui` block,
|
|
14
|
+
* including any inlined resource). Same recognition rule as the
|
|
15
|
+
* `useMcpAppsChat` walker — agent framework streams (Claude Agent
|
|
16
|
+
* SDK, ADK, OpenAI Agents) deliver renders in exactly this shape.
|
|
17
|
+
*
|
|
18
|
+
* Returns `null` if no render can be found — caller falls back to
|
|
19
|
+
* placeholder rendering.
|
|
20
|
+
*
|
|
21
|
+
* Post-Phase-B: the legacy `stackItem` wrapper is gone — every tool
|
|
22
|
+
* result carries a single flat `sessionId`. This stays a tolerant
|
|
23
|
+
* heuristic over the remaining shapes (the `render` wrapper key, the
|
|
24
|
+
* direct GguiSession object, the spec-canonical `_meta.ui` bootstrap)
|
|
25
|
+
* rather than pinning one producer's envelope.
|
|
26
|
+
*/
|
|
27
|
+
export function extractRenderFromToolResult(block: ContentBlock): unknown | null {
|
|
28
|
+
if (block.type !== 'tool_result') return null;
|
|
29
|
+
const content = block.content as unknown;
|
|
30
|
+
if (typeof content !== 'object' || content === null) return null;
|
|
31
|
+
const rec = content as Record<string, unknown>;
|
|
32
|
+
if (rec.render && typeof rec.render === 'object') return rec.render;
|
|
33
|
+
if (typeof rec.id === 'string' && typeof rec.componentCode === 'string') return rec;
|
|
34
|
+
if (rec._meta !== null && typeof rec._meta === 'object') {
|
|
35
|
+
const ui = (rec._meta as Record<string, unknown>).ui;
|
|
36
|
+
if (ui !== null && typeof ui === 'object') {
|
|
37
|
+
const uri = (ui as Record<string, unknown>).resourceUri;
|
|
38
|
+
if (typeof uri === 'string' && uri.length > 0) return rec;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const v of Object.values(rec)) {
|
|
42
|
+
if (typeof v === 'object' && v !== null) {
|
|
43
|
+
const inner = v as Record<string, unknown>;
|
|
44
|
+
if (inner.render && typeof inner.render === 'object') return inner.render;
|
|
45
|
+
if (typeof inner.id === 'string' && typeof inner.componentCode === 'string') return inner;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Same traversal looking for just a sessionId field. */
|
|
52
|
+
export function extractSessionIdFromToolResult(block: ContentBlock): string | null {
|
|
53
|
+
if (block.type !== 'tool_result') return null;
|
|
54
|
+
const content = block.content as unknown;
|
|
55
|
+
if (typeof content !== 'object' || content === null) return null;
|
|
56
|
+
const rec = content as Record<string, unknown>;
|
|
57
|
+
if (typeof rec.sessionId === 'string') return rec.sessionId;
|
|
58
|
+
for (const v of Object.values(rec)) {
|
|
59
|
+
if (typeof v === 'object' && v !== null) {
|
|
60
|
+
const inner = v as Record<string, unknown>;
|
|
61
|
+
if (typeof inner.sessionId === 'string') return inner.sessionId;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collapse rapid source updates into at most one downstream render per
|
|
5
|
+
* animation frame. Use on streaming content where per-delta re-renders
|
|
6
|
+
* would flicker composer focus or tank framerate.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const invoke = useInvoke();
|
|
10
|
+
* const messages = useRafThrottled(invoke.messages);
|
|
11
|
+
*/
|
|
12
|
+
export function useRafThrottled<T>(source: T): T {
|
|
13
|
+
const [snapshot, setSnapshot] = useState(source);
|
|
14
|
+
const sourceRef = useRef(source);
|
|
15
|
+
const frameRef = useRef<number | null>(null);
|
|
16
|
+
|
|
17
|
+
sourceRef.current = source;
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (frameRef.current !== null) return;
|
|
21
|
+
frameRef.current = requestAnimationFrame(() => {
|
|
22
|
+
frameRef.current = null;
|
|
23
|
+
setSnapshot(sourceRef.current);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
if (frameRef.current !== null) {
|
|
27
|
+
cancelAnimationFrame(frameRef.current);
|
|
28
|
+
frameRef.current = null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}, [source]);
|
|
32
|
+
|
|
33
|
+
return snapshot;
|
|
34
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error Boundary for React Native
|
|
3
|
+
*
|
|
4
|
+
* Catches runtime errors in generated components and displays
|
|
5
|
+
* a native error UI with retry capability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { Component, type ReactNode, type ErrorInfo } from 'react';
|
|
9
|
+
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
|
10
|
+
|
|
11
|
+
export interface ErrorBoundaryProps {
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
onError?: (error: Error, componentStack?: string) => void;
|
|
14
|
+
fallback?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ErrorBoundaryState {
|
|
18
|
+
hasError: boolean;
|
|
19
|
+
error: Error | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
23
|
+
constructor(props: ErrorBoundaryProps) {
|
|
24
|
+
super(props);
|
|
25
|
+
this.state = { hasError: false, error: null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
|
29
|
+
return { hasError: true, error };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
33
|
+
this.props.onError?.(error, errorInfo.componentStack || undefined);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
reset = (): void => {
|
|
37
|
+
this.setState({ hasError: false, error: null });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
render(): ReactNode {
|
|
41
|
+
const { children, fallback } = this.props;
|
|
42
|
+
const { hasError, error } = this.state;
|
|
43
|
+
|
|
44
|
+
if (!hasError) {
|
|
45
|
+
return children;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (fallback) {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<View style={styles.container}>
|
|
54
|
+
<Text style={styles.title}>Something went wrong</Text>
|
|
55
|
+
<Text style={styles.message}>{error?.message || 'An error occurred'}</Text>
|
|
56
|
+
<Pressable style={styles.retryButton} onPress={this.reset}>
|
|
57
|
+
<Text style={styles.retryText}>Try Again</Text>
|
|
58
|
+
</Pressable>
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const styles = StyleSheet.create({
|
|
65
|
+
container: {
|
|
66
|
+
padding: 16,
|
|
67
|
+
backgroundColor: '#fee2e2',
|
|
68
|
+
borderWidth: 1,
|
|
69
|
+
borderColor: '#ef4444',
|
|
70
|
+
borderRadius: 8,
|
|
71
|
+
alignItems: 'center',
|
|
72
|
+
gap: 8,
|
|
73
|
+
},
|
|
74
|
+
title: {
|
|
75
|
+
fontWeight: '700',
|
|
76
|
+
color: '#991b1b',
|
|
77
|
+
fontSize: 16,
|
|
78
|
+
},
|
|
79
|
+
message: {
|
|
80
|
+
fontSize: 14,
|
|
81
|
+
color: '#991b1b',
|
|
82
|
+
textAlign: 'center',
|
|
83
|
+
},
|
|
84
|
+
retryButton: {
|
|
85
|
+
paddingHorizontal: 16,
|
|
86
|
+
paddingVertical: 8,
|
|
87
|
+
backgroundColor: '#dc2626',
|
|
88
|
+
borderRadius: 6,
|
|
89
|
+
marginTop: 4,
|
|
90
|
+
},
|
|
91
|
+
retryText: {
|
|
92
|
+
color: '#ffffff',
|
|
93
|
+
fontWeight: '600',
|
|
94
|
+
fontSize: 14,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GguiProvider — React Native twin of `@ggui-ai/mcp-apps-react`'s
|
|
3
|
+
* `components/GguiProvider.tsx`.
|
|
4
|
+
*
|
|
5
|
+
* The provider surface mirrors the web copy: permission requests gate
|
|
6
|
+
* on `KNOWN_PERMISSION_NAMES`, `adapterImpls` is the runtime
|
|
7
|
+
* implementation slot only, and the context value shape feeds the
|
|
8
|
+
* same `GguiContext`.
|
|
9
|
+
*
|
|
10
|
+
* Platform delta (every intentional divergence from the web copy):
|
|
11
|
+
*
|
|
12
|
+
* - Interface-context detection reads RN `Dimensions` / `PixelRatio`
|
|
13
|
+
* (with rotation / split-screen change updates) instead of
|
|
14
|
+
* `window`-based `detectInterfaceContext` + resize listener.
|
|
15
|
+
* - RN-only props `reactVersion` / `designSystemUrl` feed the
|
|
16
|
+
* WebView import map.
|
|
17
|
+
* - No `apiBaseUrl` / `appMetadata` props and no app-config fetch —
|
|
18
|
+
* RN hosts pass a pre-fetched `appConfig`; the web provider can
|
|
19
|
+
* fetch it from the platform itself.
|
|
20
|
+
*
|
|
21
|
+
* Guarded by the structural twin gate in `../twin-parity.test.ts`
|
|
22
|
+
* (`DOCUMENTED_DELTA_TWINS`): exported surface must match the web
|
|
23
|
+
* copy.
|
|
24
|
+
*/
|
|
25
|
+
import { useState, useCallback, useEffect, useMemo, type ReactNode } from 'react';
|
|
26
|
+
import { Dimensions, PixelRatio } from 'react-native';
|
|
27
|
+
import type { AdapterPermissions, PermissionStatus, InterfaceContext, EndUserIdentity, AppDisplayConfig } from '@ggui-ai/protocol';
|
|
28
|
+
import { KNOWN_PERMISSION_NAMES, UnknownPermissionNameError } from '@ggui-ai/protocol';
|
|
29
|
+
import { GguiContext, type AdapterRegistry, type GguiContextValue } from '../context/GguiContext';
|
|
30
|
+
|
|
31
|
+
const KNOWN_PERMISSION_NAMES_SET: ReadonlySet<string> = new Set(KNOWN_PERMISSION_NAMES);
|
|
32
|
+
|
|
33
|
+
export { useGguiContext, useAdapter, type AdapterRegistry } from '../context/GguiContext';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Props for the {@link GguiProvider} component.
|
|
37
|
+
*/
|
|
38
|
+
export interface GguiProviderProps {
|
|
39
|
+
appId: string;
|
|
40
|
+
wsEndpoint?: string;
|
|
41
|
+
/** Host-provided adapter implementations. Host apps or downstream
|
|
42
|
+
* capability packages augment {@link AdapterRegistry} via
|
|
43
|
+
* declaration merging so each slot is strongly typed. The grant
|
|
44
|
+
* model lives entirely on `clientCapabilities.gadgets[*].permission`
|
|
45
|
+
* — this is just the runtime implementation slot. */
|
|
46
|
+
adapterImpls?: AdapterRegistry;
|
|
47
|
+
/** Override auto-detected interface context */
|
|
48
|
+
interfaceContext?: InterfaceContext;
|
|
49
|
+
/** Auth context surfaced to embedding hosts and renderer surfaces. */
|
|
50
|
+
auth?: {
|
|
51
|
+
currentUser?: EndUserIdentity;
|
|
52
|
+
userId?: string;
|
|
53
|
+
token?: string;
|
|
54
|
+
isAuthenticated: boolean;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Conversation envelope identity. Forwarded by {@link useInvoke} as
|
|
58
|
+
* the `X-Ggui-Host-Session-Id` header so the agent threads multi-turn
|
|
59
|
+
* invokes through its own keyed conversation state. Names the chat
|
|
60
|
+
* thread, not a render.
|
|
61
|
+
*/
|
|
62
|
+
hostSessionId?: string;
|
|
63
|
+
/** React version for WebView import map (default: '18.2.0') */
|
|
64
|
+
reactVersion?: string;
|
|
65
|
+
/** Base URL for design system modules in WebView import map */
|
|
66
|
+
designSystemUrl?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Custom handler for permission requests (e.g., camera, microphone).
|
|
69
|
+
*
|
|
70
|
+
* By default, all permissions are granted immediately. Provide a custom
|
|
71
|
+
* `permissionHandler` to integrate with your app's permission flow
|
|
72
|
+
* (e.g., react-native-permissions, expo-permissions, or a custom dialog).
|
|
73
|
+
*
|
|
74
|
+
* @param permission - The Web Permissions API name being requested
|
|
75
|
+
* (e.g., `'camera'`, `'microphone'`, `'geolocation'`). The SDK
|
|
76
|
+
* pre-validates the name against `KNOWN_PERMISSION_NAMES` before
|
|
77
|
+
* invoking this handler, so an unknown name surfaces as a
|
|
78
|
+
* `UnknownPermissionNameError` to the caller without ever
|
|
79
|
+
* reaching the host's permission flow.
|
|
80
|
+
* @returns The resulting permission status
|
|
81
|
+
*/
|
|
82
|
+
permissionHandler?: (permission: string) => Promise<PermissionStatus>;
|
|
83
|
+
/**
|
|
84
|
+
* App config (endpointUrl, defaultShellType, etc). Consumed by
|
|
85
|
+
* `useInvoke` for the Streamable Invoke Protocol v1.1 — pass this when
|
|
86
|
+
* you have a pre-fetched `AppDisplayConfig` (e.g. when the host fetches
|
|
87
|
+
* it from an API before mounting). Mirrors the web provider's
|
|
88
|
+
* `appConfig` prop.
|
|
89
|
+
*/
|
|
90
|
+
appConfig?: AppDisplayConfig | null;
|
|
91
|
+
children: ReactNode;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function detectRNInterfaceContext(): InterfaceContext {
|
|
95
|
+
const { width, height } = Dimensions.get('window');
|
|
96
|
+
// Align breakpoints with getDeviceCategory() in interface-context.ts:
|
|
97
|
+
// width < 768 -> phone
|
|
98
|
+
// width < 1024 -> tablet
|
|
99
|
+
// width >= 1024 -> desktop
|
|
100
|
+
const deviceType: 'phone' | 'tablet' | 'desktop' =
|
|
101
|
+
width < 768 ? 'phone' : width < 1024 ? 'tablet' : 'desktop';
|
|
102
|
+
return {
|
|
103
|
+
viewport: { width, height },
|
|
104
|
+
platform: 'mobile',
|
|
105
|
+
deviceType,
|
|
106
|
+
orientation: width >= height ? 'landscape' : 'portrait',
|
|
107
|
+
devicePixelRatio: PixelRatio.get(),
|
|
108
|
+
touchPrimary: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Root provider for the ggui React Native SDK.
|
|
114
|
+
*
|
|
115
|
+
* Wraps the application with shared configuration needed by all ggui hooks
|
|
116
|
+
* and components. Auto-detects interface context from React Native Dimensions
|
|
117
|
+
* and updates on rotation/split-screen changes.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```tsx
|
|
121
|
+
* <GguiProvider appId="my-app" wsEndpoint="wss://your-ws-gateway.example/">
|
|
122
|
+
* <App />
|
|
123
|
+
* </GguiProvider>
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export function GguiProvider({ appId, wsEndpoint, adapterImpls, interfaceContext: interfaceContextProp, auth, hostSessionId, reactVersion, designSystemUrl, permissionHandler, appConfig, children }: GguiProviderProps) {
|
|
127
|
+
const [adapterPermissions, setAdapterPermissions] = useState<AdapterPermissions>({});
|
|
128
|
+
|
|
129
|
+
// Auto-detect interface context from React Native Dimensions
|
|
130
|
+
const [detectedContext, setDetectedContext] = useState<InterfaceContext>(detectRNInterfaceContext);
|
|
131
|
+
|
|
132
|
+
// Update on dimension changes (rotation, split-screen, etc.)
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const subscription = Dimensions.addEventListener('change', () => {
|
|
135
|
+
setDetectedContext(detectRNInterfaceContext());
|
|
136
|
+
});
|
|
137
|
+
return () => subscription.remove();
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
const interfaceContext = interfaceContextProp || detectedContext;
|
|
141
|
+
|
|
142
|
+
const requestPermission = useCallback(
|
|
143
|
+
async (permission: string): Promise<PermissionStatus> => {
|
|
144
|
+
// Gate on `KNOWN_PERMISSION_NAMES` (the Web Permissions API
|
|
145
|
+
// enum). With no host-advertised `AdapterType[]` allow-list,
|
|
146
|
+
// the SDK is the last line of defense against typos or
|
|
147
|
+
// unsupported names slipping into the runtime cache.
|
|
148
|
+
if (!KNOWN_PERMISSION_NAMES_SET.has(permission)) {
|
|
149
|
+
throw new UnknownPermissionNameError({ observedName: permission });
|
|
150
|
+
}
|
|
151
|
+
const status: PermissionStatus = permissionHandler
|
|
152
|
+
? await permissionHandler(permission)
|
|
153
|
+
: 'granted';
|
|
154
|
+
const key = permission as keyof AdapterPermissions;
|
|
155
|
+
setAdapterPermissions((prev) => ({ ...prev, [key]: status }));
|
|
156
|
+
return status;
|
|
157
|
+
},
|
|
158
|
+
[permissionHandler]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// `adapterImpls` is the runtime implementation slot ONLY. There is
|
|
162
|
+
// no manifest-level allow-list — grant decisions live on
|
|
163
|
+
// `clientCapabilities.gadgets[*].permission`. Pass all impls
|
|
164
|
+
// verbatim.
|
|
165
|
+
const resolvedAdapterImpls = useMemo<AdapterRegistry>(
|
|
166
|
+
() => (adapterImpls ?? {}) as AdapterRegistry,
|
|
167
|
+
[adapterImpls],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const value = useMemo<GguiContextValue>(
|
|
171
|
+
() => ({
|
|
172
|
+
appId,
|
|
173
|
+
wsEndpoint,
|
|
174
|
+
adapterPermissions,
|
|
175
|
+
adapterImpls: resolvedAdapterImpls,
|
|
176
|
+
requestPermission,
|
|
177
|
+
interfaceContext,
|
|
178
|
+
auth,
|
|
179
|
+
hostSessionId,
|
|
180
|
+
reactVersion,
|
|
181
|
+
designSystemUrl,
|
|
182
|
+
appConfig,
|
|
183
|
+
}),
|
|
184
|
+
[appId, wsEndpoint, adapterPermissions, resolvedAdapterImpls, requestPermission, interfaceContext, auth, hostSessionId, reactVersion, designSystemUrl, appConfig]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return <GguiContext.Provider value={value}>{children}</GguiContext.Provider>;
|
|
188
|
+
}
|