clawdex-mobile 1.3.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/npm-release.yml +18 -0
  3. package/AGENTS.md +3 -3
  4. package/README.md +101 -541
  5. package/apps/mobile/.env.example +1 -2
  6. package/apps/mobile/App.tsx +261 -68
  7. package/apps/mobile/app.json +31 -5
  8. package/apps/mobile/assets/brand/splash-icon-white.png +0 -0
  9. package/apps/mobile/eas.json +30 -0
  10. package/apps/mobile/package.json +22 -21
  11. package/apps/mobile/plugins/withAndroidCleartextTraffic.js +14 -0
  12. package/apps/mobile/src/api/__tests__/ws.test.ts +44 -6
  13. package/apps/mobile/src/api/chatMapping.ts +48 -8
  14. package/apps/mobile/src/api/client.ts +6 -0
  15. package/apps/mobile/src/api/types.ts +11 -0
  16. package/apps/mobile/src/api/ws.ts +52 -10
  17. package/apps/mobile/src/bridgeUrl.ts +105 -0
  18. package/apps/mobile/src/components/ActivityBar.tsx +32 -13
  19. package/apps/mobile/src/components/ChatHeader.tsx +3 -2
  20. package/apps/mobile/src/components/ChatInput.tsx +246 -91
  21. package/apps/mobile/src/components/ChatMessage.tsx +108 -4
  22. package/apps/mobile/src/config.ts +11 -29
  23. package/apps/mobile/src/hooks/useVoiceRecorder.ts +264 -0
  24. package/apps/mobile/src/navigation/DrawerContent.tsx +18 -8
  25. package/apps/mobile/src/screens/GitScreen.tsx +1 -1
  26. package/apps/mobile/src/screens/MainScreen.tsx +906 -268
  27. package/apps/mobile/src/screens/OnboardingScreen.tsx +1132 -0
  28. package/apps/mobile/src/screens/PrivacyScreen.tsx +1 -1
  29. package/apps/mobile/src/screens/SettingsScreen.tsx +65 -1
  30. package/apps/mobile/src/screens/TerminalScreen.tsx +1 -1
  31. package/apps/mobile/src/screens/TermsScreen.tsx +1 -1
  32. package/docs/app-review-notes.md +7 -2
  33. package/docs/eas-builds.md +91 -0
  34. package/docs/realtime-streaming-limitations.md +84 -0
  35. package/docs/setup-and-operations.md +239 -0
  36. package/docs/troubleshooting.md +121 -0
  37. package/docs/voice-transcription.md +87 -0
  38. package/package.json +8 -16
  39. package/scripts/setup-secure-dev.sh +122 -8
  40. package/scripts/setup-wizard.sh +342 -122
  41. package/scripts/start-bridge-secure.sh +7 -1
  42. package/scripts/sync-versions.js +63 -0
  43. package/services/rust-bridge/.env.example +1 -1
  44. package/services/rust-bridge/Cargo.lock +1104 -23
  45. package/services/rust-bridge/Cargo.toml +3 -1
  46. package/services/rust-bridge/package.json +1 -1
  47. package/services/rust-bridge/src/main.rs +587 -12
  48. package/apps/mobile/metro.config.js +0 -3
