react-native-debug-toolkit 3.3.1 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function isExpoProject(projectRoot) {
7
+ // app.json with expo field
8
+ const appJsonPath = path.join(projectRoot, 'app.json');
9
+ if (fs.existsSync(appJsonPath)) {
10
+ try {
11
+ const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8'));
12
+ if (appJson.expo) return true;
13
+ } catch (_) {}
14
+ }
15
+
16
+ // app.config.js or app.config.ts
17
+ if (
18
+ fs.existsSync(path.join(projectRoot, 'app.config.js')) ||
19
+ fs.existsSync(path.join(projectRoot, 'app.config.ts'))
20
+ ) {
21
+ return true;
22
+ }
23
+
24
+ // expo in package.json dependencies
25
+ const pkgPath = path.join(projectRoot, 'package.json');
26
+ if (fs.existsSync(pkgPath)) {
27
+ try {
28
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
29
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
30
+ if (deps.expo) return true;
31
+ } catch (_) {}
32
+ }
33
+
34
+ return false;
35
+ }
36
+
37
+ function isExpoGo(projectRoot) {
38
+ // Expo Go doesn't support embedded bundles
39
+ // If no ios/ or android/ dirs, likely Expo Go
40
+ const hasNativeDirs =
41
+ fs.existsSync(path.join(projectRoot, 'ios')) ||
42
+ fs.existsSync(path.join(projectRoot, 'android'));
43
+
44
+ if (!hasNativeDirs) {
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function isPrebuilt(projectRoot) {
51
+ // Dev client / bare workflow after prebuild
52
+ return (
53
+ fs.existsSync(path.join(projectRoot, 'ios')) &&
54
+ fs.existsSync(path.join(projectRoot, 'android'))
55
+ );
56
+ }
57
+
58
+ function getExpoEntryPoint(projectRoot) {
59
+ const appJsonPath = path.join(projectRoot, 'app.json');
60
+ if (fs.existsSync(appJsonPath)) {
61
+ try {
62
+ const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8'));
63
+ if (appJson.expo && appJson.expo.entryPoint) {
64
+ return appJson.expo.entryPoint;
65
+ }
66
+ } catch (_) {}
67
+ }
68
+
69
+ // Expo default with .expo virtual entry
70
+ const virtualEntry = path.join(
71
+ projectRoot,
72
+ '.expo',
73
+ '.virtual-metro-entry'
74
+ );
75
+ if (fs.existsSync(virtualEntry)) {
76
+ return '.expo/.virtual-metro-entry';
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ function checkExpo(projectRoot, options) {
83
+ const expo = isExpoProject(projectRoot);
84
+ if (!expo) {
85
+ return { expo: false, skip: false };
86
+ }
87
+
88
+ if (isExpoGo(projectRoot)) {
89
+ return {
90
+ expo: true,
91
+ skip: true,
92
+ reason:
93
+ 'Expo Go does not support embedded bundles. Use expo-dev-client or run npx expo prebuild first.',
94
+ };
95
+ }
96
+
97
+ if (!isPrebuilt(projectRoot)) {
98
+ return {
99
+ expo: true,
100
+ skip: true,
101
+ reason:
102
+ 'Native directories not found. Run npx expo prebuild first, then retry.',
103
+ };
104
+ }
105
+
106
+ return { expo: true, skip: false, entryPoint: getExpoEntryPoint(projectRoot) };
107
+ }
108
+
109
+ module.exports = { isExpoProject, checkExpo, getExpoEntryPoint };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const pbxproj = require('pbxproj');
6
+
7
+ const BUNDLE_PHASE_NAME = 'Bundle React Native code and images';
8
+ const FORCE_BUNDLING_LINE = 'export FORCE_BUNDLING=1';
9
+ const CONFIG_FILE = '.debug-toolkit-embed.json';
10
+
11
+ function findXcodeproj(projectRoot) {
12
+ const iosDir = path.join(projectRoot, 'ios');
13
+ if (!fs.existsSync(iosDir)) {
14
+ return null;
15
+ }
16
+ const entries = fs.readdirSync(iosDir).filter(
17
+ (e) => e.endsWith('.xcodeproj') && e !== 'Pods.xcodeproj'
18
+ );
19
+ if (entries.length === 0) {
20
+ return null;
21
+ }
22
+ return entries;
23
+ }
24
+
25
+ function findPbxproj(xcodeprojPath) {
26
+ const pbxPath = path.join(xcodeprojPath, 'project.pbxproj');
27
+ if (!fs.existsSync(pbxPath)) {
28
+ throw new Error(`project.pbxproj not found at ${pbxPath}`);
29
+ }
30
+ return pbxPath;
31
+ }
32
+
33
+ function getBundleScriptPhase(pbxFile) {
34
+ const proj = new pbxproj(pbxFile).parseSync();
35
+ const sections = proj.hash.project.objects.PBXShellScriptBuildPhase;
36
+ if (!sections) {
37
+ return null;
38
+ }
39
+ for (const [key, section] of Object.entries(sections)) {
40
+ if (
41
+ section.name === BUNDLE_PHASE_NAME ||
42
+ section.name === `"${BUNDLE_PHASE_NAME}"`
43
+ ) {
44
+ return { id: key, section, proj, pbxFile };
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function injectForceBundling(projectRoot, xcodeprojRelPath, entryFile, options) {
51
+ const xcodeprojPath = path.join(projectRoot, xcodeprojRelPath);
52
+ const pbxFile = findPbxproj(xcodeprojPath);
53
+ const result = getBundleScriptPhase(pbxFile);
54
+ if (!result) {
55
+ throw new Error(
56
+ `"${BUNDLE_PHASE_NAME}" script phase not found in ${xcodeprojRelPath}`
57
+ );
58
+ }
59
+
60
+ const scriptBody = result.section.shellScript;
61
+ // Already injected — idempotent
62
+ if (scriptBody && scriptBody.includes(FORCE_BUNDLING_LINE)) {
63
+ console.log(` ${xcodeprojRelPath}: FORCE_BUNDLING already set. Skipping.`);
64
+ return { xcodeproj: xcodeprojRelPath, scriptPhaseId: result.id, entryFile };
65
+ }
66
+
67
+ // Decode shellScript (it's usually a quoted string)
68
+ let decoded = scriptBody;
69
+ try {
70
+ decoded = JSON.parse(scriptBody);
71
+ } catch (_) {
72
+ // Not JSON-encoded, use as-is
73
+ }
74
+
75
+ const newScript = FORCE_BUNDLING_LINE + '\n' + decoded;
76
+ result.section.shellScript = JSON.stringify(newScript);
77
+
78
+ fs.writeFileSync(pbxFile, result.proj.writeSync());
79
+ console.log(` ${xcodeprojRelPath}: Injected FORCE_BUNDLING=1`);
80
+
81
+ return { xcodeproj: xcodeprojRelPath, scriptPhaseId: result.id, entryFile };
82
+ }
83
+
84
+ function undoForceBundling(projectRoot, config) {
85
+ if (!config.ios) {
86
+ console.log(' No iOS configuration found. Skipping.');
87
+ return;
88
+ }
89
+
90
+ const xcodeprojPath = path.join(projectRoot, config.ios.xcodeproj);
91
+ const pbxFile = findPbxproj(xcodeprojPath);
92
+ const result = getBundleScriptPhase(pbxFile);
93
+ if (!result) {
94
+ console.log(' Bundle script phase not found. Skipping.');
95
+ return;
96
+ }
97
+
98
+ const scriptBody = result.section.shellScript;
99
+ let decoded = scriptBody;
100
+ try {
101
+ decoded = JSON.parse(scriptBody);
102
+ } catch (_) {}
103
+
104
+ if (!decoded.includes(FORCE_BUNDLING_LINE)) {
105
+ console.log(' FORCE_BUNDLING not found in script. Skipping.');
106
+ return;
107
+ }
108
+
109
+ const newScript = decoded
110
+ .split('\n')
111
+ .filter((line) => line !== FORCE_BUNDLING_LINE)
112
+ .join('\n');
113
+ result.section.shellScript = JSON.stringify(newScript);
114
+
115
+ fs.writeFileSync(pbxFile, result.proj.writeSync());
116
+ console.log(' Removed FORCE_BUNDLING=1 from script phase.');
117
+ }
118
+
119
+ module.exports = { findXcodeproj, injectForceBundling, undoForceBundling };
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+
7
+ const ios = require('./embed-ios');
8
+ const android = require('./embed-android');
9
+ const expo = require('./embed-expo');
10
+
11
+ const CONFIG_FILE = '.debug-toolkit-embed.json';
12
+
13
+ function readConfig(projectRoot) {
14
+ const configPath = path.join(projectRoot, CONFIG_FILE);
15
+ if (!fs.existsSync(configPath)) {
16
+ return null;
17
+ }
18
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
19
+ }
20
+
21
+ function writeConfig(projectRoot, config) {
22
+ const configPath = path.join(projectRoot, CONFIG_FILE);
23
+ const payload = { version: 1, ...config };
24
+ fs.writeFileSync(configPath, JSON.stringify(payload, null, 2) + '\n');
25
+ }
26
+
27
+ function deleteConfig(projectRoot) {
28
+ const configPath = path.join(projectRoot, CONFIG_FILE);
29
+ if (fs.existsSync(configPath)) {
30
+ fs.unlinkSync(configPath);
31
+ }
32
+ }
33
+
34
+ function detectEntryFile(projectRoot) {
35
+ const candidates = [
36
+ 'index.js',
37
+ 'index.ts',
38
+ 'index.tsx',
39
+ ];
40
+
41
+ for (const candidate of candidates) {
42
+ if (fs.existsSync(path.join(projectRoot, candidate))) {
43
+ return candidate;
44
+ }
45
+ }
46
+
47
+ // Expo virtual entry
48
+ if (fs.existsSync(path.join(projectRoot, '.expo', '.virtual-metro-entry'))) {
49
+ return '.expo/.virtual-metro-entry';
50
+ }
51
+
52
+ return 'index.js';
53
+ }
54
+
55
+ function prompt(question, options) {
56
+ if (options && options.yes) {
57
+ return Promise.resolve(true);
58
+ }
59
+
60
+ const rl = readline.createInterface({
61
+ input: process.stdin,
62
+ output: process.stdout,
63
+ });
64
+
65
+ return new Promise((resolve) => {
66
+ rl.question(question + ' [Y/n] ', (answer) => {
67
+ rl.close();
68
+ resolve(answer.toLowerCase() !== 'n');
69
+ });
70
+ });
71
+ }
72
+
73
+ async function embed(projectRoot, argv) {
74
+ const args = argv || [];
75
+ const yes = args.includes('--yes');
76
+ const platformIdx = args.indexOf('--platform');
77
+ const platform =
78
+ platformIdx >= 0 ? args[platformIdx + 1] : null; // 'ios' | 'android'
79
+
80
+ console.log('debug-toolkit embed');
81
+ console.log('---');
82
+
83
+ // Check Expo
84
+ const expoResult = expo.checkExpo(projectRoot, { yes });
85
+ if (expoResult.expo) {
86
+ console.log('Detected Expo project.');
87
+ if (expoResult.skip) {
88
+ console.log(` Skipping: ${expoResult.reason}`);
89
+ if (platform === null || platform === 'ios') {
90
+ // nothing
91
+ }
92
+ return;
93
+ }
94
+ console.log(' Native directories found — proceeding.');
95
+ }
96
+
97
+ const existingConfig = readConfig(projectRoot);
98
+ const config = {};
99
+
100
+ // Entry file
101
+ const entryFile =
102
+ (expoResult.entryPoint) || detectEntryFile(projectRoot);
103
+ console.log(`Entry file: ${entryFile}`);
104
+
105
+ // iOS
106
+ if (platform === null || platform === 'ios') {
107
+ const xcodeprojEntries = ios.findXcodeproj(projectRoot);
108
+ if (xcodeprojEntries && xcodeprojEntries.length > 0) {
109
+ let chosen = xcodeprojEntries[0];
110
+ if (xcodeprojEntries.length > 1) {
111
+ if (yes) {
112
+ console.log(
113
+ ` Multiple Xcode projects found. Using: ${chosen}`
114
+ );
115
+ } else {
116
+ console.log(' Multiple Xcode projects found:');
117
+ xcodeprojEntries.forEach((e, i) =>
118
+ console.log(` ${i + 1}. ${e}`)
119
+ );
120
+ const idx = await prompt(
121
+ ` Select project [1-${xcodeprojEntries.length}]`,
122
+ { yes }
123
+ );
124
+ // Default to first
125
+ chosen = xcodeprojEntries[0];
126
+ }
127
+ }
128
+
129
+ const xcodeprojRelPath = path.join('ios', chosen);
130
+ const shouldEmbed =
131
+ yes ||
132
+ (await prompt(
133
+ ` Inject FORCE_BUNDLING into ${xcodeprojRelPath}?`,
134
+ { yes }
135
+ ));
136
+
137
+ if (shouldEmbed !== false) {
138
+ config.ios = ios.injectForceBundling(
139
+ projectRoot,
140
+ xcodeprojRelPath,
141
+ entryFile,
142
+ { yes }
143
+ );
144
+ }
145
+ } else {
146
+ console.log(' No Xcode project found. Skipping iOS.');
147
+ }
148
+ }
149
+
150
+ // Android
151
+ if (platform === null || platform === 'android') {
152
+ const buildGradle = android.findBuildGradle(projectRoot);
153
+ if (buildGradle) {
154
+ const shouldEmbed =
155
+ yes ||
156
+ (await prompt(
157
+ ` Inject debug bundle task into ${buildGradle}?`,
158
+ { yes }
159
+ ));
160
+
161
+ if (shouldEmbed !== false) {
162
+ config.android = android.injectGradleApply(
163
+ projectRoot,
164
+ buildGradle,
165
+ entryFile,
166
+ { yes }
167
+ );
168
+ }
169
+ } else {
170
+ console.log(' No Android build.gradle found. Skipping Android.');
171
+ }
172
+ }
173
+
174
+ // Write config
175
+ if (Object.keys(config).length > 0) {
176
+ writeConfig(projectRoot, config);
177
+ console.log(`\nConfiguration saved to ${CONFIG_FILE}`);
178
+ }
179
+
180
+ console.log('\nDone. Debug builds will now embed a JS bundle.');
181
+ console.log('Use DevConnect to switch Metro hosts at runtime.');
182
+ }
183
+
184
+ async function undo(projectRoot, argv) {
185
+ const args = argv || [];
186
+ const yes = args.includes('--yes');
187
+
188
+ console.log('debug-toolkit embed --undo');
189
+ console.log('---');
190
+
191
+ const config = readConfig(projectRoot);
192
+ if (!config) {
193
+ console.log(`No ${CONFIG_FILE} found. Nothing to undo.`);
194
+ return;
195
+ }
196
+
197
+ const shouldUndo =
198
+ yes || (await prompt('Remove all debug-toolkit embed injections?', { yes }));
199
+
200
+ if (shouldUndo === false) {
201
+ console.log('Cancelled.');
202
+ return;
203
+ }
204
+
205
+ ios.undoForceBundling(projectRoot, config);
206
+ android.undoGradleApply(projectRoot, config);
207
+
208
+ deleteConfig(projectRoot);
209
+ console.log(`\nRemoved ${CONFIG_FILE}.`);
210
+ console.log('Done.');
211
+ }
212
+
213
+ function main(argv) {
214
+ const projectRoot = process.cwd();
215
+ const args = argv || [];
216
+
217
+ if (args.includes('--undo')) {
218
+ return undo(projectRoot, args);
219
+ }
220
+
221
+ return embed(projectRoot, args);
222
+ }
223
+
224
+ module.exports = { main, embed, undo };
@@ -35,7 +35,12 @@ import {
35
35
  saveDaemonPort,
36
36
  saveMetroPort,
37
37
  } from './devConnectPreferences';
38
- import { applyMetroBundle, flushNativeDiagnostic, resetMetroBundle } from './nativeDevConnect';
38
+ import {
39
+ applyMetroBundle,
40
+ getNativeDiagnostics,
41
+ resetMetroBundle,
42
+ type NativeDiagnostics,
43
+ } from './nativeDevConnect';
39
44
  import type { DevConnectFeatureControls, DevConnectSettingsPatch, DevConnectState } from './types';
40
45
 
41
46
  const CONNECTION_TIMEOUT_MS = 2000;
@@ -72,6 +77,8 @@ export function DevConnectTab({ snapshot, feature }: DebugFeatureRenderProps<Dev
72
77
  const [message, setMessage] = useState<string | null>(null);
73
78
  const [sending, setSending] = useState(false);
74
79
  const [metroBusy, setMetroBusy] = useState(false);
80
+ const [diagData, setDiagData] = useState<NativeDiagnostics | null>(null);
81
+ const [diagOpen, setDiagOpen] = useState(false);
75
82
 
76
83
  const isSim = snapshot.isSimulator;
77
84
 
@@ -80,7 +87,20 @@ export function DevConnectTab({ snapshot, feature }: DebugFeatureRenderProps<Dev
80
87
  }, [feature]);
81
88
 
82
89
  useEffect(() => {
83
- flushNativeDiagnostic('_devconnect_last_diagnostic', 'DevConnect');
90
+ getNativeDiagnostics().then((result) => {
91
+ if (result) {
92
+ setDiagData(result);
93
+ console.info(
94
+ `[DevConnect] debugBuild=${result.isDebugBuild} appDelegate=${result.appDelegateClass} persistedHost=${result.persistedMetroHost ?? 'none'}`,
95
+ );
96
+ }
97
+ }).catch(() => {});
98
+ }, []);
99
+
100
+ const refreshDiag = useCallback(() => {
101
+ getNativeDiagnostics().then((result) => {
102
+ if (result) setDiagData(result);
103
+ }).catch(() => {});
84
104
  }, []);
85
105
 
86
106
  useEffect(() => {
@@ -376,8 +396,11 @@ export function DevConnectTab({ snapshot, feature }: DebugFeatureRenderProps<Dev
376
396
  }
377
397
  }, [snapshot.nativeMetroAvailable]);
378
398
 
399
+ // Metro host switching only works in Debug builds. diagData is iOS-populated; when we know
400
+ // it's a Release build, disable the controls (Android reports null → stays enabled).
401
+ const metroReleaseBlocked = diagData ? !diagData.isDebugBuild : false;
379
402
  const canConnect = isSim || (Boolean(normalizeComputerHost(computerHost)) && Boolean(normalizePort(daemonPort)));
380
- const canUseMetro = Boolean(metroTarget) && snapshot.nativeMetroAvailable && !metroBusy;
403
+ const canUseMetro = Boolean(metroTarget) && snapshot.nativeMetroAvailable && !metroBusy && !metroReleaseBlocked;
381
404
  const busy = sending || syncState === 'checking';
382
405
  const subnetPrefix = snapshot.subnetPrefix;
383
406
  const ipPlaceholder = subnetPrefix ? `${subnetPrefix}...` : '192.168.1.10';
@@ -484,9 +507,20 @@ export function DevConnectTab({ snapshot, feature }: DebugFeatureRenderProps<Dev
484
507
  {message ? <Text style={styles.message}>{message}</Text> : null}
485
508
 
486
509
  <View style={styles.section}>
487
- <Text style={styles.sectionTitle}>Remote JS Bundle</Text>
510
+ <View style={styles.sectionTitleRow}>
511
+ <Text style={styles.sectionTitle}>Remote JS Bundle</Text>
512
+ {diagData ? (
513
+ <View style={styles.swizzleBadge}>
514
+ <View style={[styles.swizzleDot, diagData.isDebugBuild ? styles.dotGreen : styles.dotRed]} />
515
+ <Text style={styles.swizzleBadgeText}>
516
+ {diagData.isDebugBuild ? 'debug build' : 'release: disabled'}
517
+ </Text>
518
+ </View>
519
+ ) : null}
520
+ </View>
488
521
  <Text style={styles.sectionDesc}>
489
- Apply this Metro host to React Native dev settings and reload the app.
522
+ Starts from the embedded bundle. After you apply a computer IP, hot-reloads from that
523
+ Metro. Debug builds only — use Reset to go back to the embedded bundle.
490
524
  </Text>
491
525
 
492
526
  {!metroUrls ? (
@@ -530,7 +564,70 @@ export function DevConnectTab({ snapshot, feature }: DebugFeatureRenderProps<Dev
530
564
  {!snapshot.nativeMetroAvailable ? (
531
565
  <Text style={styles.hint}>Native DevConnect requires pod install / Gradle sync and app rebuild.</Text>
532
566
  ) : null}
567
+
568
+ {metroReleaseBlocked ? (
569
+ <Text style={styles.diagWarning}>
570
+ ⚠ This is a Release build. Metro host switching is disabled — RN loads the embedded
571
+ bundle and strips the packager machinery in Release. Run a Debug build to switch hosts.
572
+ </Text>
573
+ ) : null}
533
574
  </View>
575
+
576
+ {snapshot.nativeMetroAvailable && diagData ? (
577
+ <View style={styles.section}>
578
+ <TouchableOpacity
579
+ style={styles.diagHeader}
580
+ onPress={() => { setDiagOpen((v) => !v); refreshDiag(); }}
581
+ activeOpacity={0.7}
582
+ >
583
+ <Text style={styles.sectionTitle}>iOS Bundle Status</Text>
584
+ <Text style={styles.diagChevron}>{diagOpen ? '▲' : '▼'}</Text>
585
+ </TouchableOpacity>
586
+
587
+ {diagOpen ? (
588
+ <View style={styles.diagCard}>
589
+ <View style={styles.diagRow}>
590
+ <Text style={styles.diagKey}>AppDelegate</Text>
591
+ <Text style={styles.diagVal}>{diagData.appDelegateClass}</Text>
592
+ </View>
593
+ <View style={styles.diagRow}>
594
+ <Text style={styles.diagKey}>packagerHost</Text>
595
+ <Text style={styles.diagVal}>{diagData.persistedMetroHost ?? '—'}</Text>
596
+ </View>
597
+ <View style={styles.diagRow}>
598
+ <Text style={styles.diagKey}>embedded</Text>
599
+ <Text style={[styles.diagVal, diagData.hasEmbeddedBundle ? styles.diagGood : styles.diagWarn]}>
600
+ {diagData.hasEmbeddedBundle ? 'main.jsbundle' : 'missing'}
601
+ </Text>
602
+ </View>
603
+ <View style={styles.diagRow}>
604
+ <Text style={styles.diagKey}>build</Text>
605
+ <Text style={[styles.diagVal, diagData.isDebugBuild ? styles.diagGood : styles.diagWarn]}>
606
+ {diagData.isDebugBuild ? 'Debug' : 'Release'}
607
+ </Text>
608
+ </View>
609
+ <View style={styles.diagRow}>
610
+ <Text style={styles.diagKey}>embedded-first</Text>
611
+ <Text style={[styles.diagVal, diagData.embeddedFirstHookInstalled ? styles.diagGood : styles.diagWarn]}>
612
+ {diagData.embeddedFirstHookInstalled ? 'active' : 'inactive'}
613
+ </Text>
614
+ </View>
615
+ {!diagData.embeddedFirstHookInstalled ? (
616
+ <Text style={styles.diagWarning}>
617
+ ⚠ Embedded-first hook not active (bundleRoot=
618
+ {diagData.bundleRootHookInstalled ? 'Y' : 'N'}). Rebuild after pod install.
619
+ Without it, Debug may still try Metro on launch.
620
+ </Text>
621
+ ) : diagData.hasEmbeddedBundle === false ? (
622
+ <Text style={styles.diagWarning}>
623
+ ⚠ main.jsbundle is missing from the app package. Build with an embedded bundle
624
+ (e.g. export/bundle) or cold start cannot use offline JS.
625
+ </Text>
626
+ ) : null}
627
+ </View>
628
+ ) : null}
629
+ </View>
630
+ ) : null}
534
631
  </ScrollView>
535
632
  </KeyboardAvoidingView>
536
633
  );
@@ -635,4 +732,30 @@ const styles = StyleSheet.create({
635
732
  paddingVertical: 7,
636
733
  },
637
734
  urlText: { flex: 1, fontSize: 13, fontFamily: 'Courier', color: Colors.text },
735
+ sectionTitleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 },
736
+ swizzleBadge: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 7, paddingVertical: 3, borderRadius: 10, backgroundColor: Colors.surface, borderWidth: 1, borderColor: Colors.border },
737
+ swizzleDot: { width: 6, height: 6, borderRadius: 3 },
738
+ dotGreen: { backgroundColor: '#34C759' },
739
+ dotRed: { backgroundColor: '#FF3B30' },
740
+ swizzleBadgeText: { fontSize: 11, color: Colors.textSecondary, fontFamily: 'Courier' },
741
+ diagHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 },
742
+ diagChevron: { fontSize: 12, color: Colors.textSecondary },
743
+ diagCard: {
744
+ backgroundColor: Colors.surface,
745
+ borderWidth: 1,
746
+ borderColor: Colors.border,
747
+ borderRadius: 10,
748
+ padding: 10,
749
+ },
750
+ diagRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
751
+ diagKey: { fontSize: 12, color: Colors.textSecondary, fontFamily: 'Courier' },
752
+ diagVal: { fontSize: 12, color: Colors.text, fontFamily: 'Courier', fontWeight: '600' },
753
+ diagGood: { color: '#34C759' },
754
+ diagWarn: { color: '#FF9500' },
755
+ diagWarning: {
756
+ marginTop: 10,
757
+ fontSize: 11,
758
+ color: '#FF9500',
759
+ lineHeight: 16,
760
+ },
638
761
  });
@@ -2,6 +2,18 @@ import { NativeModules } from 'react-native';
2
2
 
3
3
  import { buildMetroTarget } from './devConnectUtils';
4
4
 
5
+ export interface NativeDiagnostics {
6
+ persistedMetroHost: string | null;
7
+ appDelegateClass: string;
8
+ // Metro host switching only works in Debug builds (RN strips the packager machinery in
9
+ // Release). False means the Remote JS Bundle controls should be disabled.
10
+ isDebugBuild: boolean;
11
+ hasEmbeddedBundle?: boolean;
12
+ embeddedFirstHookInstalled?: boolean;
13
+ packagerHookInstalled?: boolean;
14
+ bundleRootHookInstalled?: boolean;
15
+ }
16
+
5
17
  interface DebugToolkitDevConnectNativeModule {
6
18
  applyMetroHost: (hostPort: string) => Promise<{ hostPort?: string } | void>;
7
19
  resetMetroHost: () => Promise<void>;
@@ -9,6 +21,7 @@ interface DebugToolkitDevConnectNativeModule {
9
21
  getLocalIp?: () => Promise<string | null>;
10
22
  isDebugBuild?: () => Promise<boolean>;
11
23
  getPreference?: (key: string) => Promise<string | null>;
24
+ getDiagnostics?: () => Promise<NativeDiagnostics>;
12
25
  }
13
26
 
14
27
  type MetroBundleFailureReason =
@@ -154,17 +167,14 @@ export async function nativeIsDebugBuild(): Promise<boolean | null> {
154
167
  }
155
168
  }
156
169
 
157
- export async function flushNativeDiagnostic(key: string, label: string): Promise<void> {
170
+ export async function getNativeDiagnostics(): Promise<NativeDiagnostics | null> {
158
171
  const nativeModule = getNativeModule();
159
- if (!nativeModule?.getPreference) {
160
- return;
172
+ if (!nativeModule?.getDiagnostics) {
173
+ return null;
161
174
  }
162
175
  try {
163
- const raw = await nativeModule.getPreference(key);
164
- if (raw && typeof raw === 'string') {
165
- console.info(`[${label}] Last native diagnostic:`, raw);
166
- }
176
+ return await nativeModule.getDiagnostics();
167
177
  } catch {
168
- // Silent - diagnostic is best-effort
178
+ return null;
169
179
  }
170
180
  }