@nebula-rn/host 0.0.1

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 (37) hide show
  1. package/NebulaHost.podspec +23 -0
  2. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  3. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  4. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  5. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  6. package/android/.gradle/8.9/gc.properties +0 -0
  7. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  8. package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
  9. package/android/.gradle/vcs-1/gc.properties +0 -0
  10. package/android/build.gradle +27 -0
  11. package/android/consumer-rules.pro +1 -0
  12. package/android/src/main/AndroidManifest.xml +1 -0
  13. package/android/src/main/java/com/hectorzhuang/nebula/NebulaActivity.kt +290 -0
  14. package/android/src/main/java/com/hectorzhuang/nebula/NebulaAppManager.kt +134 -0
  15. package/android/src/main/java/com/hectorzhuang/nebula/NebulaConfig.kt +324 -0
  16. package/android/src/main/java/com/hectorzhuang/nebula/NebulaEventHub.kt +49 -0
  17. package/android/src/main/java/com/hectorzhuang/nebula/NebulaHost.kt +145 -0
  18. package/android/src/main/java/com/hectorzhuang/nebula/NebulaHostModalActivity.kt +178 -0
  19. package/android/src/main/java/com/hectorzhuang/nebula/NebulaManifestManager.kt +130 -0
  20. package/android/src/main/java/com/hectorzhuang/nebula/NebulaNativeModule.kt +604 -0
  21. package/android/src/main/java/com/hectorzhuang/nebula/NebulaPackage.kt +16 -0
  22. package/android/src/main/java/com/hectorzhuang/nebula/NebulaRouter.kt +300 -0
  23. package/ios/Nebula/NebulaAppManager.swift +355 -0
  24. package/ios/Nebula/NebulaConfig.swift +549 -0
  25. package/ios/Nebula/NebulaContainerController.swift +580 -0
  26. package/ios/Nebula/NebulaDevLoading.swift +333 -0
  27. package/ios/Nebula/NebulaHost.swift +611 -0
  28. package/ios/Nebula/NebulaManifest.swift +214 -0
  29. package/ios/Nebula/NebulaNativeModule.swift +682 -0
  30. package/ios/Nebula/NebulaNativeModuleBridge.m +364 -0
  31. package/ios/Nebula/NebulaPerformanceMonitor.swift +46 -0
  32. package/ios/Nebula/NebulaRouter.swift +594 -0
  33. package/ios/Nebula/NebulaRouterBridge.m +19 -0
  34. package/ios/Nebula/RNInstanceViewController.swift +52 -0
  35. package/package.json +41 -0
  36. package/react-native.config.js +14 -0
  37. package/src/index.ts +9 -0
