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.
package/README.md CHANGED
@@ -77,7 +77,13 @@ In the app, open Debug Panel -> `DevConnect` -> `Send Once` or `Start Live Sync`
77
77
 
78
78
  DevConnect auto-detects simulator/emulator and uses local host settings automatically. On real devices, enter your computer IP to connect.
79
79
 
80
- For Remote JS Bundle, run Metro on your computer, enter computer IP and Metro port in `DevConnect`, then tap `Use Metro Bundle`. DevConnect writes React Native's native dev-server host setting and reloads the app. The IP and ports are persisted through AsyncStorage when installed, or through the native module after rebuild.
80
+ For Remote JS Bundle, run Metro on your computer, enter computer IP and Metro port in `DevConnect`, then tap `Use Metro Bundle`. DevConnect persists the host and hot-reloads from Metro.
81
+
82
+ > **Debug builds only.** Metro host switching works in Debug builds. Release builds load the embedded bundle and the controls are disabled (`release: disabled` badge).
83
+
84
+ **iOS — no AppDelegate changes required.** On install, DevConnect hooks `RCTBundleURLProvider` so the app **cold-starts from the embedded `main.jsbundle`** and only connects to Metro after you apply a host in the panel (fixes Expo `.expo/.virtual-metro-entry` red screens when Metro is off). Use **Reset** to go back to the embedded bundle.
85
+
86
+ The IP and ports are persisted through AsyncStorage when installed, or through the native module after rebuild.
81
87
 
82
88
  QR scan is optional. Install `react-native-camera-kit` or `expo-camera` in the app to enable the scan button. The app must request camera permission before scanning.
83
89
 
package/README.zh-CN.md CHANGED
@@ -77,7 +77,13 @@ App 内打开 Debug Panel -> `DevConnect` -> `Send Once` 或 `Start Live Sync`
77
77
 
78
78
  DevConnect 自动识别模拟器/真机,模拟器下自动使用本机 Metro/daemon 地址。真机需输入电脑 IP 地址。
79
79
 
80
- Remote JS Bundle:先在电脑启动 Metro,在 `DevConnect` 输入电脑 IP 和 Metro 端口,然后点 `Use Metro Bundle`。DevConnect 会写入 React Native 原生 dev-server host 设置并 reload App。IP 和端口会通过 AsyncStorage 持久化;如果没装 AsyncStorage,则在重建后通过本库原生模块持久化。
80
+ Remote JS Bundle:先在电脑启动 Metro,在 `DevConnect` 输入电脑 IP 和 Metro 端口,然后点 `Use Metro Bundle`。DevConnect 会持久化 host 并从 Metro 热重载。
81
+
82
+ > **仅 Debug 包可用。** Release 包控件会显示 `release: disabled` 并禁用。
83
+
84
+ **iOS — 无需改 AppDelegate。** 安装本库后会 hook `RCTBundleURLProvider`:**未配置 IP 时冷启动走内置 `main.jsbundle`**,只有在面板里应用 host 后才连 Metro(可避免 Metro 未开时的 `.expo/.virtual-metro-entry` 红屏)。点 **Reset** 可回到内置 bundle。
85
+
86
+ IP 和端口会通过 AsyncStorage 持久化;如果没装 AsyncStorage,则在重建后通过本库原生模块持久化。
81
87
 
82
88
  扫码是可选能力。App 安装 `react-native-camera-kit` 或 `expo-camera` 后,DevConnect 才显示扫码按钮。App 仍需自己配置相机权限文案,并在使用扫码前申请相机权限。
83
89
 
