@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,549 @@
1
+ //
2
+ // NebulaConfig.swift
3
+ // SuperApp - Nebula Mini-App Container
4
+ //
5
+ // Configuration and sandbox management
6
+ //
7
+
8
+ import Foundation
9
+ import ZIPFoundation
10
+
11
+ @objc public final class NebulaConfig: NSObject {
12
+ private static let serverBaseURLDefaultsKey = "NebulaServerBaseURL"
13
+
14
+ struct InstalledMiniAppInfo: Codable {
15
+ let appId: String
16
+ let mode: String
17
+ let bundlePath: String
18
+ let sourceUrl: String
19
+ let version: String?
20
+ let updateStrategy: String
21
+ }
22
+
23
+ // MARK: - Singleton
24
+
25
+ @objc public static let shared = NebulaConfig()
26
+
27
+ // MARK: - Properties
28
+
29
+ @objc public var enableDebugLogging: Bool = true
30
+ @objc public var maxConcurrentApps: Int = 3
31
+ @objc public var maxNavigationStackDepth: Int = 10
32
+ @objc public var cachePolicy: CachePolicy = .memory
33
+
34
+ @objc public var serverBaseURL: String? {
35
+ get {
36
+ guard let value = UserDefaults.standard.string(forKey: Self.serverBaseURLDefaultsKey),
37
+ !value.isEmpty else {
38
+ return nil
39
+ }
40
+ return value
41
+ }
42
+ set {
43
+ let normalized = Self.normalizeServerBaseURL(newValue)
44
+ if let normalized {
45
+ UserDefaults.standard.set(normalized, forKey: Self.serverBaseURLDefaultsKey)
46
+ } else {
47
+ UserDefaults.standard.removeObject(forKey: Self.serverBaseURLDefaultsKey)
48
+ }
49
+ }
50
+ }
51
+
52
+ @objc public enum CachePolicy: Int {
53
+ case memory = 0 // Only keep in memory
54
+ case persistent = 1 // Persist to disk
55
+ case hybrid = 2 // Memory + disk
56
+ }
57
+
58
+ @objc public func resolveRemoteURL(_ rawURL: String) -> String {
59
+ let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
60
+ guard !trimmedURL.isEmpty else {
61
+ return rawURL
62
+ }
63
+
64
+ if trimmedURL.hasPrefix("file://") {
65
+ return trimmedURL
66
+ }
67
+
68
+ guard let serverBaseURL,
69
+ let baseComponents = URLComponents(string: serverBaseURL) else {
70
+ return trimmedURL
71
+ }
72
+
73
+ if trimmedURL.hasPrefix("/") {
74
+ var resolved = baseComponents
75
+ resolved.path = trimmedURL
76
+ return resolved.url?.absoluteString ?? trimmedURL
77
+ }
78
+
79
+ if let rawComponents = URLComponents(string: trimmedURL),
80
+ let scheme = rawComponents.scheme?.lowercased(),
81
+ scheme == "http" || scheme == "https" {
82
+ if Self.shouldReplaceRemoteHost(rawComponents.host) {
83
+ var resolved = rawComponents
84
+ resolved.scheme = baseComponents.scheme
85
+ resolved.host = baseComponents.host
86
+ resolved.port = baseComponents.port
87
+ return resolved.url?.absoluteString ?? trimmedURL
88
+ }
89
+ return trimmedURL
90
+ }
91
+
92
+ var resolved = baseComponents
93
+ let basePath = baseComponents.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
94
+ let relativePath = trimmedURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
95
+ resolved.path = ([basePath, relativePath].filter { !$0.isEmpty }).joined(separator: "/")
96
+ if !resolved.path.hasPrefix("/") {
97
+ resolved.path = "/\(resolved.path)"
98
+ }
99
+ return resolved.url?.absoluteString ?? trimmedURL
100
+ }
101
+
102
+ // MARK: - Sandbox Management
103
+
104
+ /// Get the sandbox directory for a mini-app
105
+ @objc public func sandboxPath(for appId: String) -> String {
106
+ let fileManager = FileManager.default
107
+ guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
108
+ return ""
109
+ }
110
+
111
+ let sandboxPath = documentsPath
112
+ .appendingPathComponent("MiniApps")
113
+ .appendingPathComponent(appId)
114
+
115
+ // Create directory if needed
116
+ try? fileManager.createDirectory(at: sandboxPath, withIntermediateDirectories: true, attributes: nil)
117
+
118
+ return sandboxPath.path
119
+ }
120
+
121
+ /// Get the bundle path for a mini-app
122
+ @objc public func bundlePath(for appId: String) -> String? {
123
+ let sandboxPath = self.sandboxPath(for: appId)
124
+ let bundlePath = "\(sandboxPath)/index.bundle"
125
+
126
+ if FileManager.default.fileExists(atPath: bundlePath) {
127
+ return bundlePath
128
+ }
129
+
130
+ return nil
131
+ }
132
+
133
+ private func metadataURL(for appId: String) -> URL {
134
+ URL(fileURLWithPath: sandboxPath(for: appId)).appendingPathComponent("installation.json")
135
+ }
136
+
137
+ func installedAppInfo(for appId: String) -> InstalledMiniAppInfo? {
138
+ let metadataURL = self.metadataURL(for: appId)
139
+ guard let data = try? Data(contentsOf: metadataURL) else {
140
+ return nil
141
+ }
142
+ return try? JSONDecoder().decode(InstalledMiniAppInfo.self, from: data)
143
+ }
144
+
145
+ private func saveInstalledAppInfo(_ info: InstalledMiniAppInfo, for appId: String) {
146
+ let metadataURL = self.metadataURL(for: appId)
147
+ guard let data = try? JSONEncoder().encode(info) else {
148
+ return
149
+ }
150
+ try? data.write(to: metadataURL, options: .atomic)
151
+ }
152
+
153
+ private func saveInstalledAppInfo(for appId: String,
154
+ connectToURLMetroServer: Bool,
155
+ sourceURL: String,
156
+ manifest: NebulaManifest?) {
157
+ let info = InstalledMiniAppInfo(
158
+ appId: appId,
159
+ mode: connectToURLMetroServer ? "development" : "production",
160
+ bundlePath: self.bundlePath(for: appId) ?? "",
161
+ sourceUrl: sourceURL,
162
+ version: connectToURLMetroServer ? nil : manifest?.version,
163
+ updateStrategy: connectToURLMetroServer ? "manual" : (manifest?.updateStrategy ?? "manual")
164
+ )
165
+ saveInstalledAppInfo(info, for: appId)
166
+ }
167
+
168
+ private func deriveManifestURL(from bundleURL: URL) -> URL? {
169
+ var components = URLComponents(url: bundleURL, resolvingAgainstBaseURL: false)
170
+ let path = components?.path ?? ""
171
+ if path.isEmpty {
172
+ return nil
173
+ }
174
+ let parent = (path as NSString).deletingLastPathComponent
175
+ components?.path = "\(parent)/app.json"
176
+ components?.query = nil
177
+ components?.fragment = nil
178
+ return components?.url
179
+ }
180
+
181
+ private func downloadManifestIfAvailable(for appId: String,
182
+ from bundleURL: URL,
183
+ completion: @escaping (NebulaManifest?) -> Void) {
184
+ guard let manifestURL = deriveManifestURL(from: bundleURL) else {
185
+ completion(nil)
186
+ return
187
+ }
188
+ downloadManifestIfAvailable(for: appId, manifestURL: manifestURL, completion: completion)
189
+ }
190
+
191
+ private func downloadManifestIfAvailable(for appId: String,
192
+ manifestURL: URL,
193
+ completion: @escaping (NebulaManifest?) -> Void) {
194
+
195
+ let sandboxPath = self.sandboxPath(for: appId)
196
+ let manifestDest = URL(fileURLWithPath: sandboxPath).appendingPathComponent("app.json")
197
+ try? FileManager.default.removeItem(at: manifestDest)
198
+
199
+ let manifestTask = URLSession.shared.dataTask(with: manifestURL) { data, response, error in
200
+ guard let data = data,
201
+ error == nil,
202
+ (response as? HTTPURLResponse)?.statusCode == 200 else {
203
+ completion(nil)
204
+ return
205
+ }
206
+
207
+ do {
208
+ try data.write(to: manifestDest)
209
+ _ = NebulaManifestManager.shared.loadManifest(forAppId: appId, sandboxPath: sandboxPath)
210
+ let manifest = try JSONDecoder().decode(NebulaManifest.self, from: data)
211
+ completion(manifest)
212
+ } catch {
213
+ completion(nil)
214
+ }
215
+ }
216
+ manifestTask.resume()
217
+ }
218
+
219
+ /// Get the development URL for a mini-app (for hot reload)
220
+ @objc public func devURL(for appId: String) -> URL? {
221
+ let sandboxPath = self.sandboxPath(for: appId)
222
+ let devURLPath = "\(sandboxPath)/dev.url"
223
+
224
+ guard let urlString = try? String(contentsOfFile: devURLPath, encoding: .utf8),
225
+ let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
226
+ return nil
227
+ }
228
+
229
+ return url
230
+ }
231
+
232
+ /// Save development URL for hot reload
233
+ private func saveDevURL(_ urlString: String, for appId: String) {
234
+ let sandboxPath = self.sandboxPath(for: appId)
235
+ let devURLPath = "\(sandboxPath)/dev.url"
236
+ try? urlString.write(toFile: devURLPath, atomically: true, encoding: .utf8)
237
+ }
238
+
239
+ /// Clear development URL to force local bundle loading
240
+ private func clearDevURL(for appId: String) {
241
+ let sandboxPath = self.sandboxPath(for: appId)
242
+ let devURLPath = "\(sandboxPath)/dev.url"
243
+ try? FileManager.default.removeItem(atPath: devURLPath)
244
+ }
245
+
246
+ /// Check if a URL is a development server
247
+ private func isDevelopmentURL(_ urlString: String) -> Bool {
248
+ return urlString.contains("127.0.0.1") || urlString.contains("localhost")
249
+ }
250
+
251
+ static func normalizeServerBaseURL(_ rawURL: String?) -> String? {
252
+ guard let rawURL else {
253
+ return nil
254
+ }
255
+
256
+ let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
257
+ guard !trimmedURL.isEmpty,
258
+ var components = URLComponents(string: trimmedURL),
259
+ let scheme = components.scheme?.lowercased(),
260
+ (scheme == "http" || scheme == "https"),
261
+ components.host != nil else {
262
+ return nil
263
+ }
264
+
265
+ if components.path.isEmpty {
266
+ components.path = ""
267
+ }
268
+ if components.path.count > 1, components.path.hasSuffix("/") {
269
+ components.path.removeLast()
270
+ }
271
+ components.query = nil
272
+ components.fragment = nil
273
+ return components.url?.absoluteString
274
+ }
275
+
276
+ private static func shouldReplaceRemoteHost(_ host: String?) -> Bool {
277
+ guard let host = host?.lowercased() else {
278
+ return false
279
+ }
280
+ return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0"
281
+ }
282
+
283
+ /// Download and install a mini-app bundle
284
+ @objc public func downloadBundle(for appId: String,
285
+ from urlString: String,
286
+ completion: @escaping (Bool, Error?) -> Void) {
287
+ let connectToURLMetroServer = isDevelopmentURL(urlString)
288
+ downloadBundle(
289
+ for: appId,
290
+ from: urlString,
291
+ connectToURLMetroServer: connectToURLMetroServer,
292
+ completion: completion
293
+ )
294
+ }
295
+
296
+ /// Install a mini-app with explicit Metro connection preference
297
+ @objc public func downloadBundle(for appId: String,
298
+ from urlString: String,
299
+ connectToURLMetroServer: Bool,
300
+ completion: @escaping (Bool, Error?) -> Void) {
301
+ guard let url = URL(string: urlString) else {
302
+ let error = NSError(
303
+ domain: "com.nebula.config",
304
+ code: -1,
305
+ userInfo: [NSLocalizedDescriptionKey: "Invalid bundle URL"]
306
+ )
307
+ completion(false, error)
308
+ return
309
+ }
310
+
311
+ if connectToURLMetroServer {
312
+ saveDevURL(urlString, for: appId)
313
+ print("[Nebula] Saved development URL for \(appId): \(urlString)")
314
+
315
+ downloadManifestIfAvailable(for: appId, from: url) { manifest in
316
+ self.saveInstalledAppInfo(
317
+ for: appId,
318
+ connectToURLMetroServer: true,
319
+ sourceURL: urlString,
320
+ manifest: manifest
321
+ )
322
+ completion(true, nil)
323
+ }
324
+ return
325
+ }
326
+
327
+ // Production mode must always use local file bundle.
328
+ clearDevURL(for: appId)
329
+
330
+ let task = URLSession.shared.downloadTask(with: url) { [weak self] location, response, error in
331
+ guard let self = self else {
332
+ completion(false, nil)
333
+ return
334
+ }
335
+
336
+ if let error = error {
337
+ completion(false, error)
338
+ return
339
+ }
340
+
341
+ guard let location = location else {
342
+ let error = NSError(
343
+ domain: "com.nebula.config",
344
+ code: -2,
345
+ userInfo: [NSLocalizedDescriptionKey: "Download failed: no data"]
346
+ )
347
+ completion(false, error)
348
+ return
349
+ }
350
+
351
+ // Move to sandbox
352
+ let sandboxPath = self.sandboxPath(for: appId)
353
+ let destinationURL = URL(fileURLWithPath: sandboxPath).appendingPathComponent("index.bundle")
354
+
355
+ do {
356
+ // Remove existing bundle
357
+ try? FileManager.default.removeItem(at: destinationURL)
358
+
359
+ // Move downloaded file to sandbox
360
+ try FileManager.default.moveItem(at: location, to: destinationURL)
361
+
362
+ print("[Nebula] Bundle installed for \(appId) at \(destinationURL.path)")
363
+ self.downloadManifestIfAvailable(for: appId, from: url) { manifest in
364
+ self.saveInstalledAppInfo(
365
+ for: appId,
366
+ connectToURLMetroServer: false,
367
+ sourceURL: urlString,
368
+ manifest: manifest
369
+ )
370
+ completion(true, nil)
371
+ }
372
+ } catch {
373
+ completion(false, error)
374
+ }
375
+ }
376
+
377
+ task.resume()
378
+ }
379
+
380
+ @objc public func downloadBundle(for appId: String,
381
+ from urlString: String,
382
+ manifestURL manifestURLString: String,
383
+ assetsURL assetsURLString: String,
384
+ connectToURLMetroServer: Bool,
385
+ completion: @escaping (Bool, Error?) -> Void) {
386
+ guard let url = URL(string: urlString),
387
+ let manifestURL = URL(string: manifestURLString),
388
+ let assetsURL = URL(string: assetsURLString) else {
389
+ let error = NSError(
390
+ domain: "com.nebula.config",
391
+ code: -1,
392
+ userInfo: [NSLocalizedDescriptionKey: "Invalid bundle, manifest, or assets URL"]
393
+ )
394
+ completion(false, error)
395
+ return
396
+ }
397
+
398
+ if connectToURLMetroServer {
399
+ saveDevURL(urlString, for: appId)
400
+ downloadManifestIfAvailable(for: appId, manifestURL: manifestURL) { manifest in
401
+ self.saveInstalledAppInfo(
402
+ for: appId,
403
+ connectToURLMetroServer: true,
404
+ sourceURL: urlString,
405
+ manifest: manifest
406
+ )
407
+ completion(true, nil)
408
+ }
409
+ return
410
+ }
411
+
412
+ clearDevURL(for: appId)
413
+
414
+ let task = URLSession.shared.downloadTask(with: url) { [weak self] location, _response, error in
415
+ guard let self = self else {
416
+ completion(false, nil)
417
+ return
418
+ }
419
+
420
+ if let error = error {
421
+ completion(false, error)
422
+ return
423
+ }
424
+
425
+ guard let location = location else {
426
+ let error = NSError(
427
+ domain: "com.nebula.config",
428
+ code: -2,
429
+ userInfo: [NSLocalizedDescriptionKey: "Download failed: no data"]
430
+ )
431
+ completion(false, error)
432
+ return
433
+ }
434
+
435
+ let sandboxPath = self.sandboxPath(for: appId)
436
+ let destinationURL = URL(fileURLWithPath: sandboxPath).appendingPathComponent("index.bundle")
437
+
438
+ do {
439
+ try? FileManager.default.removeItem(at: destinationURL)
440
+ try FileManager.default.moveItem(at: location, to: destinationURL)
441
+
442
+ self.downloadManifestIfAvailable(for: appId, manifestURL: manifestURL) { manifest in
443
+ self.downloadAndExtractAssets(for: appId, from: assetsURL) { assetsSuccess in
444
+ self.saveInstalledAppInfo(
445
+ for: appId,
446
+ connectToURLMetroServer: false,
447
+ sourceURL: urlString,
448
+ manifest: manifest
449
+ )
450
+ completion(assetsSuccess, nil)
451
+ }
452
+ }
453
+ } catch {
454
+ completion(false, error)
455
+ }
456
+ }
457
+
458
+ task.resume()
459
+ }
460
+
461
+ /// Clear sandbox for a mini-app
462
+ @objc public func clearSandbox(for appId: String) throws {
463
+ let sandboxPath = self.sandboxPath(for: appId)
464
+ let sandboxURL = URL(fileURLWithPath: sandboxPath)
465
+
466
+ if FileManager.default.fileExists(atPath: sandboxPath) {
467
+ try FileManager.default.removeItem(at: sandboxURL)
468
+ print("[Nebula] Cleared sandbox for \(appId)")
469
+ }
470
+ }
471
+
472
+ /// Clear all mini-app sandboxes
473
+ @objc public func clearAllSandboxes() throws {
474
+ let fileManager = FileManager.default
475
+ guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
476
+ return
477
+ }
478
+
479
+ let miniAppsPath = documentsPath.appendingPathComponent("MiniApps")
480
+ try fileManager.removeItem(at: miniAppsPath)
481
+ print("[Nebula] Cleared all sandboxes")
482
+ }
483
+
484
+ /// List all installed mini-apps
485
+ @objc public func listInstalledApps() -> [String] {
486
+ let fileManager = FileManager.default
487
+ guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
488
+ return []
489
+ }
490
+
491
+ let miniAppsPath = documentsPath.appendingPathComponent("MiniApps")
492
+
493
+ guard let contents = try? fileManager.contentsOfDirectory(atPath: miniAppsPath.path) else {
494
+ return []
495
+ }
496
+
497
+ return contents.filter { appId in
498
+ let bundlePath = miniAppsPath
499
+ .appendingPathComponent(appId)
500
+ .appendingPathComponent("index.bundle")
501
+ .path
502
+ return fileManager.fileExists(atPath: bundlePath)
503
+ }
504
+ }
505
+
506
+ private override init() {
507
+ super.init()
508
+ }
509
+
510
+ // MARK: - Assets Download & Extraction
511
+
512
+ @objc public func downloadAndExtractAssetsPublic(for appId: String, from assetsURL: URL, completion: @escaping (Bool) -> Void) {
513
+ downloadAndExtractAssets(for: appId, from: assetsURL, completion: completion)
514
+ }
515
+
516
+ private func downloadAndExtractAssets(for appId: String, from assetsURL: URL, completion: @escaping (Bool) -> Void) {
517
+ let sandboxPath = self.sandboxPath(for: appId)
518
+ let zipDestination = URL(fileURLWithPath: sandboxPath).appendingPathComponent("assets.zip")
519
+
520
+ try? FileManager.default.removeItem(at: zipDestination)
521
+
522
+ let task = URLSession.shared.downloadTask(with: assetsURL) { location, response, error in
523
+ guard let location = location, error == nil else {
524
+ print("[Nebula] Failed to download assets for \(appId): \(error?.localizedDescription ?? "unknown")")
525
+ completion(false)
526
+ return
527
+ }
528
+
529
+ do {
530
+ try? FileManager.default.removeItem(at: zipDestination)
531
+ try FileManager.default.moveItem(at: location, to: zipDestination)
532
+ try self.extractZip(at: zipDestination, to: URL(fileURLWithPath: sandboxPath))
533
+ try? FileManager.default.removeItem(at: zipDestination)
534
+ print("[Nebula] Assets extracted for \(appId) to \(sandboxPath)")
535
+ completion(true)
536
+ } catch {
537
+ print("[Nebula] Failed to extract assets for \(appId): \(error.localizedDescription)")
538
+ try? FileManager.default.removeItem(at: zipDestination)
539
+ completion(false)
540
+ }
541
+ }
542
+
543
+ task.resume()
544
+ }
545
+
546
+ private func extractZip(at zipURL: URL, to destinationURL: URL) throws {
547
+ try FileManager.default.unzipItem(at: zipURL, to: destinationURL)
548
+ }
549
+ }