react-native-debug-toolkit 2.2.0 → 3.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 (115) hide show
  1. package/README.md +5 -8
  2. package/README.zh-CN.md +5 -8
  3. package/bin/debug-toolkit.js +114 -0
  4. package/lib/commonjs/features/network/index.js +32 -12
  5. package/lib/commonjs/features/network/index.js.map +1 -1
  6. package/lib/commonjs/features/network/networkInterceptor.js +164 -201
  7. package/lib/commonjs/features/network/networkInterceptor.js.map +1 -1
  8. package/lib/commonjs/index.js +59 -0
  9. package/lib/commonjs/index.js.map +1 -1
  10. package/lib/commonjs/ui/panel/DebugPanel.js +25 -0
  11. package/lib/commonjs/ui/panel/DebugPanel.js.map +1 -1
  12. package/lib/commonjs/ui/panel/FloatPanelView.js +15 -62
  13. package/lib/commonjs/ui/panel/FloatPanelView.js.map +1 -1
  14. package/lib/commonjs/ui/panel/StreamingSettingsModal.js +529 -0
  15. package/lib/commonjs/ui/panel/StreamingSettingsModal.js.map +1 -0
  16. package/lib/commonjs/ui/panel/useTabAnimation.js +71 -0
  17. package/lib/commonjs/ui/panel/useTabAnimation.js.map +1 -0
  18. package/lib/commonjs/utils/autoDetectDaemon.js +141 -0
  19. package/lib/commonjs/utils/autoDetectDaemon.js.map +1 -0
  20. package/lib/commonjs/utils/createPersistedObservableStore.js +23 -3
  21. package/lib/commonjs/utils/createPersistedObservableStore.js.map +1 -1
  22. package/lib/commonjs/utils/daemonConnection.js +81 -0
  23. package/lib/commonjs/utils/daemonConnection.js.map +1 -0
  24. package/lib/commonjs/utils/daemonSettings.js +110 -0
  25. package/lib/commonjs/utils/daemonSettings.js.map +1 -0
  26. package/lib/commonjs/utils/reportToDaemon.js +112 -0
  27. package/lib/commonjs/utils/reportToDaemon.js.map +1 -0
  28. package/lib/commonjs/utils/sessionReport.js +132 -0
  29. package/lib/commonjs/utils/sessionReport.js.map +1 -0
  30. package/lib/commonjs/utils/streamToDaemon.js +334 -0
  31. package/lib/commonjs/utils/streamToDaemon.js.map +1 -0
  32. package/lib/module/features/network/index.js +30 -12
  33. package/lib/module/features/network/index.js.map +1 -1
  34. package/lib/module/features/network/networkInterceptor.js +163 -200
  35. package/lib/module/features/network/networkInterceptor.js.map +1 -1
  36. package/lib/module/index.js +5 -0
  37. package/lib/module/index.js.map +1 -1
  38. package/lib/module/ui/panel/DebugPanel.js +26 -1
  39. package/lib/module/ui/panel/DebugPanel.js.map +1 -1
  40. package/lib/module/ui/panel/FloatPanelView.js +16 -63
  41. package/lib/module/ui/panel/FloatPanelView.js.map +1 -1
  42. package/lib/module/ui/panel/StreamingSettingsModal.js +524 -0
  43. package/lib/module/ui/panel/StreamingSettingsModal.js.map +1 -0
  44. package/lib/module/ui/panel/useTabAnimation.js +67 -0
  45. package/lib/module/ui/panel/useTabAnimation.js.map +1 -0
  46. package/lib/module/utils/autoDetectDaemon.js +136 -0
  47. package/lib/module/utils/autoDetectDaemon.js.map +1 -0
  48. package/lib/module/utils/createPersistedObservableStore.js +23 -3
  49. package/lib/module/utils/createPersistedObservableStore.js.map +1 -1
  50. package/lib/module/utils/daemonConnection.js +77 -0
  51. package/lib/module/utils/daemonConnection.js.map +1 -0
  52. package/lib/module/utils/daemonSettings.js +102 -0
  53. package/lib/module/utils/daemonSettings.js.map +1 -0
  54. package/lib/module/utils/reportToDaemon.js +105 -0
  55. package/lib/module/utils/reportToDaemon.js.map +1 -0
  56. package/lib/module/utils/sessionReport.js +128 -0
  57. package/lib/module/utils/sessionReport.js.map +1 -0
  58. package/lib/module/utils/streamToDaemon.js +328 -0
  59. package/lib/module/utils/streamToDaemon.js.map +1 -0
  60. package/lib/typescript/src/features/network/index.d.ts +2 -4
  61. package/lib/typescript/src/features/network/index.d.ts.map +1 -1
  62. package/lib/typescript/src/features/network/networkInterceptor.d.ts +2 -15
  63. package/lib/typescript/src/features/network/networkInterceptor.d.ts.map +1 -1
  64. package/lib/typescript/src/index.d.ts +11 -1
  65. package/lib/typescript/src/index.d.ts.map +1 -1
  66. package/lib/typescript/src/ui/panel/DebugPanel.d.ts.map +1 -1
  67. package/lib/typescript/src/ui/panel/FloatPanelView.d.ts.map +1 -1
  68. package/lib/typescript/src/ui/panel/StreamingSettingsModal.d.ts +8 -0
  69. package/lib/typescript/src/ui/panel/StreamingSettingsModal.d.ts.map +1 -0
  70. package/lib/typescript/src/ui/panel/useTabAnimation.d.ts +14 -0
  71. package/lib/typescript/src/ui/panel/useTabAnimation.d.ts.map +1 -0
  72. package/lib/typescript/src/utils/autoDetectDaemon.d.ts +15 -0
  73. package/lib/typescript/src/utils/autoDetectDaemon.d.ts.map +1 -0
  74. package/lib/typescript/src/utils/createPersistedObservableStore.d.ts +2 -1
  75. package/lib/typescript/src/utils/createPersistedObservableStore.d.ts.map +1 -1
  76. package/lib/typescript/src/utils/daemonConnection.d.ts +18 -0
  77. package/lib/typescript/src/utils/daemonConnection.d.ts.map +1 -0
  78. package/lib/typescript/src/utils/daemonSettings.d.ts +19 -0
  79. package/lib/typescript/src/utils/daemonSettings.d.ts.map +1 -0
  80. package/lib/typescript/src/utils/reportToDaemon.d.ts +34 -0
  81. package/lib/typescript/src/utils/reportToDaemon.d.ts.map +1 -0
  82. package/lib/typescript/src/utils/sessionReport.d.ts +18 -0
  83. package/lib/typescript/src/utils/sessionReport.d.ts.map +1 -0
  84. package/lib/typescript/src/utils/streamToDaemon.d.ts +23 -0
  85. package/lib/typescript/src/utils/streamToDaemon.d.ts.map +1 -0
  86. package/node/daemon/src/cli.js +75 -0
  87. package/node/daemon/src/console/console.html +936 -0
  88. package/node/daemon/src/console/index.js +47 -0
  89. package/node/daemon/src/constants.js +32 -0
  90. package/node/daemon/src/index.js +11 -0
  91. package/node/daemon/src/server.js +365 -0
  92. package/node/daemon/src/store.js +110 -0
  93. package/node/mcp/src/cli.js +31 -0
  94. package/node/mcp/src/constants.js +13 -0
  95. package/node/mcp/src/daemonClient.js +132 -0
  96. package/node/mcp/src/httpClient.js +49 -0
  97. package/node/mcp/src/index.js +15 -0
  98. package/node/mcp/src/logs.js +95 -0
  99. package/node/mcp/src/server.js +144 -0
  100. package/node/mcp/src/tools.js +84 -0
  101. package/package.json +10 -5
  102. package/src/features/network/index.ts +35 -19
  103. package/src/features/network/networkInterceptor.ts +224 -236
  104. package/src/index.ts +15 -1
  105. package/src/ui/panel/DebugPanel.tsx +23 -1
  106. package/src/ui/panel/FloatPanelView.tsx +10 -68
  107. package/src/ui/panel/StreamingSettingsModal.tsx +566 -0
  108. package/src/ui/panel/useTabAnimation.ts +77 -0
  109. package/src/utils/autoDetectDaemon.ts +175 -0
  110. package/src/utils/createPersistedObservableStore.ts +16 -3
  111. package/src/utils/daemonConnection.ts +133 -0
  112. package/src/utils/daemonSettings.ts +134 -0
  113. package/src/utils/reportToDaemon.ts +172 -0
  114. package/src/utils/sessionReport.ts +203 -0
  115. package/src/utils/streamToDaemon.ts +419 -0