@@ -0,0 +1,1132 @@
1
+ import { Ionicons } from '@expo/vector-icons';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { CameraView, type BarcodeScanningResult, useCameraPermissions } from 'expo-camera';
4
+ import {
5
+ ActivityIndicator,
6
+ KeyboardAvoidingView,
7
+ Modal,
8
+ Platform,
9
+ Pressable,
10
+ ScrollView,
11
+ StyleSheet,
12
+ Text,
13
+ TextInput,
14
+ View,
15
+ } from 'react-native';
16
+ import { SafeAreaView } from 'react-native-safe-area-context';
17
+
18
+ import {
19
+ isInsecureRemoteUrl,
20
+ normalizeBridgeUrlInput,
21
+ toBridgeHealthUrl,
22
+ } from '../bridgeUrl';
23
+ import { HostBridgeWsClient } from '../api/ws';
24
+ import { BrandMark } from '../components/BrandMark';
25
+ import { colors, radius, spacing, typography } from '../theme';
26
+
27
+ type OnboardingMode = 'initial' | 'edit';
28
+
29
+ interface OnboardingScreenProps {
30
+ mode?: OnboardingMode;
31
+ initialBridgeUrl?: string | null;
32
+ initialBridgeToken?: string | null;
33
+ allowInsecureRemoteBridge?: boolean;
34
+ allowQueryTokenAuth?: boolean;
35
+ onSave: (bridgeUrl: string, bridgeToken: string | null) => void;
36
+ onCancel?: () => void;
37
+ }
38
+
39
+ type ConnectionCheck =
40
+ | { kind: 'idle' }
41
+ | { kind: 'success'; message: string }
42
+ | { kind: 'error'; message: string };
43
+ type OnboardingStep = 'intro' | 'connect';
44
+ type PairingPayload = { bridgeToken: string; bridgeUrl?: string };
45
+ type BridgeModePreset = 'local' | 'tailscale';
46
+
47
+ const LOCAL_EXAMPLE_URL = 'http://192.168.1.20:8787';
48
+ const TAILSCALE_EXAMPLE_URL = 'http://100.101.102.103:8787';
49
+
50
+ export function OnboardingScreen({
51
+ mode = 'initial',
52
+ initialBridgeUrl,
53
+ initialBridgeToken,
54
+ allowInsecureRemoteBridge = false,
55
+ allowQueryTokenAuth = false,
56
+ onSave,
57
+ onCancel,
58
+ }: OnboardingScreenProps) {
59
+ const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>(
60
+ mode === 'initial' ? 'intro' : 'connect'
61
+ );
62
+ const [urlInput, setUrlInput] = useState(initialBridgeUrl ?? '');
63
+ const [tokenInput, setTokenInput] = useState(initialBridgeToken ?? '');
64
+ const [tokenHidden, setTokenHidden] = useState(true);
65
+ const [formError, setFormError] = useState<string | null>(null);
66
+ const [checkingConnection, setCheckingConnection] = useState(false);
67
+ const [connectionCheck, setConnectionCheck] = useState<ConnectionCheck>({ kind: 'idle' });
68
+ const [cameraPermission, requestCameraPermission] = useCameraPermissions();
69
+ const [scannerVisible, setScannerVisible] = useState(false);
70
+ const [scannerError, setScannerError] = useState<string | null>(null);
71
+ const [scannerLocked, setScannerLocked] = useState(false);
72
+
73
+ useEffect(() => {
74
+ setOnboardingStep(mode === 'initial' ? 'intro' : 'connect');
75
+ }, [mode]);
76
+
77
+ useEffect(() => {
78
+ setUrlInput(initialBridgeUrl ?? '');
79
+ }, [initialBridgeUrl]);
80
+
81
+ useEffect(() => {
82
+ setTokenInput(initialBridgeToken ?? '');
83
+ }, [initialBridgeToken]);
84
+
85
+ const showIntroStep = mode === 'initial' && onboardingStep === 'intro';
86
+
87
+ const normalizedBridgeUrl = useMemo(
88
+ () => normalizeBridgeUrlInput(urlInput),
89
+ [urlInput]
90
+ );
91
+ const insecureRemoteWarning = useMemo(() => {
92
+ if (!normalizedBridgeUrl || allowInsecureRemoteBridge) {
93
+ return null;
94
+ }
95
+
96
+ return isInsecureRemoteUrl(normalizedBridgeUrl)
97
+ ? 'This is plain HTTP over a non-private host. Use HTTPS/WSS when crossing untrusted networks.'
98
+ : null;
99
+ }, [allowInsecureRemoteBridge, normalizedBridgeUrl]);
100
+
101
+ const modeTitle = mode === 'edit' ? 'Update Bridge URL' : 'Connect Your Bridge';
102
+ const modeDescription =
103
+ mode === 'edit'
104
+ ? 'Switch to another host bridge without rebuilding the app.'
105
+ : 'Set the host bridge URL once, then use Codex from LAN, VPN, or Tailscale.';
106
+
107
+ const validateInput = useCallback((): { bridgeUrl: string; bridgeToken: string } | null => {
108
+ const normalized = normalizeBridgeUrlInput(urlInput);
109
+ if (!normalized) {
110
+ setFormError('Enter a valid URL. Example: http://100.101.102.103:8787');
111
+ return null;
112
+ }
113
+
114
+ const normalizedToken = tokenInput.trim();
115
+ if (!normalizedToken) {
116
+ setFormError('Bridge token is required.');
117
+ return null;
118
+ }
119
+
120
+ setFormError(null);
121
+ return { bridgeUrl: normalized, bridgeToken: normalizedToken };
122
+ }, [tokenInput, urlInput]);
123
+
124
+ const normalizeTokenInput = useCallback((value: string): string | null => {
125
+ const trimmed = value.trim();
126
+ return trimmed.length > 0 ? trimmed : null;
127
+ }, []);
128
+
129
+ const runConnectionCheck = useCallback(
130
+ async (normalized: string, token: string | null): Promise<boolean> => {
131
+ setCheckingConnection(true);
132
+ setConnectionCheck({ kind: 'idle' });
133
+
134
+ let probeClient: HostBridgeWsClient | null = null;
135
+ let healthCheckError: string | null = null;
136
+ try {
137
+ const headers: Record<string, string> | undefined = token
138
+ ? { Authorization: `Bearer ${token}` }
139
+ : undefined;
140
+ const healthUrl = toBridgeHealthUrl(normalized);
141
+ try {
142
+ const response = await fetch(healthUrl, { method: 'GET', headers });
143
+ if (response.status !== 200) {
144
+ healthCheckError = `health returned ${response.status}`;
145
+ }
146
+ } catch (error) {
147
+ healthCheckError = (error as Error).message || 'network request failed';
148
+ }
149
+
150
+ probeClient = new HostBridgeWsClient(normalized, {
151
+ authToken: token,
152
+ allowQueryTokenAuth,
153
+ requestTimeoutMs: 10_000,
154
+ });
155
+ const rpcHealth = await probeClient.request<{ status?: string }>('bridge/health/read');
156
+ if (rpcHealth?.status !== 'ok') {
157
+ throw new Error('authenticated RPC probe returned unexpected response');
158
+ }
159
+
160
+ setConnectionCheck({
161
+ kind: 'success',
162
+ message: healthCheckError
163
+ ? 'Connected. Authenticated RPC verified; /health endpoint did not return 200.'
164
+ : 'Connected. URL and token both verified.',
165
+ });
166
+ return true;
167
+ } catch (error) {
168
+ const baseMessage = (error as Error).message || 'request failed';
169
+ const hint =
170
+ Platform.OS === 'android' && baseMessage.includes('Network request failed')
171
+ ? ' (If using Android emulator, use http://10.0.2.2:8787 for localhost bridge.)'
172
+ : '';
173
+ setConnectionCheck({
174
+ kind: 'error',
175
+ message: `Bridge verification failed: ${baseMessage}${hint}`,
176
+ });
177
+ return false;
178
+ } finally {
179
+ probeClient?.disconnect();
180
+ setCheckingConnection(false);
181
+ }
182
+ },
183
+ [allowQueryTokenAuth]
184
+ );
185
+
186
+ const handleSave = useCallback(async () => {
187
+ const validated = validateInput();
188
+ if (!validated) {
189
+ return;
190
+ }
191
+
192
+ const normalizedToken = normalizeTokenInput(validated.bridgeToken);
193
+ const ok = await runConnectionCheck(validated.bridgeUrl, normalizedToken);
194
+ if (!ok) {
195
+ return;
196
+ }
197
+
198
+ onSave(validated.bridgeUrl, normalizedToken);
199
+ }, [normalizeTokenInput, onSave, runConnectionCheck, validateInput]);
200
+
201
+ const handleConnectionCheck = useCallback(async () => {
202
+ const validated = validateInput();
203
+ if (!validated) {
204
+ setConnectionCheck({ kind: 'idle' });
205
+ return;
206
+ }
207
+
208
+ const normalizedToken = normalizeTokenInput(validated.bridgeToken);
209
+ await runConnectionCheck(validated.bridgeUrl, normalizedToken);
210
+ }, [normalizeTokenInput, runConnectionCheck, validateInput]);
211
+
212
+ const applyPreset = useCallback((value: string) => {
213
+ setUrlInput(value);
214
+ setFormError(null);
215
+ setConnectionCheck({ kind: 'idle' });
216
+ }, []);
217
+
218
+ const applyModePreset = useCallback((preset: BridgeModePreset) => {
219
+ applyPreset(preset === 'local' ? LOCAL_EXAMPLE_URL : TAILSCALE_EXAMPLE_URL);
220
+ }, [applyPreset]);
221
+
222
+ const goToConnectStep = useCallback(() => {
223
+ setOnboardingStep('connect');
224
+ }, []);
225
+
226
+ const closeScanner = useCallback(() => {
227
+ setScannerVisible(false);
228
+ setScannerLocked(false);
229
+ setScannerError(null);
230
+ }, []);
231
+
232
+ const openScanner = useCallback(async () => {
233
+ setFormError(null);
234
+ setConnectionCheck({ kind: 'idle' });
235
+ setScannerError(null);
236
+
237
+ if (!cameraPermission?.granted) {
238
+ const result = await requestCameraPermission();
239
+ if (!result.granted) {
240
+ setFormError('Camera permission is required to scan bridge QR.');
241
+ return;
242
+ }
243
+ }
244
+
245
+ setScannerLocked(false);
246
+ setScannerVisible(true);
247
+ }, [cameraPermission?.granted, requestCameraPermission]);
248
+
249
+ const applyPairingPayload = useCallback((pairing: PairingPayload) => {
250
+ if (pairing.bridgeUrl) {
251
+ setUrlInput(pairing.bridgeUrl);
252
+ }
253
+ setTokenInput(pairing.bridgeToken);
254
+ setFormError(null);
255
+ setConnectionCheck({ kind: 'idle' });
256
+ setScannerError(null);
257
+ setScannerLocked(false);
258
+ setScannerVisible(false);
259
+ }, []);
260
+
261
+ const handleBarcodeScanned = useCallback(
262
+ (result: BarcodeScanningResult) => {
263
+ if (scannerLocked) {
264
+ return;
265
+ }
266
+
267
+ setScannerLocked(true);
268
+ const pairing = parsePairingPayload(result.data);
269
+ if (!pairing) {
270
+ setScannerError('QR code is not a valid Clawdex bridge pairing code.');
271
+ setTimeout(() => {
272
+ setScannerLocked(false);
273
+ }, 1200);
274
+ return;
275
+ }
276
+
277
+ applyPairingPayload(pairing);
278
+ },
279
+ [applyPairingPayload, scannerLocked]
280
+ );
281
+
282
+ return (
283
+ <View style={styles.container}>
284
+ <SafeAreaView style={styles.safeArea}>
285
+ <KeyboardAvoidingView
286
+ behavior={Platform.select({ ios: 'padding', default: undefined })}
287
+ style={styles.keyboardAvoiding}
288
+ >
289
+ {showIntroStep ? (
290
+ <View style={styles.introRoot}>
291
+ <View style={styles.introBrandRow}>
292
+ <BrandMark size={24} />
293
+ <Text style={styles.introBrandName}>Clawdex</Text>
294
+ </View>
295
+ <View style={styles.introMain}>
296
+ <View style={styles.heroCard}>
297
+ <View style={styles.heroTopRow}>
298
+ <View style={styles.heroIconWrap}>
299
+ <Ionicons name="phone-portrait-outline" size={20} color={colors.textPrimary} />
300
+ </View>
301
+ </View>
302
+ <Text style={styles.heroTitle}>Codex on mobile</Text>
303
+ <Text style={styles.heroDescription}>
304
+ Run your host-side Codex workflows from your phone across LAN, VPN, or Tailscale.
305
+ </Text>
306
+ </View>
307
+
308
+ <View style={[styles.formCard, styles.introFeaturesCard]}>
309
+ <ScrollView
310
+ style={styles.introFeaturesList}
311
+ contentContainerStyle={styles.introFeaturesListContent}
312
+ showsVerticalScrollIndicator
313
+ >
314
+ <Text style={styles.introSectionTitle}>What You Can Do</Text>
315
+ <IntroFeatureRow
316
+ icon="chatbubble-ellipses-outline"
317
+ title="Continue threads"
318
+ description="Follow active chats and start new runs from your phone."
319
+ />
320
+ <IntroFeatureRow
321
+ icon="pulse-outline"
322
+ title="Track run progress"
323
+ description="See live status and streaming updates as Codex works."
324
+ />
325
+ <IntroFeatureRow
326
+ icon="git-branch-outline"
327
+ title="Handle git tasks"
328
+ description="Review status, diffs, and commits for chat workspaces."
329
+ />
330
+ <IntroFeatureRow
331
+ icon="mic-outline"
332
+ title="Talk to Codex"
333
+ description="Use voice input to speak your prompts directly from mobile."
334
+ />
335
+ <IntroFeatureRow
336
+ icon="attach-outline"
337
+ title="Share files and images"
338
+ description="Attach workspace files and phone media to your prompts."
339
+ />
340
+ <IntroFeatureRow
341
+ icon="shield-checkmark-outline"
342
+ title="Approve actions"
343
+ description="Review and approve command and file changes in-app."
344
+ />
345
+ </ScrollView>
346
+ </View>
347
+ </View>
348
+ <View style={styles.introFooter}>
349
+ <Pressable
350
+ onPress={goToConnectStep}
351
+ style={({ pressed }) => [
352
+ styles.introNextButton,
353
+ pressed && styles.introNextButtonPressed,
354
+ ]}
355
+ >
356
+ <Text style={styles.introNextButtonText}>Next</Text>
357
+ <Ionicons name="arrow-forward" size={19} color={colors.black} />
358
+ </Pressable>
359
+ </View>
360
+ </View>
361
+ ) : (
362
+ <ScrollView
363
+ style={styles.scroll}
364
+ contentContainerStyle={styles.scrollContent}
365
+ keyboardShouldPersistTaps="handled"
366
+ >
367
+ <View style={styles.heroCard}>
368
+ <View style={styles.heroTopRow}>
369
+ <View style={styles.heroIconWrap}>
370
+ <Ionicons name="hardware-chip-outline" size={20} color={colors.textPrimary} />
371
+ </View>
372
+ {mode === 'edit' && onCancel ? (
373
+ <Pressable
374
+ onPress={onCancel}
375
+ hitSlop={8}
376
+ style={({ pressed }) => [styles.cancelBtn, pressed && styles.cancelBtnPressed]}
377
+ >
378
+ <Ionicons name="close" size={16} color={colors.textPrimary} />
379
+ </Pressable>
380
+ ) : null}
381
+ </View>
382
+ <Text style={styles.heroTitle}>{modeTitle}</Text>
383
+ <Text style={styles.heroDescription}>{modeDescription}</Text>
384
+ </View>
385
+
386
+ <View style={styles.formCard}>
387
+ <Text style={styles.label}>Bridge Mode</Text>
388
+ <View style={styles.modeRow}>
389
+ <Pressable
390
+ onPress={() => applyModePreset('local')}
391
+ style={({ pressed }) => [
392
+ styles.modeButton,
393
+ pressed && styles.modeButtonPressed,
394
+ ]}
395
+ >
396
+ <Ionicons name="wifi-outline" size={16} color={colors.textPrimary} />
397
+ <Text style={styles.modeButtonText}>Local (LAN)</Text>
398
+ </Pressable>
399
+ <Pressable
400
+ onPress={() => applyModePreset('tailscale')}
401
+ style={({ pressed }) => [
402
+ styles.modeButton,
403
+ pressed && styles.modeButtonPressed,
404
+ ]}
405
+ >
406
+ <Ionicons name="shield-outline" size={16} color={colors.textPrimary} />
407
+ <Text style={styles.modeButtonText}>Tailscale</Text>
408
+ </Pressable>
409
+ </View>
410
+ <Text style={styles.helperText}>
411
+ Pick the same mode used while starting the bridge, then adjust the IP if needed.
412
+ </Text>
413
+
414
+ <Text style={styles.label}>Bridge URL</Text>
415
+ <TextInput
416
+ value={urlInput}
417
+ onChangeText={(value) => {
418
+ setUrlInput(value);
419
+ setFormError(null);
420
+ setConnectionCheck({ kind: 'idle' });
421
+ }}
422
+ autoCapitalize="none"
423
+ autoCorrect={false}
424
+ keyboardType="url"
425
+ placeholder="http://100.101.102.103:8787"
426
+ placeholderTextColor={colors.textMuted}
427
+ style={styles.input}
428
+ returnKeyType="done"
429
+ onSubmitEditing={() => {
430
+ void handleSave();
431
+ }}
432
+ />
433
+ <View style={styles.tokenHeaderRow}>
434
+ <Text style={styles.label}>Bridge Token</Text>
435
+ <Text style={styles.optionalLabel}>Required</Text>
436
+ </View>
437
+ <View style={styles.tokenInputWrap}>
438
+ <TextInput
439
+ value={tokenInput}
440
+ onChangeText={(value) => {
441
+ setTokenInput(value);
442
+ setConnectionCheck({ kind: 'idle' });
443
+ }}
444
+ autoCapitalize="none"
445
+ autoCorrect={false}
446
+ keyboardType="default"
447
+ placeholder="Paste bridge token"
448
+ placeholderTextColor={colors.textMuted}
449
+ style={[styles.input, styles.tokenInput]}
450
+ secureTextEntry={tokenHidden}
451
+ returnKeyType="done"
452
+ onSubmitEditing={() => {
453
+ void handleSave();
454
+ }}
455
+ />
456
+ <Pressable
457
+ onPress={() => setTokenHidden((prev) => !prev)}
458
+ style={({ pressed }) => [
459
+ styles.tokenRevealBtn,
460
+ pressed && styles.tokenRevealBtnPressed,
461
+ ]}
462
+ >
463
+ <Ionicons
464
+ name={tokenHidden ? 'eye-outline' : 'eye-off-outline'}
465
+ size={16}
466
+ color={colors.textSecondary}
467
+ />
468
+ <Text style={styles.tokenRevealBtnText}>
469
+ {tokenHidden ? 'Show' : 'Hide'}
470
+ </Text>
471
+ </Pressable>
472
+ </View>
473
+ <Pressable
474
+ onPress={() => {
475
+ void openScanner();
476
+ }}
477
+ style={({ pressed }) => [
478
+ styles.scanButton,
479
+ pressed && styles.scanButtonPressed,
480
+ ]}
481
+ >
482
+ <Ionicons name="qr-code-outline" size={16} color={colors.textPrimary} />
483
+ <Text style={styles.scanButtonText}>Scan Bridge QR</Text>
484
+ </Pressable>
485
+ <Text style={styles.helperText}>
486
+ URL supports `http`, `https`, `ws`, and `wss`. `/rpc` is added automatically.
487
+ </Text>
488
+
489
+ {normalizedBridgeUrl ? (
490
+ <View style={styles.previewWrap}>
491
+ <Text style={styles.previewLabel}>Normalized URL</Text>
492
+ <Text selectable style={styles.previewValue}>
493
+ {normalizedBridgeUrl}
494
+ </Text>
495
+ </View>
496
+ ) : null}
497
+
498
+ {insecureRemoteWarning ? (
499
+ <Text style={styles.warningText}>{insecureRemoteWarning}</Text>
500
+ ) : null}
501
+
502
+ {formError ? <Text style={styles.errorText}>{formError}</Text> : null}
503
+ {connectionCheck.kind === 'success' ? (
504
+ <Text style={styles.successText}>{connectionCheck.message}</Text>
505
+ ) : null}
506
+ {connectionCheck.kind === 'error' ? (
507
+ <Text style={styles.errorText}>{connectionCheck.message}</Text>
508
+ ) : null}
509
+
510
+ <View style={styles.actionRow}>
511
+ <Pressable
512
+ onPress={() => {
513
+ void handleConnectionCheck();
514
+ }}
515
+ disabled={checkingConnection}
516
+ style={({ pressed }) => [
517
+ styles.secondaryButton,
518
+ pressed && !checkingConnection && styles.secondaryButtonPressed,
519
+ checkingConnection && styles.secondaryButtonDisabled,
520
+ ]}
521
+ >
522
+ {checkingConnection ? (
523
+ <ActivityIndicator size="small" color={colors.textPrimary} />
524
+ ) : (
525
+ <Ionicons name="pulse-outline" size={16} color={colors.textPrimary} />
526
+ )}
527
+ <Text style={styles.secondaryButtonText}>Test Connection</Text>
528
+ </Pressable>
529
+ <Pressable
530
+ onPress={() => {
531
+ void handleSave();
532
+ }}
533
+ disabled={checkingConnection}
534
+ style={({ pressed }) => [
535
+ styles.primaryButton,
536
+ pressed && !checkingConnection && styles.primaryButtonPressed,
537
+ checkingConnection && styles.primaryButtonDisabled,
538
+ ]}
539
+ >
540
+ {checkingConnection ? (
541
+ <ActivityIndicator size="small" color={colors.black} />
542
+ ) : (
543
+ <Ionicons name="arrow-forward" size={16} color={colors.black} />
544
+ )}
545
+ <Text style={styles.primaryButtonText}>
546
+ {mode === 'edit' ? 'Save URL' : 'Continue'}
547
+ </Text>
548
+ </Pressable>
549
+ </View>
550
+ </View>
551
+
552
+ <View style={styles.hintCard}>
553
+ <Text style={styles.hintTitle}>Quick Setup</Text>
554
+ <Text style={styles.hintText}>1. Start the bridge in Local (LAN) or Tailscale mode.</Text>
555
+ <Text style={styles.hintText}>2. Pick Local or Tailscale above, then confirm bridge URL.</Text>
556
+ <Text style={styles.hintText}>3. Scan bridge QR, then test connection and continue.</Text>
557
+ </View>
558
+ </ScrollView>
559
+ )}
560
+ <Modal
561
+ animationType="slide"
562
+ visible={scannerVisible}
563
+ transparent
564
+ onRequestClose={closeScanner}
565
+ >
566
+ <View style={styles.scannerModalRoot}>
567
+ <View style={styles.scannerSheet}>
568
+ <View style={styles.scannerHeader}>
569
+ <Text style={styles.scannerTitle}>Scan Bridge QR</Text>
570
+ <Pressable
571
+ onPress={closeScanner}
572
+ hitSlop={8}
573
+ style={({ pressed }) => [
574
+ styles.scannerCloseBtn,
575
+ pressed && styles.scannerCloseBtnPressed,
576
+ ]}
577
+ >
578
+ <Ionicons name="close" size={18} color={colors.textPrimary} />
579
+ </Pressable>
580
+ </View>
581
+ <View style={styles.scannerCameraFrame}>
582
+ {cameraPermission?.granted ? (
583
+ <CameraView
584
+ style={styles.scannerCamera}
585
+ barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
586
+ onBarcodeScanned={scannerLocked ? undefined : handleBarcodeScanned}
587
+ />
588
+ ) : (
589
+ <View style={styles.scannerPermissionWrap}>
590
+ <Text style={styles.scannerPermissionText}>
591
+ Camera permission is required to scan bridge QR.
592
+ </Text>
593
+ </View>
594
+ )}
595
+ </View>
596
+ <Text style={styles.scannerHintText}>
597
+ Scan the bridge QR to autofill URL and token (or token-only fallback).
598
+ </Text>
599
+ {scannerError ? <Text style={styles.errorText}>{scannerError}</Text> : null}
600
+ </View>
601
+ </View>
602
+ </Modal>
603
+ </KeyboardAvoidingView>
604
+ </SafeAreaView>
605
+ </View>
606
+ );
607
+ }
608
+
609
+ function IntroFeatureRow({
610
+ icon,
611
+ title,
612
+ description,
613
+ }: {
614
+ icon: keyof typeof Ionicons.glyphMap;
615
+ title: string;
616
+ description: string;
617
+ }) {
618
+ return (
619
+ <View style={styles.introFeatureRow}>
620
+ <View style={styles.introFeatureIconWrap}>
621
+ <Ionicons name={icon} size={16} color={colors.textPrimary} />
622
+ </View>
623
+ <View style={styles.introFeatureTextWrap}>
624
+ <Text style={styles.introFeatureTitle}>{title}</Text>
625
+ <Text style={styles.introFeatureDescription}>{description}</Text>
626
+ </View>
627
+ </View>
628
+ );
629
+ }
630
+
631
+ function parsePairingPayload(rawValue: string): PairingPayload | null {
632
+ const raw = rawValue.trim();
633
+ if (!raw) {
634
+ return null;
635
+ }
636
+
637
+ try {
638
+ const parsed = JSON.parse(raw) as {
639
+ type?: unknown;
640
+ bridgeUrl?: unknown;
641
+ url?: unknown;
642
+ bridgeToken?: unknown;
643
+ token?: unknown;
644
+ };
645
+ const type = typeof parsed.type === 'string' ? parsed.type.trim().toLowerCase() : '';
646
+ const bridgeUrlRaw =
647
+ typeof parsed.bridgeUrl === 'string'
648
+ ? parsed.bridgeUrl
649
+ : typeof parsed.url === 'string'
650
+ ? parsed.url
651
+ : '';
652
+ const bridgeTokenRaw =
653
+ typeof parsed.bridgeToken === 'string'
654
+ ? parsed.bridgeToken
655
+ : typeof parsed.token === 'string'
656
+ ? parsed.token
657
+ : '';
658
+ const bridgeUrl = normalizeBridgeUrlInput(bridgeUrlRaw) ?? undefined;
659
+ const bridgeToken = bridgeTokenRaw.trim();
660
+ if (
661
+ bridgeToken &&
662
+ (
663
+ type === 'clawdex-bridge-pair' ||
664
+ type === 'clawdex/bridge-pair' ||
665
+ type === 'clawdex-bridge-token' ||
666
+ type === 'clawdex/bridge-token' ||
667
+ !type
668
+ )
669
+ ) {
670
+ return bridgeUrl ? { bridgeToken, bridgeUrl } : { bridgeToken };
671
+ }
672
+ } catch {
673
+ // Try URI form fallback below.
674
+ }
675
+
676
+ try {
677
+ const parsed = new URL(raw);
678
+ if (parsed.protocol !== 'clawdex:') {
679
+ return null;
680
+ }
681
+ const bridgeUrl =
682
+ normalizeBridgeUrlInput(
683
+ parsed.searchParams.get('bridgeUrl') ?? parsed.searchParams.get('url') ?? ''
684
+ ) ?? undefined;
685
+ const bridgeToken = (
686
+ parsed.searchParams.get('bridgeToken') ?? parsed.searchParams.get('token') ?? ''
687
+ ).trim();
688
+ if (!bridgeToken) {
689
+ return null;
690
+ }
691
+ return bridgeUrl ? { bridgeToken, bridgeUrl } : { bridgeToken };
692
+ } catch {
693
+ return null;
694
+ }
695
+ }
696
+
697
+ const styles = StyleSheet.create({
698
+ container: {
699
+ flex: 1,
700
+ backgroundColor: colors.bgMain,
701
+ },
702
+ safeArea: {
703
+ flex: 1,
704
+ },
705
+ keyboardAvoiding: {
706
+ flex: 1,
707
+ },
708
+ introRoot: {
709
+ flex: 1,
710
+ paddingHorizontal: spacing.lg,
711
+ paddingTop: spacing.md,
712
+ paddingBottom: spacing.xxl,
713
+ gap: spacing.md,
714
+ },
715
+ introBrandRow: {
716
+ flexDirection: 'row',
717
+ alignItems: 'center',
718
+ gap: spacing.sm,
719
+ paddingHorizontal: spacing.xs,
720
+ },
721
+ introBrandName: {
722
+ ...typography.headline,
723
+ color: colors.textPrimary,
724
+ fontSize: 18,
725
+ letterSpacing: -0.2,
726
+ },
727
+ introMain: {
728
+ flex: 1,
729
+ gap: spacing.md,
730
+ },
731
+ introFeaturesCard: {
732
+ flex: 1,
733
+ paddingVertical: spacing.md,
734
+ },
735
+ introFeaturesList: {
736
+ flex: 1,
737
+ },
738
+ introFeaturesListContent: {
739
+ gap: spacing.sm,
740
+ paddingBottom: spacing.sm,
741
+ },
742
+ introFooter: {
743
+ paddingTop: spacing.sm,
744
+ },
745
+ scroll: {
746
+ flex: 1,
747
+ },
748
+ scrollContent: {
749
+ paddingHorizontal: spacing.lg,
750
+ paddingTop: spacing.md,
751
+ paddingBottom: spacing.xxl,
752
+ gap: spacing.md,
753
+ },
754
+ heroCard: {
755
+ borderRadius: radius.lg,
756
+ borderWidth: StyleSheet.hairlineWidth,
757
+ borderColor: colors.borderHighlight,
758
+ backgroundColor: colors.black,
759
+ padding: spacing.lg,
760
+ gap: spacing.sm,
761
+ overflow: 'hidden',
762
+ },
763
+ heroTopRow: {
764
+ flexDirection: 'row',
765
+ justifyContent: 'space-between',
766
+ alignItems: 'center',
767
+ },
768
+ heroIconWrap: {
769
+ width: 34,
770
+ height: 34,
771
+ borderRadius: 17,
772
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
773
+ alignItems: 'center',
774
+ justifyContent: 'center',
775
+ },
776
+ cancelBtn: {
777
+ width: 30,
778
+ height: 30,
779
+ borderRadius: 15,
780
+ borderWidth: StyleSheet.hairlineWidth,
781
+ borderColor: colors.borderLight,
782
+ backgroundColor: colors.bgMain,
783
+ alignItems: 'center',
784
+ justifyContent: 'center',
785
+ },
786
+ cancelBtnPressed: {
787
+ opacity: 0.75,
788
+ },
789
+ heroTitle: {
790
+ ...typography.largeTitle,
791
+ fontSize: 28,
792
+ letterSpacing: -0.5,
793
+ },
794
+ heroDescription: {
795
+ ...typography.body,
796
+ color: colors.textSecondary,
797
+ },
798
+ introSectionTitle: {
799
+ ...typography.caption,
800
+ textTransform: 'uppercase',
801
+ letterSpacing: 0.9,
802
+ color: colors.textMuted,
803
+ },
804
+ introFeatureRow: {
805
+ flexDirection: 'row',
806
+ gap: spacing.sm,
807
+ borderRadius: radius.md,
808
+ borderWidth: StyleSheet.hairlineWidth,
809
+ borderColor: colors.borderLight,
810
+ backgroundColor: colors.black,
811
+ paddingHorizontal: spacing.md,
812
+ paddingVertical: spacing.md,
813
+ minHeight: 62,
814
+ },
815
+ introFeatureIconWrap: {
816
+ width: 30,
817
+ height: 30,
818
+ borderRadius: 15,
819
+ alignItems: 'center',
820
+ justifyContent: 'center',
821
+ backgroundColor: 'rgba(255,255,255,0.08)',
822
+ marginTop: 2,
823
+ },
824
+ introFeatureTextWrap: {
825
+ flex: 1,
826
+ gap: 2,
827
+ },
828
+ introFeatureTitle: {
829
+ ...typography.headline,
830
+ color: colors.textPrimary,
831
+ fontSize: 14,
832
+ },
833
+ introFeatureDescription: {
834
+ ...typography.caption,
835
+ color: colors.textSecondary,
836
+ lineHeight: 18,
837
+ },
838
+ introNextButton: {
839
+ borderRadius: radius.md,
840
+ backgroundColor: colors.accent,
841
+ minHeight: 62,
842
+ flexDirection: 'row',
843
+ alignItems: 'center',
844
+ justifyContent: 'center',
845
+ gap: spacing.sm,
846
+ width: '100%',
847
+ },
848
+ introNextButtonPressed: {
849
+ backgroundColor: colors.accentPressed,
850
+ },
851
+ introNextButtonText: {
852
+ ...typography.headline,
853
+ color: colors.black,
854
+ fontSize: 18,
855
+ fontWeight: '700',
856
+ },
857
+ formCard: {
858
+ borderRadius: radius.lg,
859
+ borderWidth: StyleSheet.hairlineWidth,
860
+ borderColor: colors.borderHighlight,
861
+ backgroundColor: colors.black,
862
+ padding: spacing.lg,
863
+ gap: spacing.sm,
864
+ overflow: 'hidden',
865
+ },
866
+ label: {
867
+ ...typography.caption,
868
+ textTransform: 'uppercase',
869
+ letterSpacing: 0.9,
870
+ color: colors.textMuted,
871
+ },
872
+ tokenHeaderRow: {
873
+ marginTop: spacing.xs,
874
+ flexDirection: 'row',
875
+ justifyContent: 'space-between',
876
+ alignItems: 'center',
877
+ gap: spacing.sm,
878
+ },
879
+ optionalLabel: {
880
+ ...typography.caption,
881
+ color: colors.textMuted,
882
+ fontSize: 11,
883
+ },
884
+ tokenInputWrap: {
885
+ flexDirection: 'row',
886
+ alignItems: 'center',
887
+ gap: spacing.xs,
888
+ },
889
+ input: {
890
+ ...typography.body,
891
+ color: colors.textPrimary,
892
+ borderWidth: 1,
893
+ borderColor: colors.border,
894
+ borderRadius: radius.md,
895
+ backgroundColor: colors.black,
896
+ paddingHorizontal: spacing.md,
897
+ paddingVertical: spacing.md,
898
+ },
899
+ tokenInput: {
900
+ flex: 1,
901
+ },
902
+ tokenRevealBtn: {
903
+ minWidth: 74,
904
+ borderRadius: radius.md,
905
+ borderWidth: 1,
906
+ borderColor: colors.border,
907
+ backgroundColor: colors.bgMain,
908
+ paddingHorizontal: spacing.sm,
909
+ paddingVertical: spacing.sm,
910
+ flexDirection: 'row',
911
+ alignItems: 'center',
912
+ justifyContent: 'center',
913
+ gap: 4,
914
+ },
915
+ tokenRevealBtnPressed: {
916
+ opacity: 0.8,
917
+ },
918
+ tokenRevealBtnText: {
919
+ ...typography.caption,
920
+ color: colors.textSecondary,
921
+ fontWeight: '600',
922
+ },
923
+ modeRow: {
924
+ flexDirection: 'row',
925
+ gap: spacing.sm,
926
+ marginBottom: spacing.xs,
927
+ },
928
+ modeButton: {
929
+ flex: 1,
930
+ minHeight: 44,
931
+ borderRadius: radius.md,
932
+ borderWidth: 1,
933
+ borderColor: colors.border,
934
+ backgroundColor: colors.bgMain,
935
+ paddingHorizontal: spacing.md,
936
+ flexDirection: 'row',
937
+ alignItems: 'center',
938
+ justifyContent: 'center',
939
+ gap: spacing.xs,
940
+ },
941
+ modeButtonPressed: {
942
+ opacity: 0.82,
943
+ },
944
+ modeButtonText: {
945
+ ...typography.caption,
946
+ color: colors.textPrimary,
947
+ fontWeight: '700',
948
+ },
949
+ scanButton: {
950
+ marginTop: spacing.xs,
951
+ borderRadius: radius.md,
952
+ borderWidth: 1,
953
+ borderColor: colors.border,
954
+ backgroundColor: colors.bgMain,
955
+ minHeight: 44,
956
+ paddingHorizontal: spacing.md,
957
+ flexDirection: 'row',
958
+ alignItems: 'center',
959
+ justifyContent: 'center',
960
+ gap: spacing.xs,
961
+ },
962
+ scanButtonPressed: {
963
+ opacity: 0.82,
964
+ },
965
+ scanButtonText: {
966
+ ...typography.caption,
967
+ color: colors.textPrimary,
968
+ fontWeight: '700',
969
+ },
970
+ helperText: {
971
+ ...typography.caption,
972
+ color: colors.textMuted,
973
+ },
974
+ previewWrap: {
975
+ borderRadius: radius.md,
976
+ borderWidth: StyleSheet.hairlineWidth,
977
+ borderColor: colors.borderLight,
978
+ backgroundColor: colors.black,
979
+ paddingHorizontal: spacing.md,
980
+ paddingVertical: spacing.sm,
981
+ gap: spacing.xs,
982
+ },
983
+ previewLabel: {
984
+ ...typography.caption,
985
+ color: colors.textMuted,
986
+ },
987
+ previewValue: {
988
+ ...typography.mono,
989
+ color: colors.textPrimary,
990
+ fontSize: 13,
991
+ },
992
+ warningText: {
993
+ ...typography.caption,
994
+ color: '#F7D27E',
995
+ },
996
+ errorText: {
997
+ ...typography.caption,
998
+ color: colors.error,
999
+ },
1000
+ successText: {
1001
+ ...typography.caption,
1002
+ color: colors.statusComplete,
1003
+ },
1004
+ actionRow: {
1005
+ flexDirection: 'row',
1006
+ gap: spacing.sm,
1007
+ marginTop: spacing.sm,
1008
+ },
1009
+ secondaryButton: {
1010
+ flex: 1,
1011
+ flexDirection: 'row',
1012
+ gap: spacing.xs,
1013
+ alignItems: 'center',
1014
+ justifyContent: 'center',
1015
+ borderWidth: 1,
1016
+ borderColor: colors.border,
1017
+ backgroundColor: colors.bgMain,
1018
+ borderRadius: radius.md,
1019
+ paddingVertical: spacing.md,
1020
+ },
1021
+ secondaryButtonPressed: {
1022
+ opacity: 0.8,
1023
+ },
1024
+ secondaryButtonDisabled: {
1025
+ opacity: 0.65,
1026
+ },
1027
+ secondaryButtonText: {
1028
+ ...typography.caption,
1029
+ color: colors.textPrimary,
1030
+ fontWeight: '600',
1031
+ },
1032
+ primaryButton: {
1033
+ flex: 1,
1034
+ flexDirection: 'row',
1035
+ gap: spacing.xs,
1036
+ alignItems: 'center',
1037
+ justifyContent: 'center',
1038
+ backgroundColor: colors.accent,
1039
+ borderRadius: radius.md,
1040
+ paddingVertical: spacing.md,
1041
+ },
1042
+ primaryButtonPressed: {
1043
+ backgroundColor: colors.accentPressed,
1044
+ },
1045
+ primaryButtonDisabled: {
1046
+ opacity: 0.72,
1047
+ },
1048
+ primaryButtonText: {
1049
+ ...typography.headline,
1050
+ color: colors.black,
1051
+ fontWeight: '700',
1052
+ },
1053
+ scannerModalRoot: {
1054
+ flex: 1,
1055
+ backgroundColor: 'rgba(0,0,0,0.94)',
1056
+ justifyContent: 'center',
1057
+ paddingHorizontal: spacing.lg,
1058
+ },
1059
+ scannerSheet: {
1060
+ borderRadius: radius.lg,
1061
+ borderWidth: StyleSheet.hairlineWidth,
1062
+ borderColor: colors.borderHighlight,
1063
+ backgroundColor: colors.black,
1064
+ padding: spacing.lg,
1065
+ gap: spacing.md,
1066
+ },
1067
+ scannerHeader: {
1068
+ flexDirection: 'row',
1069
+ alignItems: 'center',
1070
+ justifyContent: 'space-between',
1071
+ },
1072
+ scannerTitle: {
1073
+ ...typography.headline,
1074
+ color: colors.textPrimary,
1075
+ },
1076
+ scannerCloseBtn: {
1077
+ width: 30,
1078
+ height: 30,
1079
+ borderRadius: 15,
1080
+ borderWidth: StyleSheet.hairlineWidth,
1081
+ borderColor: colors.borderLight,
1082
+ backgroundColor: colors.bgMain,
1083
+ alignItems: 'center',
1084
+ justifyContent: 'center',
1085
+ },
1086
+ scannerCloseBtnPressed: {
1087
+ opacity: 0.75,
1088
+ },
1089
+ scannerCameraFrame: {
1090
+ width: '100%',
1091
+ aspectRatio: 1,
1092
+ borderRadius: radius.md,
1093
+ overflow: 'hidden',
1094
+ borderWidth: StyleSheet.hairlineWidth,
1095
+ borderColor: colors.borderLight,
1096
+ backgroundColor: colors.bgMain,
1097
+ },
1098
+ scannerCamera: {
1099
+ flex: 1,
1100
+ },
1101
+ scannerPermissionWrap: {
1102
+ flex: 1,
1103
+ alignItems: 'center',
1104
+ justifyContent: 'center',
1105
+ paddingHorizontal: spacing.lg,
1106
+ },
1107
+ scannerPermissionText: {
1108
+ ...typography.caption,
1109
+ color: colors.textSecondary,
1110
+ textAlign: 'center',
1111
+ },
1112
+ scannerHintText: {
1113
+ ...typography.caption,
1114
+ color: colors.textMuted,
1115
+ },
1116
+ hintCard: {
1117
+ borderRadius: radius.md,
1118
+ borderWidth: StyleSheet.hairlineWidth,
1119
+ borderColor: colors.borderLight,
1120
+ backgroundColor: colors.black,
1121
+ padding: spacing.lg,
1122
+ gap: spacing.sm,
1123
+ },
1124
+ hintTitle: {
1125
+ ...typography.headline,
1126
+ color: colors.textPrimary,
1127
+ },
1128
+ hintText: {
1129
+ ...typography.caption,
1130
+ color: colors.textSecondary,
1131
+ },
1132
+ });