@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,611 @@
|
|
|
1
|
+
//
|
|
2
|
+
// NebulaHost.swift
|
|
3
|
+
// SuperApp - Nebula Mini-App Container
|
|
4
|
+
//
|
|
5
|
+
// Main entry point for Nebula framework
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
import UIKit
|
|
10
|
+
import React
|
|
11
|
+
import React_RCTAppDelegate
|
|
12
|
+
|
|
13
|
+
private let miniappLoadingModuleName = "NebulaInternalMiniappLoading"
|
|
14
|
+
var miniappLoadingEnterContentDelay: TimeInterval = 0
|
|
15
|
+
|
|
16
|
+
@objc public final class NebulaHost: NSObject {
|
|
17
|
+
private struct ReviewInstallBundles: Decodable {
|
|
18
|
+
let ios: String
|
|
19
|
+
let android: String
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private struct ReviewInstallAssetsUrls: Decodable {
|
|
23
|
+
let ios: String
|
|
24
|
+
let android: String
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private struct ReviewInstallPayload: Decodable {
|
|
28
|
+
let appId: String
|
|
29
|
+
let appName: String
|
|
30
|
+
let versionId: String
|
|
31
|
+
let version: String
|
|
32
|
+
let bundleUrl: String?
|
|
33
|
+
let bundles: ReviewInstallBundles?
|
|
34
|
+
let assetsUrl: ReviewInstallAssetsUrls
|
|
35
|
+
let manifestUrl: String
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// MARK: - Singleton
|
|
39
|
+
|
|
40
|
+
@objc public static let shared = NebulaHost()
|
|
41
|
+
|
|
42
|
+
// MARK: - Properties
|
|
43
|
+
|
|
44
|
+
private var containerPool: [String: NebulaContainerController] = [:]
|
|
45
|
+
private var elevatedHostStacks: [String: [UIViewController]] = [:]
|
|
46
|
+
private let poolQueue = DispatchQueue(label: "com.nebula.host.pool", attributes: .concurrent)
|
|
47
|
+
public weak var appDelegate: NebulaAppDelegate?
|
|
48
|
+
|
|
49
|
+
// MARK: - Public API
|
|
50
|
+
|
|
51
|
+
/// Initialize Nebula framework
|
|
52
|
+
@objc public func initialize(config: NebulaConfig = .shared) {
|
|
53
|
+
print("╔═══════════════════════════════════════════════════════╗")
|
|
54
|
+
print("║ Nebula Mini-App Container v1.0.0 ║")
|
|
55
|
+
print("║ High-Performance SuperApp Framework for iOS ║")
|
|
56
|
+
print("╚═══════════════════════════════════════════════════════╝")
|
|
57
|
+
|
|
58
|
+
// Setup sandbox directories
|
|
59
|
+
setupSandboxDirectories()
|
|
60
|
+
|
|
61
|
+
// Register for memory warnings
|
|
62
|
+
NotificationCenter.default.addObserver(
|
|
63
|
+
self,
|
|
64
|
+
selector: #selector(handleMemoryWarning),
|
|
65
|
+
name: UIApplication.didReceiveMemoryWarningNotification,
|
|
66
|
+
object: nil
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
print("[Nebula] Framework initialized successfully")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public func initialize(config: NebulaConfig = .shared, appDelegate: NebulaAppDelegate?) {
|
|
73
|
+
self.appDelegate = appDelegate
|
|
74
|
+
initialize(config: config)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Open a mini-app
|
|
78
|
+
@objc public func openApp(_ appId: String,
|
|
79
|
+
from viewController: UIViewController,
|
|
80
|
+
initialProps: [String: Any]? = nil,
|
|
81
|
+
animated: Bool = true) {
|
|
82
|
+
NebulaPerformanceMonitor.shared.startMeasure("openApp:\(appId)")
|
|
83
|
+
let runtimeSignature = installedAppRuntimeSignature(appId)
|
|
84
|
+
|
|
85
|
+
// Try to get existing container from pool
|
|
86
|
+
var container: NebulaContainerController?
|
|
87
|
+
poolQueue.sync {
|
|
88
|
+
container = containerPool[appId]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check if container exists and is not already in navigation stack
|
|
92
|
+
if let existingContainer = container {
|
|
93
|
+
if !existingContainer.matchesInstalledRuntimeSignature(runtimeSignature) {
|
|
94
|
+
print("[Nebula] Discarding pooled container for \(appId) because installed runtime changed")
|
|
95
|
+
discardPooledContainer(appId)
|
|
96
|
+
container = nil
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if let existingContainer = container {
|
|
101
|
+
// Update initialProps if needed
|
|
102
|
+
if let props = initialProps {
|
|
103
|
+
existingContainer.updateInitialProps(props)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if container is already in a navigation stack
|
|
107
|
+
if existingContainer.navigationController != nil {
|
|
108
|
+
print("[Nebula] Container for \(appId) already in navigation stack, creating new instance")
|
|
109
|
+
container = nil
|
|
110
|
+
} else {
|
|
111
|
+
print("[Nebula] Reusing container from pool for \(appId)")
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Create new container if needed
|
|
116
|
+
if container == nil {
|
|
117
|
+
container = NebulaContainerController(
|
|
118
|
+
appId: appId,
|
|
119
|
+
initialProps: initialProps,
|
|
120
|
+
delayContentEntrance: true
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// Add to pool
|
|
124
|
+
poolQueue.async(flags: .barrier) { [weak self, weak container] in
|
|
125
|
+
guard let container = container else { return }
|
|
126
|
+
self?.containerPool[appId] = container
|
|
127
|
+
print("[Nebula] Added container for \(appId) to pool (total: \(self?.containerPool.count ?? 0))")
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
guard let finalContainer = container else { return }
|
|
133
|
+
let navigationController = resolveNavigationController(from: viewController)
|
|
134
|
+
attachMiniappLoadingIfNeeded(
|
|
135
|
+
for: appId,
|
|
136
|
+
to: finalContainer,
|
|
137
|
+
in: navigationController?.view ?? viewController.view
|
|
138
|
+
)
|
|
139
|
+
let shouldDelayPresentation = finalContainer.prepareForPresentation()
|
|
140
|
+
&& miniappLoadingEnterContentDelay > 0
|
|
141
|
+
|
|
142
|
+
let presentContainer = {
|
|
143
|
+
if let navigationController {
|
|
144
|
+
navigationController.pushViewController(finalContainer, animated: animated)
|
|
145
|
+
} else {
|
|
146
|
+
viewController.present(finalContainer, animated: animated)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
NebulaPerformanceMonitor.shared.endMeasure("openApp:\(appId)")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if shouldDelayPresentation {
|
|
153
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + miniappLoadingEnterContentDelay) {
|
|
154
|
+
presentContainer()
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
presentContainer()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Preload a mini-app for faster startup
|
|
162
|
+
@objc public func preloadApp(_ appId: String) {
|
|
163
|
+
NebulaAppManager.shared.warmUp(appId: appId)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Close and invalidate a mini-app
|
|
167
|
+
@objc public func closeApp(_ appId: String) {
|
|
168
|
+
let cleanup = { [weak self] in
|
|
169
|
+
NebulaAppManager.shared.invalidate(appId: appId)
|
|
170
|
+
self?.poolQueue.async(flags: .barrier) {
|
|
171
|
+
if let removed = self?.containerPool.removeValue(forKey: appId) {
|
|
172
|
+
print("[Nebula] Removed container for \(appId) from pool")
|
|
173
|
+
DispatchQueue.main.async {
|
|
174
|
+
_ = removed
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
NebulaRouter.shared.closeApp(appId) { success, _ in
|
|
181
|
+
cleanup()
|
|
182
|
+
if !success {
|
|
183
|
+
print("[Nebula] Closed miniapp resources for \(appId) without an active visible container")
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Clear all cached containers from pool
|
|
189
|
+
@objc public func clearContainerPool() {
|
|
190
|
+
poolQueue.async(flags: .barrier) { [weak self] in
|
|
191
|
+
let count = self?.containerPool.count ?? 0
|
|
192
|
+
self?.containerPool.removeAll()
|
|
193
|
+
print("[Nebula] Cleared container pool (\(count) containers removed)")
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Download and install a mini-app
|
|
198
|
+
@objc public func installApp(_ appId: String,
|
|
199
|
+
bundleURL: String,
|
|
200
|
+
completion: @escaping (Bool, Error?) -> Void) {
|
|
201
|
+
NebulaConfig.shared.downloadBundle(
|
|
202
|
+
for: appId,
|
|
203
|
+
from: bundleURL
|
|
204
|
+
) { [weak self] success, error in
|
|
205
|
+
if success {
|
|
206
|
+
self?.discardPooledContainer(appId)
|
|
207
|
+
NebulaAppManager.shared.invalidate(appId: appId)
|
|
208
|
+
// Load manifest (app.json) after successful installation
|
|
209
|
+
let sandboxPath = NebulaConfig.shared.sandboxPath(for: appId)
|
|
210
|
+
_ = NebulaManifestManager.shared.loadManifest(forAppId: appId, sandboxPath: sandboxPath)
|
|
211
|
+
}
|
|
212
|
+
completion(success, error)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Install a mini-app with explicit Metro connection preference
|
|
217
|
+
@objc public func installApp(_ appId: String,
|
|
218
|
+
bundleURL: String,
|
|
219
|
+
connectToURLMetroServer: Bool,
|
|
220
|
+
completion: @escaping (Bool, Error?) -> Void) {
|
|
221
|
+
NebulaConfig.shared.downloadBundle(
|
|
222
|
+
for: appId,
|
|
223
|
+
from: bundleURL,
|
|
224
|
+
connectToURLMetroServer: connectToURLMetroServer
|
|
225
|
+
) { [weak self] success, error in
|
|
226
|
+
if success {
|
|
227
|
+
self?.discardPooledContainer(appId)
|
|
228
|
+
NebulaAppManager.shared.invalidate(appId: appId)
|
|
229
|
+
// Load manifest (app.json) after successful installation
|
|
230
|
+
let sandboxPath = NebulaConfig.shared.sandboxPath(for: appId)
|
|
231
|
+
_ = NebulaManifestManager.shared.loadManifest(forAppId: appId, sandboxPath: sandboxPath)
|
|
232
|
+
}
|
|
233
|
+
completion(success, error)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// Uninstall a mini-app
|
|
238
|
+
@objc public func uninstallApp(_ appId: String) throws {
|
|
239
|
+
// Close if running
|
|
240
|
+
closeApp(appId)
|
|
241
|
+
|
|
242
|
+
// Remove manifest
|
|
243
|
+
NebulaManifestManager.shared.removeManifest(forAppId: appId)
|
|
244
|
+
|
|
245
|
+
// Clear sandbox
|
|
246
|
+
try NebulaConfig.shared.clearSandbox(for: appId)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Get list of installed apps
|
|
250
|
+
@objc public func installedApps() -> [String] {
|
|
251
|
+
return NebulaConfig.shared.listInstalledApps()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func installedAppInfo(_ appId: String) -> NebulaConfig.InstalledMiniAppInfo? {
|
|
255
|
+
return NebulaConfig.shared.installedAppInfo(for: appId)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func installedAppRuntimeSignature(_ appId: String) -> String? {
|
|
259
|
+
guard let info = installedAppInfo(appId) else {
|
|
260
|
+
return nil
|
|
261
|
+
}
|
|
262
|
+
return [info.mode, info.sourceUrl, info.bundlePath].joined(separator: "|")
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private func discardPooledContainer(_ appId: String) {
|
|
266
|
+
poolQueue.async(flags: .barrier) { [weak self] in
|
|
267
|
+
guard let self = self else { return }
|
|
268
|
+
self.containerPool.removeValue(forKey: appId)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
@objc @discardableResult
|
|
273
|
+
public func bringHostToFront(animated: Bool = true) -> String? {
|
|
274
|
+
guard Thread.isMainThread else {
|
|
275
|
+
var token: String?
|
|
276
|
+
DispatchQueue.main.sync {
|
|
277
|
+
token = self.bringHostToFront(animated: animated)
|
|
278
|
+
}
|
|
279
|
+
return token
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
guard let navigationController = currentNavigationController() else {
|
|
283
|
+
return nil
|
|
284
|
+
}
|
|
285
|
+
let viewControllers = navigationController.viewControllers
|
|
286
|
+
guard let rootViewController = viewControllers.first else {
|
|
287
|
+
return nil
|
|
288
|
+
}
|
|
289
|
+
guard viewControllers.count > 1 else {
|
|
290
|
+
return UUID().uuidString
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let token = UUID().uuidString
|
|
294
|
+
elevatedHostStacks[token] = viewControllers
|
|
295
|
+
navigationController.setViewControllers([rootViewController], animated: animated)
|
|
296
|
+
return token
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
@objc @discardableResult
|
|
300
|
+
public func restoreMiniApp(with token: String?, animated: Bool = true) -> Bool {
|
|
301
|
+
guard Thread.isMainThread else {
|
|
302
|
+
var success = false
|
|
303
|
+
DispatchQueue.main.sync {
|
|
304
|
+
success = self.restoreMiniApp(with: token, animated: animated)
|
|
305
|
+
}
|
|
306
|
+
return success
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
guard let navigationController = currentNavigationController() else {
|
|
310
|
+
return false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let resolvedToken: String?
|
|
314
|
+
if let token, !token.isEmpty {
|
|
315
|
+
resolvedToken = token
|
|
316
|
+
} else {
|
|
317
|
+
resolvedToken = elevatedHostStacks.keys.sorted().last
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
guard let snapshotToken = resolvedToken,
|
|
321
|
+
let viewControllers = elevatedHostStacks.removeValue(forKey: snapshotToken),
|
|
322
|
+
!viewControllers.isEmpty else {
|
|
323
|
+
return false
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
navigationController.setViewControllers(viewControllers, animated: animated)
|
|
327
|
+
return true
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
@objc @discardableResult
|
|
331
|
+
public func handleIncomingURL(_ url: URL, from viewController: UIViewController? = nil) -> Bool {
|
|
332
|
+
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
333
|
+
let installUrl = components.queryItems?.first(where: { $0.name == "installUrl" })?.value else {
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let isReviewInstallLink =
|
|
338
|
+
(components.host == "review" && components.path.hasPrefix("/install/")) ||
|
|
339
|
+
(components.host == "miniapp" && components.path.hasPrefix("/install/")) ||
|
|
340
|
+
components.path.hasPrefix("/review/install/")
|
|
341
|
+
|
|
342
|
+
guard isReviewInstallLink else {
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
installReviewBuild(from: installUrl, presenter: viewController)
|
|
347
|
+
return true
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
func resolveDevLoadingConfiguration(
|
|
351
|
+
title: String,
|
|
352
|
+
message: String,
|
|
353
|
+
progress: NSNumber?,
|
|
354
|
+
titleColor: UIColor? = nil,
|
|
355
|
+
messageColor: UIColor? = nil,
|
|
356
|
+
backgroundColor: UIColor? = nil,
|
|
357
|
+
dismissButton: Bool = false
|
|
358
|
+
) -> NebulaDevLoadingConfiguration {
|
|
359
|
+
let baseConfiguration = NebulaDevLoadingConfiguration(
|
|
360
|
+
title: title,
|
|
361
|
+
message: message,
|
|
362
|
+
progress: progress,
|
|
363
|
+
backgroundColor: backgroundColor ?? .systemBackground,
|
|
364
|
+
titleColor: titleColor ?? .label,
|
|
365
|
+
messageColor: messageColor ?? .secondaryLabel,
|
|
366
|
+
showsDismissHint: dismissButton
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
guard let appDelegate,
|
|
370
|
+
let customizedConfiguration = appDelegate.nebulaHost?(
|
|
371
|
+
self,
|
|
372
|
+
configureDevLoading: baseConfiguration.copy() as! NebulaDevLoadingConfiguration
|
|
373
|
+
) else {
|
|
374
|
+
return baseConfiguration
|
|
375
|
+
}
|
|
376
|
+
return customizedConfiguration
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
func makeDevLoadingView(
|
|
380
|
+
configuration: NebulaDevLoadingConfiguration
|
|
381
|
+
) -> NebulaDevLoadingView {
|
|
382
|
+
if let customView = appDelegate?.nebulaHost?(self, makeDevLoadingView: configuration) {
|
|
383
|
+
customView.render(with: configuration)
|
|
384
|
+
return customView
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let defaultView = NebulaDefaultDevLoadingView()
|
|
388
|
+
defaultView.render(with: configuration)
|
|
389
|
+
return defaultView
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// MARK: - Private Methods
|
|
393
|
+
|
|
394
|
+
private func setupSandboxDirectories() {
|
|
395
|
+
let fileManager = FileManager.default
|
|
396
|
+
|
|
397
|
+
guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let miniAppsPath = documentsPath.appendingPathComponent("MiniApps")
|
|
402
|
+
|
|
403
|
+
if !fileManager.fileExists(atPath: miniAppsPath.path) {
|
|
404
|
+
try? fileManager.createDirectory(at: miniAppsPath, withIntermediateDirectories: true, attributes: nil)
|
|
405
|
+
print("[Nebula] Created sandbox directory: \(miniAppsPath.path)")
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
@objc private func handleMemoryWarning() {
|
|
410
|
+
print("[Nebula] Memory warning received - clearing container pool")
|
|
411
|
+
clearContainerPool()
|
|
412
|
+
// Could implement additional cleanup here
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private func resolveNavigationController(from viewController: UIViewController) -> UINavigationController? {
|
|
416
|
+
if let navigationController = viewController as? UINavigationController {
|
|
417
|
+
return navigationController
|
|
418
|
+
}
|
|
419
|
+
if let navigationController = viewController.navigationController {
|
|
420
|
+
return navigationController
|
|
421
|
+
}
|
|
422
|
+
if let tabBarController = viewController as? UITabBarController {
|
|
423
|
+
if let selectedNavigationController = tabBarController.selectedViewController as? UINavigationController {
|
|
424
|
+
return selectedNavigationController
|
|
425
|
+
}
|
|
426
|
+
if let selectedViewController = tabBarController.selectedViewController {
|
|
427
|
+
return resolveNavigationController(from: selectedViewController)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return nil
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private func attachMiniappLoadingIfNeeded(for appId: String,
|
|
434
|
+
to container: NebulaContainerController,
|
|
435
|
+
in overlayContainer: UIView?) {
|
|
436
|
+
guard container.shouldAttachMiniappLoadingView(),
|
|
437
|
+
let overlayContainer,
|
|
438
|
+
let installedInfo = NebulaConfig.shared.installedAppInfo(for: appId),
|
|
439
|
+
installedInfo.mode == "production",
|
|
440
|
+
let rootViewFactory = currentRootViewFactory() else {
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let loadingView = rootViewFactory.view(
|
|
445
|
+
withModuleName: miniappLoadingModuleName,
|
|
446
|
+
initialProperties: [
|
|
447
|
+
"appId": appId,
|
|
448
|
+
"mode": installedInfo.mode,
|
|
449
|
+
"__transparentBackground": true
|
|
450
|
+
]
|
|
451
|
+
)
|
|
452
|
+
loadingView.translatesAutoresizingMaskIntoConstraints = false
|
|
453
|
+
loadingView.backgroundColor = .clear
|
|
454
|
+
loadingView.isUserInteractionEnabled = true
|
|
455
|
+
|
|
456
|
+
overlayContainer.addSubview(loadingView)
|
|
457
|
+
NSLayoutConstraint.activate([
|
|
458
|
+
loadingView.leadingAnchor.constraint(equalTo: overlayContainer.leadingAnchor),
|
|
459
|
+
loadingView.trailingAnchor.constraint(equalTo: overlayContainer.trailingAnchor),
|
|
460
|
+
loadingView.topAnchor.constraint(equalTo: overlayContainer.topAnchor),
|
|
461
|
+
loadingView.bottomAnchor.constraint(equalTo: overlayContainer.bottomAnchor)
|
|
462
|
+
])
|
|
463
|
+
overlayContainer.bringSubviewToFront(loadingView)
|
|
464
|
+
container.attachMiniappLoadingView(
|
|
465
|
+
loadingView,
|
|
466
|
+
minimumDismissDelay: miniappLoadingEnterContentDelay
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
func currentNavigationController() -> UINavigationController? {
|
|
471
|
+
appDelegate?.nebulaNavigationController?(self)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
func currentRootViewFactory() -> RCTRootViewFactory? {
|
|
475
|
+
appDelegate?.nebulaRootViewFactory?(self)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
func makeHostViewController(
|
|
479
|
+
moduleName: String,
|
|
480
|
+
initialProperties: [AnyHashable: Any]?,
|
|
481
|
+
title: String?
|
|
482
|
+
) -> UIViewController? {
|
|
483
|
+
appDelegate?.nebulaHost?(
|
|
484
|
+
self,
|
|
485
|
+
makeHostViewController: moduleName,
|
|
486
|
+
initialProperties: initialProperties,
|
|
487
|
+
title: title
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private func installReviewBuild(from installUrl: String, presenter: UIViewController?) {
|
|
492
|
+
let resolvedInstallUrl = NebulaConfig.shared.resolveRemoteURL(installUrl)
|
|
493
|
+
guard let url = URL(string: resolvedInstallUrl) else {
|
|
494
|
+
print("[Nebula] Invalid review install URL: \(installUrl)")
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
|
|
499
|
+
guard let self else { return }
|
|
500
|
+
|
|
501
|
+
if let error {
|
|
502
|
+
print("[Nebula] Failed to fetch review install payload: \(error.localizedDescription)")
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
guard let data else {
|
|
507
|
+
print("[Nebula] Missing review install payload data")
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
do {
|
|
512
|
+
let payload = try JSONDecoder().decode(ReviewInstallPayload.self, from: data)
|
|
513
|
+
self.downloadReviewAssets(payload: payload) { success in
|
|
514
|
+
guard success else { return }
|
|
515
|
+
DispatchQueue.main.async {
|
|
516
|
+
guard let presenter = presenter ?? self.currentNavigationController() else { return }
|
|
517
|
+
self.openApp(payload.appId, from: presenter, animated: true)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
print("[Nebula] Failed to decode review install payload: \(error)")
|
|
522
|
+
}
|
|
523
|
+
}.resume()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private func downloadReviewAssets(payload: ReviewInstallPayload, completion: @escaping (Bool) -> Void) {
|
|
527
|
+
let sandboxPath = NebulaConfig.shared.sandboxPath(for: payload.appId)
|
|
528
|
+
let sandboxURL = URL(fileURLWithPath: sandboxPath, isDirectory: true)
|
|
529
|
+
let bundleDestination = sandboxURL.appendingPathComponent("index.bundle")
|
|
530
|
+
let manifestDestination = sandboxURL.appendingPathComponent("app.json")
|
|
531
|
+
|
|
532
|
+
let selectedBundleURL = payload.bundles?.ios ?? payload.bundleUrl
|
|
533
|
+
guard let selectedBundleURL else {
|
|
534
|
+
print("[Nebula] Missing iOS review bundle URL for \(payload.appId)")
|
|
535
|
+
completion(false)
|
|
536
|
+
return
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let resolvedBundleURL = NebulaConfig.shared.resolveRemoteURL(selectedBundleURL)
|
|
540
|
+
let resolvedManifestURL = NebulaConfig.shared.resolveRemoteURL(payload.manifestUrl)
|
|
541
|
+
|
|
542
|
+
guard let bundleURL = URL(string: resolvedBundleURL),
|
|
543
|
+
let manifestURL = URL(string: resolvedManifestURL) else {
|
|
544
|
+
print("[Nebula] Invalid review asset URLs for \(payload.appId)")
|
|
545
|
+
completion(false)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let group = DispatchGroup()
|
|
550
|
+
var installError: Error?
|
|
551
|
+
|
|
552
|
+
func download(_ sourceURL: URL, to destinationURL: URL) {
|
|
553
|
+
group.enter()
|
|
554
|
+
URLSession.shared.downloadTask(with: sourceURL) { location, _, error in
|
|
555
|
+
defer { group.leave() }
|
|
556
|
+
|
|
557
|
+
if let error {
|
|
558
|
+
installError = error
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
guard let location else {
|
|
563
|
+
installError = NSError(
|
|
564
|
+
domain: "com.nebula.review",
|
|
565
|
+
code: -1,
|
|
566
|
+
userInfo: [NSLocalizedDescriptionKey: "Missing temporary download file"]
|
|
567
|
+
)
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
do {
|
|
572
|
+
try? FileManager.default.removeItem(at: destinationURL)
|
|
573
|
+
try FileManager.default.moveItem(at: location, to: destinationURL)
|
|
574
|
+
} catch {
|
|
575
|
+
installError = error
|
|
576
|
+
}
|
|
577
|
+
}.resume()
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
download(bundleURL, to: bundleDestination)
|
|
581
|
+
download(manifestURL, to: manifestDestination)
|
|
582
|
+
|
|
583
|
+
// Download and extract iOS assets
|
|
584
|
+
let assetsURLString = NebulaConfig.shared.resolveRemoteURL(payload.assetsUrl.ios)
|
|
585
|
+
if let assetsURL = URL(string: assetsURLString) {
|
|
586
|
+
group.enter()
|
|
587
|
+
NebulaConfig.shared.downloadAndExtractAssetsPublic(for: payload.appId, from: assetsURL) { success in
|
|
588
|
+
if !success {
|
|
589
|
+
installError = NSError(domain: "com.nebula.review", code: -4, userInfo: [NSLocalizedDescriptionKey: "Failed to download/extract assets"])
|
|
590
|
+
}
|
|
591
|
+
group.leave()
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
group.notify(queue: .main) {
|
|
596
|
+
if let installError {
|
|
597
|
+
print("[Nebula] Failed to install review build for \(payload.appId): \(installError.localizedDescription)")
|
|
598
|
+
completion(false)
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
_ = NebulaManifestManager.shared.loadManifest(forAppId: payload.appId, sandboxPath: sandboxPath)
|
|
603
|
+
print("[Nebula] Installed review build \(payload.appId)@\(payload.version)")
|
|
604
|
+
completion(true)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private override init() {
|
|
609
|
+
super.init()
|
|
610
|
+
}
|
|
611
|
+
}
|