@@ -4,8 +4,6 @@ import {
4
4
  Text,
5
5
  StyleSheet,
6
6
  Animated,
7
- PanResponder,
8
- Easing,
9
7
  } from 'react-native';
10
8
  import type { AnyDebugFeature } from '../../types';
11
9
  import { getPreference, setPreference, KEYS } from '../../utils/debugPreferences';
@@ -13,6 +11,7 @@ import { FloatIcon } from '../floating/FloatIcon';
13
11
  import { DebugPanel } from './DebugPanel';
14
12
  import { FeatureTabBar } from './FeatureTabBar';
15
13
  import type { TabItem } from './FeatureTabBar';
14
+ import { useTabAnimation } from './useTabAnimation';
16
15
 
17
16
  // ─── Error Boundary ────────────────────────────────────
18
17
  interface ErrorBoundaryState {
@@ -66,34 +65,14 @@ export function FloatPanelView({ features, panelOpen, onOpenPanel, onClosePanel,
66
65
  return () => { mounted = false; };
67
66
  }, []);
68
67
 
69
- // Content slide animation
70
- const contentOpacity = useRef(new Animated.Value(1)).current;
71
- const contentTranslateX = useRef(new Animated.Value(0)).current;
72
- const isSwitchingTab = useRef(false);
73
-
74
- // Refs to avoid stale closures in PanResponder
75
- const activeTabRef = useRef(0);
76
- activeTabRef.current = activeTab;
77
- const featuresLengthRef = useRef(features.length);
78
- featuresLengthRef.current = features.length;
79
- const switchTabRef = useRef<(index: number) => void>(() => {});
80
-
81
- // Swipe-to-switch responder
82
- const swipeResponder = useRef(
83
- PanResponder.create({
84
- onStartShouldSetPanResponder: () => false,
85
- onMoveShouldSetPanResponder: (_, gs) => {
86
- if (isSwitchingTab.current) return false;
87
- return Math.abs(gs.dx) > 25 && Math.abs(gs.dx) > Math.abs(gs.dy) * 2.5;
88
- },
89
- onPanResponderRelease: (_, gs) => {
90
- const tab = activeTabRef.current;
91
- if (gs.dx < -40 && tab < featuresLengthRef.current - 1) switchTabRef.current(tab + 1);
92
- else if (gs.dx > 40 && tab > 0) switchTabRef.current(tab - 1);
93
- },
94
- onPanResponderTerminationRequest: () => true,
95
- }),
96
- ).current;
68
+ const { contentOpacity, contentTranslateX, panHandlers, switchTab } = useTabAnimation({
69
+ activeTab,
70
+ tabCount: features.length,
71
+ onTabChange: useCallback((index: number) => {
72
+ setActiveTab(index);
73
+ setPreference(KEYS.lastTab, String(index));
74
+ }, []),
75
+ });
97
76
 
98
77
  // Feature subscription → re-render on data changes
99
78
  const [, setTick] = useState(0);
@@ -122,43 +101,6 @@ export function FloatPanelView({ features, panelOpen, onOpenPanel, onClosePanel,
122
101
  }
123
102
  }, [features.length, activeTab]);
