@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,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
+ }