@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,300 @@
1
+ package com.hectorzhuang.nebula
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.os.Bundle
6
+ import android.widget.FrameLayout
7
+ import com.facebook.react.interfaces.fabric.ReactSurface
8
+ import java.lang.ref.WeakReference
9
+ import java.util.concurrent.CopyOnWriteArrayList
10
+
11
+ object NebulaRouter {
12
+ private const val MINIAPP_LOADING_MODULE = "NebulaInternalMiniappLoading"
13
+ const val LOADING_EXIT_ANIMATION_MS = 240L
14
+
15
+ @Volatile
16
+ private var intentionalLoadingDelayMs = 0L
17
+ @Volatile
18
+ private var enterContentDelayMs = 0L
19
+
20
+ fun setMiniappLoadingDelay(delayMs: Long) {
21
+ intentionalLoadingDelayMs = delayMs.coerceAtLeast(0L)
22
+ }
23
+
24
+ fun setMiniappLoadingEnterContentDelay(delayMs: Long) {
25
+ enterContentDelayMs = delayMs.coerceAtLeast(0L)
26
+ }
27
+
28
+ private val activityRefs = CopyOnWriteArrayList<WeakReference<NebulaActivity>>()
29
+
30
+ // Tracks loading overlays attached to caller activities, keyed by appId.
31
+ // The overlay lives in the caller's window while NebulaActivity is starting.
32
+ private data class PendingOverlay(
33
+ val callerActivity: WeakReference<Activity>,
34
+ val overlayHost: FrameLayout,
35
+ val reactSurface: ReactSurface,
36
+ )
37
+
38
+ private val pendingOverlays = CopyOnWriteArrayList<Pair<String, PendingOverlay>>()
39
+
40
+ fun register(activity: NebulaActivity) {
41
+ cleanup()
42
+ activityRefs.add(WeakReference(activity))
43
+ }
44
+
45
+ fun unregister(activity: NebulaActivity) {
46
+ activityRefs.removeAll { it.get() == null || it.get() === activity }
47
+ }
48
+
49
+ fun openApp(
50
+ activity: Activity,
51
+ appId: String,
52
+ routePath: String,
53
+ initialProps: Map<String, Any?>
54
+ ) {
55
+ val installedApp = NebulaConfig.getInstalledApp(appId)
56
+ val isProduction = installedApp?.mode?.equals("production", ignoreCase = true) == true
57
+ val enterDelay = enterContentDelayMs
58
+
59
+ val launchActivity = {
60
+ activity.startActivity(
61
+ NebulaActivity.createIntent(
62
+ activity = activity,
63
+ appId = appId,
64
+ routePath = routePath,
65
+ routeUrl = buildRouteUrl(appId, routePath),
66
+ initialProps = initialProps,
67
+ ),
68
+ )
69
+ }
70
+
71
+ if (isProduction && enterDelay > 0L) {
72
+ // 1. Attach loading overlay to caller's window decor view BEFORE starting NebulaActivity.
73
+ val overlay = attachLoadingOverlayToCallerWindow(activity, appId, installedApp!!)
74
+ if (overlay != null) {
75
+ // 2. Wait enterContentDelayMs, then launch NebulaActivity.
76
+ activity.window.decorView.postDelayed({
77
+ NebulaAppManager.startHost(appId) {
78
+ launchActivity()
79
+ }
80
+ }, enterDelay)
81
+ return
82
+ }
83
+ }
84
+
85
+ // Pre-warm the ReactHostImpl so the ReactInstance is ready before NebulaActivity is created.
86
+ // onReactContextInitialized is always called on the UI thread.
87
+ NebulaAppManager.startHost(appId) {
88
+ launchActivity()
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Called by NebulaActivity once its first frame is drawn and ready.
94
+ * Dismisses the loading overlay that was attached to the caller's window.
95
+ */
96
+ fun onMiniappContentReady(appId: String) {
97
+ val iterator = pendingOverlays.iterator()
98
+ while (iterator.hasNext()) {
99
+ val (id, overlay) = iterator.next()
100
+ if (id == appId) {
101
+ pendingOverlays.remove(id to overlay)
102
+ dismissOverlay(overlay)
103
+ break
104
+ }
105
+ }
106
+ }
107
+
108
+ private fun attachLoadingOverlayToCallerWindow(
109
+ callerActivity: Activity,
110
+ appId: String,
111
+ installedApp: NebulaInstalledApp,
112
+ ): PendingOverlay? {
113
+ val decorView = callerActivity.window.decorView as? FrameLayout ?: return null
114
+ val hostReactHost =
115
+ runCatching { NebulaHost.resolveHostReactHost(callerActivity.application) }
116
+ .getOrNull() ?: return null
117
+
118
+ val overlayHost = FrameLayout(callerActivity).apply {
119
+ layoutParams = FrameLayout.LayoutParams(
120
+ FrameLayout.LayoutParams.MATCH_PARENT,
121
+ FrameLayout.LayoutParams.MATCH_PARENT,
122
+ )
123
+ isClickable = true
124
+ isFocusable = true
125
+ }
126
+
127
+ val surface = hostReactHost.createSurface(
128
+ callerActivity,
129
+ MINIAPP_LOADING_MODULE,
130
+ Bundle().apply {
131
+ putString("appId", appId)
132
+ putString("mode", installedApp.mode)
133
+ },
134
+ )
135
+ surface.start()
136
+ surface.view?.let { view ->
137
+ overlayHost.addView(
138
+ view,
139
+ FrameLayout.LayoutParams(
140
+ FrameLayout.LayoutParams.MATCH_PARENT,
141
+ FrameLayout.LayoutParams.MATCH_PARENT,
142
+ ),
143
+ )
144
+ }
145
+
146
+ decorView.addView(overlayHost)
147
+ decorView.bringChildToFront(overlayHost)
148
+
149
+ val pending = PendingOverlay(
150
+ callerActivity = WeakReference(callerActivity),
151
+ overlayHost = overlayHost,
152
+ reactSurface = surface,
153
+ )
154
+ pendingOverlays.add(appId to pending)
155
+ return pending
156
+ }
157
+
158
+ private fun dismissOverlay(overlay: PendingOverlay) {
159
+ val totalDelay = intentionalLoadingDelayMs + LOADING_EXIT_ANIMATION_MS
160
+ val caller = overlay.callerActivity.get() ?: run {
161
+ overlay.reactSurface.stop()
162
+ overlay.reactSurface.clear()
163
+ return
164
+ }
165
+ caller.runOnUiThread {
166
+ caller.window.decorView.postDelayed({
167
+ overlay.reactSurface.stop()
168
+ overlay.reactSurface.clear()
169
+ (overlay.overlayHost.parent as? FrameLayout)?.removeView(overlay.overlayHost)
170
+ }, totalDelay)
171
+ }
172
+ }
173
+
174
+ fun navigateToURL(url: String, fromAppId: String): Result<Unit> {
175
+ val source = currentActivity(fromAppId)
176
+ ?: return Result.failure(IllegalStateException("No active page"))
177
+ val parsed = parseUrl(url, fromAppId)
178
+ NebulaManifestManager.getComponentName(parsed.appId, parsed.routePath)
179
+ ?: return Result.failure(IllegalArgumentException(
180
+ "No component registered for route '${parsed.routePath}' in app '${parsed.appId}'"
181
+ ))
182
+ source.startActivity(
183
+ NebulaActivity.createIntent(
184
+ activity = source,
185
+ appId = parsed.appId,
186
+ routePath = parsed.routePath,
187
+ routeUrl = parsed.routeUrl,
188
+ initialProps = parsed.params,
189
+ ),
190
+ )
191
+ return Result.success(Unit)
192
+ }
193
+
194
+ fun redirectToURL(url: String, fromAppId: String): Result<Unit> {
195
+ val source = currentActivity(fromAppId)
196
+ ?: return Result.failure(IllegalStateException("No active page"))
197
+ val parsed = parseUrl(url, fromAppId)
198
+ NebulaManifestManager.getComponentName(parsed.appId, parsed.routePath)
199
+ ?: return Result.failure(IllegalArgumentException(
200
+ "No component registered for route '${parsed.routePath}' in app '${parsed.appId}'"
201
+ ))
202
+ source.startActivity(
203
+ NebulaActivity.createIntent(
204
+ activity = source,
205
+ appId = parsed.appId,
206
+ routePath = parsed.routePath,
207
+ routeUrl = parsed.routeUrl,
208
+ initialProps = parsed.params,
209
+ ),
210
+ )
211
+ source.finish()
212
+ return Result.success(Unit)
213
+ }
214
+
215
+ fun reLaunchURL(url: String, fromAppId: String): Result<Unit> {
216
+ val source = currentActivity(fromAppId)
217
+ ?: return Result.failure(IllegalStateException("No active page"))
218
+ val parsed = parseUrl(url, fromAppId)
219
+ NebulaManifestManager.getComponentName(parsed.appId, parsed.routePath)
220
+ ?: return Result.failure(IllegalArgumentException(
221
+ "No component registered for route '${parsed.routePath}' in app '${parsed.appId}'"
222
+ ))
223
+ val intent =
224
+ NebulaActivity.createIntent(
225
+ activity = source,
226
+ appId = parsed.appId,
227
+ routePath = parsed.routePath,
228
+ routeUrl = parsed.routeUrl,
229
+ initialProps = parsed.params,
230
+ ).apply {
231
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
232
+ }
233
+ source.startActivity(intent)
234
+ activitiesForApp(parsed.appId).forEach { existing ->
235
+ if (existing !== source) {
236
+ existing.finish()
237
+ }
238
+ }
239
+ source.finish()
240
+ return Result.success(Unit)
241
+ }
242
+
243
+ fun navigateBack(fromAppId: String, delta: Int): Result<Unit> {
244
+ val activities = activitiesForApp(fromAppId)
245
+ if (activities.isEmpty()) {
246
+ return Result.failure(IllegalStateException("No active page"))
247
+ }
248
+ activities.takeLast(delta.coerceAtLeast(1)).forEach { it.finish() }
249
+ return Result.success(Unit)
250
+ }
251
+
252
+ fun closeApp(appId: String): Result<Unit> {
253
+ val target =
254
+ currentActivity(appId) ?: return Result.failure(IllegalStateException("No active page"))
255
+ target.finish()
256
+ return Result.success(Unit)
257
+ }
258
+
259
+ fun updatePageStyle(appId: String, style: Map<String, Any?>): Result<Unit> {
260
+ val target =
261
+ currentActivity(appId) ?: return Result.failure(IllegalStateException("No active page"))
262
+ target.updatePageStyle(style)
263
+ return Result.success(Unit)
264
+ }
265
+
266
+ private fun currentActivity(appId: String): NebulaActivity? =
267
+ activitiesForApp(appId).lastOrNull()
268
+
269
+ private fun activitiesForApp(appId: String): List<NebulaActivity> {
270
+ cleanup()
271
+ return activityRefs.mapNotNull { it.get() }.filter { it.appId == appId && !it.isFinishing }
272
+ }
273
+
274
+ private fun cleanup() {
275
+ activityRefs.removeAll { it.get() == null }
276
+ }
277
+
278
+ private fun buildRouteUrl(appId: String, routePath: String): String {
279
+ val normalizedRoute = NebulaManifestManager.normalizeRoute(routePath)
280
+ return "nebula://$appId${if (normalizedRoute == "/") "" else normalizedRoute}"
281
+ }
282
+
283
+ private fun parseUrl(url: String, fallbackAppId: String): ParsedNebulaUrl {
284
+ val uri = android.net.Uri.parse(url)
285
+ val appId = uri.host?.takeIf { it.isNotBlank() } ?: fallbackAppId
286
+ val routePath = NebulaManifestManager.normalizeRoute(uri.path)
287
+ val params = linkedMapOf<String, Any?>()
288
+ uri.queryParameterNames.forEach { key ->
289
+ params[key] = uri.getQueryParameter(key)
290
+ }
291
+ return ParsedNebulaUrl(appId, routePath, url, params)
292
+ }
293
+ }
294
+
295
+ private data class ParsedNebulaUrl(
296
+ val appId: String,
297
+ val routePath: String,
298
+ val routeUrl: String,
299
+ val params: Map<String, Any?>,
300
+ )
@@ -0,0 +1,355 @@
1
+ //
2
+ // NebulaAppManager.swift
3
+ // SuperApp - Nebula Mini-App Container
4
+ //
5
+ // Mini-app preload manager with lifecycle control
6
+ //
7
+
8
+ import Foundation
9
+ import UIKit
10
+ import React
11
+ import React_RCTAppDelegate
12
+ import ReactAppDependencyProvider
13
+
14
+ private final class NebulaPreloadReactDelegate: RCTDefaultReactNativeFactoryDelegate {
15
+ private let miniBundleURL: URL
16
+
17
+ init(bundleURL: URL) {
18
+ self.miniBundleURL = bundleURL
19
+ super.init()
20
+ }
21
+
22
+ override func bundleURL() -> URL? {
23
+ return miniBundleURL
24
+ }
25
+ }
26
+
27
+ /// Thread-safe manager for a single preloaded mini-app root view
28
+ @objc public final class NebulaAppManager: NSObject {
29
+
30
+ // MARK: - Singleton
31
+
32
+ @objc public static let shared = NebulaAppManager()
33
+
34
+ // MARK: - Properties
35
+
36
+ private let queue = DispatchQueue(label: "com.nebula.appmanager", attributes: .concurrent)
37
+ private var preloadedAppId: String?
38
+ private var preloadedView: UIView?
39
+ private var preloadedFactory: RCTReactNativeFactory?
40
+ private var preloadedDelegate: NebulaPreloadReactDelegate?
41
+ private var preloadContainer: UIView?
42
+ private var activeFactories: [String: RCTReactNativeFactory] = [:]
43
+ private var activeFactoryDelegates: [String: NebulaPreloadReactDelegate] = [:]
44
+ private var activeFactoryRefCounts: [String: Int] = [:]
45
+ private var pendingPreloadAppId: String?
46
+ private var preloadGeneration: UInt64 = 0
47
+ private let semaphore = DispatchSemaphore(value: 1)
48
+
49
+ private func removeViewOnMain(_ view: UIView?) {
50
+ guard let view = view else { return }
51
+ if Thread.isMainThread {
52
+ view.removeFromSuperview()
53
+ } else {
54
+ DispatchQueue.main.async {
55
+ view.removeFromSuperview()
56
+ }
57
+ }
58
+ }
59
+
60
+ // MARK: - Initialization
61
+
62
+ private override init() {
63
+ super.init()
64
+ setupPreloadContainer()
65
+ }
66
+
67
+ private func setupPreloadContainer() {
68
+ DispatchQueue.main.async {
69
+ guard let window = UIApplication.shared.keyWindow else {
70
+ print("[Nebula] Warning: Cannot setup preload container - no key window")
71
+ return
72
+ }
73
+
74
+ let container = UIView()
75
+ container.backgroundColor = .clear
76
+ container.isUserInteractionEnabled = false
77
+ container.frame = window.bounds
78
+ container.autoresizingMask = [.flexibleWidth, .flexibleHeight]
79
+
80
+ // Insert at the very bottom layer of the key window
81
+ window.insertSubview(container, at: 0)
82
+
83
+ self.semaphore.wait()
84
+ self.preloadContainer = container
85
+ self.semaphore.signal()
86
+
87
+ print("[Nebula] Preload container initialized at bottom layer")
88
+ }
89
+ }
90
+
91
+ // MARK: - Public API
92
+
93
+ /// Warm-up: preload a mini-app root view for faster startup
94
+ @objc public func warmUp(appId: String) {
95
+ preloadRootView(for: appId, moduleName: "NebulaApp", initialProps: nil)
96
+ }
97
+
98
+ /// Preload root view with custom module name and initial props
99
+ @objc public func preloadRootView(for appId: String,
100
+ moduleName: String = "NebulaApp",
101
+ initialProps: [String: Any]? = nil) {
102
+ let bundleURL: URL
103
+ if let devURL = NebulaConfig.shared.devURL(for: appId) {
104
+ bundleURL = devURL
105
+ } else if let bundlePath = NebulaConfig.shared.bundlePath(for: appId) {
106
+ bundleURL = URL(fileURLWithPath: bundlePath)
107
+ } else {
108
+ print("[Nebula] Warm-up skipped for \(appId): bundle not found")
109
+ return
110
+ }
111
+
112
+ // Clear any existing preload cache before creating new one (single-slot cache)
113
+ let generation: UInt64
114
+ var staleViewToRemove: UIView?
115
+ semaphore.wait()
116
+ preloadGeneration &+= 1
117
+ generation = preloadGeneration
118
+ pendingPreloadAppId = appId
119
+ if preloadedAppId != nil {
120
+ staleViewToRemove = preloadedView
121
+ preloadedAppId = nil
122
+ preloadedView = nil
123
+ preloadedFactory = nil
124
+ print("[Nebula] Cleared existing preload cache before preloading \(appId)")
125
+ }
126
+ semaphore.signal()
127
+ removeViewOnMain(staleViewToRemove)
128
+
129
+ DispatchQueue.main.async {
130
+ self.semaphore.wait()
131
+ let isCanceledBeforeCreate = (self.preloadGeneration != generation) || (self.pendingPreloadAppId != appId)
132
+ self.semaphore.signal()
133
+ if isCanceledBeforeCreate {
134
+ print("[Nebula] Skipped stale preload task for \(appId)")
135
+ return
136
+ }
137
+
138
+ let delegate = NebulaPreloadReactDelegate(bundleURL: bundleURL)
139
+ delegate.dependencyProvider = RCTAppDependencyProvider()
140
+ let factory = RCTReactNativeFactory(delegate: delegate)
141
+
142
+ var props: [String: Any] = [
143
+ "appId": appId,
144
+ "sandboxPath": NebulaConfig.shared.sandboxPath(for: appId)
145
+ ]
146
+ if let initialProps = initialProps {
147
+ props.merge(initialProps) { _, new in new }
148
+ }
149
+
150
+ let rootView = factory.rootViewFactory.view(
151
+ withModuleName: moduleName,
152
+ initialProperties: props
153
+ )
154
+
155
+ rootView.backgroundColor = .systemBackground
156
+
157
+ self.semaphore.wait()
158
+ let container = self.preloadContainer
159
+ self.semaphore.signal()
160
+
161
+ // Mount preloaded view to global bottom-layer container
162
+ guard let container = container else {
163
+ print("[Nebula] Warning: Preload container not ready, view not mounted")
164
+ self.semaphore.wait()
165
+ if self.preloadGeneration != generation || self.pendingPreloadAppId != appId {
166
+ self.semaphore.signal()
167
+ print("[Nebula] Dropped stale preload cache for \(appId) before store")
168
+ return
169
+ }
170
+ self.preloadedAppId = appId
171
+ self.preloadedFactory = factory
172
+ self.preloadedDelegate = delegate
173
+ self.preloadedView = rootView
174
+ self.pendingPreloadAppId = nil
175
+ self.semaphore.signal()
176
+ print("[Nebula] Preloaded root view for \(appId), module=\(moduleName) (not mounted)")
177
+ return
178
+ }
179
+
180
+ self.semaphore.wait()
181
+ let isStaleBeforeMount = (self.preloadGeneration != generation) || (self.pendingPreloadAppId != appId)
182
+ self.semaphore.signal()
183
+ if isStaleBeforeMount {
184
+ print("[Nebula] Skipped mounting stale preload view for \(appId)")
185
+ return
186
+ }
187
+
188
+ rootView.frame = container.bounds
189
+ rootView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
190
+
191
+ // Ensure preload container is clean before adding new view
192
+ for subview in container.subviews {
193
+ subview.removeFromSuperview()
194
+ }
195
+
196
+ container.addSubview(rootView)
197
+
198
+ // Force layout pass to ensure RN rendering pipeline is triggered
199
+ rootView.setNeedsLayout()
200
+ rootView.layoutIfNeeded()
201
+
202
+ self.semaphore.wait()
203
+ if self.preloadGeneration != generation || self.pendingPreloadAppId != appId {
204
+ self.semaphore.signal()
205
+ print("[Nebula] Dropped stale preloaded root view for \(appId) before cache commit")
206
+ return
207
+ }
208
+ self.preloadedAppId = appId
209
+ self.preloadedFactory = factory
210
+ self.preloadedDelegate = delegate
211
+ self.preloadedView = rootView
212
+ self.pendingPreloadAppId = nil
213
+ self.semaphore.signal()
214
+
215
+ print("[Nebula] Preloaded root view for \(appId), module=\(moduleName) (mounted at bottom layer)")
216
+ }
217
+ }
218
+
219
+ /// Consume and remove the preloaded root view from bottom layer (single-use)
220
+ @objc public func consumePreloadedRootView(for appId: String) -> UIView? {
221
+ semaphore.wait()
222
+ guard preloadedAppId == appId else {
223
+ // If preload is still pending for this app, cancel it to avoid stale bottom-layer mount.
224
+ if pendingPreloadAppId == appId {
225
+ preloadGeneration &+= 1
226
+ pendingPreloadAppId = nil
227
+ print("[Nebula] Canceled pending preload for \(appId) while opening app")
228
+ }
229
+ semaphore.signal()
230
+ return nil
231
+ }
232
+
233
+ let view = preloadedView
234
+
235
+ if let factory = preloadedFactory {
236
+ // Keep factory alive while container is using the consumed rootView.
237
+ activeFactories[appId] = factory
238
+ if let delegate = preloadedDelegate {
239
+ activeFactoryDelegates[appId] = delegate
240
+ }
241
+ activeFactoryRefCounts[appId, default: 0] += 1
242
+ }
243
+ preloadedAppId = nil
244
+ preloadedView = nil
245
+ preloadedFactory = nil
246
+ preloadedDelegate = nil
247
+ pendingPreloadAppId = nil
248
+ semaphore.signal()
249
+ // Remove from bottom-layer preload container on main thread.
250
+ removeViewOnMain(view)
251
+ return view
252
+ }
253
+
254
+ @objc public func acquireFactory(for appId: String,
255
+ bundleURL: URL) -> RCTReactNativeFactory {
256
+ semaphore.wait()
257
+ if let existingFactory = activeFactories[appId] {
258
+ activeFactoryRefCounts[appId, default: 0] += 1
259
+ semaphore.signal()
260
+ return existingFactory
261
+ }
262
+ semaphore.signal()
263
+
264
+ let delegate = NebulaPreloadReactDelegate(bundleURL: bundleURL)
265
+ delegate.dependencyProvider = RCTAppDependencyProvider()
266
+ let factory = RCTReactNativeFactory(delegate: delegate)
267
+
268
+ semaphore.wait()
269
+ if let existingFactory = activeFactories[appId] {
270
+ activeFactoryRefCounts[appId, default: 0] += 1
271
+ semaphore.signal()
272
+ return existingFactory
273
+ }
274
+
275
+ activeFactories[appId] = factory
276
+ activeFactoryDelegates[appId] = delegate
277
+ activeFactoryRefCounts[appId] = 1
278
+ semaphore.signal()
279
+ return factory
280
+ }
281
+
282
+ /// Release runtime resources held for a consumed preloaded view.
283
+ @objc public func releaseConsumedResources(appId: String) {
284
+ semaphore.wait()
285
+ let nextCount = max(0, (activeFactoryRefCounts[appId] ?? 0) - 1)
286
+ if nextCount == 0 {
287
+ activeFactoryRefCounts.removeValue(forKey: appId)
288
+ activeFactories.removeValue(forKey: appId)
289
+ activeFactoryDelegates.removeValue(forKey: appId)
290
+ } else {
291
+ activeFactoryRefCounts[appId] = nextCount
292
+ }
293
+ semaphore.signal()
294
+ }
295
+
296
+ /// Invalidate and remove preload cache for one mini-app
297
+ @objc public func invalidate(appId: String) {
298
+ queue.async(flags: .barrier) { [weak self] in
299
+ guard let self = self else { return }
300
+
301
+ var staleViewToRemove: UIView?
302
+ self.semaphore.wait()
303
+ if self.preloadedAppId == appId {
304
+ staleViewToRemove = self.preloadedView
305
+ self.preloadedAppId = nil
306
+ self.preloadedView = nil
307
+ self.preloadedFactory = nil
308
+ self.preloadedDelegate = nil
309
+ }
310
+ if self.pendingPreloadAppId == appId {
311
+ self.pendingPreloadAppId = nil
312
+ self.preloadGeneration &+= 1
313
+ }
314
+ self.activeFactoryRefCounts.removeValue(forKey: appId)
315
+ self.activeFactories.removeValue(forKey: appId)
316
+ self.activeFactoryDelegates.removeValue(forKey: appId)
317
+ self.semaphore.signal()
318
+
319
+ self.removeViewOnMain(staleViewToRemove)
320
+ print("[Nebula] Invalidated preload cache for \(appId)")
321
+ }
322
+ }
323
+
324
+ /// Invalidate all preload caches
325
+ @objc public func invalidateAll() {
326
+ queue.async(flags: .barrier) { [weak self] in
327
+ guard let self = self else { return }
328
+
329
+ var staleViewToRemove: UIView?
330
+ self.semaphore.wait()
331
+ staleViewToRemove = self.preloadedView
332
+ self.preloadedAppId = nil
333
+ self.preloadedView = nil
334
+ self.preloadedFactory = nil
335
+ self.preloadedDelegate = nil
336
+ self.pendingPreloadAppId = nil
337
+ self.preloadGeneration &+= 1
338
+ self.activeFactoryRefCounts.removeAll()
339
+ self.activeFactories.removeAll()
340
+ self.activeFactoryDelegates.removeAll()
341
+ self.semaphore.signal()
342
+
343
+ self.removeViewOnMain(staleViewToRemove)
344
+ print("[Nebula] Invalidated all preload caches")
345
+ }
346
+ }
347
+
348
+ /// Check whether a preloaded root view is available
349
+ @objc public func hasPreloadedRootView(appId: String) -> Bool {
350
+ semaphore.wait()
351
+ let result = (preloadedAppId == appId) && (preloadedView != nil)
352
+ semaphore.signal()
353
+ return result
354
+ }
355
+ }