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