@@ -27,10 +27,19 @@ function hasHelpFlag(args) {
27
27
  function printHelp() {
28
28
  process.stderr.write(
29
29
  'Usage: debug-toolkit [--host 0.0.0.0] [--port 3799] [--token dev-token] [--store ~/.react-native-debug-toolkit/daemon-devices.json] [--daemon-only]\n'
30
+ + ' debug-toolkit embed [--platform ios|android] [--undo] [--yes]\n'
30
31
  + '\n'
31
32
  + 'Starts the debug toolkit: daemon (HTTP + Web Console) and MCP stdio server.\n'
32
33
  + '\n'
33
- + 'Options:\n'
34
+ + 'Commands:\n'
35
+ + ' embed Embed JS bundle in debug builds (run in host app root)\n'
36
+ + '\n'
37
+ + 'Embed options:\n'
38
+ + ' --platform <p> Target platform: ios or android (default: both)\n'
39
+ + ' --undo Remove embed injections\n'
40
+ + ' --yes Skip confirmations (CI/EAS)\n'
41
+ + '\n'
42
+ + 'Daemon options:\n'
34
43
  + ' --host <addr> Host to bind (default: 0.0.0.0)\n'
35
44
  + ' --port <port> Port to bind (default: 3799)\n'
36
45
  + ' --token <str> Auth token for daemon endpoints\n'
@@ -57,6 +66,12 @@ async function main() {
57
66
  return;
58
67
  }
59
68
 
69
+ // Route embed subcommand
70
+ if (args[0] === 'embed') {
71
+ const { main: embedMain } = require('../scripts/embed');
72
+ return embedMain(args.slice(1));
73
+ }
74
+
60
75
  const host = readOption(args, '--host', process.env.DEBUG_TOOLKIT_DAEMON_HOST || DEFAULT_HOST);
61
76
  const port = Number(readOption(args, '--port', process.env.DEBUG_TOOLKIT_DAEMON_PORT || DEFAULT_PORT));
62
77
  const token = readOption(args, '--token', process.env.DEBUG_TOOLKIT_DAEMON_TOKEN || '');
@@ -0,0 +1,17 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ /**
6
+ Returns a Metro bundle URL when the user has applied a host via DevConnect; otherwise nil.
7
+
8
+ Zero-config: installing this pod hooks RCTBundleURLProvider so cold start uses the embedded
9
+ main.jsbundle until DevConnect applies a host. You do not need to change AppDelegate.
10
+
11
+ Optional override for custom bundleURL() implementations:
12
+
13
+ if let metro = DebugToolkitMetroBundleURL() { return metro }
14
+ */
15
+ FOUNDATION_EXPORT NSURL * _Nullable DebugToolkitMetroBundleURL(void);
16
+
17
+ NS_ASSUME_NONNULL_END
@@ -1,49 +1,220 @@
1
+ #import "DebugToolkitDevConnect.h"
2
+
1
3
  #import <Foundation/Foundation.h>
4
+ #import <UIKit/UIKit.h>
2
5
  #import <React/RCTBridge.h>
3
6
  #import <React/RCTBundleManager.h>
4
7
  #import <React/RCTBridgeModule.h>
5
8
  #import <React/RCTBundleURLProvider.h>
6
- #import <React/RCTDefines.h>
7
9
  #import <React/RCTReloadCommand.h>
8
10
  #import <objc/runtime.h>
9
11
  #include <ifaddrs.h>
10
12
  #include <arpa/inet.h>
11
13
  #include <net/if.h>
12
14
 
13
- static NSString *const DebugToolkitBundleRoot = @"index";
14
- static NSString *const kDevConnectMetroHost = @"_devconnect_metro_host";
15
+ // Debug-only Metro host switching — zero host-app native changes required.
16
+ //
17
+ // RN/Expo Debug templates call jsBundleURLForBundleRoot:fallbackURLProvider:, which consults
18
+ // -packagerServerHostPort (guessPackagerHost on simulator when Metro is running). We hook:
19
+ // 1. jsBundleURLForBundleRoot:fallbackURLProvider: — no DevConnect host → main.jsbundle first.
20
+ // 2. packagerServerHostPort — no DevConnect host → nil (block auto-discovered localhost:8081).
21
+ //
22
+ // Optional: host apps may call DebugToolkitMetroBundleURL() from bundleURL() for explicit control.
23
+
24
+ static NSString *const kBundleRoot = @"index";
25
+ // Expo prebuild templates pass this to jsBundleURLForBundleRoot: in Debug (see expo/expo#21643).
26
+ static NSString *const kExpoVirtualMetroEntry = @".expo/.virtual-metro-entry";
27
+ static NSString *const kMetroHostKey = @"_devconnect_metro_host";
28
+
29
+ static BOOL gPackagerHookInstalled = NO;
30
+ static BOOL gBundleRootHookInstalled = NO;
15
31
 
16
- #pragma mark - AppDelegate Swizzling
32
+ static NSURL *(*gOrigJsBundleURLForBundleRootWithFallback)(id, SEL, NSString *, NSURL * _Nonnull (^)(void));
17
33
 
18
- static IMP original_sourceURLForBridge = NULL;
34
+ static BOOL DevConnectEmbeddedFirstHooksActive(void)
35
+ {
36
+ return gBundleRootHookInstalled;
37
+ }
19
38
 
20
- static NSURL *devconnect_sourceURLForBridge(id self, SEL _cmd, RCTBridge *bridge)
39
+ static NSURL *DebugToolkitEmbeddedBundleURL(void)
21
40
  {
22
- NSString *metroHost = [[NSUserDefaults standardUserDefaults] stringForKey:kDevConnectMetroHost];
23
- if (metroHost.length > 0) {
24
- NSString *urlStr = [NSString stringWithFormat:
25
- @"http://%@/%@.bundle?platform=ios&dev=true&minify=false&lazy=true", metroHost, DebugToolkitBundleRoot];
26
- NSURL *url = [NSURL URLWithString:urlStr];
27
- NSLog(@"[DevConnect] swizzle: returning Metro URL %@ (host=%@)", url, metroHost);
28
- return url;
41
+ return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
42
+ }
43
+
44
+ static NSString *DevConnectPersistedMetroHost(void)
45
+ {
46
+ return [[NSUserDefaults standardUserDefaults] stringForKey:kMetroHostKey];
47
+ }
48
+
49
+ static void DevConnectSetPersistedMetroHost(NSString *_Nullable hostPort)
50
+ {
51
+ NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
52
+ if (hostPort.length > 0) {
53
+ [defaults setObject:hostPort forKey:kMetroHostKey];
54
+ } else {
55
+ [defaults removeObjectForKey:kMetroHostKey];
29
56
  }
30
- if (original_sourceURLForBridge) {
31
- return ((NSURL *(*)(id, SEL, RCTBridge *))original_sourceURLForBridge)(self, _cmd, bridge);
57
+ [defaults synchronize];
58
+ }
59
+
60
+ static void DebugToolkitPrepareBundleSourceIfNeeded(void)
61
+ {
62
+ // Touch sharedSettings so RCTBundleURLProvider is linked before hook install retries.
63
+ RCTBundleURLProvider *settings = [RCTBundleURLProvider sharedSettings];
64
+ if (DevConnectPersistedMetroHost().length > 0) {
65
+ return;
32
66
  }
33
- return nil;
67
+ [settings resetToDefaults];
68
+ }
69
+
70
+ static BOOL DevConnectIsExpoProject(void)
71
+ {
72
+ static dispatch_once_t onceToken;
73
+ static BOOL isExpo = NO;
74
+ dispatch_once(&onceToken, ^{
75
+ // Heuristic: matches common Expo prebuild / dev-client binaries.
76
+ isExpo = NSClassFromString(@"EXAppDelegateWrapper") != nil
77
+ || [[NSBundle mainBundle] objectForInfoDictionaryKey:@"EXUpdatesURL"] != nil
78
+ || [[NSBundle mainBundle] objectForInfoDictionaryKey:@"EXPO_RUNTIME_VERSION"] != nil;
79
+ });
80
+ return isExpo;
34
81
  }
35
82
 
36
- static void swizzleSourceURLForBridge(Class targetClass)
83
+ /// Bundle root Metro expects when DevConnect steers the packager (index for plain RN, virtual entry for Expo).
84
+ static NSString *DevConnectMetroBundleRoot(void)
37
85
  {
38
- if (!targetClass) return;
39
- SEL selector = @selector(sourceURLForBridge:);
40
- Method method = class_getInstanceMethod(targetClass, selector);
41
- if (!method) return;
42
- if (original_sourceURLForBridge) return; // Already swizzled
43
- original_sourceURLForBridge = method_setImplementation(method, (IMP)devconnect_sourceURLForBridge);
44
- NSLog(@"[DevConnect] swizzled sourceURLForBridge: on %@", NSStringFromClass(targetClass));
86
+ return DevConnectIsExpoProject() ? kExpoVirtualMetroEntry : kBundleRoot;
87
+ }
88
+
89
+ static NSURL *DevConnectMetroURLForPersistedHost(void)
90
+ {
91
+ NSString *host = DevConnectPersistedMetroHost();
92
+ if (host.length == 0) {
93
+ return nil;
94
+ }
95
+ RCTBundleURLProvider *settings = [RCTBundleURLProvider sharedSettings];
96
+ settings.jsLocation = host;
97
+ return [settings jsBundleURLForBundleRoot:DevConnectMetroBundleRoot()];
98
+ }
99
+
100
+ /// When no DevConnect host: block guessPackagerHost (simulator often has Metro on :8081).
101
+ static NSString *replacement_packagerServerHostPort(id self, SEL _cmd)
102
+ {
103
+ NSString *host = DevConnectPersistedMetroHost();
104
+ if (host.length == 0) {
105
+ return nil;
106
+ }
107
+ [(RCTBundleURLProvider *)self setJsLocation:host];
108
+ return host;
45
109
  }
46
110
 
111
+ /// Primary hook: return embedded main.jsbundle before RN/Expo tries Metro / virtual-metro-entry.
112
+ static NSURL *replacement_jsBundleURLForBundleRoot_fallback(
113
+ id self, SEL _cmd, NSString *bundleRoot, NSURL * _Nonnull (^fallbackURLProvider)(void))
114
+ {
115
+ if (DevConnectPersistedMetroHost().length == 0) {
116
+ NSURL *embedded = DebugToolkitEmbeddedBundleURL();
117
+ if (embedded) {
118
+ NSLog(@"[DevConnect] cold start → embedded bundle (root=%@)", bundleRoot);
119
+ return embedded;
120
+ }
121
+ NSLog(@"[DevConnect] no embedded main.jsbundle — falling back to Metro (root=%@)", bundleRoot);
122
+ }
123
+
124
+ if (gOrigJsBundleURLForBundleRootWithFallback) {
125
+ return gOrigJsBundleURLForBundleRootWithFallback(self, _cmd, bundleRoot, fallbackURLProvider);
126
+ }
127
+ return fallbackURLProvider ? fallbackURLProvider() : nil;
128
+ }
129
+
130
+ static void DebugToolkitInstallPackagerHook(Class cls)
131
+ {
132
+ if (gPackagerHookInstalled) {
133
+ return;
134
+ }
135
+ Method method = class_getInstanceMethod(cls, @selector(packagerServerHostPort));
136
+ if (!method) {
137
+ NSLog(@"[DevConnect] packagerServerHostPort not found");
138
+ return;
139
+ }
140
+ IMP replacement = (IMP)replacement_packagerServerHostPort;
141
+ if (method_getImplementation(method) == replacement) {
142
+ gPackagerHookInstalled = YES;
143
+ return;
144
+ }
145
+ method_setImplementation(method, replacement);
146
+ gPackagerHookInstalled = YES;
147
+ }
148
+
149
+ static void DebugToolkitInstallBundleRootHook(Class cls)
150
+ {
151
+ if (gBundleRootHookInstalled) {
152
+ return;
153
+ }
154
+ SEL selector = @selector(jsBundleURLForBundleRoot:fallbackURLProvider:);
155
+ Method method = class_getInstanceMethod(cls, selector);
156
+ if (!method) {
157
+ NSLog(@"[DevConnect] jsBundleURLForBundleRoot:fallbackURLProvider: not found");
158
+ return;
159
+ }
160
+ IMP replacement = (IMP)replacement_jsBundleURLForBundleRoot_fallback;
161
+ gOrigJsBundleURLForBundleRootWithFallback =
162
+ (NSURL * (*)(id, SEL, NSString *, NSURL * _Nonnull (^)(void)))method_getImplementation(method);
163
+ if ((IMP)gOrigJsBundleURLForBundleRootWithFallback == replacement) {
164
+ gBundleRootHookInstalled = YES;
165
+ return;
166
+ }
167
+ method_setImplementation(method, replacement);
168
+ gBundleRootHookInstalled = YES;
169
+ }
170
+
171
+ static void DebugToolkitInstallAllHooks(void)
172
+ {
173
+ if (gBundleRootHookInstalled && gPackagerHookInstalled) {
174
+ return;
175
+ }
176
+
177
+ Class cls = NSClassFromString(@"RCTBundleURLProvider");
178
+ if (!cls) {
179
+ NSLog(@"[DevConnect] RCTBundleURLProvider not loaded — hooks will retry");
180
+ return;
181
+ }
182
+
183
+ DebugToolkitInstallBundleRootHook(cls);
184
+ DebugToolkitInstallPackagerHook(cls);
185
+
186
+ static BOOL didLogOutcome = NO;
187
+ if (!didLogOutcome && (gBundleRootHookInstalled || gPackagerHookInstalled)) {
188
+ didLogOutcome = YES;
189
+ if (DevConnectEmbeddedFirstHooksActive()) {
190
+ NSLog(@"[DevConnect] embedded-first hooks active (bundleRoot=%@ packager=%@)",
191
+ gBundleRootHookInstalled ? @"Y" : @"N",
192
+ gPackagerHookInstalled ? @"Y" : @"N");
193
+ } else {
194
+ NSLog(@"[DevConnect] embedded-first hooks FAILED — rebuild / check React linkage");
195
+ }
196
+ }
197
+ }
198
+
199
+ NSURL *DebugToolkitMetroBundleURL(void)
200
+ {
201
+ return DevConnectMetroURLForPersistedHost();
202
+ }
203
+
204
+ // RCT_EXPORT_MODULE defines +load for module registration — use a separate class for hooks.
205
+ @interface DebugToolkitDevConnectBootstrap : NSObject
206
+ @end
207
+
208
+ @implementation DebugToolkitDevConnectBootstrap
209
+
210
+ + (void)load
211
+ {
212
+ DebugToolkitPrepareBundleSourceIfNeeded();
213
+ DebugToolkitInstallAllHooks();
214
+ }
215
+
216
+ @end
217
+
47
218
  #pragma mark - Module
48
219
 
49
220
  @interface DebugToolkitDevConnect : NSObject <RCTBridgeModule>
@@ -56,61 +227,64 @@ RCT_EXPORT_MODULE(DebugToolkitDevConnect)
56
227
  @synthesize bridge = _bridge;
57
228
  @synthesize bundleManager = _bundleManager;
58
229
 
59
- __attribute__((constructor))
60
- static void devconnect_swizzle_init(void)
61
- {
62
- // Runs after all +load methods, before main() — same timing as +load but no conflict with RCT_EXPORT_MODULE
63
- swizzleSourceURLForBridge(NSClassFromString(@"AppDelegate"));
64
- }
65
-
66
230
  - (instancetype)init
67
231
  {
68
232
  if ((self = [super init])) {
69
- // Fallback — use actual delegate class at runtime (covers custom class names / Swift)
70
- if (!original_sourceURLForBridge) {
71
- Class delegateClass = object_getClass([UIApplication sharedApplication].delegate);
72
- swizzleSourceURLForBridge(delegateClass);
73
- }
233
+ DebugToolkitInstallAllHooks();
74
234
  }
75
235
  return self;
76
236
  }
77
237
 
78
- + (BOOL)requiresMainQueueSetup
79
- {
80
- return NO;
81
- }
238
+ + (BOOL)requiresMainQueueSetup { return NO; }
239
+ - (dispatch_queue_t)methodQueue { return dispatch_get_main_queue(); }
82
240
 
83
- - (dispatch_queue_t)methodQueue
241
+ #pragma mark - Bundle Manager Resolution
242
+
243
+ - (RCTBundleManager *)resolveBundleManager
84
244
  {
85
- return dispatch_get_main_queue();
245
+ if (_bundleManager) return _bundleManager;
246
+ if (_bridge) return [_bridge moduleForClass:[RCTBundleManager class]];
247
+ return nil;
86
248
  }
87
249
 
88
- #pragma mark - Bundle Manager Resolution
250
+ #pragma mark - Host Parsing
89
251
 
90
- - (RCTBundleManager *)resolveBundleManager
252
+ - (NSString *)normalizeHostPort:(NSString *)hostPort
91
253
  {
92
- if (_bundleManager) {
93
- return _bundleManager;
94
- }
95
- if (_bridge) {
96
- return [_bridge moduleForClass:[RCTBundleManager class]];
254
+ NSRange sep = [hostPort rangeOfString:@":" options:NSBackwardsSearch];
255
+ NSString *host = sep.location == NSNotFound ? hostPort : [hostPort substringToIndex:sep.location];
256
+ NSString *portStr = sep.location == NSNotFound ? @"" : [hostPort substringFromIndex:sep.location + 1];
257
+
258
+ NSNumberFormatter *formatter = [NSNumberFormatter new];
259
+ formatter.numberStyle = NSNumberFormatterDecimalStyle;
260
+ NSNumber *parsed = [formatter numberFromString:portStr];
261
+ int port;
262
+ if (parsed && parsed.intValue > 0 && parsed.intValue <= 65535) {
263
+ port = parsed.intValue;
264
+ } else {
265
+ #ifdef RCT_METRO_PORT
266
+ port = RCT_METRO_PORT;
267
+ #else
268
+ port = 8081;
269
+ #endif
97
270
  }
98
- return nil;
271
+ return [NSString stringWithFormat:@"%@:%d", host, port];
99
272
  }
100
273
 
101
- #pragma mark - Exported Methods
274
+ #pragma mark - Reload helper
102
275
 
103
- RCT_EXPORT_METHOD(getMetroHost:(RCTPromiseResolveBlock)resolve
104
- rejecter:(__unused RCTPromiseRejectBlock)reject)
276
+ - (void)reloadWithBundleURL:(NSURL *)bundleURL reason:(NSString *)reason
105
277
  {
106
- @try {
107
- NSString *host = [RCTBundleURLProvider sharedSettings].jsLocation;
108
- resolve(host ?: [NSNull null]);
109
- } @catch (NSException *exception) {
110
- reject(@"native_error", exception.reason, nil);
278
+ if (bundleURL) {
279
+ RCTBundleManager *bm = [self resolveBundleManager];
280
+ if (bm) bm.bundleURL = bundleURL;
281
+ RCTReloadCommandSetBundleURL(bundleURL);
111
282
  }
283
+ RCTTriggerReloadCommandListeners(reason);
112
284
  }
113
285
 
286
+ #pragma mark - Exported Methods
287
+
114
288
  RCT_EXPORT_METHOD(applyMetroHost:(NSString *)hostPort
115
289
  resolver:(RCTPromiseResolveBlock)resolve
116
290
  rejecter:(RCTPromiseRejectBlock)reject)
@@ -120,71 +294,24 @@ RCT_EXPORT_METHOD(applyMetroHost:(NSString *)hostPort
120
294
  reject(@"invalid_host", @"Metro host cannot be empty.", nil);
121
295
  return;
122
296
  }
297
+ NSString *normalized = [self normalizeHostPort:hostPort];
123
298
 
124
- RCTBundleURLProvider *settings = [RCTBundleURLProvider sharedSettings];
125
-
126
- // Parse host and port
127
- NSRange separator = [hostPort rangeOfString:@":" options:NSBackwardsSearch];
128
- NSString *host = separator.location == NSNotFound
129
- ? hostPort
130
- : [hostPort substringToIndex:separator.location];
131
- NSString *port = separator.location == NSNotFound
132
- ? @""
133
- : [hostPort substringFromIndex:separator.location + 1];
134
-
135
- if (host.length == 0 && port.length == 0) {
136
- [self resetMetroHost:resolve rejecter:reject];
137
- return;
138
- }
139
-
140
- NSNumberFormatter *formatter = [NSNumberFormatter new];
141
- formatter.numberStyle = NSNumberFormatterDecimalStyle;
142
- NSNumber *portNumber = [formatter numberFromString:port];
143
- if (portNumber == nil) {
144
- #ifdef RCT_METRO_PORT
145
- portNumber = [NSNumber numberWithInt:RCT_METRO_PORT];
146
- #else
147
- portNumber = [NSNumber numberWithInt:8081];
148
- #endif
149
- }
150
-
151
- NSString *normalizedHostPort = [NSString stringWithFormat:@"%@:%d", host, portNumber.intValue];
152
-
153
- // Persist for AppDelegate swizzle (Release mode + restart)
154
- [[NSUserDefaults standardUserDefaults] setObject:normalizedHostPort forKey:kDevConnectMetroHost];
155
- [[NSUserDefaults standardUserDefaults] synchronize];
156
-
157
- // Also set jsLocation (Debug mode + hot reload)
158
- settings.jsLocation = normalizedHostPort;
159
-
160
- // Try hot reload via bundleManager (works in Debug)
161
- NSURL *bundleURL = nil;
162
- if (DebugToolkitBundleRoot.length > 0) {
163
- bundleURL = [settings jsBundleURLForBundleRoot:DebugToolkitBundleRoot];
164
- }
165
- RCTBundleManager *bm = [self resolveBundleManager];
166
- if (bm) {
167
- bm.bundleURL = bundleURL;
168
- }
169
- #ifdef RCTReloadCommandSetBundleURL
170
- else if (bundleURL) {
171
- RCTReloadCommandSetBundleURL(bundleURL);
172
- }
173
- #endif
174
-
175
- NSLog(@"[DevConnect] applyMetroHost: %@ | bm=%@ | bridge=%@ | url=%@",
176
- normalizedHostPort, bm ? @"YES" : @"nil", _bridge ? @"YES" : @"nil", bundleURL);
299
+ DevConnectSetPersistedMetroHost(normalized);
177
300
 
178
- RCTTriggerReloadCommandListeners(@"Dev menu - apply changes");
179
-
180
- NSMutableDictionary *result = [@{@"hostPort" : normalizedHostPort} mutableCopy];
181
- if (bm && bm.bundleURL.absoluteString) {
182
- result[@"bundleURL"] = bm.bundleURL.absoluteString;
183
- }
184
- resolve(result);
185
- } @catch (NSException *exception) {
186
- NSLog(@"[DevConnect] applyMetroHost EXCEPTION: %@", exception.reason);
187
- reject(@"native_error", exception.reason, nil);
301
+ RCTBundleURLProvider *settings = [RCTBundleURLProvider sharedSettings];
302
+ settings.jsLocation = normalized;
303
+ NSURL *bundleURL = [settings jsBundleURLForBundleRoot:DevConnectMetroBundleRoot()];
304
+
305
+ [self reloadWithBundleURL:bundleURL reason:@"Dev menu - apply changes"];
306
+
307
+ NSLog(@"[DevConnect] applyMetroHost host=%@ url=%@", normalized, bundleURL);
308
+ resolve(@{
309
+ @"hostPort": normalized,
310
+ @"bundleURL": bundleURL.absoluteString ?: [NSNull null],
311
+ });
312
+ } @catch (NSException *e) {
313
+ NSLog(@"[DevConnect] applyMetroHost EXCEPTION: %@", e.reason);
314
+ reject(@"native_error", e.reason ?: @"unknown", nil);
188
315
  }
189
316
  }
190
317
 
@@ -192,29 +319,57 @@ RCT_EXPORT_METHOD(resetMetroHost:(RCTPromiseResolveBlock)resolve
192
319
  rejecter:(__unused RCTPromiseRejectBlock)reject)
193
320
  {
194
321
  @try {
195
- // Clear stored Metro host
196
- [[NSUserDefaults standardUserDefaults] removeObjectForKey:kDevConnectMetroHost];
197
- [[NSUserDefaults standardUserDefaults] synchronize];
322
+ DevConnectSetPersistedMetroHost(nil);
198
323
 
199
- // Reset RN settings
200
324
  RCTBundleURLProvider *settings = [RCTBundleURLProvider sharedSettings];
201
325
  [settings resetToDefaults];
202
- NSURL *bundleURL = [settings jsBundleURLForFallbackExtension:nil];
203
326
 
204
- RCTBundleManager *bm = [self resolveBundleManager];
205
- if (bm) {
206
- bm.bundleURL = bundleURL;
327
+ NSURL *embedded = DebugToolkitEmbeddedBundleURL();
328
+ if (!embedded) {
329
+ embedded = [settings jsBundleURLForFallbackExtension:nil];
207
330
  }
208
331
 
209
- NSLog(@"[DevConnect] resetMetroHost | bm=%@ | url=%@", bm ? @"YES" : @"nil", bundleURL);
210
- RCTTriggerReloadCommandListeners(@"Dev menu - reset to default");
332
+ [self reloadWithBundleURL:embedded reason:@"Dev menu - reset to default"];
333
+
334
+ NSLog(@"[DevConnect] resetMetroHost url=%@", embedded);
211
335
  resolve([NSNull null]);
212
- } @catch (NSException *exception) {
213
- NSLog(@"[DevConnect] resetMetroHost EXCEPTION: %@", exception.reason);
214
- reject(@"native_error", exception.reason, nil);
336
+ } @catch (NSException *e) {
337
+ NSLog(@"[DevConnect] resetMetroHost EXCEPTION: %@", e.reason);
338
+ reject(@"native_error", e.reason ?: @"unknown", nil);
215
339
  }
216
340
  }
217
341
 
342
+ RCT_EXPORT_METHOD(getMetroHost:(RCTPromiseResolveBlock)resolve
343
+ rejecter:(__unused RCTPromiseRejectBlock)reject)
344
+ {
345
+ NSString *host = DevConnectPersistedMetroHost();
346
+ resolve(host.length > 0 ? host : [NSNull null]);
347
+ }
348
+
349
+ RCT_EXPORT_METHOD(getDiagnostics:(RCTPromiseResolveBlock)resolve
350
+ rejecter:(__unused RCTPromiseRejectBlock)reject)
351
+ {
352
+ Class realDelegate = object_getClass([UIApplication sharedApplication].delegate);
353
+ NSString *delegateName = realDelegate ? NSStringFromClass(realDelegate) : @"unknown";
354
+ NSString *persisted = DevConnectPersistedMetroHost();
355
+
356
+ #if DEBUG
357
+ BOOL isDebug = YES;
358
+ #else
359
+ BOOL isDebug = NO;
360
+ #endif
361
+
362
+ resolve(@{
363
+ @"persistedMetroHost": persisted.length > 0 ? persisted : [NSNull null],
364
+ @"appDelegateClass": delegateName,
365
+ @"isDebugBuild": @(isDebug),
366
+ @"hasEmbeddedBundle": @(DebugToolkitEmbeddedBundleURL() != nil),
367
+ @"embeddedFirstHookInstalled": @(DevConnectEmbeddedFirstHooksActive()),
368
+ @"packagerHookInstalled": @(gPackagerHookInstalled),
369
+ @"bundleRootHookInstalled": @(gBundleRootHookInstalled),
370
+ });
371
+ }
372
+
218
373
  RCT_EXPORT_METHOD(getPreference:(NSString *)key
219
374
  resolver:(RCTPromiseResolveBlock)resolve
220
375
  rejecter:(__unused RCTPromiseRejectBlock)reject)
@@ -222,8 +377,8 @@ RCT_EXPORT_METHOD(getPreference:(NSString *)key
222
377
  @try {
223
378
  NSString *value = [[NSUserDefaults standardUserDefaults] stringForKey:key];
224
379
  resolve(value ?: [NSNull null]);
225
- } @catch (NSException *exception) {
226
- reject(@"native_error", exception.reason, nil);
380
+ } @catch (NSException *e) {
381
+ reject(@"native_error", e.reason ?: @"unknown", nil);
227
382
  }
228
383
  }
229
384
 
@@ -236,8 +391,8 @@ RCT_EXPORT_METHOD(setPreference:(NSString *)key
236
391
  [[NSUserDefaults standardUserDefaults] setObject:value forKey:key];
237
392
  [[NSUserDefaults standardUserDefaults] synchronize];
238
393
  resolve([NSNull null]);
239
- } @catch (NSException *exception) {
240
- reject(@"native_error", exception.reason, nil);
394
+ } @catch (NSException *e) {
395
+ reject(@"native_error", e.reason ?: @"unknown", nil);
241
396
  }
242
397
  }
243
398
 
@@ -256,40 +411,27 @@ RCT_EXPORT_METHOD(getLocalIp:(RCTPromiseResolveBlock)resolve
256
411
  {
257
412
  @try {
258
413
  struct ifaddrs *interfaces = NULL;
259
- if (getifaddrs(&interfaces) == 0) {
260
- struct ifaddrs *iface = interfaces;
261
- while (iface != NULL) {
262
- if (iface->ifa_addr != NULL && iface->ifa_addr->sa_family == AF_INET && !(iface->ifa_flags & IFF_LOOPBACK)) {
263
- if (strcmp(iface->ifa_name, "en0") == 0) {
264
- char addrStr[INET_ADDRSTRLEN];
265
- struct sockaddr_in *sin = (struct sockaddr_in *)iface->ifa_addr;
266
- inet_ntop(AF_INET, &sin->sin_addr, addrStr, sizeof(addrStr));
267
- NSString *ip = [NSString stringWithUTF8String:addrStr];
268
- freeifaddrs(interfaces);
269
- resolve(ip);
270
- return;
271
- }
272
- }
273
- iface = iface->ifa_next;
274
- }
275
- iface = interfaces;
276
- while (iface != NULL) {
277
- if (iface->ifa_addr != NULL && iface->ifa_addr->sa_family == AF_INET && !(iface->ifa_flags & IFF_LOOPBACK)) {
278
- char addrStr[INET_ADDRSTRLEN];
279
- struct sockaddr_in *sin = (struct sockaddr_in *)iface->ifa_addr;
280
- inet_ntop(AF_INET, &sin->sin_addr, addrStr, sizeof(addrStr));
281
- NSString *ip = [NSString stringWithUTF8String:addrStr];
282
- freeifaddrs(interfaces);
283
- resolve(ip);
284
- return;
285
- }
286
- iface = iface->ifa_next;
287
- }
414
+ if (getifaddrs(&interfaces) != 0) {
415
+ resolve([NSNull null]);
416
+ return;
417
+ }
418
+
419
+ NSString *preferred = nil;
420
+ NSString *fallback = nil;
421
+ for (struct ifaddrs *iface = interfaces; iface != NULL; iface = iface->ifa_next) {
422
+ if (!iface->ifa_addr || iface->ifa_addr->sa_family != AF_INET) continue;
423
+ if (iface->ifa_flags & IFF_LOOPBACK) continue;
424
+ char addrStr[INET_ADDRSTRLEN];
425
+ struct sockaddr_in *sin = (struct sockaddr_in *)iface->ifa_addr;
426
+ inet_ntop(AF_INET, &sin->sin_addr, addrStr, sizeof(addrStr));
427
+ NSString *ip = [NSString stringWithUTF8String:addrStr];
428
+ if (strcmp(iface->ifa_name, "en0") == 0) { preferred = ip; break; }
429
+ if (!fallback) fallback = ip;
288
430
  }
289
431
  freeifaddrs(interfaces);
290
- resolve([NSNull null]);
291
- } @catch (NSException *exception) {
292
- reject(@"native_error", exception.reason, nil);
432
+ resolve(preferred ?: fallback ?: [NSNull null]);
433
+ } @catch (NSException *e) {
434
+ reject(@"native_error", e.reason ?: @"unknown", nil);
293
435
  }
294
436
  }
295
437