124
103
 
125
- // Tab switching with content animation
126
- const switchTab = useCallback(
127
- (index: number) => {
128
- if (isSwitchingTab.current || index === activeTabRef.current) return;
129
- isSwitchingTab.current = true;
130
- const direction = index > activeTabRef.current ? 1 : -1;
131
-
132
- Animated.parallel([
133
- Animated.timing(contentOpacity, { toValue: 0, duration: 80, useNativeDriver: true }),
134
- Animated.timing(contentTranslateX, {
135
- toValue: -direction * 40,
136
- duration: 80,
137
- useNativeDriver: true,
138
- }),
139
- ]).start(() => {
140
- setActiveTab(index);
141
- setPreference(KEYS.lastTab, String(index));
142
- contentTranslateX.setValue(direction * 40);
143
- Animated.parallel([
144
- Animated.timing(contentOpacity, { toValue: 1, duration: 150, useNativeDriver: true }),
145
- Animated.timing(contentTranslateX, {
146
- toValue: 0,
147
- duration: 200,
148
- easing: Easing.out(Easing.cubic),
149
- useNativeDriver: true,
150
- }),
151
- ]).start(() => {
152
- isSwitchingTab.current = false;
153
- });
154
- });
155
- },
156
- [contentOpacity, contentTranslateX],
157
- );
158
-
159
- // Keep ref in sync
160
- switchTabRef.current = switchTab;
161
-
162
104
  // Badge (first feature that returns one)
