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