@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.
- package/NebulaHost.podspec +23 -0
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build.gradle +27 -0
- package/android/consumer-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaActivity.kt +290 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaAppManager.kt +134 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaConfig.kt +324 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaEventHub.kt +49 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaHost.kt +145 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaHostModalActivity.kt +178 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaManifestManager.kt +130 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaNativeModule.kt +604 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaPackage.kt +16 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaRouter.kt +300 -0
- package/ios/Nebula/NebulaAppManager.swift +355 -0
- package/ios/Nebula/NebulaConfig.swift +549 -0
- package/ios/Nebula/NebulaContainerController.swift +580 -0
- package/ios/Nebula/NebulaDevLoading.swift +333 -0
- package/ios/Nebula/NebulaHost.swift +611 -0
- package/ios/Nebula/NebulaManifest.swift +214 -0
- package/ios/Nebula/NebulaNativeModule.swift +682 -0
- package/ios/Nebula/NebulaNativeModuleBridge.m +364 -0
- package/ios/Nebula/NebulaPerformanceMonitor.swift +46 -0
- package/ios/Nebula/NebulaRouter.swift +594 -0
- package/ios/Nebula/NebulaRouterBridge.m +19 -0
- package/ios/Nebula/RNInstanceViewController.swift +52 -0
- package/package.json +41 -0
- package/react-native.config.js +14 -0
- 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
|
+
}
|