@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,580 @@
1
+ //
2
+ // NebulaContainerController.swift
3
+ // SuperApp - Nebula Mini-App Container
4
+ //
5
+ // Container view controller for displaying mini-app instances
6
+ //
7
+
8
+ import UIKit
9
+ import React
10
+ import React_RCTAppDelegate
11
+
12
+ private let nebulaLoadingExitAnimationDuration: TimeInterval = 0.24
13
+ var miniappLoadingDelay: TimeInterval = 0
14
+
15
+ @objc public final class NebulaContainerController: UIViewController, UIGestureRecognizerDelegate {
16
+
17
+ // MARK: - Properties
18
+
19
+ let appId: String
20
+ public let instanceId: String
21
+ private var initialProps: [AnyHashable: Any]?
22
+ private let shouldDelayContentEntrance: Bool
23
+ private let installedAppSignature: String?
24
+ private var miniAppFactory: RCTReactNativeFactory?
25
+ private var pageStyle: [String: Any] = [:]
26
+ private var backgroundEffectView: UIView?
27
+ private weak var rootContentView: UIView?
28
+ private weak var miniappLoadingView: UIView?
29
+ private var rootContentConstraints: [NSLayoutConstraint] = []
30
+ private var willResignActiveObserver: NSObjectProtocol?
31
+ private var didBecomeActiveObserver: NSObjectProtocol?
32
+ private var contentDidAppearObserver: NSObjectProtocol?
33
+ private var hasDismissedLoadingOverlay = false
34
+ private var minimumLoadingDismissDate: Date?
35
+
36
+ private let loadingIndicator: UIActivityIndicatorView = {
37
+ let indicator = UIActivityIndicatorView(style: .large)
38
+ indicator.translatesAutoresizingMaskIntoConstraints = false
39
+ indicator.hidesWhenStopped = true
40
+ return indicator
41
+ }()
42
+
43
+ // MARK: - Initialization
44
+
45
+ @objc public init(appId: String, initialProps: [AnyHashable: Any]? = nil) {
46
+ self.appId = appId
47
+ self.instanceId = UUID().uuidString
48
+ self.initialProps = initialProps
49
+ self.shouldDelayContentEntrance = false
50
+ self.installedAppSignature = NebulaHost.shared.installedAppRuntimeSignature(appId)
51
+ super.init(nibName: nil, bundle: nil)
52
+ self.title = appId
53
+ if let pageStyle = initialProps?["__pageConfig"] as? [String: Any] {
54
+ self.pageStyle = pageStyle
55
+ }
56
+
57
+ // Store reference to allow JS-side navigation using unique instanceId
58
+ NebulaRouter.shared.registerContainer(self, instanceId: instanceId, appId: appId)
59
+ }
60
+
61
+ @objc public init(appId: String,
62
+ initialProps: [AnyHashable: Any]? = nil,
63
+ delayContentEntrance: Bool) {
64
+ self.appId = appId
65
+ self.instanceId = UUID().uuidString
66
+ self.initialProps = initialProps
67
+ self.shouldDelayContentEntrance = delayContentEntrance
68
+ self.installedAppSignature = NebulaHost.shared.installedAppRuntimeSignature(appId)
69
+ super.init(nibName: nil, bundle: nil)
70
+ self.title = appId
71
+ if let pageStyle = initialProps?["__pageConfig"] as? [String: Any] {
72
+ self.pageStyle = pageStyle
73
+ }
74
+
75
+ // Store reference to allow JS-side navigation using unique instanceId
76
+ NebulaRouter.shared.registerContainer(self, instanceId: instanceId, appId: appId)
77
+ }
78
+
79
+ required init?(coder: NSCoder) {
80
+ fatalError("init(coder:) has not been implemented")
81
+ }
82
+
83
+ deinit {
84
+ dismissLoading()
85
+ NotificationCenter.default.post(
86
+ name: NSNotification.Name("NebulaContainerDidUnload"),
87
+ object: nil,
88
+ userInfo: [
89
+ "appId": appId,
90
+ "instanceId": instanceId,
91
+ "routePath": initialProps?["__routePath"] as? String ?? "/"
92
+ ]
93
+ )
94
+ if let observer = willResignActiveObserver {
95
+ NotificationCenter.default.removeObserver(observer)
96
+ }
97
+ if let observer = didBecomeActiveObserver {
98
+ NotificationCenter.default.removeObserver(observer)
99
+ }
100
+ if let observer = contentDidAppearObserver {
101
+ NotificationCenter.default.removeObserver(observer)
102
+ }
103
+ NebulaRouter.shared.unregisterContainer(for: instanceId)
104
+ NebulaAppManager.shared.releaseConsumedResources(appId: appId)
105
+ print("[Nebula] Container deallocated for \(appId) (instance: \(instanceId))")
106
+ }
107
+
108
+ // MARK: - Lifecycle
109
+
110
+ public override func viewDidLoad() {
111
+ super.viewDidLoad()
112
+
113
+ applyBaseBackgroundColor()
114
+ applyPageStyle()
115
+ registerAppStateObservers()
116
+
117
+ // Setup loading indicator
118
+ view.addSubview(loadingIndicator)
119
+ NSLayoutConstraint.activate([
120
+ loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
121
+ loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
122
+ ])
123
+ loadingIndicator.startAnimating()
124
+ setupRootView()
125
+ }
126
+
127
+ public override func viewWillAppear(_ animated: Bool) {
128
+ super.viewWillAppear(animated)
129
+ applyPageStyle()
130
+ if let navigationController {
131
+ navigationController.interactivePopGestureRecognizer?.delegate = self
132
+ navigationController.interactivePopGestureRecognizer?.isEnabled =
133
+ navigationController.viewControllers.count > 1
134
+ }
135
+
136
+ // Notify JS about lifecycle
137
+ NotificationCenter.default.post(
138
+ name: NSNotification.Name("NebulaContainerWillAppear"),
139
+ object: nil,
140
+ userInfo: [
141
+ "appId": appId,
142
+ "instanceId": instanceId,
143
+ "routePath": initialProps?["__routePath"] as? String ?? "/"
144
+ ]
145
+ )
146
+ }
147
+
148
+ public override func viewDidDisappear(_ animated: Bool) {
149
+ super.viewDidDisappear(animated)
150
+
151
+ // Notify JS about lifecycle
152
+ NotificationCenter.default.post(
153
+ name: NSNotification.Name("NebulaContainerDidDisappear"),
154
+ object: nil,
155
+ userInfo: [
156
+ "appId": appId,
157
+ "instanceId": instanceId,
158
+ "routePath": initialProps?["__routePath"] as? String ?? "/"
159
+ ]
160
+ )
161
+ }
162
+
163
+ // MARK: - Public Methods
164
+
165
+ /// Update initial props for container reuse
166
+ @objc public func updateInitialProps(_ props: [AnyHashable: Any]) {
167
+ self.initialProps = props
168
+ if let pageStyle = props["__pageConfig"] as? [String: Any] {
169
+ self.pageStyle = pageStyle
170
+ } else if let routePath = props["__routePath"] as? String,
171
+ let manifestPageStyle = NebulaManifestManager.shared.getPageConfig(forAppId: appId, path: routePath) as? [String: Any] {
172
+ self.pageStyle = manifestPageStyle
173
+ }
174
+ applyPageStyle()
175
+ print("[Nebula] Updated initialProps for container \(appId) (instance: \(instanceId))")
176
+ // Note: Existing RootView will not be recreated.
177
+ // For full props update, consider implementing JS-side prop refresh mechanism.
178
+ }
179
+
180
+ @objc public func updatePageStyle(_ nextStyle: [String: Any]) {
181
+ self.pageStyle.merge(nextStyle) { _, new in new }
182
+ if let title = nextStyle["navigationBarTitleText"] as? String {
183
+ self.title = title
184
+ }
185
+ applyPageStyle()
186
+ }
187
+
188
+ @objc public func matchesInstalledRuntimeSignature(_ signature: String?) -> Bool {
189
+ return installedAppSignature == signature
190
+ }
191
+
192
+ func prepareForPresentation() -> Bool {
193
+ loadViewIfNeeded()
194
+ return shouldDelayContentEntrance && miniappLoadingView != nil
195
+ }
196
+
197
+ func shouldAttachMiniappLoadingView() -> Bool {
198
+ return shouldDelayContentEntrance && !isViewLoaded
199
+ }
200
+
201
+ func attachMiniappLoadingView(_ loadingView: UIView, minimumDismissDelay: TimeInterval) {
202
+ miniappLoadingView?.removeFromSuperview()
203
+ hasDismissedLoadingOverlay = false
204
+ minimumLoadingDismissDate = Date().addingTimeInterval(minimumDismissDelay)
205
+ miniappLoadingView = loadingView
206
+ }
207
+
208
+ @objc public func dismissLoading() {
209
+ hasDismissedLoadingOverlay = true
210
+ minimumLoadingDismissDate = nil
211
+ miniappLoadingView?.removeFromSuperview()
212
+ miniappLoadingView = nil
213
+ loadingIndicator.stopAnimating()
214
+ loadingIndicator.removeFromSuperview()
215
+ if let observer = contentDidAppearObserver {
216
+ NotificationCenter.default.removeObserver(observer)
217
+ contentDidAppearObserver = nil
218
+ }
219
+ }
220
+
221
+ // MARK: - Private Methods
222
+
223
+ private func setupRootView() {
224
+
225
+ // Clean old root content before attaching a new mini-app view.
226
+ for subview in view.subviews where subview != loadingIndicator && subview != miniappLoadingView {
227
+ subview.removeFromSuperview()
228
+ }
229
+
230
+ if let preloadedRootView = NebulaAppManager.shared.consumePreloadedRootView(for: appId) {
231
+ view.addSubview(preloadedRootView)
232
+ attachRootContentView(preloadedRootView)
233
+ view.bringSubviewToFront(preloadedRootView)
234
+ bringMiniappLoadingViewToFrontIfNeeded()
235
+ handleMiniappContentReady()
236
+ loadingIndicator.stopAnimating()
237
+ loadingIndicator.removeFromSuperview()
238
+ print("[Nebula] Debug z-order(preloaded): subviews=\(view.subviews.count), top=\(String(describing: view.subviews.last))")
239
+ print("[Nebula] Consumed preloaded root view for \(appId)")
240
+ return
241
+ }
242
+
243
+ let bundleURL: URL
244
+ if let devURL = NebulaConfig.shared.devURL(for: appId) {
245
+ bundleURL = devURL
246
+ print("[Nebula] Using development URL for hot reload: \(devURL)")
247
+ } else if let bundlePath = NebulaConfig.shared.bundlePath(for: appId) {
248
+ bundleURL = URL(fileURLWithPath: bundlePath)
249
+ print("[Nebula] Using local bundle: \(bundlePath)")
250
+ } else {
251
+ showError(NSError(
252
+ domain: "com.nebula.container",
253
+ code: -1,
254
+ userInfo: [NSLocalizedDescriptionKey: "Bundle not found for appId: \(appId)"]
255
+ ))
256
+ return
257
+ }
258
+
259
+ let miniFactory = NebulaAppManager.shared.acquireFactory(
260
+ for: appId,
261
+ bundleURL: bundleURL
262
+ )
263
+ self.miniAppFactory = miniFactory
264
+
265
+ let routePath = (initialProps?["__routePath"] as? String)
266
+ ?? (NebulaManifestManager.shared.getEntryPagePath(forAppId: appId) ?? "/")
267
+ guard let moduleName = NebulaManifestManager.shared.getComponentName(forAppId: appId, path: routePath) else {
268
+ return
269
+ }
270
+
271
+ let rootView = miniFactory.rootViewFactory.view(
272
+ withModuleName: moduleName,
273
+ initialProperties: buildInitialProps()
274
+ )
275
+ registerContentDidAppearObserver(for: rootView)
276
+
277
+ rootView.backgroundColor = resolvedPageBackgroundColor()
278
+
279
+ // Debug: force mini-app content on top to rule out z-order issues.
280
+ view.addSubview(rootView)
281
+ attachRootContentView(rootView)
282
+ view.bringSubviewToFront(rootView)
283
+ bringMiniappLoadingViewToFrontIfNeeded()
284
+
285
+ // Hide loading indicator
286
+ loadingIndicator.stopAnimating()
287
+ loadingIndicator.removeFromSuperview()
288
+
289
+ print("[Nebula] Debug z-order(new): subviews=\(view.subviews.count), top=\(String(describing: view.subviews.last))")
290
+
291
+ // Some RN loading overlays can persist in mixed lifecycle paths; clean once after mount.
292
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self, weak rootView] in
293
+ guard let self = self, let rootView = rootView else { return }
294
+ self.removeInternalLoadingViews(from: rootView)
295
+ }
296
+
297
+ print("[Nebula] Container loaded for \(appId), module=\(moduleName)")
298
+ }
299
+
300
+ private func registerContentDidAppearObserver(for rootView: UIView) {
301
+ if let observer = contentDidAppearObserver {
302
+ NotificationCenter.default.removeObserver(observer)
303
+ }
304
+
305
+ contentDidAppearObserver = NotificationCenter.default.addObserver(
306
+ forName: NSNotification.Name(rawValue: "RCTContentDidAppearNotification"),
307
+ object: nil,
308
+ queue: .main
309
+ ) { [weak self, weak rootView] notification in
310
+ guard let self,
311
+ let rootView,
312
+ let contentView = notification.object as? UIView else {
313
+ return
314
+ }
315
+
316
+ if contentView === rootView || contentView.isDescendant(of: rootView) {
317
+ self.handleMiniappContentReady()
318
+ }
319
+ }
320
+ }
321
+
322
+ private func handleMiniappContentReady() {
323
+ guard !hasDismissedLoadingOverlay else {
324
+ return
325
+ }
326
+ hasDismissedLoadingOverlay = true
327
+ NotificationCenter.default.post(
328
+ name: NSNotification.Name("NebulaContainerDidBecomeReady"),
329
+ object: nil,
330
+ userInfo: [
331
+ "appId": appId,
332
+ "instanceId": instanceId,
333
+ "routePath": initialProps?["__routePath"] as? String ?? "/"
334
+ ]
335
+ )
336
+ dismissLoadingOverlayIfNeeded()
337
+ }
338
+
339
+ private func dismissLoadingOverlayIfNeeded() {
340
+ let remainingEntranceDelay = max(0, minimumLoadingDismissDate?.timeIntervalSinceNow ?? 0)
341
+ let totalDelay = remainingEntranceDelay + miniappLoadingDelay + nebulaLoadingExitAnimationDuration
342
+ DispatchQueue.main.asyncAfter(deadline: .now() + totalDelay) {
343
+ self.dismissLoading()
344
+ }
345
+ }
346
+
347
+ private func bringMiniappLoadingViewToFrontIfNeeded() {
348
+ guard let loadingView = miniappLoadingView else { return }
349
+ loadingView.superview?.bringSubviewToFront(loadingView)
350
+ }
351
+
352
+ private func removeInternalLoadingViews(from view: UIView) {
353
+ for subview in view.subviews {
354
+ let className = String(describing: type(of: subview))
355
+ if subview is UIActivityIndicatorView || className.contains("Loading") || className.contains("RCT") && className.contains("Progress") {
356
+ subview.removeFromSuperview()
357
+ continue
358
+ }
359
+ removeInternalLoadingViews(from: subview)
360
+ }
361
+ }
362
+
363
+ private func buildInitialProps() -> [AnyHashable: Any] {
364
+ var props: [AnyHashable: Any] = [
365
+ "appId": appId,
366
+ "instanceId": instanceId,
367
+ "sandboxPath": getSandboxPath()
368
+ ]
369
+
370
+ // Merge caller-provided props first so route/page config resolution uses the actual target page.
371
+ if let initialProps = initialProps {
372
+ props.merge(initialProps) { _, new in new }
373
+ }
374
+
375
+ if props["__routePath"] == nil {
376
+ let entryPath = NebulaManifestManager.shared.getEntryPagePath(forAppId: appId) ?? "/"
377
+ props["__routePath"] = entryPath
378
+ }
379
+ if let providedPageStyle = props["__pageConfig"] as? [String: Any] {
380
+ pageStyle = providedPageStyle
381
+ } else if let routePath = props["__routePath"] as? String,
382
+ let manifestPageStyle = NebulaManifestManager.shared.getPageConfig(forAppId: appId, path: routePath) as? [String: Any] {
383
+ pageStyle = manifestPageStyle
384
+ }
385
+ if !pageStyle.isEmpty {
386
+ props["__pageConfig"] = pageStyle
387
+ }
388
+
389
+ return props
390
+ }
391
+
392
+ private func getSandboxPath() -> String {
393
+ return NebulaConfig.shared.sandboxPath(for: appId)
394
+ }
395
+
396
+ private func applyPageStyle() {
397
+ applyBaseBackgroundColor()
398
+ rootContentView?.backgroundColor = resolvedPageBackgroundColor()
399
+
400
+ navigationItem.hidesBackButton = shouldHideBackButton()
401
+
402
+ if let title = pageStyle["navigationBarTitleText"] as? String, !title.isEmpty {
403
+ self.title = title
404
+ }
405
+
406
+ let navigationStyle = (pageStyle["navigationStyle"] as? String)?.lowercased() ?? "default"
407
+ updateLayoutForNavigationStyle(navigationStyle)
408
+
409
+ guard let navigationController else { return }
410
+ navigationController.setNavigationBarHidden(navigationStyle == "custom", animated: false)
411
+
412
+ let appearance = UINavigationBarAppearance()
413
+ appearance.configureWithOpaqueBackground()
414
+
415
+ if let backgroundColorHex = pageStyle["navigationBarBackgroundColor"] as? String,
416
+ let color = UIColor(nebulaHex: backgroundColorHex) {
417
+ appearance.backgroundColor = color
418
+ } else {
419
+ appearance.backgroundColor = .systemBackground
420
+ }
421
+
422
+ let textColor = UIColor(nebulaHex: pageStyle["navigationBarTextColor"] as? String ?? "") ?? .label
423
+ appearance.titleTextAttributes = [.foregroundColor: textColor]
424
+ appearance.largeTitleTextAttributes = [.foregroundColor: textColor]
425
+
426
+ navigationController.navigationBar.tintColor = textColor
427
+ navigationController.navigationBar.standardAppearance = appearance
428
+ navigationController.navigationBar.scrollEdgeAppearance = appearance
429
+ navigationController.navigationBar.compactAppearance = appearance
430
+ }
431
+
432
+ private func shouldHideBackButton() -> Bool {
433
+ let currentRoute = normalizeRoute(
434
+ (initialProps?["__routePath"] as? String)
435
+ ?? (pageStyle["route"] as? String)
436
+ )
437
+ let entryRoute = normalizeRoute(
438
+ NebulaManifestManager.shared.getEntryPagePath(forAppId: appId)
439
+ )
440
+ return currentRoute == "/" || currentRoute == entryRoute
441
+ }
442
+
443
+ private func normalizeRoute(_ routePath: String?) -> String {
444
+ let raw = routePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
445
+ if raw.isEmpty {
446
+ return "/"
447
+ }
448
+ return raw.hasPrefix("/") ? raw : "/\(raw)"
449
+ }
450
+
451
+ public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
452
+ guard gestureRecognizer === navigationController?.interactivePopGestureRecognizer else {
453
+ return true
454
+ }
455
+ return (navigationController?.viewControllers.count ?? 0) > 1
456
+ }
457
+
458
+ private func registerAppStateObservers() {
459
+ willResignActiveObserver = NotificationCenter.default.addObserver(
460
+ forName: UIApplication.willResignActiveNotification,
461
+ object: nil,
462
+ queue: .main
463
+ ) { [weak self] _ in
464
+ self?.applyBackgroundVisualEffectIfNeeded()
465
+ }
466
+
467
+ didBecomeActiveObserver = NotificationCenter.default.addObserver(
468
+ forName: UIApplication.didBecomeActiveNotification,
469
+ object: nil,
470
+ queue: .main
471
+ ) { [weak self] _ in
472
+ self?.removeBackgroundVisualEffect()
473
+ }
474
+ }
475
+
476
+ private func applyBackgroundVisualEffectIfNeeded() {
477
+ let visualEffect = (pageStyle["visualEffectInBackground"] as? String)?.lowercased() ?? "none"
478
+ guard visualEffect == "blur", backgroundEffectView == nil else { return }
479
+
480
+ let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
481
+ blurView.frame = view.bounds
482
+ blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
483
+ blurView.isUserInteractionEnabled = false
484
+ blurView.backgroundColor = resolvedPageBackgroundColor().withAlphaComponent(0.15)
485
+ view.addSubview(blurView)
486
+ view.bringSubviewToFront(blurView)
487
+ backgroundEffectView = blurView
488
+ }
489
+
490
+ private func removeBackgroundVisualEffect() {
491
+ backgroundEffectView?.removeFromSuperview()
492
+ backgroundEffectView = nil
493
+ }
494
+
495
+ private func applyBaseBackgroundColor() {
496
+ view.backgroundColor = resolvedPageBackgroundColor()
497
+ }
498
+
499
+ private func attachRootContentView(_ rootView: UIView) {
500
+ rootView.translatesAutoresizingMaskIntoConstraints = false
501
+ rootContentView = rootView
502
+ updateLayoutForNavigationStyle(
503
+ (pageStyle["navigationStyle"] as? String)?.lowercased() ?? "default"
504
+ )
505
+ }
506
+
507
+ private func updateLayoutForNavigationStyle(_ navigationStyle: String) {
508
+ let usesDefaultNavigationBar = navigationStyle != "custom"
509
+
510
+ if usesDefaultNavigationBar {
511
+ edgesForExtendedLayout = []
512
+ extendedLayoutIncludesOpaqueBars = false
513
+ } else {
514
+ edgesForExtendedLayout = [.top, .left, .bottom, .right]
515
+ extendedLayoutIncludesOpaqueBars = true
516
+ }
517
+
518
+ guard let rootContentView, rootContentView.superview === view else { return }
519
+
520
+ NSLayoutConstraint.deactivate(rootContentConstraints)
521
+ let topAnchor = usesDefaultNavigationBar ? view.safeAreaLayoutGuide.topAnchor : view.topAnchor
522
+ rootContentConstraints = [
523
+ rootContentView.topAnchor.constraint(equalTo: topAnchor),
524
+ rootContentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
525
+ rootContentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
526
+ rootContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
527
+ ]
528
+ NSLayoutConstraint.activate(rootContentConstraints)
529
+ view.layoutIfNeeded()
530
+ }
531
+
532
+ private func resolvedPageBackgroundColor() -> UIColor {
533
+ if let backgroundColorHex = pageStyle["backgroundColor"] as? String,
534
+ let color = UIColor(nebulaHex: backgroundColorHex) {
535
+ return color
536
+ }
537
+ return .systemBackground
538
+ }
539
+
540
+ private func showError(_ error: Error) {
541
+ loadingIndicator.stopAnimating()
542
+
543
+ let label = UILabel()
544
+ label.text = "Load failed\n\(error.localizedDescription)"
545
+ label.textAlignment = .center
546
+ label.numberOfLines = 0
547
+ label.textColor = .systemRed
548
+ label.translatesAutoresizingMaskIntoConstraints = false
549
+
550
+ view.addSubview(label)
551
+ NSLayoutConstraint.activate([
552
+ label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
553
+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
554
+ label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
555
+ label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
556
+ ])
557
+
558
+ print("[Nebula] Error: \(error.localizedDescription)")
559
+ }
560
+ }
561
+
562
+ private extension UIColor {
563
+ convenience init?(nebulaHex: String) {
564
+ let sanitized = nebulaHex
565
+ .trimmingCharacters(in: .whitespacesAndNewlines)
566
+ .replacingOccurrences(of: "#", with: "")
567
+
568
+ guard sanitized.count == 6,
569
+ let value = Int(sanitized, radix: 16) else {
570
+ return nil
571
+ }
572
+
573
+ self.init(
574
+ red: CGFloat((value >> 16) & 0xFF) / 255.0,
575
+ green: CGFloat((value >> 8) & 0xFF) / 255.0,
576
+ blue: CGFloat(value & 0xFF) / 255.0,
577
+ alpha: 1.0
578
+ )
579
+ }
580
+ }