diff --git a/expo-app/CLAUDE.md b/expo-app/CLAUDE.md index 1ffcc9528b..3cf5c754a5 100644 --- a/expo-app/CLAUDE.md +++ b/expo-app/CLAUDE.md @@ -91,10 +91,18 @@ sources/ ├── app/ # Expo Router screens ├── auth/ # Authentication logic (QR code based) ├── components/ # Reusable UI components +├── clawdbot/ # Clawdbot gateway integration ├── sync/ # Real-time sync engine with encryption └── utils/ # Utility functions ``` +### Clawdbot Integration +The app includes Clawdbot gateway integration for AI chat sessions. Reference implementation: +- **Clawdbot source code**: `/Users/kirilldubovitskiy/projects/happy/research/clawdbot` +- **Gateway protocol**: See `research/clawdbot/src/gateway/` for server-side implementation +- **Web UI reference**: See `research/clawdbot/ui/src/ui/` for official web client implementation +- **Types**: Match types with `research/clawdbot/ui/src/ui/types.ts` (e.g., `GatewaySessionRow`, `SessionsListResult`) + ### Key Architectural Patterns 1. **Authentication Flow**: QR code-based authentication using expo-camera with challenge-response mechanism diff --git a/expo-app/metro.config.js b/expo-app/metro.config.js index ef943635ed..8378db1e98 100644 --- a/expo-app/metro.config.js +++ b/expo-app/metro.config.js @@ -20,4 +20,4 @@ config.transformer.getTransformOptions = async () => ({ }, }); -module.exports = config; \ No newline at end of file +module.exports = config; diff --git a/expo-app/package.json b/expo-app/package.json index cd2e1f6bdb..862863dd00 100644 --- a/expo-app/package.json +++ b/expo-app/package.json @@ -47,6 +47,7 @@ "@livekit/react-native-webrtc": "^137.0.0", "@lottiefiles/dotlottie-react": "^0.14.3", "@more-tech/react-native-libsodium": "^1.5.5", + "@noble/ed25519": "^3.0.0", "@peoplesgrocers/seti-ui-file-icons": "1.11.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/datetimepicker": "8.4.4", @@ -121,7 +122,7 @@ "expo-updates": "~29.0.11", "expo-web-browser": "~15.0.7", "fuse.js": "^7.1.0", - "libsodium-wrappers": "^0.7.15", + "libsodium-wrappers": "0.7.15", "livekit-client": "^2.15.4", "lottie-react-native": "~7.3.1", "mermaid": "^11.12.1", @@ -142,7 +143,7 @@ "react-native-purchases": "^9.4.2", "react-native-purchases-ui": "^9.4.2", "react-native-quick-base64": "^2.2.1", - "react-native-reanimated": "^4.2.1", + "react-native-reanimated": "^4.3.0-nightly-20260126-ab4831559", "react-native-safe-area-context": "~5.6.0", "react-native-screen-transitions": "^1.2.0", "react-native-screens": "~4.16.0", @@ -153,7 +154,7 @@ "react-native-vision-camera": "^4.7.3", "react-native-web": "^0.21.0", "react-native-webview": "13.15.0", - "react-native-worklets": "^0.7.1", + "react-native-worklets": "^0.8.0-nightly-20260126-ab4831559", "react-syntax-highlighter": "^15.6.1", "react-textarea-autosize": "^8.5.9", "resolve-path": "^1.4.0", diff --git a/expo-app/sources/-zen/components/TodoList.tsx b/expo-app/sources/-zen/components/TodoList.tsx index c4d50650e4..59ccfb2dd3 100644 --- a/expo-app/sources/-zen/components/TodoList.tsx +++ b/expo-app/sources/-zen/components/TodoList.tsx @@ -6,8 +6,9 @@ import Animated, { withSpring, useDerivedValue, SharedValue, + runOnJS, + runOnUI, } from 'react-native-reanimated'; -import { runOnJS, runOnUI, scheduleOnRN } from 'react-native-worklets'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { TODO_HEIGHT, TodoView } from './TodoView'; @@ -128,7 +129,7 @@ const AnimatedTodoItem = React.memo(({ // Call the reorder callback with the final position if (onReorder && finalPosition !== index) { - scheduleOnRN(onReorder, todo.id, finalPosition); + runOnJS(onReorder)(todo.id, finalPosition); } }) .onFinalize(() => { diff --git a/expo-app/sources/app/(app)/_layout.tsx b/expo-app/sources/app/(app)/_layout.tsx index e2d5a24b8e..3d27beba4d 100644 --- a/expo-app/sources/app/(app)/_layout.tsx +++ b/expo-app/sources/app/(app)/_layout.tsx @@ -388,6 +388,38 @@ export default function RootLayout() { headerBackTitle: t('common.back'), }} /> + + + + ); } diff --git a/expo-app/sources/app/(app)/clawdbot/chat/[sessionKey].tsx b/expo-app/sources/app/(app)/clawdbot/chat/[sessionKey].tsx new file mode 100644 index 0000000000..83cfbadcec --- /dev/null +++ b/expo-app/sources/app/(app)/clawdbot/chat/[sessionKey].tsx @@ -0,0 +1,427 @@ +import * as React from 'react'; +import { View, FlatList, Platform, KeyboardAvoidingView, Pressable, ActivityIndicator } from 'react-native'; +import { Text } from '@/components/StyledText'; +import { useLocalSearchParams, useNavigation } from 'expo-router'; +import { Typography } from '@/constants/Typography'; +import { Ionicons, Octicons } from '@expo/vector-icons'; +import { t } from '@/text'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ClawdbotSocket, useClawdbotStatus, useClawdbotChatEvents } from '@/clawdbot'; +import type { ClawdbotChatMessage } from '@/clawdbot'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { layout } from '@/components/layout'; +import { MultiTextInput } from '@/components/MultiTextInput'; +import { hapticsLight } from '@/components/haptics'; + +interface DisplayMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp?: number; + isStreaming?: boolean; +} + +export default React.memo(function ClawdbotChatScreen() { + const { sessionKey } = useLocalSearchParams<{ sessionKey: string }>(); + const navigation = useNavigation(); + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const { isConnected } = useClawdbotStatus(); + const { events, currentRunId, clearEvents } = useClawdbotChatEvents(sessionKey ?? null); + + const [messages, setMessages] = React.useState([]); + const [inputText, setInputText] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(true); + const [isSending, setIsSending] = React.useState(false); + const [streamingContent, setStreamingContent] = React.useState(''); + const flatListRef = React.useRef(null); + + // Set navigation title + React.useEffect(() => { + const label = sessionKey?.includes('/') + ? sessionKey.split('/').pop() + : sessionKey; + navigation.setOptions({ + headerTitle: label ?? t('clawdbot.chat'), + }); + }, [navigation, sessionKey]); + + // Load history on mount + React.useEffect(() => { + if (!sessionKey || !isConnected) return; + + setIsLoading(true); + ClawdbotSocket.getHistory(sessionKey) + .then((history) => { + const displayMessages: DisplayMessage[] = history.map((msg, idx) => ({ + id: `history-${idx}`, + role: msg.role, + content: extractTextContent(msg.content), + timestamp: msg.timestamp, + })); + setMessages(displayMessages); + }) + .catch((err) => { + console.error('Failed to load history:', err); + }) + .finally(() => { + setIsLoading(false); + }); + }, [sessionKey, isConnected]); + + // Handle streaming events + React.useEffect(() => { + if (events.length === 0) return; + + const latestEvent = events[events.length - 1]; + + if (latestEvent.state === 'started') { + setStreamingContent(''); + } else if (latestEvent.state === 'delta' && latestEvent.delta) { + setStreamingContent((prev) => prev + latestEvent.delta); + } else if (latestEvent.state === 'final' && latestEvent.message) { + // Add final message to list + const finalContent = extractTextContent(latestEvent.message.content); + setMessages((prev) => [ + ...prev, + { + id: `msg-${Date.now()}`, + role: 'assistant', + content: finalContent, + timestamp: Date.now(), + }, + ]); + setStreamingContent(''); + setIsSending(false); + clearEvents(); + } else if (latestEvent.state === 'error') { + setStreamingContent(''); + setIsSending(false); + clearEvents(); + } + }, [events, clearEvents]); + + // Auto-scroll to bottom + React.useEffect(() => { + if (flatListRef.current && (messages.length > 0 || streamingContent)) { + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } + }, [messages, streamingContent]); + + const hasText = inputText.trim().length > 0; + + const handleSend = React.useCallback(async () => { + if (!sessionKey || !inputText.trim() || isSending) return; + + const userMessage = inputText.trim(); + setInputText(''); + setIsSending(true); + hapticsLight(); + + // Add user message immediately + setMessages((prev) => [ + ...prev, + { + id: `user-${Date.now()}`, + role: 'user', + content: userMessage, + timestamp: Date.now(), + }, + ]); + + try { + await ClawdbotSocket.sendMessage(sessionKey, userMessage); + } catch (err) { + console.error('Failed to send message:', err); + setIsSending(false); + } + }, [sessionKey, inputText, isSending]); + + const handleAbort = React.useCallback(async () => { + if (!sessionKey || !currentRunId) return; + + try { + await ClawdbotSocket.abortRun(sessionKey, currentRunId); + } catch (err) { + console.error('Failed to abort:', err); + } + }, [sessionKey, currentRunId]); + + const renderMessage = React.useCallback(({ item }: { item: DisplayMessage }) => { + const isUser = item.role === 'user'; + return ( + + + + {item.content} + + + + ); + }, [theme]); + + if (!isConnected) { + return ( + + + + {t('clawdbot.notConnected')} + + + ); + } + + if (isLoading) { + return ( + + + + ); + } + + // Prepare data with streaming message if present + const displayData = [...messages]; + if (streamingContent) { + displayData.push({ + id: 'streaming', + role: 'assistant', + content: streamingContent, + isStreaming: true, + }); + } + + return ( + + item.id} + contentContainerStyle={[ + styles.listContent, + { paddingBottom: 16 }, + ]} + ListEmptyComponent={ + + + + {t('clawdbot.startConversation')} + + + } + /> + + {/* Input Area - matching AgentInput unified panel style */} + + + + {/* Input field */} + + + + + {/* Action buttons row */} + + {/* Abort button when running */} + + {currentRunId && ( + [ + styles.abortButton, + p.pressed && styles.buttonPressed, + ]} + > + + + )} + + + {/* Send button */} + + [ + styles.sendButtonInner, + p.pressed && styles.buttonPressed, + ]} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + onPress={handleSend} + disabled={!hasText || isSending} + > + {isSending ? ( + + ) : ( + + )} + + + + + + + + ); +}); + +function extractTextContent(content: ClawdbotChatMessage['content']): string { + if (typeof content === 'string') { + return content; + } + return content + .filter((block) => block.type === 'text' && block.text) + .map((block) => block.text) + .join('\n'); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 16, + }, + listContent: { + paddingHorizontal: 16, + paddingTop: 16, + }, + messageContainer: { + marginBottom: 12, + maxWidth: '85%', + }, + userMessage: { + alignSelf: 'flex-end', + }, + assistantMessage: { + alignSelf: 'flex-start', + }, + messageBubble: { + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 10, + }, + messageText: { + ...Typography.default(), + fontSize: 16, + lineHeight: 22, + }, + emptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingTop: 100, + gap: 12, + }, + emptyText: { + ...Typography.default(), + fontSize: 14, + textAlign: 'center', + }, + // Unified panel input styles (matching AgentInput) + inputOuter: { + alignItems: 'center', + paddingBottom: 8, + paddingTop: 8, + paddingHorizontal: 8, + }, + inputInner: { + width: '100%', + position: 'relative', + }, + unifiedPanel: { + borderRadius: Platform.select({ default: 16, android: 20 }), + overflow: 'hidden', + paddingVertical: 2, + paddingBottom: 8, + paddingHorizontal: 8, + }, + inputFieldContainer: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 0, + paddingLeft: 8, + paddingRight: 8, + paddingVertical: 4, + minHeight: 40, + }, + actionRow: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + paddingHorizontal: 0, + }, + actionLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + abortButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 8, + }, + sendButton: { + width: 32, + height: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + marginLeft: 8, + marginRight: 8, + }, + sendButtonInner: { + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + }, + buttonPressed: { + opacity: 0.7, + }, +})); diff --git a/expo-app/sources/app/(app)/clawdbot/connect.tsx b/expo-app/sources/app/(app)/clawdbot/connect.tsx new file mode 100644 index 0000000000..f3cacc24b7 --- /dev/null +++ b/expo-app/sources/app/(app)/clawdbot/connect.tsx @@ -0,0 +1,386 @@ +import * as React from 'react'; +import { View, TextInput, Platform } from 'react-native'; +import { Text } from '@/components/StyledText'; +import { useRouter } from 'expo-router'; +import { Typography } from '@/constants/Typography'; +import { RoundButton } from '@/components/RoundButton'; +import { Ionicons } from '@expo/vector-icons'; +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { t } from '@/text'; +import { useUnistyles } from 'react-native-unistyles'; +import { ClawdbotSocket, useClawdbotStatus } from '@/clawdbot'; +import { saveClawdbotConfig, loadClawdbotConfig, clearClawdbotConfig } from '@/clawdbot'; +import { useHappyAction } from '@/hooks/useHappyAction'; + +export default React.memo(function ClawdbotConnectScreen() { + const router = useRouter(); + const { theme } = useUnistyles(); + const { status, isConnected, isPairingRequired, deviceId, serverHost, retryConnect } = useClawdbotStatus(); + + // Load saved config on mount + const savedConfig = React.useMemo(() => loadClawdbotConfig(), []); + const [url, setUrl] = React.useState(savedConfig?.url ?? 'ws://127.0.0.1:18789'); + const [token, setToken] = React.useState(savedConfig?.token ?? ''); + const [password, setPassword] = React.useState(savedConfig?.password ?? ''); + + const [isConnecting, doConnect] = useHappyAction(React.useCallback(async () => { + const config = { + url: url.trim(), + token: token.trim() || undefined, + password: password.trim() || undefined, + }; + saveClawdbotConfig(config); + ClawdbotSocket.connect(config); + + // Wait a bit for connection + await new Promise((resolve) => setTimeout(resolve, 2000)); + + if (ClawdbotSocket.isConnected()) { + router.back(); + } + }, [url, token, password, router])); + + const handleDisconnect = React.useCallback(() => { + ClawdbotSocket.disconnect(); + clearClawdbotConfig(); + }, []); + + // If already connected, show connected state + if (isConnected) { + return ( + + + + + + {t('clawdbot.connected')} + + + {serverHost ?? url} + + + + + + + router.replace('/(app)/clawdbot')} + size="large" + /> + + + + + ); + } + + // Show pairing required UI + if (isPairingRequired) { + const shortDeviceId = deviceId ? `${deviceId.slice(0, 8)}...${deviceId.slice(-8)}` : ''; + return ( + + + + + + {t('clawdbot.pairingRequired')} + + + {t('clawdbot.pairingDescription')} + + + + + {/* Instructions */} + + 1} + showChevron={false} + /> + 2} + showChevron={false} + /> + 3} + showChevron={false} + /> + + + {/* Device ID for reference */} + {deviceId && ( + + } + showChevron={false} + /> + + )} + + {/* Retry Button */} + + + + + + + + ); + } + + return ( + + {/* Header */} + + + + + {t('clawdbot.connectTitle')} + + + {t('clawdbot.connectDescription')} + + + + + {/* Connection Settings */} + + } + /> + + + + + } + /> + + + + + + {/* Token Command Help */} + + + + {t('clawdbot.tokenCommandHint')} + + + + {t('clawdbot.tokenCommandValue')} + + + + {t('clawdbot.tokenCommandDescription')} + + + + + {/* Status */} + {status === 'error' && ( + + } + showChevron={false} + /> + + )} + + {/* Connect Button */} + + + + + + + {/* Info */} + + } + showChevron={false} + /> + + + ); +}); diff --git a/expo-app/sources/app/(app)/clawdbot/index.tsx b/expo-app/sources/app/(app)/clawdbot/index.tsx new file mode 100644 index 0000000000..2241a09af2 --- /dev/null +++ b/expo-app/sources/app/(app)/clawdbot/index.tsx @@ -0,0 +1,207 @@ +import * as React from 'react'; +import { View, RefreshControl } from 'react-native'; +import { Text } from '@/components/StyledText'; +import { useRouter } from 'expo-router'; +import { Typography } from '@/constants/Typography'; +import { RoundButton } from '@/components/RoundButton'; +import { Ionicons } from '@expo/vector-icons'; +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { t } from '@/text'; +import { useUnistyles } from 'react-native-unistyles'; +import { useClawdbotStatus, useClawdbotSessions, ClawdbotSocket } from '@/clawdbot'; +import type { ClawdbotSession } from '@/clawdbot'; + +export default React.memo(function ClawdbotSessionsScreen() { + const router = useRouter(); + const { theme } = useUnistyles(); + const { isConnected, serverHost } = useClawdbotStatus(); + const { sessions, loading, error, refresh } = useClawdbotSessions(); + + // Not connected - show connect prompt + if (!isConnected) { + return ( + + + + + + {t('clawdbot.notConnected')} + + + {t('clawdbot.notConnectedDescription')} + + + + + + + router.push('/(app)/clawdbot/connect')} + size="large" + /> + + + + ); + } + + const handleSessionPress = (session: ClawdbotSession) => { + router.push({ + pathname: '/(app)/clawdbot/chat/[sessionKey]', + params: { sessionKey: session.key } + }); + }; + + const handleNewChat = () => { + router.push('/(app)/clawdbot/new'); + }; + + return ( + + } + > + {/* Connection Status */} + + } + showChevron={false} + /> + + + {/* New Chat Button */} + + + + + + + {/* Error State */} + {error && ( + + } + showChevron={false} + /> + + )} + + {/* Sessions List */} + {sessions.length > 0 && ( + + {sessions.map((session) => ( + } + onPress={() => handleSessionPress(session)} + /> + ))} + + )} + + {/* Empty State */} + {!loading && !error && sessions.length === 0 && ( + + + + + {t('clawdbot.noSessions')} + + + + )} + + {/* Settings Link */} + + } + onPress={() => router.push('/(app)/clawdbot/connect')} + /> + + + ); +}); + +function formatSessionSubtitle(session: ClawdbotSession): string { + const parts: string[] = []; + + // Show kind/surface info + if (session.surface) { + parts.push(session.surface); + } else if (session.kind && session.kind !== 'unknown') { + parts.push(session.kind); + } + + // Show token count if available + if (session.totalTokens !== undefined && session.totalTokens > 0) { + parts.push(`${session.totalTokens.toLocaleString()} tokens`); + } + + // Show last update time + if (session.updatedAt) { + const date = new Date(session.updatedAt); + parts.push(date.toLocaleDateString()); + } + + return parts.join(' • ') || session.key; +} diff --git a/expo-app/sources/app/(app)/clawdbot/new.tsx b/expo-app/sources/app/(app)/clawdbot/new.tsx new file mode 100644 index 0000000000..fece0ea7ea --- /dev/null +++ b/expo-app/sources/app/(app)/clawdbot/new.tsx @@ -0,0 +1,268 @@ +import * as React from 'react'; +import { View, Platform, KeyboardAvoidingView, Pressable, ActivityIndicator } from 'react-native'; +import { Text } from '@/components/StyledText'; +import { useRouter } from 'expo-router'; +import { Typography } from '@/constants/Typography'; +import { Octicons, Ionicons } from '@expo/vector-icons'; +import { t } from '@/text'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ClawdbotSocket, useClawdbotStatus } from '@/clawdbot'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { layout } from '@/components/layout'; +import { MultiTextInput } from '@/components/MultiTextInput'; +import { hapticsLight } from '@/components/haptics'; + +/** + * Simplified composer for starting new Clawdbot sessions. + * Styled to match the main app's AgentInput component. + */ +export default React.memo(function ClawdbotNewSessionScreen() { + const router = useRouter(); + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const { isConnected, mainSessionKey } = useClawdbotStatus(); + + const [inputText, setInputText] = React.useState(''); + const [isSending, setIsSending] = React.useState(false); + + const hasText = inputText.trim().length > 0; + + const handleSend = React.useCallback(async () => { + if (!inputText.trim() || isSending || !isConnected) return; + + const message = inputText.trim(); + setIsSending(true); + hapticsLight(); + + try { + // Always create a new unique session key for new chats + // (don't use mainSessionKey - that's the persistent "main" session) + const sessionKey = `happy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Send the message to start the session + await ClawdbotSocket.sendMessage(sessionKey, message); + + // Navigate to the chat screen + router.replace({ + pathname: '/(app)/clawdbot/chat/[sessionKey]', + params: { sessionKey } + }); + } catch (err) { + console.error('Failed to start session:', err); + setIsSending(false); + } + }, [inputText, isSending, isConnected, mainSessionKey, router]); + + // Not connected state + if (!isConnected) { + return ( + + + + {t('clawdbot.notConnected')} + + router.push('/(app)/clawdbot/connect')} + style={[styles.connectButton, { backgroundColor: theme.colors.button.primary.background }]} + > + + {t('clawdbot.connectToGateway')} + + + + ); + } + + return ( + + {/* Empty state with prompt */} + + + + + {t('clawdbot.newSessionTitle')} + + + {t('clawdbot.newSessionDescription')} + + + + + {/* Composer - matching AgentInput styling */} + + + + {/* Input field */} + + + + + {/* Action buttons row */} + + {/* Placeholder for future action chips */} + + + {/* Send button */} + + [ + styles.sendButtonInner, + p.pressed && styles.sendButtonPressed, + ]} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + onPress={handleSend} + disabled={!hasText || isSending} + > + {isSending ? ( + + ) : ( + + )} + + + + + + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 16, + paddingHorizontal: 32, + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 32, + }, + promptContainer: { + alignItems: 'center', + maxWidth: 320, + }, + title: { + ...Typography.default('semiBold'), + fontSize: 22, + textAlign: 'center', + marginBottom: 8, + }, + subtitle: { + ...Typography.default(), + fontSize: 15, + textAlign: 'center', + lineHeight: 22, + }, + emptyText: { + ...Typography.default(), + fontSize: 14, + textAlign: 'center', + }, + connectButton: { + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 12, + marginTop: 8, + }, + connectButtonText: { + ...Typography.default('semiBold'), + fontSize: 16, + }, + // Composer styles matching AgentInput + inputOuter: { + alignItems: 'center', + paddingBottom: 8, + paddingTop: 8, + paddingHorizontal: 8, + }, + inputInner: { + width: '100%', + position: 'relative', + }, + unifiedPanel: { + borderRadius: Platform.select({ default: 16, android: 20 }), + overflow: 'hidden', + paddingVertical: 2, + paddingBottom: 8, + paddingHorizontal: 8, + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 0, + paddingLeft: 8, + paddingRight: 8, + paddingVertical: 4, + minHeight: 40, + }, + actionRow: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + paddingHorizontal: 0, + }, + actionLeft: { + flex: 1, + }, + sendButton: { + width: 32, + height: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + marginLeft: 8, + marginRight: 8, + }, + sendButtonInner: { + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + }, + sendButtonPressed: { + opacity: 0.7, + }, +})); diff --git a/expo-app/sources/clawdbot/ClawdbotSocket.ts b/expo-app/sources/clawdbot/ClawdbotSocket.ts new file mode 100644 index 0000000000..18147d1e9b --- /dev/null +++ b/expo-app/sources/clawdbot/ClawdbotSocket.ts @@ -0,0 +1,509 @@ +import { randomUUID } from 'expo-crypto'; +import { Platform } from 'react-native'; +import type { + ClawdbotGatewayConfig, + ClawdbotFrame, + ClawdbotConnectParams, + ClawdbotHelloOk, + ClawdbotSession, + ClawdbotChatMessage, + ClawdbotChatHistoryResult, + ClawdbotSessionsListResult, + ClawdbotChatSendResult, + ClawdbotClientId, + ClawdbotClientMode, +} from './clawdbotTypes'; +import { + loadOrCreateDeviceIdentity, + loadDeviceAuthToken, + storeDeviceAuthToken, + buildDeviceAuthPayload, + signPayload, + getPublicKeyBase64Url, +} from './deviceIdentity'; + +const PROTOCOL_VERSION = 3; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +export type ClawdbotConnectionStatus = + | 'disconnected' + | 'connecting' + | 'connected' + | 'pairing_required' + | 'error'; + +export type ClawdbotEventHandler = (event: string, payload: unknown) => void; +export type ClawdbotStatusHandler = (status: ClawdbotConnectionStatus, error?: string, details?: { pairingRequestId?: string }) => void; + +/** + * ClawdbotSocket - Raw WebSocket client for Clawdbot Gateway + * + * Implements the Clawdbot gateway protocol (req/res/event frames) + * for direct communication with a user's local or remote gateway. + * Uses device identity for secure authentication with pairing flow. + */ +class ClawdbotSocketClass { + private ws: WebSocket | null = null; + private config: ClawdbotGatewayConfig | null = null; + private pending = new Map(); + private status: ClawdbotConnectionStatus = 'disconnected'; + private reconnectTimer: ReturnType | null = null; + private mainSessionKey: string | null = null; + private serverHost: string | null = null; + private pairingRequestId: string | null = null; + private deviceId: string | null = null; + private connectNonce: string | null = null; + private connectSent = false; + + // Listeners + private statusListeners = new Set(); + private eventListeners = new Set(); + + // Public getters + getStatus(): ClawdbotConnectionStatus { + return this.status; + } + getMainSessionKey(): string | null { + return this.mainSessionKey; + } + getServerHost(): string | null { + return this.serverHost; + } + isConnected(): boolean { + return this.status === 'connected'; + } + getConfig(): ClawdbotGatewayConfig | null { + return this.config; + } + getPairingRequestId(): string | null { + return this.pairingRequestId; + } + getDeviceId(): string | null { + return this.deviceId; + } + + /** + * Connect to a Clawdbot gateway + */ + connect(config: ClawdbotGatewayConfig) { + this.config = config; + this.pairingRequestId = null; + this.doConnect(); + } + + /** + * Disconnect from gateway + */ + disconnect() { + this.config = null; + this.clearReconnectTimer(); + this.closeSocket(); + this.updateStatus('disconnected'); + this.mainSessionKey = null; + this.serverHost = null; + this.pairingRequestId = null; + } + + /** + * Retry connection (e.g., after pairing is approved) + */ + retryConnect() { + if (this.config) { + this.pairingRequestId = null; + this.doConnect(); + } + } + + /** + * Register a status change listener + */ + onStatusChange(handler: ClawdbotStatusHandler): () => void { + this.statusListeners.add(handler); + handler(this.status, undefined, { pairingRequestId: this.pairingRequestId ?? undefined }); + return () => this.statusListeners.delete(handler); + } + + /** + * Register an event listener (for chat events, etc.) + */ + onEvent(handler: ClawdbotEventHandler): () => void { + this.eventListeners.add(handler); + return () => this.eventListeners.delete(handler); + } + + /** + * Send a request to the gateway and wait for response + */ + async request(method: string, params?: unknown, timeoutMs = 15000): Promise { + if (!this.ws || this.status !== 'connected') { + throw new Error('Not connected to gateway'); + } + + const id = randomUUID(); + const frame = { type: 'req', id, method, params }; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Request timeout: ${method}`)); + }, timeoutMs); + + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value as T); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + + this.ws!.send(JSON.stringify(frame)); + }); + } + + // ───────────────────────────────────────────────────────────── + // High-level API methods + // ───────────────────────────────────────────────────────────── + + /** + * List all chat sessions + */ + async listSessions(limit?: number): Promise { + const result = await this.request( + 'sessions.list', + { includeGlobal: true, includeUnknown: false, limit } + ); + console.log('[Clawdbot] sessions.list response:', JSON.stringify(result, null, 2)); + return result.sessions ?? []; + } + + /** + * Get chat history for a session + */ + async getHistory(sessionKey: string): Promise { + const result = await this.request( + 'chat.history', + { sessionKey } + ); + return result.messages ?? []; + } + + /** + * Send a message to a session + */ + async sendMessage( + sessionKey: string, + message: string, + options?: { thinking?: string; attachments?: unknown[] } + ): Promise { + const result = await this.request( + 'chat.send', + { + sessionKey, + message, + thinking: options?.thinking ?? 'low', + attachments: options?.attachments, + timeoutMs: 30000, + idempotencyKey: randomUUID(), + }, + 35000 + ); + return result; + } + + /** + * Abort an in-progress run + */ + async abortRun(sessionKey: string, runId?: string): Promise { + await this.request('chat.abort', { sessionKey, runId }, 10000); + } + + /** + * Health check + */ + async healthCheck(): Promise { + try { + const result = await this.request<{ ok?: boolean }>('health', undefined, 5000); + return result.ok !== false; + } catch { + return false; + } + } + + // ───────────────────────────────────────────────────────────── + // Private implementation + // ───────────────────────────────────────────────────────────── + + private doConnect() { + if (!this.config) return; + + this.updateStatus('connecting'); + this.closeSocket(); + this.connectNonce = null; + this.connectSent = false; + + const url = this.config.url; + console.log(`[Clawdbot] Connecting to gateway: ${url}`); + + try { + this.ws = new WebSocket(url); + } catch (err) { + console.error(`[Clawdbot] Failed to create WebSocket:`, err); + this.updateStatus('error', 'Failed to create connection'); + this.scheduleReconnect(); + return; + } + + this.ws.onopen = () => { + console.log('[Clawdbot] WebSocket opened, waiting for challenge...'); + // Don't send connect immediately - wait for challenge event + }; + + this.ws.onmessage = (event) => { + this.handleMessage(event.data as string); + }; + + this.ws.onerror = (event) => { + console.error(`[Clawdbot] WebSocket error:`, event); + if (this.status === 'connecting') { + this.updateStatus('error', 'Connection failed'); + } + }; + + this.ws.onclose = (event) => { + console.log(`[Clawdbot] WebSocket closed: ${event.code} ${event.reason}`); + this.failAllPending(new Error('Connection closed')); + // Don't auto-reconnect if pairing is required + if (this.config && this.status !== 'pairing_required') { + this.scheduleReconnect(); + } + }; + } + + private async sendConnect() { + if (!this.ws || !this.config || this.connectSent) return; + this.connectSent = true; + + try { + // Load device identity (creates one if doesn't exist) + const identity = await loadOrCreateDeviceIdentity(); + this.deviceId = identity.deviceId; + console.log(`[Clawdbot] Using device ID: ${identity.deviceId.slice(0, 16)}...`); + + // Load stored auth token if available + const storedToken = await loadDeviceAuthToken(); + + // Client IDs for each platform + const clientId: ClawdbotClientId = + Platform.OS === 'ios' ? 'clawdbot-ios' : + Platform.OS === 'android' ? 'clawdbot-android' : + 'webchat-ui'; + + const clientMode: ClawdbotClientMode = 'ui'; + const role = 'operator'; + const scopes = ['operator.admin', 'operator.approvals', 'operator.pairing']; + const signedAtMs = Date.now(); + + // Determine auth token (config token takes priority, then stored device token) + const authToken = this.config.token ?? storedToken?.token ?? undefined; + + // Build and sign device auth payload (with nonce if available) + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: authToken ?? null, + nonce: this.connectNonce, + }); + const signature = await signPayload(identity.privateKey, payload); + + const params: ClawdbotConnectParams = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: clientId, + displayName: 'Happy', + version: '1.0.0', + platform: Platform.OS, + mode: clientMode, + }, + role, + scopes, + device: { + id: identity.deviceId, + publicKey: getPublicKeyBase64Url(identity.publicKey), + signature, + signedAt: signedAtMs, + nonce: this.connectNonce ?? undefined, + }, + auth: authToken + ? { token: authToken } + : this.config.password + ? { password: this.config.password } + : undefined, + }; + + // Send connect request + const id = randomUUID(); + const frame = { type: 'req', id, method: 'connect', params }; + + const resultPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error('Connect timeout')); + }, 10000); + + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value as ClawdbotHelloOk); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + }); + + this.ws.send(JSON.stringify(frame)); + + const result = await resultPromise; + + // Store device auth token if provided + if (result.auth?.deviceToken) { + console.log('[Clawdbot] Storing device auth token'); + await storeDeviceAuthToken({ + token: result.auth.deviceToken, + role: result.auth.role ?? role, + scopes: result.auth.scopes ?? scopes, + }); + } + + this.mainSessionKey = result.snapshot?.sessionDefaults?.mainSessionKey ?? null; + this.serverHost = result.server?.host ?? null; + this.updateStatus('connected'); + console.log(`[Clawdbot] Connected! Server: ${this.serverHost}, Main session: ${this.mainSessionKey}`); + } catch (error) { + console.error(`[Clawdbot] Connect failed:`, error); + + // Check if pairing is required + const errorMsg = error instanceof Error ? error.message : ''; + if (errorMsg.includes('NOT_PAIRED')) { + // Extract request ID from error details if available + const match = errorMsg.match(/requestId['":\s]+([a-f0-9-]+)/i); + this.pairingRequestId = match?.[1] ?? null; + this.updateStatus('pairing_required', 'Device pairing required', { pairingRequestId: this.pairingRequestId ?? undefined }); + this.closeSocket(); + return; + } + + this.updateStatus('error', error instanceof Error ? error.message : 'Connect failed'); + this.closeSocket(); + this.scheduleReconnect(); + } + } + + private handleMessage(data: string) { + let frame: ClawdbotFrame; + try { + frame = JSON.parse(data); + } catch { + console.error(`[Clawdbot] Invalid JSON: ${data.slice(0, 100)}`); + return; + } + + if (frame.type === 'res') { + // Response to a pending request + const pending = this.pending.get(frame.id); + if (pending) { + this.pending.delete(frame.id); + if (frame.ok) { + pending.resolve(frame.payload); + } else { + const err = frame.error; + pending.reject(new Error(`${err?.code ?? 'ERROR'}: ${err?.message ?? 'Request failed'}`)); + } + } + } else if (frame.type === 'event') { + // Server-pushed event + let payload = frame.payload; + if (!payload && frame.payloadJSON) { + try { + payload = JSON.parse(frame.payloadJSON); + } catch { + // ignore + } + } + + // Handle connect.challenge event - receive nonce and send connect + if (frame.event === 'connect.challenge' && !this.connectSent) { + const nonce = (payload as { nonce?: string } | undefined)?.nonce; + if (nonce) { + console.log(`[Clawdbot] Received challenge nonce: ${nonce.slice(0, 8)}...`); + this.connectNonce = nonce; + } + this.sendConnect(); + return; + } + + this.eventListeners.forEach((handler) => handler(frame.event, payload)); + } + } + + private updateStatus(status: ClawdbotConnectionStatus, error?: string, details?: { pairingRequestId?: string }) { + this.status = status; + this.statusListeners.forEach((handler) => handler(status, error, details)); + } + + private closeSocket() { + if (this.ws) { + this.ws.onopen = null; + this.ws.onmessage = null; + this.ws.onerror = null; + this.ws.onclose = null; + try { + this.ws.close(); + } catch { + /* ignore */ + } + this.ws = null; + } + } + + private failAllPending(error: Error) { + for (const [, pending] of this.pending) { + pending.reject(error); + } + this.pending.clear(); + } + + private scheduleReconnect() { + if (!this.config) return; // Intentionally disconnected + + this.clearReconnectTimer(); + this.updateStatus('disconnected'); + + this.reconnectTimer = setTimeout(() => { + this.doConnect(); + }, 3000); + } + + private clearReconnectTimer() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } +} + +// Singleton export +export const ClawdbotSocket = new ClawdbotSocketClass(); diff --git a/expo-app/sources/clawdbot/clawdbotStorage.ts b/expo-app/sources/clawdbot/clawdbotStorage.ts new file mode 100644 index 0000000000..086a9901b1 --- /dev/null +++ b/expo-app/sources/clawdbot/clawdbotStorage.ts @@ -0,0 +1,39 @@ +import { MMKV } from 'react-native-mmkv'; +import type { ClawdbotGatewayConfig } from './clawdbotTypes'; + +const STORAGE_KEY = 'clawdbot-gateway-config'; +const mmkv = new MMKV(); + +/** + * Load saved gateway configuration from storage + */ +export function loadClawdbotConfig(): ClawdbotGatewayConfig | null { + const raw = mmkv.getString(STORAGE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as ClawdbotGatewayConfig; + } catch { + return null; + } +} + +/** + * Save gateway configuration to storage + */ +export function saveClawdbotConfig(config: ClawdbotGatewayConfig): void { + mmkv.set(STORAGE_KEY, JSON.stringify(config)); +} + +/** + * Clear gateway configuration from storage + */ +export function clearClawdbotConfig(): void { + mmkv.delete(STORAGE_KEY); +} + +/** + * Check if a gateway config is saved + */ +export function hasClawdbotConfig(): boolean { + return mmkv.contains(STORAGE_KEY); +} diff --git a/expo-app/sources/clawdbot/clawdbotTypes.ts b/expo-app/sources/clawdbot/clawdbotTypes.ts new file mode 100644 index 0000000000..b2ed80d955 --- /dev/null +++ b/expo-app/sources/clawdbot/clawdbotTypes.ts @@ -0,0 +1,167 @@ +/** + * Clawdbot Gateway Protocol Types + * + * These types match the Clawdbot gateway WebSocket protocol. + * Reference: clawdbot/src/gateway/protocol/schema.ts + */ + +export interface ClawdbotGatewayConfig { + url: string; // e.g., "ws://192.168.1.100:18789" or Tailscale URL + token?: string; // Auth token (for remote access) + password?: string; // Auth password (alternative) +} + +// Frame types matching Clawdbot protocol +export interface ClawdbotRequestFrame { + type: 'req'; + id: string; + method: string; + params?: unknown; +} + +export interface ClawdbotResponseFrame { + type: 'res'; + id: string; + ok: boolean; + payload?: unknown; + error?: { code: string; message: string }; +} + +export interface ClawdbotEventFrame { + type: 'event'; + event: string; + payload?: unknown; + payloadJSON?: string; + seq?: number; +} + +export type ClawdbotFrame = ClawdbotRequestFrame | ClawdbotResponseFrame | ClawdbotEventFrame; + +// Valid client IDs accepted by the gateway protocol +export type ClawdbotClientId = + | 'webchat-ui' + | 'clawdbot-control-ui' + | 'webchat' + | 'cli' + | 'gateway-client' + | 'clawdbot-macos' + | 'clawdbot-ios' + | 'clawdbot-android' + | 'node-host' + | 'test' + | 'fingerprint' + | 'clawdbot-probe'; + +// Valid client modes accepted by the gateway protocol +export type ClawdbotClientMode = + | 'webchat' + | 'cli' + | 'ui' + | 'backend' + | 'node' + | 'probe' + | 'test'; + +// Device identity for secure authentication +export interface ClawdbotDeviceIdentity { + id: string; // Device ID (SHA256 hash of public key) + publicKey: string; // Base64URL encoded Ed25519 public key + signature: string; // Base64URL encoded signature of auth payload + signedAt: number; // Timestamp when payload was signed + nonce?: string; // Nonce for remote connections +} + +// Connect params (simplified - full spec in clawdbot/src/gateway/protocol/schema.ts) +export interface ClawdbotConnectParams { + minProtocol: number; + maxProtocol: number; + client: { + id: ClawdbotClientId; + displayName?: string; + version: string; + platform: string; + mode: ClawdbotClientMode; + }; + role: string; + scopes: string[]; + device?: ClawdbotDeviceIdentity; + auth?: { token?: string; password?: string }; +} + +export interface ClawdbotHelloOk { + server?: { host?: string }; + snapshot?: { + sessionDefaults?: { mainSessionKey?: string }; + }; + auth?: { + deviceToken?: string; // Token issued after successful pairing + role?: string; + scopes?: string[]; + }; +} + +// Session types (matches GatewaySessionRow from clawdbot) +export interface ClawdbotSession { + key: string; + kind: 'direct' | 'group' | 'global' | 'unknown'; + label?: string; + displayName?: string; + surface?: string; + subject?: string; + room?: string; + space?: string; + updatedAt: number | null; + sessionId?: string; + systemSent?: boolean; + abortedLastRun?: boolean; + thinkingLevel?: string; + verboseLevel?: string; + reasoningLevel?: string; + elevatedLevel?: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + model?: string; + modelProvider?: string; + contextTokens?: number; +} + +export interface ClawdbotSessionsListResult { + ts: number; + path: string; + count: number; + defaults: { model: string | null; contextTokens: number | null }; + sessions: ClawdbotSession[]; +} + +// Chat message types +export interface ClawdbotChatMessage { + role: 'user' | 'assistant'; + content: Array<{ type: string; text?: string }> | string; + timestamp?: number; + stopReason?: string; +} + +export interface ClawdbotChatHistoryResult { + sessionKey: string; + sessionId?: string; + messages: ClawdbotChatMessage[]; + thinkingLevel?: string; +} + +// Chat events (streamed from gateway) +export interface ClawdbotChatEvent { + runId: string; + sessionKey: string; + seq: number; + state: 'started' | 'thinking' | 'delta' | 'tool' | 'final' | 'error'; + message?: ClawdbotChatMessage; + delta?: string; + errorMessage?: string; +} + +export interface ClawdbotChatSendResult { + runId: string; + status: 'started' | 'ok' | 'error' | 'in_flight'; + summary?: string; +} diff --git a/expo-app/sources/clawdbot/deviceIdentity.ts b/expo-app/sources/clawdbot/deviceIdentity.ts new file mode 100644 index 0000000000..63868ffa1e --- /dev/null +++ b/expo-app/sources/clawdbot/deviceIdentity.ts @@ -0,0 +1,303 @@ +/** + * Clawdbot Device Identity + * + * Manages device identity for secure gateway authentication. + * Uses Ed25519 for signing, matching the official Clawdbot UI implementation. + */ + +import * as SecureStore from 'expo-secure-store'; +import { Platform } from 'react-native'; +import { getPublicKeyAsync, signAsync, utils } from '@noble/ed25519'; + +const DEVICE_IDENTITY_KEY = 'clawdbot-device-identity-v1'; +const DEVICE_AUTH_TOKEN_KEY = 'clawdbot_device_auth_token'; + +export interface DeviceIdentity { + deviceId: string; + publicKey: string; // base64url encoded + privateKey: string; // base64url encoded (32-byte seed) +} + +interface StoredDeviceIdentity { + version: 1; + deviceId: string; + publicKey: string; + privateKey: string; + createdAtMs: number; +} + +interface StoredDeviceAuthToken { + token: string; + role: string; + scopes: string[]; + createdAtMs: number; +} + +// In-memory cache +let identityCache: DeviceIdentity | null = null; +let authTokenCache: StoredDeviceAuthToken | null = null; + +// Base64URL encoding/decoding (matching official implementation) +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, ''); +} + +function base64UrlDecode(input: string): Uint8Array { + const normalized = input.replaceAll('-', '+').replaceAll('_', '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +// Storage helpers (web uses localStorage, native uses SecureStore) +async function getStorageItem(key: string): Promise { + if (Platform.OS === 'web') { + return localStorage.getItem(key); + } + return SecureStore.getItemAsync(key); +} + +async function setStorageItem(key: string, value: string): Promise { + if (Platform.OS === 'web') { + localStorage.setItem(key, value); + return; + } + await SecureStore.setItemAsync(key, value); +} + +async function deleteStorageItem(key: string): Promise { + if (Platform.OS === 'web') { + localStorage.removeItem(key); + return; + } + await SecureStore.deleteItemAsync(key); +} + +/** + * Derive device ID from public key (SHA-256 hash, hex encoded) + */ +async function fingerprintPublicKey(publicKey: Uint8Array): Promise { + const hash = await crypto.subtle.digest('SHA-256', publicKey.buffer as ArrayBuffer); + return bytesToHex(new Uint8Array(hash)); +} + +/** + * Generate a new device identity (Ed25519 key pair using @noble/ed25519) + */ +async function generateDeviceIdentity(): Promise { + const privateKey = utils.randomSecretKey(); // 32-byte seed + const publicKey = await getPublicKeyAsync(privateKey); + const deviceId = await fingerprintPublicKey(publicKey); + return { + deviceId, + publicKey: base64UrlEncode(publicKey), + privateKey: base64UrlEncode(privateKey), + }; +} + +/** + * Load or create device identity + */ +export async function loadOrCreateDeviceIdentity(): Promise { + // Return cached if available + if (identityCache) { + return identityCache; + } + + // Try to load from storage + const stored = await getStorageItem(DEVICE_IDENTITY_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored) as StoredDeviceIdentity; + if ( + parsed?.version === 1 && + typeof parsed.deviceId === 'string' && + typeof parsed.publicKey === 'string' && + typeof parsed.privateKey === 'string' + ) { + // Verify device ID matches + const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey)); + if (derivedId !== parsed.deviceId) { + // Update stored identity with correct device ID + const updated: StoredDeviceIdentity = { + ...parsed, + deviceId: derivedId, + }; + await setStorageItem(DEVICE_IDENTITY_KEY, JSON.stringify(updated)); + identityCache = { + deviceId: derivedId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + return identityCache; + } + identityCache = { + deviceId: parsed.deviceId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + return identityCache; + } + } catch (e) { + console.warn('[Clawdbot] Failed to parse stored device identity, regenerating:', e); + } + } + + // Generate new identity + console.log('[Clawdbot] Generating new device identity...'); + const identity = await generateDeviceIdentity(); + + // Store it + const toStore: StoredDeviceIdentity = { + version: 1, + deviceId: identity.deviceId, + publicKey: identity.publicKey, + privateKey: identity.privateKey, + createdAtMs: Date.now(), + }; + await setStorageItem(DEVICE_IDENTITY_KEY, JSON.stringify(toStore)); + + identityCache = identity; + return identity; +} + +/** + * Get device identity if it exists (doesn't create new one) + */ +export async function getDeviceIdentity(): Promise { + if (identityCache) return identityCache; + + const stored = await getStorageItem(DEVICE_IDENTITY_KEY); + if (!stored) return null; + + try { + const parsed = JSON.parse(stored) as StoredDeviceIdentity; + if ( + parsed?.version === 1 && + typeof parsed.publicKey === 'string' && + typeof parsed.privateKey === 'string' + ) { + const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey)); + identityCache = { + deviceId: derivedId === parsed.deviceId ? parsed.deviceId : derivedId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + return identityCache; + } + } catch { + // Ignore + } + return null; +} + +/** + * Clear device identity (for testing/reset) + */ +export async function clearDeviceIdentity(): Promise { + identityCache = null; + authTokenCache = null; + await deleteStorageItem(DEVICE_IDENTITY_KEY); + await deleteStorageItem(DEVICE_AUTH_TOKEN_KEY); +} + +/** + * Build the device auth payload string (to be signed) + */ +export function buildDeviceAuthPayload(params: { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + token?: string | null; + nonce?: string | null; +}): string { + const version = params.nonce ? 'v2' : 'v1'; + const scopes = params.scopes.join(','); + const token = params.token ?? ''; + const base = [ + version, + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + ]; + if (version === 'v2') { + base.push(params.nonce ?? ''); + } + return base.join('|'); +} + +/** + * Sign a payload with the device's private key + */ +export async function signPayload(privateKeyBase64Url: string, payload: string): Promise { + const key = base64UrlDecode(privateKeyBase64Url); + const data = new TextEncoder().encode(payload); + const sig = await signAsync(data, key); + return base64UrlEncode(sig); +} + +/** + * Get the public key in base64url format (for sending to gateway) + */ +export function getPublicKeyBase64Url(publicKeyBase64Url: string): string { + // Already in base64url format + return publicKeyBase64Url; +} + +/** + * Store device auth token (received after successful pairing) + */ +export async function storeDeviceAuthToken(params: { + token: string; + role: string; + scopes: string[]; +}): Promise { + const stored: StoredDeviceAuthToken = { + token: params.token, + role: params.role, + scopes: params.scopes, + createdAtMs: Date.now(), + }; + await setStorageItem(DEVICE_AUTH_TOKEN_KEY, JSON.stringify(stored)); + authTokenCache = stored; +} + +/** + * Load device auth token + */ +export async function loadDeviceAuthToken(): Promise { + if (authTokenCache) return authTokenCache; + + const stored = await getStorageItem(DEVICE_AUTH_TOKEN_KEY); + if (!stored) return null; + + try { + authTokenCache = JSON.parse(stored) as StoredDeviceAuthToken; + return authTokenCache; + } catch { + return null; + } +} + +/** + * Clear device auth token + */ +export async function clearDeviceAuthToken(): Promise { + authTokenCache = null; + await deleteStorageItem(DEVICE_AUTH_TOKEN_KEY); +} diff --git a/expo-app/sources/clawdbot/index.ts b/expo-app/sources/clawdbot/index.ts new file mode 100644 index 0000000000..77644de1bb --- /dev/null +++ b/expo-app/sources/clawdbot/index.ts @@ -0,0 +1,10 @@ +export { ClawdbotSocket } from './ClawdbotSocket'; +export type { ClawdbotConnectionStatus, ClawdbotEventHandler, ClawdbotStatusHandler } from './ClawdbotSocket'; +export { useClawdbotStatus, useClawdbotSessions, useClawdbotChatEvents } from './useClawdbotConnection'; +export { loadClawdbotConfig, saveClawdbotConfig, clearClawdbotConfig, hasClawdbotConfig } from './clawdbotStorage'; +export type { + ClawdbotGatewayConfig, + ClawdbotSession, + ClawdbotChatMessage, + ClawdbotChatEvent, +} from './clawdbotTypes'; diff --git a/expo-app/sources/clawdbot/useClawdbotConnection.ts b/expo-app/sources/clawdbot/useClawdbotConnection.ts new file mode 100644 index 0000000000..fd38c5bb80 --- /dev/null +++ b/expo-app/sources/clawdbot/useClawdbotConnection.ts @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { ClawdbotSocket, type ClawdbotConnectionStatus } from './ClawdbotSocket'; +import { loadClawdbotConfig } from './clawdbotStorage'; +import type { ClawdbotChatEvent, ClawdbotSession } from './clawdbotTypes'; + +// Track if we've already attempted auto-connect this session +let autoConnectAttempted = false; + +/** + * Hook to track Clawdbot gateway connection status. + * Automatically attempts to connect if there's a saved config and no active connection. + */ +export function useClawdbotStatus() { + const [status, setStatus] = React.useState( + ClawdbotSocket.getStatus() + ); + const [error, setError] = React.useState(); + const [pairingRequestId, setPairingRequestId] = React.useState(); + + React.useEffect(() => { + return ClawdbotSocket.onStatusChange((newStatus, err, details) => { + setStatus(newStatus); + setError(err); + setPairingRequestId(details?.pairingRequestId); + }); + }, []); + + // Auto-connect on mount if saved config exists and not already connected/connecting + React.useEffect(() => { + if (autoConnectAttempted) return; + autoConnectAttempted = true; + + const currentStatus = ClawdbotSocket.getStatus(); + if (currentStatus === 'connected' || currentStatus === 'connecting') { + return; + } + + const savedConfig = loadClawdbotConfig(); + if (savedConfig) { + console.log('[Clawdbot] Auto-connecting with saved config...'); + ClawdbotSocket.connect(savedConfig); + } + }, []); + + return { + status, + error, + isConnected: status === 'connected', + isConnecting: status === 'connecting', + isPairingRequired: status === 'pairing_required', + pairingRequestId, + deviceId: ClawdbotSocket.getDeviceId(), + serverHost: ClawdbotSocket.getServerHost(), + mainSessionKey: ClawdbotSocket.getMainSessionKey(), + retryConnect: () => ClawdbotSocket.retryConnect(), + }; +} + +/** + * Hook to load and manage Clawdbot sessions list + */ +export function useClawdbotSessions() { + const { isConnected } = useClawdbotStatus(); + const [sessions, setSessions] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const loadSessions = React.useCallback(async () => { + if (!ClawdbotSocket.isConnected()) { + setError('Not connected'); + return; + } + + setLoading(true); + setError(null); + + try { + const list = await ClawdbotSocket.listSessions(100); + setSessions(list); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load sessions'); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + if (isConnected) { + loadSessions(); + } else { + setSessions([]); + } + }, [isConnected, loadSessions]); + + return { + sessions, + loading, + error, + refresh: loadSessions, + }; +} + +/** + * Hook to subscribe to chat events for a specific session + */ +export function useClawdbotChatEvents(sessionKey: string | null) { + const [events, setEvents] = React.useState([]); + const [currentRunId, setCurrentRunId] = React.useState(null); + + React.useEffect(() => { + if (!sessionKey) return; + + // Listen for chat events + return ClawdbotSocket.onEvent((event, payload) => { + if (event === 'chat' && payload) { + const chatEvent = payload as ClawdbotChatEvent; + if (chatEvent.sessionKey === sessionKey) { + setEvents((prev) => [...prev, chatEvent]); + if (chatEvent.state === 'started') { + setCurrentRunId(chatEvent.runId); + } else if (chatEvent.state === 'final' || chatEvent.state === 'error') { + setCurrentRunId(null); + } + } + } + }); + }, [sessionKey]); + + const clearEvents = React.useCallback(() => { + setEvents([]); + setCurrentRunId(null); + }, []); + + return { events, currentRunId, clearEvents }; +} diff --git a/expo-app/sources/components/ClawdbotViewWrapper.tsx b/expo-app/sources/components/ClawdbotViewWrapper.tsx new file mode 100644 index 0000000000..9a3d4a57e9 --- /dev/null +++ b/expo-app/sources/components/ClawdbotViewWrapper.tsx @@ -0,0 +1,211 @@ +import * as React from 'react'; +import { View, RefreshControl } from 'react-native'; +import { Text } from '@/components/StyledText'; +import { useRouter } from 'expo-router'; +import { Typography } from '@/constants/Typography'; +import { RoundButton } from '@/components/RoundButton'; +import { Ionicons } from '@expo/vector-icons'; +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { t } from '@/text'; +import { useUnistyles } from 'react-native-unistyles'; +import { useClawdbotStatus, useClawdbotSessions, ClawdbotSocket } from '@/clawdbot'; +import type { ClawdbotSession } from '@/clawdbot'; + +/** + * Wrapper for Clawdbot view in the main tab bar. + * Shows sessions list when connected, connect prompt otherwise. + */ +export const ClawdbotViewWrapper = React.memo(function ClawdbotViewWrapper() { + const router = useRouter(); + const { theme } = useUnistyles(); + const { isConnected, serverHost } = useClawdbotStatus(); + const { sessions, loading, error, refresh } = useClawdbotSessions(); + + // Not connected - show connect prompt + if (!isConnected) { + return ( + + + + + + {t('clawdbot.notConnected')} + + + {t('clawdbot.notConnectedDescription')} + + + + + + + router.push('/(app)/clawdbot/connect')} + size="large" + /> + + + + ); + } + + const handleSessionPress = (session: ClawdbotSession) => { + router.push({ + pathname: '/(app)/clawdbot/chat/[sessionKey]', + params: { sessionKey: session.key } + }); + }; + + const handleNewChat = () => { + router.push('/(app)/clawdbot/new'); + }; + + return ( + + } + > + {/* Connection Status */} + + } + showChevron={false} + /> + + + {/* New Chat Button */} + + + + + + + {/* Error State */} + {error && ( + + } + showChevron={false} + /> + + )} + + {/* Sessions List */} + {sessions.length > 0 && ( + + {sessions.map((session) => ( + } + onPress={() => handleSessionPress(session)} + /> + ))} + + )} + + {/* Empty State */} + {!loading && !error && sessions.length === 0 && ( + + + + + {t('clawdbot.noSessions')} + + + + )} + + {/* Settings Link */} + + } + onPress={() => router.push('/(app)/clawdbot/connect')} + /> + + + ); +}); + +function formatSessionSubtitle(session: ClawdbotSession): string { + const parts: string[] = []; + + // Show kind/surface info + if (session.surface) { + parts.push(session.surface); + } else if (session.kind && session.kind !== 'unknown') { + parts.push(session.kind); + } + + // Show token count if available + if (session.totalTokens !== undefined && session.totalTokens > 0) { + parts.push(`${session.totalTokens.toLocaleString()} tokens`); + } + + // Show last update time + if (session.updatedAt) { + const date = new Date(session.updatedAt); + parts.push(date.toLocaleDateString()); + } + + return parts.join(' • ') || session.key; +} diff --git a/expo-app/sources/components/MainView.tsx b/expo-app/sources/components/MainView.tsx index b7dd807b2e..a6c7970f6f 100644 --- a/expo-app/sources/components/MainView.tsx +++ b/expo-app/sources/components/MainView.tsx @@ -12,6 +12,7 @@ import { TabBar, TabType } from './TabBar'; import { InboxView } from './InboxView'; import { SettingsViewWrapper } from './SettingsViewWrapper'; import { SessionsListWrapper } from './SessionsListWrapper'; +import { ClawdbotViewWrapper } from './ClawdbotViewWrapper'; import { Header } from './navigation/Header'; import { HeaderLogo } from './HeaderLogo'; import { VoiceAssistantStatusBar } from './VoiceAssistantStatusBar'; @@ -104,10 +105,11 @@ const TAB_TITLES = { sessions: 'tabs.sessions', inbox: 'tabs.inbox', settings: 'tabs.settings', + clawdbot: 'tabs.clawdbot', } as const; // Active tabs (excludes zen which is disabled) -type ActiveTabType = 'sessions' | 'inbox' | 'settings'; +type ActiveTabType = 'sessions' | 'inbox' | 'settings' | 'clawdbot'; // Header title component with connection status const HeaderTitle = React.memo(({ activeTab }: { activeTab: ActiveTabType }) => { @@ -202,6 +204,8 @@ export const MainView = React.memo(({ variant }: MainViewProps) => { return ; case 'settings': return ; + case 'clawdbot': + return ; case 'sessions': default: return ; diff --git a/expo-app/sources/components/OAuthView.tsx b/expo-app/sources/components/OAuthView.tsx index d8201f29fc..166fb176e9 100644 --- a/expo-app/sources/components/OAuthView.tsx +++ b/expo-app/sources/components/OAuthView.tsx @@ -6,8 +6,8 @@ import Animated, { useSharedValue, useAnimatedStyle, withTiming, + runOnJS, } from 'react-native-reanimated'; -import { runOnJS } from 'react-native-worklets'; import WebView from 'react-native-webview'; import { t } from '@/text'; import { Modal } from '@/modal'; diff --git a/expo-app/sources/components/SettingsView.tsx b/expo-app/sources/components/SettingsView.tsx index ef7ecc49ca..db2de4a644 100644 --- a/expo-app/sources/components/SettingsView.tsx +++ b/expo-app/sources/components/SettingsView.tsx @@ -442,6 +442,16 @@ export const SettingsView = React.memo(function SettingsView() { )} + {/* Clawdbot */} + + } + onPress={() => router.push('/(app)/clawdbot')} + /> + + {/* Developer */} {(__DEV__ || devModeEnabled) && ( diff --git a/expo-app/sources/components/TabBar.tsx b/expo-app/sources/components/TabBar.tsx index 73c70f6d06..8f2fefb2fe 100644 --- a/expo-app/sources/components/TabBar.tsx +++ b/expo-app/sources/components/TabBar.tsx @@ -8,7 +8,7 @@ import { Typography } from '@/constants/Typography'; import { layout } from '@/components/layout'; import { useInboxHasContent } from '@/hooks/useInboxHasContent'; -export type TabType = 'zen' | 'inbox' | 'sessions' | 'settings'; +export type TabType = 'zen' | 'inbox' | 'sessions' | 'settings' | 'clawdbot'; interface TabBarProps { activeTab: TabType; @@ -90,6 +90,7 @@ export const TabBar = React.memo(({ activeTab, onTabPress, inboxBadgeCount = 0 } return [ { key: 'inbox', icon: require('@/assets/images/brutalist/Brutalism 27.png'), label: t('tabs.inbox') }, { key: 'sessions', icon: require('@/assets/images/brutalist/Brutalism 15.png'), label: t('tabs.sessions') }, + { key: 'clawdbot', icon: require('@/assets/images/brutalist/Brutalism 28.png'), label: t('tabs.clawdbot') }, { key: 'settings', icon: require('@/assets/images/brutalist/Brutalism 9.png'), label: t('tabs.settings') }, ]; }, []); diff --git a/expo-app/sources/text/translations/ca.ts b/expo-app/sources/text/translations/ca.ts index d40416239d..b056632b1c 100644 --- a/expo-app/sources/text/translations/ca.ts +++ b/expo-app/sources/text/translations/ca.ts @@ -20,6 +20,7 @@ export const ca: TranslationStructure = { inbox: 'Safata', sessions: 'Terminals', settings: 'Configuració', + clawdbot: 'Clawdbot', }, inbox: { @@ -1435,7 +1436,61 @@ export const ca: TranslationStructure = { friendRequestGeneric: 'Nova sol·licitud d\'amistat', friendAccepted: ({ name }: { name: string }) => `Ara ets amic de ${name}`, friendAcceptedGeneric: 'Sol·licitud d\'amistat acceptada', - } + }, + + clawdbot: { + title: 'Clawdbot', + connect: 'Connectar', + connecting: 'Connectant...', + connected: 'Connectat', + disconnect: 'Desconnectar', + notConnected: 'No connectat', + notConnectedDescription: 'Connecta\'t a la passarel·la Clawdbot per començar a xatejar.', + connectToGateway: 'Connectar a la passarel·la', + connectTitle: 'Connectar a Clawdbot', + connectDescription: 'Introdueix l\'URL de la teva passarel·la Clawdbot. La passarel·la s\'executa localment al teu ordinador.', + connectionSettings: 'Configuració de connexió', + gatewayUrl: 'URL de la passarel·la', + token: 'Token d\'accés', + tokenDescription: 'Genera des del CLI o panell de control de clawdbot', + tokenPlaceholder: 'Introdueix el token d\'accés', + password: 'Contrasenya', + passwordOptional: 'Per a passarel·les protegides amb contrasenya', + passwordPlaceholder: 'Introdueix la contrasenya si cal', + connectionFailed: 'Error de connexió', + checkSettings: 'Comprova la configuració de connexió i torna-ho a provar.', + connectFooter: 'La teva connexió és directa a la passarel·la local. Les dades no passen per servidors externs.', + localConnection: 'Connexió local', + localConnectionDescription: 'Tota la comunicació es fa directament amb la teva passarel·la.', + viewSessions: 'Veure sessions', + connectedTo: 'Connectat a', + newChat: 'Nou xat', + recentSessions: 'Sessions recents', + noSessions: 'Encara no hi ha sessions. Comença un nou xat.', + chat: 'Xat', + startConversation: 'Comença una conversa amb Clawdbot', + messagePlaceholder: 'Escriu un missatge...', + pairingRequired: 'Emparellament requerit', + pairingDescription: 'Aquest dispositiu ha de ser aprovat abans de connectar-se a la passarel·la.', + pairingInstructions: 'Com aprovar', + pairingStep1Title: 'Obre Clawdbot', + pairingStep1Description: 'Fes clic a la icona de Clawdbot a la barra de menú', + pairingStep2Title: 'Troba la sol·licitud d\'emparellament', + pairingStep2Description: 'Cerca "Happy" a la llista de dispositius pendents', + pairingStep3Title: 'Aprova el dispositiu', + pairingStep3Description: 'Fes clic a "Aprovar" per permetre la connexió', + retryConnection: 'Reintentar connexió', + deviceInfo: 'Informació del dispositiu', + deviceId: 'ID del dispositiu', + newSession: 'Nova sessió', + newSessionTitle: 'Iniciar una nova conversa', + newSessionDescription: 'Escriu el teu missatge a continuació per començar a xatejar amb Clawdbot.', + newSessionPlaceholder: 'De què voldries parlar?', + tokenCommand: 'Comanda per obtenir el token', + tokenCommandHint: 'Executa aquesta comanda al terminal:', + tokenCommandValue: 'clawdbot dashboard --no-open', + tokenCommandDescription: 'Això mostrarà una URL amb el teu token. Copia el valor després de "?token="', + }, } as const; export type TranslationsCa = typeof ca; diff --git a/expo-app/sources/text/translations/en.ts b/expo-app/sources/text/translations/en.ts index 242c9a2a33..b8c83e4323 100644 --- a/expo-app/sources/text/translations/en.ts +++ b/expo-app/sources/text/translations/en.ts @@ -33,6 +33,7 @@ export const en = { inbox: 'Inbox', sessions: 'Terminals', settings: 'Settings', + clawdbot: 'Clawdbot', }, inbox: { @@ -1450,7 +1451,65 @@ export const en = { confirm: 'Delete', cancel: 'Cancel', }, - } + }, + + clawdbot: { + // Clawdbot gateway integration + title: 'Clawdbot', + connect: 'Connect', + connecting: 'Connecting...', + connected: 'Connected', + disconnect: 'Disconnect', + notConnected: 'Not Connected', + notConnectedDescription: 'Connect to your Clawdbot gateway to start chatting.', + connectToGateway: 'Connect to Gateway', + connectTitle: 'Connect to Clawdbot', + connectDescription: 'Enter your Clawdbot gateway URL to connect. The gateway runs locally on your computer.', + connectionSettings: 'Connection Settings', + gatewayUrl: 'Gateway URL', + token: 'Access Token', + tokenDescription: 'Generate from clawdbot CLI or control UI', + tokenPlaceholder: 'Enter gateway access token', + password: 'Password', + passwordOptional: 'Optional, for password-protected gateways', + passwordPlaceholder: 'Enter password if required', + connectionFailed: 'Connection Failed', + checkSettings: 'Please check your connection settings and try again.', + connectFooter: 'Your connection is direct to your local gateway. No data passes through external servers.', + localConnection: 'Local Connection', + localConnectionDescription: 'All communication happens directly with your gateway.', + viewSessions: 'View Sessions', + connectedTo: 'Connected to', + newChat: 'New Chat', + recentSessions: 'Recent Sessions', + noSessions: 'No sessions yet. Start a new chat to begin.', + chat: 'Chat', + startConversation: 'Start a conversation with Clawdbot', + messagePlaceholder: 'Type a message...', + // Pairing flow + pairingRequired: 'Pairing Required', + pairingDescription: 'This device needs to be approved before it can connect to your gateway.', + pairingInstructions: 'How to Approve', + pairingStep1Title: 'Open Clawdbot', + pairingStep1Description: 'Click the Clawdbot icon in your menu bar or system tray', + pairingStep2Title: 'Find the pairing request', + pairingStep2Description: 'Look for "Happy" in the pending devices list', + pairingStep3Title: 'Approve the device', + pairingStep3Description: 'Click "Approve" to allow this device to connect', + retryConnection: 'Retry Connection', + deviceInfo: 'Device Info', + deviceId: 'Device ID', + // New session + newSession: 'New Session', + newSessionTitle: 'Start a New Conversation', + newSessionDescription: 'Type your message below to start chatting with Clawdbot.', + newSessionPlaceholder: 'What would you like to talk about?', + // Token help + tokenCommand: 'Get Token Command', + tokenCommandHint: 'Run this command in your terminal:', + tokenCommandValue: 'clawdbot dashboard --no-open', + tokenCommandDescription: 'This will print a URL with your token. Copy the token value after "?token="', + }, }; export type TranslationStructure = typeof en; diff --git a/expo-app/sources/text/translations/es.ts b/expo-app/sources/text/translations/es.ts index 8eb63b7fdb..f4d006b78a 100644 --- a/expo-app/sources/text/translations/es.ts +++ b/expo-app/sources/text/translations/es.ts @@ -20,6 +20,7 @@ export const es: TranslationStructure = { inbox: 'Bandeja', sessions: 'Terminales', settings: 'Configuración', + clawdbot: 'Clawdbot', }, inbox: { @@ -1437,7 +1438,61 @@ export const es: TranslationStructure = { confirm: 'Eliminar', cancel: 'Cancelar', }, - } + }, + + clawdbot: { + title: 'Clawdbot', + connect: 'Conectar', + connecting: 'Conectando...', + connected: 'Conectado', + disconnect: 'Desconectar', + notConnected: 'No conectado', + notConnectedDescription: 'Conéctate a tu puerta de enlace Clawdbot para comenzar a chatear.', + connectToGateway: 'Conectar a la puerta de enlace', + connectTitle: 'Conectar a Clawdbot', + connectDescription: 'Introduce la URL de tu puerta de enlace Clawdbot. La puerta de enlace se ejecuta localmente en tu computadora.', + connectionSettings: 'Configuración de conexión', + gatewayUrl: 'URL de la puerta de enlace', + token: 'Token de acceso', + tokenDescription: 'Generar desde CLI o panel de control de clawdbot', + tokenPlaceholder: 'Introduce el token de acceso', + password: 'Contraseña', + passwordOptional: 'Para puertas de enlace protegidas con contraseña', + passwordPlaceholder: 'Introduce la contraseña si es necesario', + connectionFailed: 'Error de conexión', + checkSettings: 'Verifica la configuración de conexión e intenta de nuevo.', + connectFooter: 'Tu conexión es directa a tu puerta de enlace local. Los datos no pasan por servidores externos.', + localConnection: 'Conexión local', + localConnectionDescription: 'Toda la comunicación ocurre directamente con tu puerta de enlace.', + viewSessions: 'Ver sesiones', + connectedTo: 'Conectado a', + newChat: 'Nuevo chat', + recentSessions: 'Sesiones recientes', + noSessions: 'No hay sesiones todavía. Inicia un nuevo chat para comenzar.', + chat: 'Chat', + startConversation: 'Inicia una conversación con Clawdbot', + messagePlaceholder: 'Escribe un mensaje...', + pairingRequired: 'Emparejamiento requerido', + pairingDescription: 'Este dispositivo debe ser aprobado antes de conectarse a la puerta de enlace.', + pairingInstructions: 'Cómo aprobar', + pairingStep1Title: 'Abre Clawdbot', + pairingStep1Description: 'Haz clic en el icono de Clawdbot en la barra de menú', + pairingStep2Title: 'Encuentra la solicitud de emparejamiento', + pairingStep2Description: 'Busca "Happy" en la lista de dispositivos pendientes', + pairingStep3Title: 'Aprueba el dispositivo', + pairingStep3Description: 'Haz clic en "Aprobar" para permitir la conexión', + retryConnection: 'Reintentar conexión', + deviceInfo: 'Información del dispositivo', + deviceId: 'ID del dispositivo', + newSession: 'Nueva sesión', + newSessionTitle: 'Iniciar una nueva conversación', + newSessionDescription: 'Escribe tu mensaje abajo para empezar a chatear con Clawdbot.', + newSessionPlaceholder: '¿De qué te gustaría hablar?', + tokenCommand: 'Comando para obtener token', + tokenCommandHint: 'Ejecuta este comando en tu terminal:', + tokenCommandValue: 'clawdbot dashboard --no-open', + tokenCommandDescription: 'Esto mostrará una URL con tu token. Copia el valor después de "?token="', + }, } as const; export type TranslationsEs = typeof es; diff --git a/expo-app/sources/text/translations/it.ts b/expo-app/sources/text/translations/it.ts index 0864d76085..794d1c08ac 100644 --- a/expo-app/sources/text/translations/it.ts +++ b/expo-app/sources/text/translations/it.ts @@ -20,6 +20,7 @@ export const it: TranslationStructure = { inbox: 'Posta', sessions: 'Terminali', settings: 'Impostazioni', + clawdbot: 'Clawdbot', }, inbox: { @@ -1435,7 +1436,61 @@ export const it: TranslationStructure = { friendRequestGeneric: 'Nuova richiesta di amicizia', friendAccepted: ({ name }: { name: string }) => `Ora sei amico di ${name}`, friendAcceptedGeneric: 'Richiesta di amicizia accettata', - } + }, + + clawdbot: { + title: 'Clawdbot', + connect: 'Connetti', + connecting: 'Connessione...', + connected: 'Connesso', + disconnect: 'Disconnetti', + notConnected: 'Non connesso', + notConnectedDescription: 'Connettiti al gateway Clawdbot per iniziare a chattare.', + connectToGateway: 'Connetti al gateway', + connectTitle: 'Connetti a Clawdbot', + connectDescription: 'Inserisci l\'URL del tuo gateway Clawdbot. Il gateway funziona localmente sul tuo computer.', + connectionSettings: 'Impostazioni di connessione', + gatewayUrl: 'URL del gateway', + token: 'Token di accesso', + tokenDescription: 'Genera dal CLI o pannello di controllo di clawdbot', + tokenPlaceholder: 'Inserisci il token di accesso', + password: 'Password', + passwordOptional: 'Per gateway protetti da password', + passwordPlaceholder: 'Inserisci la password se richiesta', + connectionFailed: 'Connessione fallita', + checkSettings: 'Controlla le impostazioni di connessione e riprova.', + connectFooter: 'La tua connessione è diretta al gateway locale. I dati non passano attraverso server esterni.', + localConnection: 'Connessione locale', + localConnectionDescription: 'Tutta la comunicazione avviene direttamente con il tuo gateway.', + viewSessions: 'Vedi sessioni', + connectedTo: 'Connesso a', + newChat: 'Nuova chat', + recentSessions: 'Sessioni recenti', + noSessions: 'Nessuna sessione ancora. Inizia una nuova chat.', + chat: 'Chat', + startConversation: 'Inizia una conversazione con Clawdbot', + messagePlaceholder: 'Scrivi un messaggio...', + pairingRequired: 'Abbinamento richiesto', + pairingDescription: 'Questo dispositivo deve essere approvato dal tuo gateway Clawdbot prima di potersi connettere. Questa è una configurazione iniziale.', + pairingInstructions: 'Come approvare', + pairingStep1Title: 'Apri Clawdbot', + pairingStep1Description: 'Clicca sull\'icona Clawdbot nella barra dei menu o nel vassoio di sistema', + pairingStep2Title: 'Trova la richiesta di abbinamento', + pairingStep2Description: 'Cerca "Happy" nell\'elenco dei dispositivi in attesa', + pairingStep3Title: 'Approva il dispositivo', + pairingStep3Description: 'Clicca "Approva" per consentire a questo dispositivo di connettersi', + retryConnection: 'Riprova connessione', + deviceInfo: 'Info dispositivo', + deviceId: 'ID dispositivo', + newSession: 'Nuova sessione', + newSessionTitle: 'Inizia una nuova conversazione', + newSessionDescription: 'Scrivi il tuo messaggio qui sotto per iniziare a chattare con Clawdbot.', + newSessionPlaceholder: 'Di cosa vorresti parlare?', + tokenCommand: 'Comando per ottenere il token', + tokenCommandHint: 'Esegui questo comando nel terminale:', + tokenCommandValue: 'clawdbot dashboard --no-open', + tokenCommandDescription: 'Questo mostrerà un URL con il tuo token. Copia il valore dopo "?token="', + }, } as const; export type TranslationsIt = typeof it; diff --git a/expo-app/sources/text/translations/ja.ts b/expo-app/sources/text/translations/ja.ts index d4757ad176..1b5f6c73f5 100644 --- a/expo-app/sources/text/translations/ja.ts +++ b/expo-app/sources/text/translations/ja.ts @@ -13,6 +13,7 @@ export const ja: TranslationStructure = { inbox: '受信トレイ', sessions: 'ターミナル', settings: '設定', + clawdbot: 'Clawdbot', }, inbox: { @@ -1428,5 +1429,59 @@ export const ja: TranslationStructure = { friendRequestGeneric: '新しい友達リクエスト', friendAccepted: ({ name }: { name: string }) => `${name}さんと友達になりました`, friendAcceptedGeneric: '友達リクエストが承認されました', - } + }, + + clawdbot: { + title: 'Clawdbot', + connect: '接続', + connecting: '接続中...', + connected: '接続済み', + disconnect: '切断', + notConnected: '未接続', + notConnectedDescription: 'Clawdbotゲートウェイに接続してチャットを開始してください。', + connectToGateway: 'ゲートウェイに接続', + connectTitle: 'Clawdbotに接続', + connectDescription: 'ClawdbotゲートウェイのURLを入力してください。ゲートウェイはローカルコンピュータ上で動作します。', + connectionSettings: '接続設定', + gatewayUrl: 'ゲートウェイURL', + token: 'アクセストークン', + tokenDescription: 'clawdbot CLIまたはコントロールUIから生成', + tokenPlaceholder: 'アクセストークンを入力', + password: 'パスワード', + passwordOptional: 'パスワード保護されたゲートウェイ用', + passwordPlaceholder: '必要な場合はパスワードを入力', + connectionFailed: '接続失敗', + checkSettings: '接続設定を確認して再試行してください。', + connectFooter: '接続はローカルゲートウェイへの直接接続です。データは外部サーバーを経由しません。', + localConnection: 'ローカル接続', + localConnectionDescription: 'すべての通信はゲートウェイと直接行われます。', + viewSessions: 'セッションを表示', + connectedTo: '接続先', + newChat: '新しいチャット', + recentSessions: '最近のセッション', + noSessions: 'セッションがありません。新しいチャットを開始してください。', + chat: 'チャット', + startConversation: 'Clawdbotとの会話を開始', + messagePlaceholder: 'メッセージを入力...', + pairingRequired: 'ペアリングが必要', + pairingDescription: 'このデバイスは接続前にClawdbotゲートウェイで承認される必要があります。これは初回のみの設定です。', + pairingInstructions: '承認方法', + pairingStep1Title: 'Clawdbotを開く', + pairingStep1Description: 'メニューバーまたはシステムトレイのClawdbotアイコンをクリック', + pairingStep2Title: 'ペアリングリクエストを探す', + pairingStep2Description: '保留中のデバイスリストで「Happy」を探してください', + pairingStep3Title: 'デバイスを承認', + pairingStep3Description: '「承認」をクリックしてこのデバイスの接続を許可', + retryConnection: '再接続', + deviceInfo: 'デバイス情報', + deviceId: 'デバイスID', + newSession: '新しいセッション', + newSessionTitle: '新しい会話を始める', + newSessionDescription: '下にメッセージを入力してClawdbotとチャットを始めましょう。', + newSessionPlaceholder: '何について話したいですか?', + tokenCommand: 'トークン取得コマンド', + tokenCommandHint: 'ターミナルでこのコマンドを実行:', + tokenCommandValue: 'clawdbot dashboard --no-open', + tokenCommandDescription: 'トークン付きのURLが表示されます。"?token="の後の値をコピーしてください', + }, } as const; diff --git a/expo-app/sources/text/translations/pl.ts b/expo-app/sources/text/translations/pl.ts index b94885fd5e..7c97c1d56f 100644 --- a/expo-app/sources/text/translations/pl.ts +++ b/expo-app/sources/text/translations/pl.ts @@ -31,6 +31,7 @@ export const pl: TranslationStructure = { inbox: 'Skrzynka', sessions: 'Terminale', settings: 'Ustawienia', + clawdbot: 'Clawdbot', }, inbox: { @@ -1460,7 +1461,61 @@ export const pl: TranslationStructure = { confirm: 'Usuń', cancel: 'Anuluj', }, - } + }, + + clawdbot: { + title: 'Clawdbot', + connect: 'Połącz', + connecting: 'Łączenie...', + connected: 'Połączono', + disconnect: 'Rozłącz', + notConnected: 'Nie połączono', + notConnectedDescription: 'Połącz się z bramką Clawdbot, aby rozpocząć rozmowę.', + connectToGateway: 'Połącz z bramką', + connectTitle: 'Połącz z Clawdbot', + connectDescription: 'Wprowadź adres URL bramki Clawdbot. Bramka działa lokalnie na twoim komputerze.', + connectionSettings: 'Ustawienia połączenia', + gatewayUrl: 'Adres URL bramki', + token: 'Token dostępu', + tokenDescription: 'Wygeneruj przez CLI lub panel sterowania clawdbot', + tokenPlaceholder: 'Wprowadź token dostępu do bramki', + password: 'Hasło', + passwordOptional: 'Dla bramek chronionych hasłem', + passwordPlaceholder: 'Wprowadź hasło, jeśli wymagane', + connectionFailed: 'Połączenie nie powiodło się', + checkSettings: 'Sprawdź ustawienia połączenia i spróbuj ponownie.', + connectFooter: 'Połączenie jest bezpośrednie z lokalną bramką. Dane nie przechodzą przez zewnętrzne serwery.', + localConnection: 'Połączenie lokalne', + localConnectionDescription: 'Cała komunikacja odbywa się bezpośrednio z twoją bramką.', + viewSessions: 'Zobacz sesje', + connectedTo: 'Połączono z', + newChat: 'Nowy czat', + recentSessions: 'Ostatnie sesje', + noSessions: 'Brak sesji. Rozpocznij nowy czat.', + chat: 'Czat', + startConversation: 'Rozpocznij rozmowę z Clawdbot', + messagePlaceholder: 'Wpisz wiadomość...', + pairingRequired: 'Wymagane parowanie', + pairingDescription: 'To urządzenie musi zostać zatwierdzone przed połączeniem z bramką.', + pairingInstructions: 'Jak zatwierdzić', + pairingStep1Title: 'Otwórz Clawdbot', + pairingStep1Description: 'Kliknij ikonę Clawdbot na pasku menu', + pairingStep2Title: 'Znajdź żądanie parowania', + pairingStep2Description: 'Poszukaj "Happy" na liście oczekujących urządzeń', + pairingStep3Title: 'Zatwierdź urządzenie', + pairingStep3Description: 'Kliknij "Zatwierdź" aby zezwolić na połączenie', + retryConnection: 'Ponów połączenie', + deviceInfo: 'Informacje o urządzeniu', + deviceId: 'ID urządzenia', + newSession: 'Nowa sesja', + newSessionTitle: 'Rozpocznij nową rozmowę', + newSessionDescription: 'Wpisz wiadomość poniżej, aby rozpocząć czat z Clawdbot.', + newSessionPlaceholder: 'O czym chcesz porozmawiać?', + tokenCommand: 'Polecenie do pobrania tokena', + tokenCommandHint: 'Uruchom to polecenie w terminalu:', + tokenCommandValue: 'clawdbot dashboard --no-open', + tokenCommandDescription: 'To wyświetli URL z twoim tokenem. Skopiuj wartość po "?token="', + }, } as const; export type TranslationsPl = typeof pl; diff --git a/expo-app/sources/text/translations/pt.ts b/expo-app/sources/text/translations/pt.ts index 2364780e17..e821ddf45c 100644 --- a/expo-app/sources/text/translations/pt.ts +++ b/expo-app/sources/text/translations/pt.ts @@ -20,6 +20,7 @@ export const pt: TranslationStructure = { inbox: 'Caixa de entrada', sessions: 'Terminais', settings: 'Configurações', + clawdbot: 'Clawdbot', }, inbox: { @@ -1435,7 +1436,61 @@ export const pt: TranslationStructure = { friendRequestGeneric: 'Novo pedido de amizade', friendAccepted: ({ name }: { name: string }) => `Agora você é amigo de ${name}`, friendAcceptedGeneric: 'Pedido de amizade aceito', - } + }, + + clawdbot: { + title: 'Clawdbot', + connect: 'Conectar', + connecting: 'Conectando...', + connected: 'Conectado', + disconnect: 'Desconectar', + notConnected: 'Não conectado', + notConnectedDescription: 'Conecte-se ao gateway Clawdbot para começar a conversar.', + connectToGateway: 'Conectar ao gateway', + connectTitle: 'Conectar ao Clawdbot', + connectDescription: 'Insira o URL do seu gateway Clawdbot. O gateway funciona localmente no seu computador.', + connectionSettings: 'Configurações de conexão', + gatewayUrl: 'URL do gateway', + token: 'Token de acesso', + tokenDescription: 'Gere pelo CLI ou painel de controle do clawdbot', + tokenPlaceholder: 'Insira o token de acesso', + password: 'Senha', + passwordOptional: 'Para gateways protegidos por senha', + passwordPlaceholder: 'Insira a senha se necessário', + connectionFailed: 'Falha na conexão', + checkSettings: 'Verifique as configurações de conexão e tente novamente.', + connectFooter: 'Sua conexão é direta com o gateway local. Os dados não passam por servidores externos.', + localConnection: 'Conexão local', + localConnectionDescription: 'Toda a comunicação acontece diretamente com seu gateway.', + viewSessions: 'Ver sessões', + connectedTo: 'Conectado a', + newChat: 'Novo chat', + recentSessions: 'Sessões recentes', + noSessions: 'Nenhuma sessão ainda. Inicie um novo chat.', + chat: 'Chat', + startConversation: 'Inicie uma conversa com Clawdbot', + messagePlaceholder: 'Digite uma mensagem...', + pairingRequired: 'Emparelhamento necessário', + pairingDescription: 'Este dispositivo precisa ser aprovado pelo seu gateway Clawdbot antes de poder conectar. Esta é uma configuração única.', + pairingInstructions: 'Como aprovar', + pairingStep1Title: 'Abra o Clawdbot', + pairingStep1Description: 'Clique no ícone do Clawdbot na barra de menu ou bandeja do sistema', + pairingStep2Title: 'Encontre a solicitação de emparelhamento', + pairingStep2Description: 'Procure "Happy" na lista de dispositivos pendentes', + pairingStep3Title: 'Aprove o dispositivo', + pairingStep3Description: 'Clique em "Aprovar" para permitir que este dispositivo se conecte', + retryConnection: 'Tentar novamente', + deviceInfo: 'Info do dispositivo', + deviceId: 'ID do dispositivo', + newSession: 'Nova sessão', + newSessionTitle: 'Iniciar uma nova conversa', + newSessionDescription: 'Digite sua mensagem abaixo para começar a conversar com o Clawdbot.', + newSessionPlaceholder: 'Sobre o que você gostaria de falar?', + tokenCommand: 'Comando para obter token', + tokenCommandHint: 'Execute este comando no terminal:', + tokenCommandValue: 'clawdbot dashboard --no-open', + tokenCommandDescription: 'Isso mostrará uma URL com seu token. Copie o valor após "?token="', + }, } as const; export type TranslationsPt = typeof pt; diff --git a/expo-app/sources/text/translations/ru.ts b/expo-app/sources/text/translations/ru.ts index 69dd42133c..0337f6e84f 100644 --- a/expo-app/sources/text/translations/ru.ts +++ b/expo-app/sources/text/translations/ru.ts @@ -31,6 +31,7 @@ export const ru: TranslationStructure = { inbox: 'Входящие', sessions: 'Терминалы', settings: 'Настройки', + clawdbot: 'Clawdbot', }, inbox: { @@ -1461,7 +1462,61 @@ export const ru: TranslationStructure = { confirm: 'Удалить', cancel: 'Отмена', }, - } + }, + + clawdbot: { + title: 'Clawdbot', + connect: 'Подключить', + connecting: 'Подключение...', + connected: 'Подключено', + disconnect: 'Отключить', + notConnected: 'Не подключено', + notConnectedDescription: 'Подключитесь к шлюзу Clawdbot, чтобы начать общение.', + connectToGateway: 'Подключиться к шлюзу', + connectTitle: 'Подключение к Clawdbot', + connectDescription: 'Введите URL вашего шлюза Clawdbot. Шлюз работает локально на вашем компьютере.', + connectionSettings: 'Настройки подключения', + gatewayUrl: 'URL шлюза', + token: 'Токен доступа', + tokenDescription: 'Получите через CLI или панель управления clawdbot', + tokenPlaceholder: 'Введите токен доступа к шлюзу', + password: 'Пароль', + passwordOptional: 'Для шлюзов, защищённых паролем', + passwordPlaceholder: 'Введите пароль, если требуется', + connectionFailed: 'Ошибка подключения', + checkSettings: 'Проверьте настройки подключения и попробуйте снова.', + connectFooter: 'Подключение напрямую к вашему локальному шлюзу. Данные не проходят через внешние серверы.', + localConnection: 'Локальное подключение', + localConnectionDescription: 'Вся коммуникация происходит напрямую с вашим шлюзом.', + viewSessions: 'Просмотр сессий', + connectedTo: 'Подключено к', + newChat: 'Новый чат', + recentSessions: 'Недавние сессии', + noSessions: 'Сессий пока нет. Начните новый чат.', + chat: 'Чат', + startConversation: 'Начните разговор с Clawdbot', + messagePlaceholder: 'Введите сообщение...', + pairingRequired: 'Требуется сопряжение', + pairingDescription: 'Это устройство должно быть одобрено для подключения к шлюзу.', + pairingInstructions: 'Как одобрить', + pairingStep1Title: 'Откройте Clawdbot', + pairingStep1Description: 'Нажмите на значок Clawdbot в строке меню', + pairingStep2Title: 'Найдите запрос на сопряжение', + pairingStep2Description: 'Найдите "Happy" в списке ожидающих устройств', + pairingStep3Title: 'Одобрите устройство', + pairingStep3Description: 'Нажмите "Одобрить" для подключения этого устройства', + retryConnection: 'Повторить подключение', + deviceInfo: 'Информация об устройстве', + deviceId: 'ID устройства', + newSession: 'Новая сессия', + newSessionTitle: 'Начать новый разговор', + newSessionDescription: 'Введите сообщение ниже, чтобы начать общение с Clawdbot.', + newSessionPlaceholder: 'О чём вы хотите поговорить?', + tokenCommand: 'Команда для получения токена', + tokenCommandHint: 'Выполните эту команду в терминале:', + tokenCommandValue: 'clawdbot dashboard --no-open', + tokenCommandDescription: 'Это выведет URL с вашим токеном. Скопируйте значение после "?token="', + }, } as const; export type TranslationsRu = typeof ru; diff --git a/expo-app/sources/text/translations/zh-Hans.ts b/expo-app/sources/text/translations/zh-Hans.ts index a0f4268bcd..1aca66d813 100644 --- a/expo-app/sources/text/translations/zh-Hans.ts +++ b/expo-app/sources/text/translations/zh-Hans.ts @@ -22,6 +22,7 @@ export const zhHans: TranslationStructure = { inbox: '收件箱', sessions: '终端', settings: '设置', + clawdbot: 'Clawdbot', }, inbox: { @@ -1437,5 +1438,59 @@ export const zhHans: TranslationStructure = { friendRequestGeneric: '新的好友请求', friendAccepted: ({ name }: { name: string }) => `您现在与 ${name} 成为了好友`, friendAcceptedGeneric: '好友请求已接受', - } + }, + + clawdbot: { + title: 'Clawdbot', + connect: '连接', + connecting: '连接中...', + connected: '已连接', + disconnect: '断开', + notConnected: '未连接', + notConnectedDescription: '连接到您的Clawdbot网关以开始聊天。', + connectToGateway: '连接到网关', + connectTitle: '连接到Clawdbot', + connectDescription: '输入您的Clawdbot网关URL。网关在您的本地计算机上运行。', + connectionSettings: '连接设置', + gatewayUrl: '网关URL', + token: '访问令牌', + tokenDescription: '通过clawdbot CLI或控制面板生成', + tokenPlaceholder: '输入访问令牌', + password: '密码', + passwordOptional: '用于密码保护的网关', + passwordPlaceholder: '如有需要请输入密码', + connectionFailed: '连接失败', + checkSettings: '请检查连接设置并重试。', + connectFooter: '您的连接直接连接到本地网关。数据不会经过外部服务器。', + localConnection: '本地连接', + localConnectionDescription: '所有通信直接与您的网关进行。', + viewSessions: '查看会话', + connectedTo: '已连接到', + newChat: '新聊天', + recentSessions: '最近会话', + noSessions: '暂无会话。开始新聊天。', + chat: '聊天', + startConversation: '开始与Clawdbot对话', + messagePlaceholder: '输入消息...', + pairingRequired: '需要配对', + pairingDescription: '此设备需要在连接前获得您的 Clawdbot 网关批准。这是一次性设置。', + pairingInstructions: '如何批准', + pairingStep1Title: '打开 Clawdbot', + pairingStep1Description: '点击菜单栏或系统托盘中的 Clawdbot 图标', + pairingStep2Title: '找到配对请求', + pairingStep2Description: '在待处理设备列表中找到"Happy"', + pairingStep3Title: '批准设备', + pairingStep3Description: '点击"批准"允许此设备连接', + retryConnection: '重试连接', + deviceInfo: '设备信息', + deviceId: '设备 ID', + newSession: '新会话', + newSessionTitle: '开始新对话', + newSessionDescription: '在下方输入消息开始与 Clawdbot 聊天。', + newSessionPlaceholder: '你想聊些什么?', + tokenCommand: '获取令牌命令', + tokenCommandHint: '在终端中运行此命令:', + tokenCommandValue: 'clawdbot dashboard --no-open', + tokenCommandDescription: '这将显示包含令牌的 URL。复制 "?token=" 后面的值', + }, } as const; diff --git a/expo-app/sources/trash/test-clawdbot-auth.ts b/expo-app/sources/trash/test-clawdbot-auth.ts new file mode 100644 index 0000000000..d0f4a96d96 --- /dev/null +++ b/expo-app/sources/trash/test-clawdbot-auth.ts @@ -0,0 +1,164 @@ +/** + * Test script to verify Clawdbot device auth implementation + * Run with: npx tsx sources/trash/test-clawdbot-auth.ts + */ + +import WebSocket from 'ws'; + +// Base64URL encoding/decoding (same as our implementation) +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, ''); +} + +function base64UrlDecode(input: string): Uint8Array { + const normalized = input.replaceAll('-', '+').replaceAll('_', '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +async function fingerprintPublicKey(publicKey: Uint8Array): Promise { + const hash = await crypto.subtle.digest('SHA-256', publicKey); + return bytesToHex(new Uint8Array(hash)); +} + +// Use @noble/ed25519 like the official UI +async function testWithNoble() { + const { getPublicKeyAsync, signAsync, utils } = await import('@noble/ed25519'); + + // Generate identity (same as official UI) + const privateKey = utils.randomSecretKey(); + const publicKey = await getPublicKeyAsync(privateKey); + const deviceId = await fingerprintPublicKey(publicKey); + + console.log('Device ID:', deviceId.slice(0, 16) + '...'); + console.log('Public key (base64url):', base64UrlEncode(publicKey)); + console.log('Private key length:', privateKey.length, 'bytes'); + + // Build payload + const signedAtMs = Date.now(); + const token = 'df0a80a5ce3933ba8ce96963d06b302559ef46bea2119788'; // From clawdbot dashboard + const payload = [ + 'v1', + deviceId, + 'webchat-ui', + 'ui', + 'operator', + 'operator.admin,operator.approvals,operator.pairing', + String(signedAtMs), + token, + ].join('|'); + + console.log('Payload:', payload.slice(0, 100) + '...'); + + // Sign with noble + const data = new TextEncoder().encode(payload); + const sig = await signAsync(data, privateKey); + const signatureBase64Url = base64UrlEncode(sig); + + console.log('Signature length:', sig.length, 'bytes'); + console.log('Signature (base64url):', signatureBase64Url.slice(0, 32) + '...'); + + // Test connection + const ws = new WebSocket('ws://127.0.0.1:18789'); + + ws.on('open', () => { + console.log('\nWebSocket opened, waiting for challenge...'); + }); + + let connectSent = false; + + ws.on('message', async (data) => { + const msg = JSON.parse(data.toString()); + console.log('Received:', msg.type, msg.event || msg.method || ''); + + if (msg.type === 'event' && msg.event === 'connect.challenge') { + const nonce = msg.payload?.nonce; + console.log('Got nonce:', nonce); + + if (!connectSent) { + connectSent = true; + + // Rebuild payload with nonce for v2 + const payloadV2 = [ + 'v2', + deviceId, + 'webchat-ui', + 'ui', + 'operator', + 'operator.admin,operator.approvals,operator.pairing', + String(signedAtMs), + token, + nonce, + ].join('|'); + + const dataV2 = new TextEncoder().encode(payloadV2); + const sigV2 = await signAsync(dataV2, privateKey); + const signatureV2 = base64UrlEncode(sigV2); + + const connectParams = { + minProtocol: 3, + maxProtocol: 3, + client: { + id: 'webchat-ui', + displayName: 'Happy Test', + version: '1.0.0', + platform: 'test', + mode: 'ui', + }, + role: 'operator', + scopes: ['operator.admin', 'operator.approvals', 'operator.pairing'], + device: { + id: deviceId, + publicKey: base64UrlEncode(publicKey), + signature: signatureV2, + signedAt: signedAtMs, + nonce, + }, + auth: { token }, + }; + + console.log('\nSending connect request...'); + ws.send(JSON.stringify({ + type: 'req', + id: 'connect-1', + method: 'connect', + params: connectParams, + })); + } + } + + if (msg.type === 'res' && msg.id === 'connect-1') { + if (msg.ok) { + console.log('\n✅ CONNECTION SUCCESSFUL!'); + console.log('Auth:', msg.payload?.auth); + } else { + console.log('\n❌ CONNECTION FAILED:', msg.error?.message); + } + ws.close(); + process.exit(msg.ok ? 0 : 1); + } + }); + + ws.on('error', (err) => { + console.error('WebSocket error:', err.message); + process.exit(1); + }); + + ws.on('close', () => { + console.log('WebSocket closed'); + }); +} + +testWithNoble().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/yarn.lock b/yarn.lock index 343428a63e..f21a7de9d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -803,20 +803,7 @@ "@babel/parser" "^7.28.6" "@babel/types" "^7.28.6" -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.6.tgz#871ddc79a80599a5030c53b1cc48cbe3a5583c2e" - integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== - dependencies: - "@babel/code-frame" "^7.28.6" - "@babel/generator" "^7.28.6" - "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.28.6" - "@babel/template" "^7.28.6" - "@babel/types" "^7.28.6" - debug "^4.3.1" - -"@babel/traverse@^7.25.3", "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.4", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6": +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.4", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.6.tgz#871ddc79a80599a5030c53b1cc48cbe3a5583c2e" integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== @@ -1769,6 +1756,11 @@ libsodium-wrappers "^0.7.13" libsodium-wrappers-sumo "^0.7.13" +"@noble/ed25519@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@noble/ed25519/-/ed25519-3.0.0.tgz#720d4cdb6b5f632e29164a7e9d5cdfeb82a7ac86" + integrity sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg== + "@noble/hashes@^1.3.2": version "1.8.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" @@ -2303,6 +2295,17 @@ "@babel/helper-module-imports" "^7.18.6" "@rollup/pluginutils" "^5.0.1" +"@rollup/plugin-node-resolve@16.0.3": + version "16.0.3" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz#0988e6f2cbb13316b0f5e7213f757bc9ed44928f" + integrity sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg== + dependencies: + "@rollup/pluginutils" "^5.0.1" + "@types/resolve" "1.20.2" + deepmerge "^4.2.2" + is-module "^1.0.0" + resolve "^1.22.1" + "@rollup/plugin-typescript@^12.1.2": version "12.3.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz#cc51b830973bc14c9456fe6532f322f2a40f5f12" @@ -2311,6 +2314,11 @@ "@rollup/pluginutils" "^5.1.0" resolve "^1.22.1" +"@rollup/plugin-virtual@3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz#17e17eeecb4c9fa1c0a6e72c9e5f66382fddbb82" + integrity sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A== + "@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.1.0": version "5.3.0" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4" @@ -2320,56 +2328,111 @@ estree-walker "^2.0.2" picomatch "^4.0.2" +"@rollup/rollup-android-arm-eabi@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.2.tgz#3a43e904367cd6147c5a8de9df4ff7ffa48634ec" + integrity sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ== + "@rollup/rollup-android-arm-eabi@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz#76e0fef6533b3ce313f969879e61e8f21f0eeb28" integrity sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg== +"@rollup/rollup-android-arm64@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.2.tgz#7af548eefb4def2fb678a207ff0236a045678be7" + integrity sha512-cqFSWO5tX2vhC9hJTK8WAiPIm4Q8q/cU8j2HQA0L3E1uXvBYbOZMhE2oFL8n2pKB5sOCHY6bBuHaRwG7TkfJyw== + "@rollup/rollup-android-arm64@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz#d3cfc675a40bbdec97bda6d7fe3b3b05f0e1cd93" integrity sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg== +"@rollup/rollup-darwin-arm64@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.2.tgz#13a9b8d3e31e7425b71d0caf13527ead19baf27a" + integrity sha512-vngduywkkv8Fkh3wIZf5nFPXzWsNsVu1kvtLETWxTFf/5opZmflgVSeLgdHR56RQh71xhPhWoOkEBvbehwTlVA== + "@rollup/rollup-darwin-arm64@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz#eb912b8f59dd47c77b3c50a78489013b1d6772b4" integrity sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg== +"@rollup/rollup-darwin-x64@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.2.tgz#c794e406914ff9e3ffbfe994080590135e70ad9a" + integrity sha512-h11KikYrUCYTrDj6h939hhMNlqU2fo/X4NB0OZcys3fya49o1hmFaczAiJWVAFgrM1NCP6RrO7lQKeVYSKBPSQ== + "@rollup/rollup-darwin-x64@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz#e7d0839fdfd1276a1d34bc5ebbbd0dfd7d0b81a0" integrity sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ== +"@rollup/rollup-freebsd-arm64@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.2.tgz#63fa5783edd02a7aae141fc718e1f26882736c2b" + integrity sha512-/eg4CI61ZUkLXxMHyVlmlGrSQZ34xqWlZNW43IAU4RmdzWEx0mQJ2mN/Cx4IHLVZFL6UBGAh+/GXhgvGb+nVxw== + "@rollup/rollup-freebsd-arm64@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz#7ff8118760f7351e48fd0cd3717ff80543d6aac8" integrity sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg== +"@rollup/rollup-freebsd-x64@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.2.tgz#5c22816795cebb4f64d6440dd52951e5948ed1e3" + integrity sha512-QOWgFH5X9+p+S1NAfOqc0z8qEpJIoUHf7OWjNUGOeW18Mx22lAUOiA9b6r2/vpzLdfxi/f+VWsYjUOMCcYh0Ng== + "@rollup/rollup-freebsd-x64@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz#49d330dadbda1d4e9b86b4a3951b59928a9489a9" integrity sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw== +"@rollup/rollup-linux-arm-gnueabihf@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.2.tgz#e65c6cf40153e06cfc7d2e15bb9ce8333a033649" + integrity sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA== + "@rollup/rollup-linux-arm-gnueabihf@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz#98c5f1f8b9776b4a36e466e2a1c9ed1ba52ef1b6" integrity sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ== +"@rollup/rollup-linux-arm-musleabihf@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.2.tgz#d17ffee4a8b73d9dac55590748f8ec1d88c9398d" + integrity sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w== + "@rollup/rollup-linux-arm-musleabihf@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz#b9acecd3672e742f70b0c8a94075c816a91ff040" integrity sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg== +"@rollup/rollup-linux-arm64-gnu@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.2.tgz#b359b24b1c1f40f5920d2fd827fde1407608a941" + integrity sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg== + "@rollup/rollup-linux-arm64-gnu@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz#7a6ab06651bc29e18b09a50ed1a02bc972977c9b" integrity sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ== +"@rollup/rollup-linux-arm64-musl@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.2.tgz#d8260f24d292525b03e5c257dee8e46de0df61bc" + integrity sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA== + "@rollup/rollup-linux-arm64-musl@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz#3c8c9072ba4a4d4ef1156b85ab9a2cbb57c1fad0" integrity sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA== +"@rollup/rollup-linux-loong64-gnu@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.2.tgz#da159bad4467c41868a0803d4009839aac2f38d3" + integrity sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw== + "@rollup/rollup-linux-loong64-gnu@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz#17a7af13530f4e4a7b12cd26276c54307a84a8b0" @@ -2380,6 +2443,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz#5cd7a900fd7b077ecd753e34a9b7ff1157fe70c1" integrity sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw== +"@rollup/rollup-linux-ppc64-gnu@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.2.tgz#f0b10d49210bef2eed9ae7a0ec9ef3e3bf1beffd" + integrity sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A== + "@rollup/rollup-linux-ppc64-gnu@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz#03a097e70243ddf1c07b59d3c20f38e6f6800539" @@ -2390,26 +2458,51 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz#a5389873039d4650f35b4fa060d286392eb21a94" integrity sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw== +"@rollup/rollup-linux-riscv64-gnu@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.2.tgz#f3d023dc14669780de638c662b3ecf6431253bb8" + integrity sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w== + "@rollup/rollup-linux-riscv64-gnu@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz#789e60e7d6e2b76132d001ffb24ba80007fb17d0" integrity sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw== +"@rollup/rollup-linux-riscv64-musl@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.2.tgz#1c451e83ae32ad926c3af90a0a64073d432aa179" + integrity sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q== + "@rollup/rollup-linux-riscv64-musl@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz#3556fa88d139282e9a73c337c9a170f3c5fe7aa4" integrity sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg== +"@rollup/rollup-linux-s390x-gnu@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.2.tgz#ca91af9d54132db20f06ffdf6b81720aeb434e7b" + integrity sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ== + "@rollup/rollup-linux-s390x-gnu@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz#c085995b10143c16747a67f1a5487512b2ff04b2" integrity sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg== +"@rollup/rollup-linux-x64-gnu@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.2.tgz#074807dca3a15542b5e224ef6138f000a1015193" + integrity sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA== + "@rollup/rollup-linux-x64-gnu@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz#9563a5419dd2604841bad31a39ccfdd2891690fb" integrity sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg== +"@rollup/rollup-linux-x64-musl@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.2.tgz#b786fd7a6b0a1146be56d952626170f3784594e9" + integrity sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ== + "@rollup/rollup-linux-x64-musl@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz#691bb06e6269a8959c13476b0cd2aa7458facb31" @@ -2420,26 +2513,51 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz#223e71224746a59ce6d955bbc403577bb5a8be9d" integrity sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg== +"@rollup/rollup-openharmony-arm64@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.2.tgz#4bd9469e14c178186c5c594a7d418aaeb031df81" + integrity sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q== + "@rollup/rollup-openharmony-arm64@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz#0817e5d8ecbfeb8b7939bf58f8ce3c9dd67fce77" integrity sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw== +"@rollup/rollup-win32-arm64-msvc@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.2.tgz#3e82d9cfcbcf268dbb861c49f631b17a68ed0411" + integrity sha512-Z9MUCrSgIaUeeHAiNkm3cQyst2UhzjPraR3gYYfOjAuZI7tcFRTOD+4cHLPoS/3qinchth+V56vtqz1Tv+6KPA== + "@rollup/rollup-win32-arm64-msvc@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz#de56d8f2013c84570ef5fb917aae034abda93e4a" integrity sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g== +"@rollup/rollup-win32-ia32-msvc@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.2.tgz#f4e68265d5c758afd2e1c6ff13319558b0c8a205" + integrity sha512-+GnYBmpjldD3XQd+HMejo+0gJGwYIOfFeoBQv32xF/RUIvccUz20/V6Otdv+57NE70D5pa8W/jVGDoGq0oON4A== + "@rollup/rollup-win32-ia32-msvc@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz#659aff5244312475aeea2c9479a6c7d397b517bf" integrity sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA== +"@rollup/rollup-win32-x64-gnu@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.2.tgz#54f9e64b3550416c8520e3dc22301ef8e454b37e" + integrity sha512-ApXFKluSB6kDQkAqZOKXBjiaqdF1BlKi+/eqnYe9Ee7U2K3pUDKsIyr8EYm/QDHTJIM+4X+lI0gJc3TTRhd+dA== + "@rollup/rollup-win32-x64-gnu@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz#2cb09549cbb66c1b979f9238db6dd454cac14a88" integrity sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg== +"@rollup/rollup-win32-x64-msvc@4.52.2": + version "4.52.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.2.tgz#cf83e2c56b581bad4614eeb3d2da5b5917ed34ec" + integrity sha512-ARz+Bs8kY6FtitYM96PqPEVvPXqEZmPZsSkXvyX19YzDqkCaIlhCieLLMI5hxO9SRZ2XtCtm8wxhy0iJ2jxNfw== + "@rollup/rollup-win32-x64-msvc@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz#f79437939020b83057faf07e98365b1fa51c458b" @@ -2954,6 +3072,13 @@ dependencies: "@types/react" "*" +"@types/react-test-renderer@^19.1.0": + version "19.1.0" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz#1d0af8f2e1b5931e245b8b5b234d1502b854dc10" + integrity sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ== + dependencies: + "@types/react" "*" + "@types/react@*": version "19.2.8" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.8.tgz#307011c9f5973a6abab8e17d0293f48843627994" @@ -2973,6 +3098,11 @@ resolved "https://registry.yarnpkg.com/@types/resolve-path/-/resolve-path-1.4.3.tgz#f9be1f6eb6e5d3b1532f8987dcba9e490e2e0efd" integrity sha512-6mMvIPfQhbOOvntC6GbLqlEZP9fmkhqICNDbdCj2BbhaF7hoYuTiDiW63mOYtUQ9+E+b4ahyuqY1N0uVMxDH9w== +"@types/resolve@1.20.2": + version "1.20.2" + resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" + integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== + "@types/responselike@^1.0.0": version "1.0.3" resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.3.tgz#cc29706f0a397cfe6df89debfe4bf5cea159db50" @@ -3124,7 +3254,14 @@ accepts@^1.3.7, accepts@^1.3.8: mime-types "~2.1.34" negotiator "0.6.3" -acorn@^8.15.0: +acorn-walk@8.3.4: + version "8.3.4" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" + +acorn@8.15.0, acorn@^8.11.0, acorn@^8.15.0: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== @@ -3683,6 +3820,11 @@ chai@^5.2.0: loupe "^3.1.0" pathval "^2.0.0" +chalk@5.6.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + chalk@^2.0.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -4496,7 +4638,7 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== -deepmerge@^4.3.1: +deepmerge@^4.2.2, deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== @@ -6217,6 +6359,11 @@ is-map@^2.0.2, is-map@^2.0.3: resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== +is-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" + integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== + is-nan@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" @@ -6287,6 +6434,19 @@ is-symbol@^1.0.4, is-symbol@^1.1.1: has-symbols "^1.1.0" safe-regex-test "^1.1.0" +is-tree-shakable@0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/is-tree-shakable/-/is-tree-shakable-0.4.1.tgz#84f5396da9ac298bd1ff67d3590d9aecbcc2e9e9" + integrity sha512-3DzYTCnGqrWZTIox6fueyt8Q0RIrah2bdilQXSzEiJe0cE7GThZKDxfZZm1r0dVeoDZ50oj80+Zgqvb8vMqAHA== + dependencies: + "@rollup/plugin-node-resolve" "16.0.3" + "@rollup/plugin-virtual" "3.0.2" + acorn "8.15.0" + acorn-walk "8.3.4" + chalk "5.6.2" + rollup "4.52.2" + source-map "0.7.6" + is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed-array@^1.1.3: version "1.1.15" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" @@ -6623,14 +6783,21 @@ libsodium-wrappers-sumo@^0.7.13: dependencies: libsodium-sumo "^0.7.16" -libsodium-wrappers@^0.7.13, libsodium-wrappers@^0.7.15: +libsodium-wrappers@0.7.15: + version "0.7.15" + resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.15.tgz#53f13e483820272a3d55b23be2e34402ac988055" + integrity sha512-E4anqJQwcfiC6+Yrl01C1m8p99wEhLmJSs0VQqST66SbQXXBoaJY0pF4BNjRYa/sOQAxx6lXAaAFIlx+15tXJQ== + dependencies: + libsodium "^0.7.15" + +libsodium-wrappers@^0.7.13: version "0.7.16" resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.16.tgz#abaa065e914562695c6c1d66527c8e72bbbaec15" integrity sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg== dependencies: libsodium "^0.7.16" -libsodium@^0.7.16: +libsodium@^0.7.15, libsodium@^0.7.16: version "0.7.16" resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.16.tgz#3d4f9d68ed887bb8bf2e76bb3ba231265eae58a0" integrity sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q== @@ -7912,7 +8079,7 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-is@^19.0.0, react-is@^19.1.0: +react-is@^19.1.0: version "19.2.3" resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29" integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA== @@ -8006,10 +8173,10 @@ react-native-quick-base64@^2.2.1: resolved "https://registry.yarnpkg.com/react-native-quick-base64/-/react-native-quick-base64-2.2.2.tgz#52005a0b455b04acc1c6ff3eb8fa220401656aae" integrity sha512-WLHSifHLoamr2kF00Gov0W9ud6CfPshe1rmqWTquVIi9c62qxOaJCFVDrXFZhEBU8B8PvGLVuOlVKH78yhY0Fg== -react-native-reanimated@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz#fbdee721bff0946a6e5ae67c8c38c37ca4a0a057" - integrity sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg== +react-native-reanimated@^4.3.0-nightly-20260126-ab4831559: + version "4.3.0-nightly-20260126-ab4831559" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.3.0-nightly-20260126-ab4831559.tgz#2d4c07899cd6527674c8fa07b6447706877ea4bd" + integrity sha512-o14rUfg/dl457/F3l22WFVol6d3tTc4npE8QpANQZXKhcIWYsi3tTVaUCy6OAL8WMswi1PC9al3orYQkds7n8w== dependencies: react-native-is-edge-to-edge "1.2.1" semver "7.7.3" @@ -8095,10 +8262,10 @@ react-native-webview@13.15.0: escape-string-regexp "^4.0.0" invariant "2.2.4" -react-native-worklets@^0.7.1: - version "0.7.2" - resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.7.2.tgz#acfbfe4f8c7f3b2889e7f394e4fbd7e78e167134" - integrity sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog== +react-native-worklets@^0.8.0-nightly-20260126-ab4831559: + version "0.8.0-nightly-20260126-ab4831559" + resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.8.0-nightly-20260126-ab4831559.tgz#3646c97b440decb2adfc62e3235f6790fe46b1ab" + integrity sha512-K91ezNmu1Fcg62XT4wMnU09JTmWSNWAnhsfVEMlKv2Xf+LSjhPLeFnW3Suq/KKJHkYHTg9T0k8/T3ujKUSr0Bg== dependencies: "@babel/plugin-transform-arrow-functions" "7.27.1" "@babel/plugin-transform-class-properties" "7.27.1" @@ -8110,6 +8277,7 @@ react-native-worklets@^0.7.1: "@babel/plugin-transform-unicode-regex" "7.27.1" "@babel/preset-typescript" "7.27.1" convert-source-map "2.0.0" + is-tree-shakable "0.4.1" semver "7.7.3" react-native@0.81.4: @@ -8203,13 +8371,13 @@ react-syntax-highlighter@^15.6.1: prismjs "^1.30.0" refractor "^3.6.0" -react-test-renderer@19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.0.0.tgz#ca6fa322c58d4bfa34635788fe242a8c3daa4c7d" - integrity sha512-oX5u9rOQlHzqrE/64CNr0HB0uWxkCQmZNSfozlYvwE71TLVgeZxVf0IjouGEr1v7r1kcDifdAJBeOhdhxsG/DA== +react-test-renderer@19.1.0: + version "19.1.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.1.0.tgz#89e1baa9e45a6da064b9760f92251d5b8e1f34ab" + integrity sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw== dependencies: - react-is "^19.0.0" - scheduler "^0.25.0" + react-is "^19.1.0" + scheduler "^0.26.0" react-textarea-autosize@^8.5.9: version "8.5.9" @@ -8465,6 +8633,37 @@ robust-predicates@^3.0.2: resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== +rollup@4.52.2: + version "4.52.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.52.2.tgz#43dd135805c919285376634c8520074c5eb7a91a" + integrity sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.52.2" + "@rollup/rollup-android-arm64" "4.52.2" + "@rollup/rollup-darwin-arm64" "4.52.2" + "@rollup/rollup-darwin-x64" "4.52.2" + "@rollup/rollup-freebsd-arm64" "4.52.2" + "@rollup/rollup-freebsd-x64" "4.52.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.52.2" + "@rollup/rollup-linux-arm-musleabihf" "4.52.2" + "@rollup/rollup-linux-arm64-gnu" "4.52.2" + "@rollup/rollup-linux-arm64-musl" "4.52.2" + "@rollup/rollup-linux-loong64-gnu" "4.52.2" + "@rollup/rollup-linux-ppc64-gnu" "4.52.2" + "@rollup/rollup-linux-riscv64-gnu" "4.52.2" + "@rollup/rollup-linux-riscv64-musl" "4.52.2" + "@rollup/rollup-linux-s390x-gnu" "4.52.2" + "@rollup/rollup-linux-x64-gnu" "4.52.2" + "@rollup/rollup-linux-x64-musl" "4.52.2" + "@rollup/rollup-openharmony-arm64" "4.52.2" + "@rollup/rollup-win32-arm64-msvc" "4.52.2" + "@rollup/rollup-win32-ia32-msvc" "4.52.2" + "@rollup/rollup-win32-x64-gnu" "4.52.2" + "@rollup/rollup-win32-x64-msvc" "4.52.2" + fsevents "~2.3.2" + rollup@^4.43.0: version "4.55.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.55.1.tgz#4ec182828be440648e7ee6520dc35e9f20e05144" @@ -8850,6 +9049,11 @@ source-map-support@~0.5.20, source-map-support@~0.5.21: buffer-from "^1.0.0" source-map "^0.6.0" +source-map@0.7.6: + version "0.7.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02" + integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== + source-map@^0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"