@@ -0,0 +1,682 @@
1
+ //
2
+ // NebulaNativeModule.swift
3
+ // SuperApp - Nebula Mini-App Container
4
+ //
5
+ // Native module for main app to open mini-apps
6
+ //
7
+
8
+ import Foundation
9
+ import React
10
+
11
+ @objc(NebulaNativeModule)
12
+ class NebulaNativeModule: RCTEventEmitter {
13
+
14
+ private enum BridgeMessageDirection: String {
15
+ case toHost
16
+ case toMiniApp
17
+ }
18
+
19
+ private static let messageNotificationName = Notification.Name("NebulaBridgeMessage")
20
+ private static let hostMessageEvent = "NebulaHostMessage"
21
+ private static let miniAppMessageEvent = "NebulaMiniAppMessage"
22
+ private static let pageLifecycleEvent = "NebulaPageLifecycle"
23
+
24
+ private let emitterId = UUID().uuidString
25
+ private var hasListeners = false
26
+ private var currentRuntimeAppId: String?
27
+ private var messageObserver: NSObjectProtocol?
28
+ private var pageLifecycleObservers: [NSObjectProtocol] = []
29
+ private weak var presentedHostModalViewController: UIViewController?
30
+
31
+ override init() {
32
+ super.init()
33
+ messageObserver = NotificationCenter.default.addObserver(
34
+ forName: Self.messageNotificationName,
35
+ object: nil,
36
+ queue: .main
37
+ ) { [weak self] notification in
38
+ self?.handleBridgeMessage(notification)
39
+ }
40
+ pageLifecycleObservers = [
41
+ NotificationCenter.default.addObserver(
42
+ forName: NSNotification.Name("NebulaContainerWillAppear"),
43
+ object: nil,
44
+ queue: .main
45
+ ) { [weak self] notification in
46
+ self?.handlePageLifecycle(notification, type: "show")
47
+ },
48
+ NotificationCenter.default.addObserver(
49
+ forName: NSNotification.Name("NebulaContainerDidDisappear"),
50
+ object: nil,
51
+ queue: .main
52
+ ) { [weak self] notification in
53
+ self?.handlePageLifecycle(notification, type: "hide")
54
+ },
55
+ NotificationCenter.default.addObserver(
56
+ forName: NSNotification.Name("NebulaContainerDidUnload"),
57
+ object: nil,
58
+ queue: .main
59
+ ) { [weak self] notification in
60
+ self?.handlePageLifecycle(notification, type: "unload")
61
+ },
62
+ NotificationCenter.default.addObserver(
63
+ forName: NSNotification.Name("NebulaContainerDidBecomeReady"),
64
+ object: nil,
65
+ queue: .main
66
+ ) { [weak self] notification in
67
+ self?.handlePageLifecycle(notification, type: "ready")
68
+ }
69
+ ]
70
+ }
71
+
72
+ deinit {
73
+ if let observer = messageObserver {
74
+ NotificationCenter.default.removeObserver(observer)
75
+ }
76
+ for observer in pageLifecycleObservers {
77
+ NotificationCenter.default.removeObserver(observer)
78
+ }
79
+ }
80
+
81
+ private func resolveTopViewController() -> UIViewController? {
82
+ guard let navigationController = NebulaHost.shared.currentNavigationController() else {
83
+ return nil
84
+ }
85
+
86
+ var topViewController: UIViewController = navigationController
87
+ while let presented = topViewController.presentedViewController {
88
+ topViewController = presented
89
+ }
90
+
91
+ if let navigation = topViewController as? UINavigationController {
92
+ return navigation.topViewController ?? navigation
93
+ }
94
+
95
+ return topViewController
96
+ }
97
+
98
+ @objc override class func moduleName() -> String {
99
+ return "NebulaNativeModule"
100
+ }
101
+
102
+ @objc override class func requiresMainQueueSetup() -> Bool {
103
+ return true
104
+ }
105
+
106
+ override func supportedEvents() -> [String]! {
107
+ return [Self.hostMessageEvent, Self.miniAppMessageEvent, Self.pageLifecycleEvent]
108
+ }
109
+
110
+ override func startObserving() {
111
+ hasListeners = true
112
+ }
113
+
114
+ override func stopObserving() {
115
+ hasListeners = false
116
+ }
117
+
118
+ /// Open a mini-app from the main React Native app
119
+ @objc func openMiniApp(_ appId: String,
120
+ initialProps: NSDictionary?,
121
+ resolver: @escaping RCTPromiseResolveBlock,
122
+ rejecter: @escaping RCTPromiseRejectBlock) {
123
+ DispatchQueue.main.async {
124
+ guard let window = UIApplication.shared.keyWindow,
125
+ let rootVC = window.rootViewController else {
126
+ rejecter("NO_ROOT_VC", "Cannot find root view controller", nil)
127
+ return
128
+ }
129
+
130
+ // Find the topmost view controller
131
+ var topVC = rootVC
132
+ while let presented = topVC.presentedViewController {
133
+ topVC = presented
134
+ }
135
+
136
+ // If it's a navigation controller, use it
137
+ let sourceVC = (topVC as? UINavigationController)?.topViewController ?? topVC
138
+
139
+ // Open mini-app
140
+ let props = initialProps as? [String: Any]
141
+ NebulaHost.shared.openApp(appId, from: sourceVC, initialProps: props)
142
+
143
+ resolver(["success": true, "appId": appId])
144
+ }
145
+ }
146
+
147
+ /// Preload a mini-app
148
+ @objc func preloadMiniApp(_ appId: String,
149
+ resolver: @escaping RCTPromiseResolveBlock,
150
+ rejecter: @escaping RCTPromiseRejectBlock) {
151
+ NebulaHost.shared.preloadApp(appId)
152
+ resolver(["success": true, "appId": appId])
153
+ }
154
+
155
+ @objc func openMiniAppWithBundleURL(_ appId: String,
156
+ bundleURL: String,
157
+ connectToURLMetroServer: Bool,
158
+ initialProps: NSDictionary?,
159
+ resolver: @escaping RCTPromiseResolveBlock,
160
+ rejecter: @escaping RCTPromiseRejectBlock) {
161
+ NebulaHost.shared.installApp(
162
+ appId,
163
+ bundleURL: bundleURL,
164
+ connectToURLMetroServer: connectToURLMetroServer
165
+ ) { success, error in
166
+ if success {
167
+ DispatchQueue.main.async {
168
+ guard let window = UIApplication.shared.keyWindow,
169
+ let rootVC = window.rootViewController else {
170
+ rejecter("NO_ROOT_VC", "Cannot find root view controller", nil)
171
+ return
172
+ }
173
+
174
+ var topVC = rootVC
175
+ while let presented = topVC.presentedViewController {
176
+ topVC = presented
177
+ }
178
+
179
+ let sourceVC = (topVC as? UINavigationController)?.topViewController ?? topVC
180
+ let props = initialProps as? [String: Any]
181
+ NebulaHost.shared.openApp(appId, from: sourceVC, initialProps: props)
182
+ resolver(["success": true, "appId": appId])
183
+ }
184
+ } else {
185
+ rejecter("OPEN_ERROR", error?.localizedDescription ?? "Failed to open", error)
186
+ }
187
+ }
188
+ }
189
+
190
+ /// Install and preload a mini-app from a bundle URL
191
+ @objc func preloadMiniAppWithBundleURL(_ appId: String,
192
+ bundleURL: String,
193
+ connectToURLMetroServer: Bool,
194
+ resolver: @escaping RCTPromiseResolveBlock,
195
+ rejecter: @escaping RCTPromiseRejectBlock) {
196
+ NebulaHost.shared.installApp(
197
+ appId,
198
+ bundleURL: bundleURL,
199
+ connectToURLMetroServer: connectToURLMetroServer
200
+ ) { success, error in
201
+ if success {
202
+ NebulaHost.shared.preloadApp(appId)
203
+ resolver(["success": true, "appId": appId])
204
+ } else {
205
+ rejecter("PRELOAD_ERROR", error?.localizedDescription ?? "Failed to preload", error)
206
+ }
207
+ }
208
+ }
209
+
210
+ /// Install a mini-app from URL
211
+ @objc func installMiniApp(_ appId: String,
212
+ bundleURL: String,
213
+ resolver: @escaping RCTPromiseResolveBlock,
214
+ rejecter: @escaping RCTPromiseRejectBlock) {
215
+ NebulaHost.shared.installApp(appId, bundleURL: bundleURL) { success, error in
216
+ if success {
217
+ resolver(["success": true, "appId": appId])
218
+ } else {
219
+ rejecter("INSTALL_ERROR", error?.localizedDescription ?? "Failed to install", error)
220
+ }
221
+ }
222
+ }
223
+
224
+ /// Install a mini-app from URL with explicit Metro connection preference
225
+ @objc func installMiniAppWithBundleURL(_ appId: String,
226
+ bundleURL: String,
227
+ connectToURLMetroServer: Bool,
228
+ resolver: @escaping RCTPromiseResolveBlock,
229
+ rejecter: @escaping RCTPromiseRejectBlock) {
230
+ NebulaHost.shared.installApp(
231
+ appId,
232
+ bundleURL: bundleURL,
233
+ connectToURLMetroServer: connectToURLMetroServer
234
+ ) { success, error in
235
+ if success {
236
+ resolver(["success": true, "appId": appId])
237
+ } else {
238
+ rejecter("INSTALL_ERROR", error?.localizedDescription ?? "Failed to install", error)
239
+ }
240
+ }
241
+ }
242
+
243
+ @objc func installMiniAppFromURLs(_ appId: String,
244
+ bundleURL: String,
245
+ manifestURL: String,
246
+ assetsURL: String,
247
+ connectToURLMetroServer: Bool,
248
+ resolver: @escaping RCTPromiseResolveBlock,
249
+ rejecter: @escaping RCTPromiseRejectBlock) {
250
+ NebulaConfig.shared.downloadBundle(
251
+ for: appId,
252
+ from: bundleURL,
253
+ manifestURL: manifestURL,
254
+ assetsURL: assetsURL,
255
+ connectToURLMetroServer: connectToURLMetroServer,
256
+ ) { success, error in
257
+ if success {
258
+ NebulaAppManager.shared.invalidate(appId: appId)
259
+ resolver(["success": true, "appId": appId])
260
+ } else {
261
+ rejecter("INSTALL_ERROR", error?.localizedDescription ?? "Failed to install", error)
262
+ }
263
+ }
264
+ }
265
+
266
+ @objc func closeMiniApp(_ appId: String,
267
+ resolver: @escaping RCTPromiseResolveBlock,
268
+ rejecter: @escaping RCTPromiseRejectBlock) {
269
+ DispatchQueue.main.async {
270
+ NebulaHost.shared.closeApp(appId)
271
+ resolver([
272
+ "success": true,
273
+ "appId": appId
274
+ ])
275
+ }
276
+ }
277
+
278
+ @objc func uninstallMiniApp(_ appId: String,
279
+ resolver: @escaping RCTPromiseResolveBlock,
280
+ rejecter: @escaping RCTPromiseRejectBlock) {
281
+ do {
282
+ try NebulaHost.shared.uninstallApp(appId)
283
+ resolver(["success": true, "appId": appId])
284
+ } catch {
285
+ rejecter("UNINSTALL_ERROR", error.localizedDescription, error)
286
+ }
287
+ }
288
+
289
+ /// Register route table from mini-app JS at startup
290
+ /// Called by the mini-app itself so native routing knows which component handles each path
291
+ @objc func registerRoutes(_ appId: String,
292
+ routes: NSDictionary,
293
+ resolver: @escaping RCTPromiseResolveBlock,
294
+ rejecter: @escaping RCTPromiseRejectBlock) {
295
+ guard let pages = routes as? [String: String] else {
296
+ rejecter("INVALID_ROUTES", "routes must be a flat object mapping path -> componentName", nil)
297
+ return
298
+ }
299
+ currentRuntimeAppId = appId
300
+ NebulaManifestManager.shared.registerManifest(forAppId: appId, pages: pages)
301
+ resolver(["success": true, "appId": appId, "count": pages.count])
302
+ }
303
+
304
+ @objc func registerManifest(_ appId: String,
305
+ manifest: NSDictionary,
306
+ resolver: @escaping RCTPromiseResolveBlock,
307
+ rejecter: @escaping RCTPromiseRejectBlock) {
308
+ do {
309
+ let data = try JSONSerialization.data(withJSONObject: manifest, options: [])
310
+ let decodedManifest = try JSONDecoder().decode(NebulaManifest.self, from: data)
311
+ currentRuntimeAppId = appId
312
+ NebulaManifestManager.shared.registerManifest(forAppId: appId, manifest: decodedManifest)
313
+ let entryPath = decodedManifest.entryPagePath ?? "/"
314
+ if let pageConfig = NebulaManifestManager.shared.getPageConfig(forAppId: appId, path: entryPath) as? [String: Any] {
315
+ NebulaRouter.shared.updatePageStyle(forAppId: appId, style: pageConfig as NSDictionary) { _, _ in }
316
+ }
317
+ resolver(["success": true, "appId": appId, "count": decodedManifest.pages.count])
318
+ } catch {
319
+ rejecter("INVALID_MANIFEST", "manifest must be a valid Nebula manifest object", error)
320
+ }
321
+ }
322
+
323
+ /// Mini-app -> Host communication
324
+ @objc func postMessageToHost(_ appId: String,
325
+ message: NSDictionary,
326
+ resolver: @escaping RCTPromiseResolveBlock,
327
+ rejecter: @escaping RCTPromiseRejectBlock) {
328
+ currentRuntimeAppId = appId
329
+ publishBridgeMessage(direction: .toHost, appId: appId, message: message)
330
+ resolver(["errMsg": "postMessageToHost:ok"])
331
+ }
332
+
333
+ /// Host -> Mini-app communication
334
+ @objc func postMessageToMiniApp(_ appId: String,
335
+ message: NSDictionary,
336
+ resolver: @escaping RCTPromiseResolveBlock,
337
+ rejecter: @escaping RCTPromiseRejectBlock) {
338
+ publishBridgeMessage(direction: .toMiniApp, appId: appId, message: message)
339
+ resolver(["errMsg": "postMessageToMiniApp:ok"])
340
+ }
341
+
342
+ /// Get list of installed mini-apps
343
+ @objc func getInstalledMiniApps(_ resolver: @escaping RCTPromiseResolveBlock,
344
+ rejecter: @escaping RCTPromiseRejectBlock) {
345
+ let apps = NebulaHost.shared.installedApps()
346
+ resolver(["apps": apps])
347
+ }
348
+
349
+ @objc func getInstalledMiniAppInfo(_ appId: String,
350
+ resolver: @escaping RCTPromiseResolveBlock,
351
+ rejecter: @escaping RCTPromiseRejectBlock) {
352
+ guard let info = NebulaHost.shared.installedAppInfo(appId) else {
353
+ resolver([
354
+ "installed": false,
355
+ "app": NSNull()
356
+ ])
357
+ return
358
+ }
359
+
360
+ resolver([
361
+ "installed": true,
362
+ "app": [
363
+ "appId": info.appId,
364
+ "mode": info.mode,
365
+ "bundlePath": info.bundlePath,
366
+ "sourceUrl": info.sourceUrl,
367
+ "version": info.version as Any,
368
+ "updateStrategy": info.updateStrategy
369
+ ]
370
+ ])
371
+ }
372
+
373
+ @objc func getServerBaseURL(_ resolver: @escaping RCTPromiseResolveBlock,
374
+ rejecter: @escaping RCTPromiseRejectBlock) {
375
+ resolver([
376
+ "serverBaseURL": NebulaConfig.shared.serverBaseURL
377
+ ])
378
+ }
379
+
380
+ @objc func setServerBaseURL(_ serverBaseURL: String?,
381
+ resolver: @escaping RCTPromiseResolveBlock,
382
+ rejecter: @escaping RCTPromiseRejectBlock) {
383
+ let trimmedValue = serverBaseURL?.trimmingCharacters(in: .whitespacesAndNewlines)
384
+ if let trimmedValue,
385
+ !trimmedValue.isEmpty,
386
+ NebulaConfig.normalizeServerBaseURL(trimmedValue) == nil {
387
+ rejecter("INVALID_SERVER_URL", "serverBaseURL must be a valid http(s) URL", nil)
388
+ return
389
+ }
390
+
391
+ NebulaConfig.shared.serverBaseURL = trimmedValue
392
+ resolver([
393
+ "serverBaseURL": NebulaConfig.shared.serverBaseURL
394
+ ])
395
+ }
396
+
397
+ @objc func setMiniappLoadingDelay(_ delayMs: NSNumber?,
398
+ resolver: @escaping RCTPromiseResolveBlock,
399
+ rejecter: @escaping RCTPromiseRejectBlock) {
400
+ let normalizedDelayMs = max(0, delayMs?.doubleValue ?? 0)
401
+ miniappLoadingDelay = normalizedDelayMs / 1000.0
402
+ resolver([
403
+ "delayMs": normalizedDelayMs
404
+ ])
405
+ }
406
+
407
+ @objc func setMiniappLoadingEnterContentDelay(_ delayMs: NSNumber?,
408
+ resolver: @escaping RCTPromiseResolveBlock,
409
+ rejecter: @escaping RCTPromiseRejectBlock) {
410
+ let normalizedDelayMs = max(0, delayMs?.doubleValue ?? 0)
411
+ miniappLoadingEnterContentDelay = normalizedDelayMs / 1000.0
412
+ resolver([
413
+ "delayMs": normalizedDelayMs
414
+ ])
415
+ }
416
+
417
+ @objc func bringHostToFront(_ resolver: @escaping RCTPromiseResolveBlock,
418
+ rejecter: @escaping RCTPromiseRejectBlock) {
419
+ DispatchQueue.main.async {
420
+ let token = NebulaHost.shared.bringHostToFront()
421
+ resolver([
422
+ "success": token != nil,
423
+ "token": token
424
+ ])
425
+ }
426
+ }
427
+
428
+ @objc func restoreMiniApp(_ token: String?,
429
+ resolver: @escaping RCTPromiseResolveBlock,
430
+ rejecter: @escaping RCTPromiseRejectBlock) {
431
+ DispatchQueue.main.async {
432
+ let success = NebulaHost.shared.restoreMiniApp(with: token)
433
+ resolver([
434
+ "success": success,
435
+ "token": token as Any
436
+ ])
437
+ }
438
+ }
439
+
440
+ @objc func presentHostModal(_ moduleName: String,
441
+ props: NSDictionary,
442
+ resolver: @escaping RCTPromiseResolveBlock,
443
+ rejecter: @escaping RCTPromiseRejectBlock) {
444
+ DispatchQueue.main.async {
445
+ guard let presenter = self.resolveTopViewController() else {
446
+ resolver(["errMsg": "presentHostModal:fail no_presenter"])
447
+ return
448
+ }
449
+
450
+ let initialProps = props as? [AnyHashable: Any]
451
+ guard let modalViewController = NebulaHost.shared.makeHostViewController(
452
+ moduleName: moduleName,
453
+ initialProperties: initialProps,
454
+ title: moduleName
455
+ ) else {
456
+ resolver(["errMsg": "presentHostModal:fail no_host_view_controller"])
457
+ return
458
+ }
459
+ modalViewController.modalPresentationStyle = .overFullScreen
460
+ modalViewController.modalTransitionStyle = .crossDissolve
461
+ modalViewController.view.backgroundColor = .clear
462
+ self.presentedHostModalViewController = modalViewController
463
+
464
+ presenter.present(modalViewController, animated: true) {
465
+ resolver(["errMsg": "presentHostModal:ok"])
466
+ }
467
+ }
468
+ }
469
+
470
+ @objc func dismissHostModal(_ resolver: @escaping RCTPromiseResolveBlock,
471
+ rejecter: @escaping RCTPromiseRejectBlock) {
472
+ DispatchQueue.main.async {
473
+ let modalViewController =
474
+ self.presentedHostModalViewController
475
+ ?? self.resolveTopViewController()?.presentedViewController
476
+ ?? self.resolveTopViewController()
477
+
478
+ guard let modalViewController else {
479
+ resolver(["errMsg": "dismissHostModal:ok"])
480
+ return
481
+ }
482
+
483
+ modalViewController.dismiss(animated: true) {
484
+ self.presentedHostModalViewController = nil
485
+ resolver(["errMsg": "dismissHostModal:ok"])
486
+ }
487
+ }
488
+ }
489
+
490
+ // MARK: - Mini-App Navigation APIs
491
+
492
+ @objc func navigateTo(_ appId: String,
493
+ url: String,
494
+ resolver: @escaping RCTPromiseResolveBlock,
495
+ rejecter: @escaping RCTPromiseRejectBlock) {
496
+ DispatchQueue.main.async {
497
+ NebulaRouter.shared.navigateToURL(url, fromAppId: appId) { success, error in
498
+ if success {
499
+ resolver(["errMsg": "navigateTo:ok"])
500
+ } else {
501
+ rejecter("navigateTo", error?.localizedDescription ?? "unknown", nil)
502
+ }
503
+ }
504
+ }
505
+ }
506
+
507
+ @objc func redirectTo(_ appId: String,
508
+ url: String,
509
+ resolver: @escaping RCTPromiseResolveBlock,
510
+ rejecter: @escaping RCTPromiseRejectBlock) {
511
+ DispatchQueue.main.async {
512
+ NebulaRouter.shared.redirectToURL(url, fromAppId: appId) { success, error in
513
+ if success {
514
+ resolver(["errMsg": "redirectTo:ok"])
515
+ } else {
516
+ rejecter("redirectTo", error?.localizedDescription ?? "unknown", nil)
517
+ }
518
+ }
519
+ }
520
+ }
521
+
522
+ @objc func reLaunch(_ appId: String,
523
+ url: String,
524
+ resolver: @escaping RCTPromiseResolveBlock,
525
+ rejecter: @escaping RCTPromiseRejectBlock) {
526
+ DispatchQueue.main.async {
527
+ NebulaRouter.shared.reLaunchURL(url, fromAppId: appId) { success, error in
528
+ if success {
529
+ resolver(["errMsg": "reLaunch:ok"])
530
+ } else {
531
+ rejecter("reLaunch", error?.localizedDescription ?? "unknown", nil)
532
+ }
533
+ }
534
+ }
535
+ }
536
+
537
+ @objc func navigateBack(_ appId: String,
538
+ delta: Int,
539
+ resolver: @escaping RCTPromiseResolveBlock,
540
+ rejecter: @escaping RCTPromiseRejectBlock) {
541
+ DispatchQueue.main.async {
542
+ NebulaRouter.shared.navigateBack(fromAppId: appId, delta: delta) { success, error in
543
+ if success {
544
+ resolver(["errMsg": "navigateBack:ok"])
545
+ } else {
546
+ rejecter("navigateBack", error?.localizedDescription ?? "unknown", nil)
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ @objc func setPageStyle(_ appId: String,
553
+ style: NSDictionary,
554
+ resolver: @escaping RCTPromiseResolveBlock,
555
+ rejecter: @escaping RCTPromiseRejectBlock) {
556
+ DispatchQueue.main.async {
557
+ NebulaRouter.shared.updatePageStyle(forAppId: appId, style: style) { success, error in
558
+ if success {
559
+ resolver(["errMsg": "setPageStyle:ok"])
560
+ } else {
561
+ rejecter("setPageStyle", error?.localizedDescription ?? "unknown", nil)
562
+ }
563
+ }
564
+ }
565
+ }
566
+
567
+ @objc func showToast(_ title: String,
568
+ resolver: @escaping RCTPromiseResolveBlock,
569
+ rejecter: @escaping RCTPromiseRejectBlock) {
570
+ DispatchQueue.main.async {
571
+ guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }),
572
+ let rootVC = window.rootViewController else {
573
+ resolver(["errMsg": "showToast:fail no_active_window"])
574
+ return
575
+ }
576
+
577
+ let alert = UIAlertController(title: nil, message: title, preferredStyle: .alert)
578
+ rootVC.presentedViewController?.present(alert, animated: true)
579
+ ?? rootVC.present(alert, animated: true)
580
+
581
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
582
+ alert.dismiss(animated: true)
583
+ }
584
+
585
+ resolver(["errMsg": "showToast:ok"])
586
+ }
587
+ }
588
+
589
+ @objc func getDeviceInfo() -> NSDictionary {
590
+ let device = UIDevice.current
591
+ return [
592
+ "model": device.model,
593
+ "systemVersion": device.systemVersion,
594
+ "platform": "iOS"
595
+ ]
596
+ }
597
+
598
+ private func publishBridgeMessage(direction: BridgeMessageDirection,
599
+ appId: String,
600
+ message: NSDictionary) {
601
+ let payload = message as? [String: Any] ?? [:]
602
+ NotificationCenter.default.post(
603
+ name: Self.messageNotificationName,
604
+ object: nil,
605
+ userInfo: [
606
+ "direction": direction.rawValue,
607
+ "appId": appId,
608
+ "message": payload,
609
+ "sourceEmitterId": emitterId,
610
+ "timestamp": Date().timeIntervalSince1970
611
+ ]
612
+ )
613
+ }
614
+
615
+ private func handleBridgeMessage(_ notification: Notification) {
616
+ guard hasListeners,
617
+ let userInfo = notification.userInfo,
618
+ let directionRaw = userInfo["direction"] as? String,
619
+ let direction = BridgeMessageDirection(rawValue: directionRaw),
620
+ let appId = userInfo["appId"] as? String,
621
+ let message = userInfo["message"] as? [String: Any] else {
622
+ return
623
+ }
624
+
625
+ let sourceEmitterId = userInfo["sourceEmitterId"] as? String
626
+ if sourceEmitterId == emitterId {
627
+ return
628
+ }
629
+
630
+ switch direction {
631
+ case .toHost:
632
+ guard !isMiniAppRuntime() else {
633
+ return
634
+ }
635
+ sendEvent(withName: Self.hostMessageEvent, body: [
636
+ "appId": appId,
637
+ "message": message,
638
+ "timestamp": userInfo["timestamp"] ?? Date().timeIntervalSince1970
639
+ ])
640
+ case .toMiniApp:
641
+ let miniAppRuntime = isMiniAppRuntime()
642
+ guard miniAppRuntime, currentRuntimeAppId == appId else {
643
+ return
644
+ }
645
+ sendEvent(withName: Self.miniAppMessageEvent, body: [
646
+ "appId": appId,
647
+ "message": message,
648
+ "timestamp": userInfo["timestamp"] ?? Date().timeIntervalSince1970
649
+ ])
650
+ }
651
+ }
652
+
653
+ private func handlePageLifecycle(_ notification: Notification, type: String) {
654
+ guard hasListeners,
655
+ let userInfo = notification.userInfo,
656
+ let appId = userInfo["appId"] as? String,
657
+ (!isMiniAppRuntime() || currentRuntimeAppId == appId),
658
+ let instanceId = userInfo["instanceId"] as? String else {
659
+ return
660
+ }
661
+
662
+ sendEvent(withName: Self.pageLifecycleEvent, body: [
663
+ "appId": appId,
664
+ "instanceId": instanceId,
665
+ "routePath": userInfo["routePath"] as? String ?? "/",
666
+ "type": type
667
+ ])
668
+ }
669
+
670
+ private func isMiniAppRuntime() -> Bool {
671
+ if let currentRuntimeAppId, !currentRuntimeAppId.isEmpty {
672
+ return true
673
+ }
674
+
675
+ guard let bundleURLString = bridge?.bundleURL?.absoluteString.lowercased() else {
676
+ return false
677
+ }
678
+ return bundleURLString.contains("/miniapps/")
679
+ || bundleURLString.contains("entryfile=miniapps/")
680
+ || bundleURLString.contains("entryfile=miniapps%2f")
681
+ }
682
+ }