163
105
  const envBadge = features.map((f) => f.badge?.()).find((b) => b != null) ?? null;
164
106
  const tabs: TabItem[] = features.map((f) => ({ label: f.label, id: f.name }));
@@ -195,7 +137,7 @@ export function FloatPanelView({ features, panelOpen, onOpenPanel, onClosePanel,
195
137
  styles.contentContainer,
196
138
  { opacity: contentOpacity, transform: [{ translateX: contentTranslateX }] },
197
139
  ]}
198
- {...swipeResponder.panHandlers}
140
+ {...panHandlers}
199
141
  >
200
142
  {renderFeatureContent()}
201
143
  </Animated.View>
@@ -0,0 +1,566 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ KeyboardAvoidingView,
4
+ Modal,
5
+ Platform,
6
+ ScrollView,
7
+ View,
8
+ Text,
9
+ TextInput,
10
+ TouchableOpacity,
11
+ StyleSheet,
12
+ Pressable,
13
+ } from 'react-native';
14
+ import { Colors } from '../theme/colors';
15
+ import {
16
+ buildDeviceDaemonEndpoint,
17
+ type DaemonConnectionMode,
18
+ type DaemonSettings,
19
+ loadDaemonSettings,
20
+ normalizeDaemonSettings,
21
+ saveDaemonSettings,
22
+ } from '../../utils/daemonSettings';
23
+ import { checkDaemonConnection } from '../../utils/daemonConnection';
24
+ import { getDefaultDaemonEndpoint, reportDebugSessionToDaemon } from '../../utils/reportToDaemon';
25
+ import { autoDetectDaemonIp, getMetroHost } from '../../utils/autoDetectDaemon';
26
+ import { startStreaming, stopStreaming, isStreaming } from '../../utils/streamToDaemon';
27
+
28
+ interface StreamingSettingsModalProps {
29
+ visible: boolean;
30
+ onClose: () => void;
31
+ }
32
+
33
+ type SyncUiState = 'idle' | 'connecting' | 'connected' | 'retrying' | 'failed' | 'running';
34
+
35
+ const CONNECTION_TIMEOUT_MS = 2000;
36
+
37
+ function formatConnectionFailure(): string {
38
+ return 'Cannot reach desktop. Try /health in phone browser.';
39
+ }
40
+
41
+ export function StreamingSettingsModal({ visible, onClose }: StreamingSettingsModalProps) {
42
+ const inputRef = useRef<TextInput>(null);
43
+ const [mode, setMode] = useState<DaemonConnectionMode>('simulator');
44
+ const [deviceHost, setDeviceHost] = useState('');
45
+ const [streaming, setStreaming] = useState(isStreaming());
46
+ const [syncState, setSyncState] = useState<SyncUiState>(isStreaming() ? 'running' : 'idle');
47
+ const [message, setMessage] = useState<string | null>(null);
48
+ const [sending, setSending] = useState(false);
49
+ const [detecting, setDetecting] = useState(false);
50
+
51
+ const handleDeviceHostChange = useCallback((value: string) => {
52
+ setDeviceHost(value);
53
+ if (syncState === 'failed') {
54
+ setSyncState('idle');
55
+ }
56
+ setMessage(null);
57
+ }, [syncState]);
58
+
59
+ const detectDeviceHost = useCallback(async () => {
60
+ setDetecting(true);
61
+ setMessage('Detecting desktop...');
62
+ const result = await autoDetectDaemonIp({
63
+ timeoutMs: 800,
64
+ scanSubnets: false,
65
+ });
66
+ setDetecting(false);
67
+
68
+ if (result.ip) {
69
+ setDeviceHost((current) => current.trim() ? current : result.ip || current);
70
+ setMessage(`Detected desktop at ${buildDeviceDaemonEndpoint(result.ip)}.`);
71
+ return;
72
+ }
73
+
74
+ setMessage('Enter your Mac IP, or open /health on the phone browser to verify reachability.');
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ if (visible) {
79
+ loadDaemonSettings().then((settings) => {
80
+ setMode(settings.mode);
81
+ const savedHost = settings.deviceHost;
82
+ if (settings.mode === 'device' && !savedHost) {
83
+ const metroHost = getMetroHost();
84
+ if (metroHost) {
85
+ setDeviceHost(metroHost);
86
+ } else {
87
+ detectDeviceHost();
88
+ }
89
+ } else {
90
+ setDeviceHost(savedHost);
91
+ }
92
+ });
93
+ }
94
+ }, [detectDeviceHost, visible]);
95
+
96
+ useEffect(() => {
97
+ const active = isStreaming();
98
+ setStreaming(active);
99
+ setSyncState(active ? 'running' : 'idle');
100
+ }, [visible]);
101
+
102
+ const getSettings = useCallback((): DaemonSettings => ({
103
+ mode,
104
+ endpoint: '',
105
+ deviceHost,
106
+ token: '',
107
+ }), [deviceHost, mode]);
108
+
109
+ const validateSettings = useCallback((): boolean => {
110
+ if (mode === 'device' && !deviceHost.trim()) {
111
+ setMessage('Enter your Mac IP first.');
112
+ return false;
113
+ }
114
+ return true;
115
+ }, [deviceHost, mode]);
116
+
117
+ const handleModeChange = useCallback((nextMode: DaemonConnectionMode) => {
118
+ setMode(nextMode);
119
+ if (nextMode === 'device' && !deviceHost) {
120
+ const metroHost = getMetroHost();
121
+ if (metroHost) {
122
+ setDeviceHost(metroHost);
123
+ } else {
124
+ detectDeviceHost();
125
+ }
126
+ }
127
+ }, [detectDeviceHost, deviceHost]);
128
+
129
+ const toggleLiveSync = useCallback(async () => {
130
+ if (streaming) {
131
+ stopStreaming();
132
+ setStreaming(false);
133
+ setSyncState('idle');
134
+ setMessage(null);
135
+ return;
136
+ }
137
+
138
+ if (!validateSettings()) {
139
+ return;
140
+ }
141
+
142
+ const settings = getSettings();
143
+ const daemonOptions = normalizeDaemonSettings(settings);
144
+ setMessage('Checking desktop connection...');
145
+ setSyncState('connecting');
146
+ await saveDaemonSettings(settings);
147
+
148
+ const connection = await checkDaemonConnection({
149
+ ...daemonOptions,
150
+ timeoutMs: CONNECTION_TIMEOUT_MS,
151
+ });
152
+ if (!connection.ok) {
153
+ setStreaming(false);
154
+ setSyncState('failed');
155
+ setMessage(formatConnectionFailure());
156
+ return;
157
+ }
158
+
159
+ startStreaming({
160
+ ...daemonOptions,
161
+ timeoutMs: 3000,
162
+ onStatus: (status) => {
163
+ if (status.state === 'connected') {
164
+ setSyncState('connected');
165
+ setMessage(null);
166
+ } else if (status.state === 'retrying') {
167
+ setSyncState('retrying');
168
+ setMessage('Desktop not reachable. Retrying...');
169
+ } else if (status.state === 'failed') {
170
+ setStreaming(false);
171
+ setSyncState('failed');
172
+ setMessage(
173
+ status.reason === 'auth'
174
+ ? 'Desktop token rejected.'
175
+ : 'Desktop not reachable after multiple retries.',
176
+ );
177
+ } else {
178
+ setSyncState('connecting');
179
+ }
180
+ },
181
+ });
182
+ setStreaming(true);
183
+ }, [getSettings, streaming, validateSettings]);
184
+
185
+ const sendOnce = useCallback(async () => {
186
+ if (!validateSettings()) {
187
+ return;
188
+ }
189
+
190
+ const settings = getSettings();
191
+ const daemonOptions = normalizeDaemonSettings(settings);
192
+ setSending(true);
193
+ setMessage('Checking desktop connection...');
194
+ await saveDaemonSettings(settings);
195
+
196
+ try {
197
+ const connection = await checkDaemonConnection({
198
+ ...daemonOptions,
199
+ timeoutMs: CONNECTION_TIMEOUT_MS,
200
+ });
201
+ if (!connection.ok) {
202
+ setMessage(formatConnectionFailure());
203
+ return;
204
+ }
205
+
206
+ setMessage('Sending logs...');
207
+ const result = await reportDebugSessionToDaemon({
208
+ ...daemonOptions,
209
+ timeoutMs: 2000,
210
+ });
211
+
212
+ if (result.ok) {
213
+ const totalLogs = Object.values(result.logCount ?? {}).reduce((total, count) => total + count, 0);
214
+ setMessage(`Sent ${totalLogs} logs.`);
215
+ } else {
216
+ setMessage(result.error ? `Send failed: ${result.error}` : 'Send failed.');
217
+ }
218
+ } finally {
219
+ setSending(false);
220
+ }
221
+ }, [getSettings, validateSettings]);
222
+
223
+ const target = mode === 'device'
224
+ ? buildDeviceDaemonEndpoint(deviceHost) || 'Enter Mac IP'
225
+ : getDefaultDaemonEndpoint();
226
+ const canConnect = mode === 'simulator' || Boolean(deviceHost.trim());
227
+ const connecting = !streaming && syncState === 'connecting';
228
+ const busy = detecting || sending || connecting;
229
+ const statusTitle = detecting
230
+ ? 'Checking'
231
+ : sending
232
+ ? 'Checking'
233
+ : connecting
234
+ ? 'Checking'
235
+ : streaming && syncState === 'connected'
236
+ ? 'Live sync connected'
237
+ : streaming && syncState === 'retrying'
238
+ ? 'Retrying desktop sync'
239
+ : syncState === 'failed'
240
+ ? 'Failed'
241
+ : streaming
242
+ ? 'Live sync running'
243
+ : mode === 'device' && !deviceHost.trim()
244
+ ? 'Enter Mac IP'
245
+ : 'Ready';
246
+
247
+ return (
248
+ <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
249
+ <KeyboardAvoidingView
250
+ style={styles.keyboardAvoiding}
251
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
252
+ >
253
+ <Pressable style={styles.backdrop} onPress={onClose}>
254
+ <Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
255
+ <View style={styles.handle} />
256
+
257
+ <View style={styles.header}>
258
+ <Text style={styles.title}>Desktop Logs</Text>
259
+ <TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
260
+ <Text style={styles.closeButtonText}>Close</Text>
261
+ </TouchableOpacity>
262
+ </View>
263
+
264
+ <ScrollView
265
+ keyboardShouldPersistTaps="handled"
266
+ showsVerticalScrollIndicator={false}
267
+ contentContainerStyle={styles.scrollContent}
268
+ >
269
+ <View style={styles.statusCard}>
270
+ <View style={[styles.statusDot, streaming ? styles.dotActive : styles.dotInactive]} />
271
+ <View style={styles.statusCopy}>
272
+ <Text style={styles.statusTitle}>{statusTitle}</Text>
273
+ <Text style={styles.statusTarget} numberOfLines={1}>{target}</Text>
274
+ </View>
275
+ </View>
276
+
277
+ <View style={styles.section}>
278
+ <Text style={styles.label}>Connection</Text>
279
+ <View style={styles.segment}>
280
+ <TouchableOpacity
281
+ style={[styles.segmentButton, mode === 'simulator' && styles.segmentButtonActive]}
282
+ onPress={() => handleModeChange('simulator')}
283
+ disabled={streaming}
284
+ activeOpacity={0.7}
285
+ >
286
+ <Text style={[styles.segmentText, mode === 'simulator' && styles.segmentTextActive]}>
287
+ Simulator
288
+ </Text>
289
+ </TouchableOpacity>
290
+ <TouchableOpacity
291
+ style={[styles.segmentButton, mode === 'device' && styles.segmentButtonActive]}
292
+ onPress={() => handleModeChange('device')}
293
+ disabled={streaming}
294
+ activeOpacity={0.7}
295
+ >
296
+ <Text style={[styles.segmentText, mode === 'device' && styles.segmentTextActive]}>
297
+ Real device
298
+ </Text>
299
+ </TouchableOpacity>
300
+ </View>
301
+ </View>
302
+
303
+ {mode === 'device' ? (
304
+ <View style={styles.section}>
305
+ <View style={styles.inputRow}>
306
+ <Text style={styles.inputLabel}>Mac IP</Text>
307
+ <TextInput
308
+ ref={inputRef}
309
+ style={styles.input}
310
+ value={deviceHost}
311
+ onChangeText={handleDeviceHostChange}
312
+ placeholder="192.168.1.10"
313
+ placeholderTextColor={Colors.textLight}
314
+ autoCapitalize="none"
315
+ autoCorrect={false}
316
+ keyboardType="numbers-and-punctuation"
317
+ returnKeyType="done"
318
+ onSubmitEditing={() => inputRef.current?.blur()}
319
+ editable={!streaming}
320
+ />
321
+ <TouchableOpacity
322
+ style={[styles.detectButton, (streaming || busy) && styles.buttonDisabled]}
323
+ onPress={detectDeviceHost}
324
+ disabled={streaming || busy}
325
+ activeOpacity={0.7}
326
+ >
327
+ <Text style={styles.detectButtonText}>{detecting ? '...' : 'Detect'}</Text>
328
+ </TouchableOpacity>
329
+ </View>
330
+ </View>
331
+ ) : null}
332
+
333
+ {message ? <Text style={styles.message}>{message}</Text> : null}
334
+
335
+ <View style={styles.actions}>
336
+ <TouchableOpacity
337
+ style={[styles.primaryButton, (!canConnect || busy) && styles.buttonDisabled]}
338
+ onPress={toggleLiveSync}
339
+ disabled={!canConnect || busy}
340
+ activeOpacity={0.75}
341
+ >
342
+ <Text style={styles.primaryButtonText}>
343
+ {streaming ? 'Stop Live Sync' : busy ? 'Checking...' : 'Start Live Sync'}
344
+ </Text>
345
+ </TouchableOpacity>
346
+ <TouchableOpacity
347
+ style={[styles.secondaryButton, (!canConnect || busy) && styles.buttonDisabled]}
348
+ onPress={sendOnce}
349
+ disabled={!canConnect || busy}
350
+ activeOpacity={0.75}
351
+ >
352
+ <Text style={styles.secondaryButtonText}>{sending ? 'Sending...' : 'Send Once'}</Text>
353
+ </TouchableOpacity>
354
+ </View>
355
+ </ScrollView>
356
+ </Pressable>
357
+ </Pressable>
358
+ </KeyboardAvoidingView>
359
+ </Modal>
360
+ );
361
+ }
362
+
363
+ const styles = StyleSheet.create({
364
+ keyboardAvoiding: {
365
+ flex: 1,
366
+ },
367
+ backdrop: {
368
+ flex: 1,
369
+ backgroundColor: 'rgba(0,0,0,0.4)',
370
+ justifyContent: 'flex-end',
371
+ },
372
+ sheet: {
373
+ backgroundColor: Colors.surface,
374
+ borderTopLeftRadius: 20,
375
+ borderTopRightRadius: 20,
376
+ paddingHorizontal: 20,
377
+ paddingTop: 12,
378
+ paddingBottom: 20,
379
+ maxHeight: '82%',
380
+ },
381
+ handle: {
382
+ width: 40,
383
+ height: 4,
384
+ borderRadius: 2,
385
+ backgroundColor: Colors.textLight,
386
+ alignSelf: 'center',
387
+ marginBottom: 16,
388
+ },
389
+ header: {
390
+ flexDirection: 'row',
391
+ alignItems: 'center',
392
+ justifyContent: 'space-between',
393
+ marginBottom: 14,
394
+ },
395
+ title: {
396
+ fontSize: 18,
397
+ fontWeight: '600',
398
+ color: Colors.text,
399
+ },
400
+ closeButton: {
401
+ paddingHorizontal: 4,
402
+ paddingVertical: 4,
403
+ },
404
+ closeButtonText: {
405
+ fontSize: 14,
406
+ fontWeight: '500',
407
+ color: Colors.primary,
408
+ },
409
+ scrollContent: {
410
+ paddingBottom: 4,
411
+ },
412
+ statusCard: {
413
+ flexDirection: 'row',
414
+ alignItems: 'center',
415
+ gap: 10,
416
+ padding: 12,
417
+ borderRadius: 8,
418
+ backgroundColor: Colors.background,
419
+ borderWidth: 1,
420
+ borderColor: Colors.border,
421
+ marginBottom: 16,
422
+ },
423
+ statusCopy: {
424
+ flex: 1,
425
+ },
426
+ statusTitle: {
427
+ fontSize: 14,
428
+ fontWeight: '600',
429
+ color: Colors.text,
430
+ },
431
+ statusTarget: {
432
+ marginTop: 2,
433
+ fontSize: 12,
434
+ color: Colors.textLight,
435
+ fontFamily: 'Courier',
436
+ },
437
+ section: {
438
+ marginBottom: 14,
439
+ },
440
+ label: {
441
+ fontSize: 13,
442
+ fontWeight: '500',
443
+ color: Colors.textSecondary,
444
+ marginBottom: 6,
445
+ },
446
+ segment: {
447
+ flexDirection: 'row',
448
+ padding: 3,
449
+ borderRadius: 10,
450
+ backgroundColor: Colors.background,
451
+ borderWidth: 1,
452
+ borderColor: Colors.border,
453
+ },
454
+ segmentButton: {
455
+ flex: 1,
456
+ alignItems: 'center',
457
+ paddingVertical: 9,
458
+ borderRadius: 7,
459
+ },
460
+ segmentButtonActive: {
461
+ backgroundColor: Colors.surface,
462
+ borderWidth: 1,
463
+ borderColor: Colors.primary,
464
+ },
465
+ segmentText: {
466
+ fontSize: 13,
467
+ fontWeight: '500',
468
+ color: Colors.textSecondary,
469
+ },
470
+ segmentTextActive: {
471
+ color: Colors.primary,
472
+ fontWeight: '600',
473
+ },
474
+ inputRow: {
475
+ flexDirection: 'row',
476
+ alignItems: 'center',
477
+ gap: 8,
478
+ },
479
+ inputLabel: {
480
+ width: 50,
481
+ fontSize: 13,
482
+ fontWeight: '600',
483
+ color: Colors.textSecondary,
484
+ },
485
+ input: {
486
+ flex: 1,
487
+ backgroundColor: Colors.background,
488
+ borderWidth: 1,
489
+ borderColor: Colors.border,
490
+ borderRadius: 8,
491
+ paddingHorizontal: 12,
492
+ paddingVertical: 10,
493
+ fontSize: 14,
494
+ color: Colors.text,
495
+ fontFamily: 'Courier',
496
+ },
497
+ detectButton: {
498
+ minWidth: 68,
499
+ alignItems: 'center',
500
+ justifyContent: 'center',
501
+ paddingHorizontal: 10,
502
+ paddingVertical: 10,
503
+ borderRadius: 8,
504
+ backgroundColor: Colors.surface,
505
+ borderWidth: 1,
506
+ borderColor: Colors.primary,
507
+ },
508
+ detectButtonText: {
509
+ color: Colors.primary,
510
+ fontSize: 13,
511
+ fontWeight: '600',
512
+ },
513
+ statusDot: {
514
+ width: 8,
515
+ height: 8,
516
+ borderRadius: 4,
517
+ },
518
+ dotActive: {
519
+ backgroundColor: Colors.success,
520
+ },
521
+ dotInactive: {
522
+ backgroundColor: Colors.textLight,
523
+ },
524
+ message: {
525
+ fontSize: 12,
526
+ lineHeight: 17,
527
+ color: Colors.textSecondary,
528
+ marginBottom: 12,
529
+ },
530
+ actions: {
531
+ flexDirection: 'row',
532
+ gap: 10,
533
+ marginTop: 4,
534
+ },
535
+ primaryButton: {
536
+ flex: 1,
537
+ alignItems: 'center',
538
+ justifyContent: 'center',
539
+ paddingVertical: 11,
540
+ borderRadius: 10,
541
+ backgroundColor: Colors.primary,
542
+ },
543
+ primaryButtonText: {
544
+ color: '#fff',
545
+ fontSize: 14,
546
+ fontWeight: '600',
547
+ },
548
+ secondaryButton: {
549
+ flex: 1,
550
+ alignItems: 'center',
551
+ justifyContent: 'center',
552
+ paddingVertical: 11,
553
+ borderRadius: 10,
554
+ backgroundColor: Colors.background,
555
+ borderWidth: 1,
556
+ borderColor: Colors.border,
557
+ },
558
+ secondaryButtonText: {
559
+ color: Colors.primary,
560
+ fontSize: 14,
561
+ fontWeight: '600',
562
+ },
563
+ buttonDisabled: {
564
+ opacity: 0.5,
565
+ },
566
+ });