@onekeyfe/react-native-split-bundle-loader 0.1.1 → 1.1.51
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/android/src/main/java/com/splitbundleloader/SplitBundleLoaderModule.kt +169 -23
- package/ios/SplitBundleLoader.h +4 -0
- package/ios/SplitBundleLoader.mm +150 -62
- package/lib/module/NativeSplitBundleLoader.js.map +1 -1
- package/lib/typescript/src/NativeSplitBundleLoader.d.ts +1 -0
- package/lib/typescript/src/NativeSplitBundleLoader.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeSplitBundleLoader.ts +1 -0
|
@@ -9,6 +9,7 @@ import com.facebook.react.module.annotations.ReactModule
|
|
|
9
9
|
import java.io.File
|
|
10
10
|
import java.io.FileOutputStream
|
|
11
11
|
import java.io.IOException
|
|
12
|
+
import java.util.concurrent.Semaphore
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* TurboModule entry point for SplitBundleLoader.
|
|
@@ -26,6 +27,9 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
26
27
|
companion object {
|
|
27
28
|
const val NAME = "SplitBundleLoader"
|
|
28
29
|
private const val BUILTIN_EXTRACT_DIR = "onekey-builtin-segments"
|
|
30
|
+
// #18: Limit concurrent asset extractions to avoid I/O contention
|
|
31
|
+
private const val MAX_CONCURRENT_EXTRACTS = 2
|
|
32
|
+
private val extractSemaphore = Semaphore(MAX_CONCURRENT_EXTRACTS)
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
override fun getName(): String = NAME
|
|
@@ -73,11 +77,34 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
73
77
|
result.putString("bundleVersion", bundleVersion)
|
|
74
78
|
|
|
75
79
|
promise.resolve(result)
|
|
80
|
+
|
|
81
|
+
// #17: Clean up old version extract directories asynchronously
|
|
82
|
+
cleanupOldExtractDirs(context, nativeVersion)
|
|
76
83
|
} catch (e: Exception) {
|
|
77
84
|
promise.reject("SPLIT_BUNDLE_CONTEXT_ERROR", e.message, e)
|
|
78
85
|
}
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
// -----------------------------------------------------------------------
|
|
89
|
+
// resolveSegmentPath (Phase 3)
|
|
90
|
+
// -----------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
override fun resolveSegmentPath(relativePath: String, sha256: String, promise: Promise) {
|
|
93
|
+
try {
|
|
94
|
+
val absolutePath = resolveSegmentPath(relativePath, sha256)
|
|
95
|
+
if (absolutePath != null) {
|
|
96
|
+
promise.resolve(absolutePath)
|
|
97
|
+
} else {
|
|
98
|
+
promise.reject(
|
|
99
|
+
"SPLIT_BUNDLE_NOT_FOUND",
|
|
100
|
+
"Segment file not found: $relativePath"
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
} catch (e: Exception) {
|
|
104
|
+
promise.reject("SPLIT_BUNDLE_RESOLVE_ERROR", e.message, e)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
// -----------------------------------------------------------------------
|
|
82
109
|
// loadSegment
|
|
83
110
|
// -----------------------------------------------------------------------
|
|
@@ -89,10 +116,14 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
89
116
|
sha256: String,
|
|
90
117
|
promise: Promise
|
|
91
118
|
) {
|
|
119
|
+
// NOTE (#44): sha256 param is not verified at load time by design.
|
|
120
|
+
// Per §6.4.1, runtime trusts that OTA install has already verified
|
|
121
|
+
// segment integrity. Builtin segments are signed as part of the APK/IPA.
|
|
122
|
+
// If runtime SHA-256 verification is needed, add it here.
|
|
92
123
|
try {
|
|
93
124
|
val segId = segmentId.toInt()
|
|
94
125
|
|
|
95
|
-
val absolutePath = resolveSegmentPath(relativePath)
|
|
126
|
+
val absolutePath = resolveSegmentPath(relativePath, sha256)
|
|
96
127
|
if (absolutePath == null) {
|
|
97
128
|
promise.reject(
|
|
98
129
|
"SPLIT_BUNDLE_NOT_FOUND",
|
|
@@ -101,49 +132,95 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
101
132
|
return
|
|
102
133
|
}
|
|
103
134
|
|
|
104
|
-
//
|
|
135
|
+
// #19: Try CatalystInstance first (bridge mode), fall back to
|
|
136
|
+
// ReactHost registerSegment if available (bridgeless / new arch).
|
|
105
137
|
val reactContext = reactApplicationContext
|
|
106
138
|
if (reactContext.hasCatalystInstance()) {
|
|
107
139
|
reactContext.catalystInstance.registerSegment(segId, absolutePath)
|
|
108
140
|
SBLLogger.info("Loaded segment $segmentKey (id=$segId)")
|
|
109
141
|
promise.resolve(null)
|
|
110
142
|
} else {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
143
|
+
// Bridgeless: try ReactHost via reflection
|
|
144
|
+
val registered = tryRegisterViaBridgeless(segId, absolutePath)
|
|
145
|
+
if (registered) {
|
|
146
|
+
SBLLogger.info("Loaded segment $segmentKey (id=$segId) via bridgeless")
|
|
147
|
+
promise.resolve(null)
|
|
148
|
+
} else {
|
|
149
|
+
promise.reject(
|
|
150
|
+
"SPLIT_BUNDLE_NO_INSTANCE",
|
|
151
|
+
"Neither CatalystInstance nor ReactHost available"
|
|
152
|
+
)
|
|
153
|
+
}
|
|
115
154
|
}
|
|
116
155
|
} catch (e: Exception) {
|
|
117
156
|
promise.reject("SPLIT_BUNDLE_LOAD_ERROR", e.message, e)
|
|
118
157
|
}
|
|
119
158
|
}
|
|
120
159
|
|
|
160
|
+
// -----------------------------------------------------------------------
|
|
161
|
+
// Bridgeless support (#19)
|
|
162
|
+
// -----------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
private fun tryRegisterViaBridgeless(segmentId: Int, path: String): Boolean {
|
|
165
|
+
return try {
|
|
166
|
+
val appContext = reactApplicationContext.applicationContext
|
|
167
|
+
val appClass = appContext.javaClass
|
|
168
|
+
val hostMethod = appClass.getMethod("getReactHost")
|
|
169
|
+
val host = hostMethod.invoke(appContext) ?: return false
|
|
170
|
+
val registerMethod = host.javaClass.getMethod(
|
|
171
|
+
"registerSegment", Int::class.java, String::class.java
|
|
172
|
+
)
|
|
173
|
+
registerMethod.invoke(host, segmentId, path)
|
|
174
|
+
true
|
|
175
|
+
} catch (_: Exception) {
|
|
176
|
+
false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
121
180
|
// -----------------------------------------------------------------------
|
|
122
181
|
// Path resolution helpers
|
|
123
182
|
// -----------------------------------------------------------------------
|
|
124
183
|
|
|
125
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Verify resolved path stays within the expected root directory (#45).
|
|
186
|
+
* Prevents path traversal via ".." components in relativePath.
|
|
187
|
+
*/
|
|
188
|
+
private fun isPathWithinRoot(root: File, resolved: File): Boolean {
|
|
189
|
+
return resolved.canonicalPath.startsWith(root.canonicalPath + File.separator) ||
|
|
190
|
+
resolved.canonicalPath == root.canonicalPath
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private fun resolveSegmentPath(relativePath: String, expectedSha256: String): String? {
|
|
194
|
+
// Path traversal guard (#45)
|
|
195
|
+
if (relativePath.contains("..")) {
|
|
196
|
+
SBLLogger.warn("Path traversal rejected: $relativePath")
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
|
|
126
200
|
// 1. Try OTA bundle directory first
|
|
127
201
|
val otaBundlePath = getOtaBundlePath()
|
|
128
202
|
if (!otaBundlePath.isNullOrEmpty()) {
|
|
129
203
|
val otaRoot = File(otaBundlePath).parentFile
|
|
130
204
|
if (otaRoot != null) {
|
|
131
205
|
val candidate = File(otaRoot, relativePath)
|
|
132
|
-
if (candidate.exists()) {
|
|
206
|
+
if (candidate.exists() && isPathWithinRoot(otaRoot, candidate)) {
|
|
133
207
|
return candidate.absolutePath
|
|
134
208
|
}
|
|
135
209
|
}
|
|
136
210
|
}
|
|
137
211
|
|
|
138
212
|
// 2. Try builtin: extract from assets if needed
|
|
139
|
-
return extractBuiltinSegmentIfNeeded(relativePath)
|
|
213
|
+
return extractBuiltinSegmentIfNeeded(relativePath, expectedSha256)
|
|
140
214
|
}
|
|
141
215
|
|
|
142
216
|
/**
|
|
143
217
|
* For Android builtin segments, APK assets can't be passed directly as file paths.
|
|
144
218
|
* Extract the asset to the extract cache directory on first access.
|
|
219
|
+
*
|
|
220
|
+
* #16: Validates extracted file size against the asset to detect truncated extractions.
|
|
221
|
+
* #18: Uses semaphore to limit concurrent extractions.
|
|
145
222
|
*/
|
|
146
|
-
private fun extractBuiltinSegmentIfNeeded(relativePath: String): String? {
|
|
223
|
+
private fun extractBuiltinSegmentIfNeeded(relativePath: String, expectedSha256: String): String? {
|
|
147
224
|
val context = reactApplicationContext
|
|
148
225
|
val nativeVersion = try {
|
|
149
226
|
context.packageManager
|
|
@@ -155,32 +232,101 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
155
232
|
val extractDir = File(context.filesDir, "$BUILTIN_EXTRACT_DIR/$nativeVersion")
|
|
156
233
|
val extractedFile = File(extractDir, relativePath)
|
|
157
234
|
|
|
158
|
-
//
|
|
235
|
+
// #16: If file exists, verify it's not truncated by checking size against asset
|
|
159
236
|
if (extractedFile.exists()) {
|
|
160
|
-
|
|
237
|
+
val assetSize = getAssetSize(context.assets, relativePath)
|
|
238
|
+
if (assetSize >= 0 && extractedFile.length() == assetSize) {
|
|
239
|
+
return extractedFile.absolutePath
|
|
240
|
+
}
|
|
241
|
+
// Truncated or size mismatch — delete and re-extract
|
|
242
|
+
SBLLogger.warn("Extracted file size mismatch for $relativePath, re-extracting")
|
|
243
|
+
extractedFile.delete()
|
|
161
244
|
}
|
|
162
245
|
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
246
|
+
// #18: Limit concurrent extractions
|
|
247
|
+
extractSemaphore.acquire()
|
|
248
|
+
try {
|
|
249
|
+
// Double-check after acquiring semaphore (another thread may have extracted)
|
|
250
|
+
if (extractedFile.exists()) {
|
|
251
|
+
return extractedFile.absolutePath
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
val assets: AssetManager = context.assets
|
|
255
|
+
return try {
|
|
256
|
+
// Extract to temp file first, then atomically rename
|
|
257
|
+
val tempFile = File(extractedFile.parentFile, "${extractedFile.name}.tmp")
|
|
258
|
+
assets.open(relativePath).use { input ->
|
|
259
|
+
extractedFile.parentFile?.let { parent ->
|
|
260
|
+
if (!parent.exists()) parent.mkdirs()
|
|
261
|
+
}
|
|
262
|
+
FileOutputStream(tempFile).use { output ->
|
|
263
|
+
val buffer = ByteArray(8192)
|
|
264
|
+
var len: Int
|
|
265
|
+
while (input.read(buffer).also { len = it } != -1) {
|
|
266
|
+
output.write(buffer, 0, len)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Atomic rename prevents partial file observation
|
|
271
|
+
if (tempFile.renameTo(extractedFile)) {
|
|
272
|
+
extractedFile.absolutePath
|
|
273
|
+
} else {
|
|
274
|
+
tempFile.delete()
|
|
275
|
+
null
|
|
169
276
|
}
|
|
170
|
-
|
|
277
|
+
} catch (_: IOException) {
|
|
278
|
+
null
|
|
279
|
+
}
|
|
280
|
+
} finally {
|
|
281
|
+
extractSemaphore.release()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Returns the size of an asset file, or -1 if it can't be determined.
|
|
287
|
+
*/
|
|
288
|
+
private fun getAssetSize(assets: AssetManager, assetPath: String): Long {
|
|
289
|
+
return try {
|
|
290
|
+
assets.openFd(assetPath).use { it.length }
|
|
291
|
+
} catch (_: IOException) {
|
|
292
|
+
// Asset may be compressed; fall back to reading the stream
|
|
293
|
+
try {
|
|
294
|
+
assets.open(assetPath).use { input ->
|
|
295
|
+
var size = 0L
|
|
171
296
|
val buffer = ByteArray(8192)
|
|
172
297
|
var len: Int
|
|
173
298
|
while (input.read(buffer).also { len = it } != -1) {
|
|
174
|
-
|
|
299
|
+
size += len
|
|
175
300
|
}
|
|
301
|
+
size
|
|
176
302
|
}
|
|
303
|
+
} catch (_: IOException) {
|
|
304
|
+
-1
|
|
177
305
|
}
|
|
178
|
-
extractedFile.absolutePath
|
|
179
|
-
} catch (_: IOException) {
|
|
180
|
-
null
|
|
181
306
|
}
|
|
182
307
|
}
|
|
183
308
|
|
|
309
|
+
/**
|
|
310
|
+
* #17: Asynchronously clean up extract directories from previous native versions.
|
|
311
|
+
*/
|
|
312
|
+
private fun cleanupOldExtractDirs(context: Context, currentVersion: String) {
|
|
313
|
+
Thread {
|
|
314
|
+
try {
|
|
315
|
+
val baseDir = File(context.filesDir, BUILTIN_EXTRACT_DIR)
|
|
316
|
+
if (!baseDir.exists() || !baseDir.isDirectory) return@Thread
|
|
317
|
+
val dirs = baseDir.listFiles() ?: return@Thread
|
|
318
|
+
for (dir in dirs) {
|
|
319
|
+
if (dir.isDirectory && dir.name != currentVersion) {
|
|
320
|
+
SBLLogger.info("Cleaning up old extract dir: ${dir.name}")
|
|
321
|
+
dir.deleteRecursively()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch (e: Exception) {
|
|
325
|
+
SBLLogger.warn("Failed to cleanup old extract dirs: ${e.message}")
|
|
326
|
+
}
|
|
327
|
+
}.start()
|
|
328
|
+
}
|
|
329
|
+
|
|
184
330
|
private fun getOtaBundlePath(): String? {
|
|
185
331
|
return try {
|
|
186
332
|
val bundleUpdateStore = Class.forName(
|
package/ios/SplitBundleLoader.h
CHANGED
|
@@ -10,5 +10,9 @@
|
|
|
10
10
|
sha256:(NSString *)sha256
|
|
11
11
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
12
12
|
reject:(RCTPromiseRejectBlock)reject;
|
|
13
|
+
- (void)resolveSegmentPath:(NSString *)relativePath
|
|
14
|
+
sha256:(NSString *)sha256
|
|
15
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
16
|
+
reject:(RCTPromiseRejectBlock)reject;
|
|
13
17
|
|
|
14
18
|
@end
|
package/ios/SplitBundleLoader.mm
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
#import "SBLLogger.h"
|
|
3
3
|
#import <React/RCTBridge.h>
|
|
4
4
|
|
|
5
|
+
// Bridgeless (New Architecture) support: RCTHost segment registration
|
|
6
|
+
@interface RCTHost (SplitBundle)
|
|
7
|
+
- (void)registerSegmentWithId:(NSNumber *)segmentId path:(NSString *)path;
|
|
8
|
+
@end
|
|
9
|
+
|
|
5
10
|
@implementation SplitBundleLoader
|
|
6
11
|
|
|
7
12
|
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
@@ -21,6 +26,78 @@
|
|
|
21
26
|
return NO;
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
// MARK: - OTA bundle path helper
|
|
30
|
+
|
|
31
|
+
/// Safely retrieves the OTA bundle path via typed NSInvocation to avoid
|
|
32
|
+
/// performSelector ARC/signature issues (#15).
|
|
33
|
+
+ (nullable NSString *)otaBundlePath
|
|
34
|
+
{
|
|
35
|
+
Class cls = NSClassFromString(@"ReactNativeBundleUpdate.BundleUpdateStore");
|
|
36
|
+
if (!cls) return nil;
|
|
37
|
+
|
|
38
|
+
SEL sel = NSSelectorFromString(@"currentBundleMainJSBundle");
|
|
39
|
+
if (![cls respondsToSelector:sel]) return nil;
|
|
40
|
+
|
|
41
|
+
NSMethodSignature *sig = [cls methodSignatureForSelector:sel];
|
|
42
|
+
if (!sig || strcmp(sig.methodReturnType, @encode(id)) != 0) {
|
|
43
|
+
[SBLLogger warn:@"OTA method signature mismatch — skipping"];
|
|
44
|
+
return nil;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
|
48
|
+
inv.target = cls;
|
|
49
|
+
inv.selector = sel;
|
|
50
|
+
[inv invoke];
|
|
51
|
+
|
|
52
|
+
__unsafe_unretained id rawResult = nil;
|
|
53
|
+
[inv getReturnValue:&rawResult];
|
|
54
|
+
if (![rawResult isKindOfClass:[NSString class]]) return nil;
|
|
55
|
+
|
|
56
|
+
NSString *result = (NSString *)rawResult;
|
|
57
|
+
if (result.length == 0) return nil;
|
|
58
|
+
|
|
59
|
+
if ([result hasPrefix:@"file://"]) {
|
|
60
|
+
result = [[NSURL URLWithString:result] path];
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// MARK: - Segment registration helper
|
|
66
|
+
|
|
67
|
+
/// Registers a segment with the current runtime, supporting both legacy bridge
|
|
68
|
+
/// and bridgeless (RCTHost) architectures (#13).
|
|
69
|
+
///
|
|
70
|
+
/// Thread safety (#57): This method is called from the TurboModule (JS thread).
|
|
71
|
+
/// RCTBridge.registerSegmentWithId:path: internally registers the segment with
|
|
72
|
+
/// the Hermes runtime on the JS thread, which is the correct calling context.
|
|
73
|
+
/// No queue dispatch is needed.
|
|
74
|
+
+ (BOOL)registerSegment:(int)segmentId path:(NSString *)path error:(NSError **)outError
|
|
75
|
+
{
|
|
76
|
+
// Try legacy bridge first
|
|
77
|
+
RCTBridge *bridge = [RCTBridge currentBridge];
|
|
78
|
+
if (bridge && [bridge respondsToSelector:@selector(registerSegmentWithId:path:)]) {
|
|
79
|
+
[bridge registerSegmentWithId:@(segmentId) path:path];
|
|
80
|
+
return YES;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Try bridgeless RCTHost via AppDelegate
|
|
84
|
+
id<UIApplicationDelegate> appDelegate = [UIApplication sharedApplication].delegate;
|
|
85
|
+
if ([appDelegate respondsToSelector:NSSelectorFromString(@"reactHost")]) {
|
|
86
|
+
id host = [appDelegate performSelector:NSSelectorFromString(@"reactHost")];
|
|
87
|
+
if (host && [host respondsToSelector:@selector(registerSegmentWithId:path:)]) {
|
|
88
|
+
[host registerSegmentWithId:@(segmentId) path:path];
|
|
89
|
+
return YES;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (outError) {
|
|
94
|
+
*outError = [NSError errorWithDomain:@"SplitBundleLoader"
|
|
95
|
+
code:1
|
|
96
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Neither RCTBridge nor RCTHost available for segment registration"}];
|
|
97
|
+
}
|
|
98
|
+
return NO;
|
|
99
|
+
}
|
|
100
|
+
|
|
24
101
|
// MARK: - getRuntimeBundleContext
|
|
25
102
|
|
|
26
103
|
- (void)getRuntimeBundleContext:(RCTPromiseResolveBlock)resolve
|
|
@@ -33,31 +110,12 @@
|
|
|
33
110
|
NSString *nativeVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"";
|
|
34
111
|
NSString *bundleVersion = @"";
|
|
35
112
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
SEL sel = NSSelectorFromString(@"currentBundleMainJSBundle");
|
|
41
|
-
if ([bundleUpdateStoreClass respondsToSelector:sel]) {
|
|
42
|
-
#pragma clang diagnostic push
|
|
43
|
-
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
|
44
|
-
id result = [bundleUpdateStoreClass performSelector:sel];
|
|
45
|
-
#pragma clang diagnostic pop
|
|
46
|
-
otaBundlePath = [result isKindOfClass:[NSString class]] ? (NSString *)result : nil;
|
|
47
|
-
}
|
|
48
|
-
if (otaBundlePath && otaBundlePath.length > 0) {
|
|
49
|
-
NSString *filePath = otaBundlePath;
|
|
50
|
-
if ([otaBundlePath hasPrefix:@"file://"]) {
|
|
51
|
-
filePath = [[NSURL URLWithString:otaBundlePath] path];
|
|
52
|
-
}
|
|
53
|
-
if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
|
|
54
|
-
sourceKind = @"ota";
|
|
55
|
-
bundleRoot = [filePath stringByDeletingLastPathComponent];
|
|
56
|
-
}
|
|
57
|
-
}
|
|
113
|
+
NSString *otaPath = [SplitBundleLoader otaBundlePath];
|
|
114
|
+
if (otaPath && [[NSFileManager defaultManager] fileExistsAtPath:otaPath]) {
|
|
115
|
+
sourceKind = @"ota";
|
|
116
|
+
bundleRoot = [otaPath stringByDeletingLastPathComponent];
|
|
58
117
|
}
|
|
59
118
|
|
|
60
|
-
// Builtin: use main bundle resource path
|
|
61
119
|
if ([sourceKind isEqualToString:@"builtin"]) {
|
|
62
120
|
bundleRoot = [[NSBundle mainBundle] resourcePath] ?: @"";
|
|
63
121
|
}
|
|
@@ -74,6 +132,62 @@
|
|
|
74
132
|
}
|
|
75
133
|
}
|
|
76
134
|
|
|
135
|
+
// MARK: - Path resolution helper
|
|
136
|
+
|
|
137
|
+
/// Resolves a relative segment path to an absolute path, checking OTA then builtin.
|
|
138
|
+
/// Returns nil if the segment file is not found.
|
|
139
|
+
+ (nullable NSString *)resolveAbsolutePath:(NSString *)relativePath
|
|
140
|
+
{
|
|
141
|
+
// 1. Try OTA bundle root first
|
|
142
|
+
NSString *otaPath = [SplitBundleLoader otaBundlePath];
|
|
143
|
+
if (otaPath) {
|
|
144
|
+
NSString *otaRoot = [otaPath stringByDeletingLastPathComponent];
|
|
145
|
+
NSString *candidate = [[otaRoot stringByAppendingPathComponent:relativePath] stringByStandardizingPath];
|
|
146
|
+
if ([candidate hasPrefix:otaRoot] &&
|
|
147
|
+
[[NSFileManager defaultManager] fileExistsAtPath:candidate]) {
|
|
148
|
+
return candidate;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 2. Fallback to builtin resource path
|
|
153
|
+
NSString *builtinRoot = [[NSBundle mainBundle] resourcePath];
|
|
154
|
+
NSString *candidate = [[builtinRoot stringByAppendingPathComponent:relativePath] stringByStandardizingPath];
|
|
155
|
+
if ([candidate hasPrefix:builtinRoot] &&
|
|
156
|
+
[[NSFileManager defaultManager] fileExistsAtPath:candidate]) {
|
|
157
|
+
return candidate;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return nil;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// MARK: - resolveSegmentPath (Phase 3)
|
|
164
|
+
|
|
165
|
+
- (void)resolveSegmentPath:(NSString *)relativePath
|
|
166
|
+
sha256:(NSString *)sha256
|
|
167
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
168
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
169
|
+
{
|
|
170
|
+
@try {
|
|
171
|
+
if ([relativePath containsString:@".."]) {
|
|
172
|
+
reject(@"SPLIT_BUNDLE_INVALID_PATH",
|
|
173
|
+
[NSString stringWithFormat:@"Path traversal rejected: %@", relativePath],
|
|
174
|
+
nil);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
NSString *absolutePath = [SplitBundleLoader resolveAbsolutePath:relativePath];
|
|
179
|
+
if (absolutePath) {
|
|
180
|
+
resolve(absolutePath);
|
|
181
|
+
} else {
|
|
182
|
+
reject(@"SPLIT_BUNDLE_NOT_FOUND",
|
|
183
|
+
[NSString stringWithFormat:@"Segment file not found: %@", relativePath],
|
|
184
|
+
nil);
|
|
185
|
+
}
|
|
186
|
+
} @catch (NSException *exception) {
|
|
187
|
+
reject(@"SPLIT_BUNDLE_RESOLVE_ERROR", exception.reason, nil);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
77
191
|
// MARK: - loadSegment
|
|
78
192
|
|
|
79
193
|
- (void)loadSegment:(double)segmentId
|
|
@@ -86,42 +200,15 @@
|
|
|
86
200
|
@try {
|
|
87
201
|
int segId = (int)segmentId;
|
|
88
202
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
NSString *otaBundlePath = nil;
|
|
96
|
-
SEL sel = NSSelectorFromString(@"currentBundleMainJSBundle");
|
|
97
|
-
if ([bundleUpdateStoreClass respondsToSelector:sel]) {
|
|
98
|
-
#pragma clang diagnostic push
|
|
99
|
-
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
|
100
|
-
id result = [bundleUpdateStoreClass performSelector:sel];
|
|
101
|
-
#pragma clang diagnostic pop
|
|
102
|
-
otaBundlePath = [result isKindOfClass:[NSString class]] ? (NSString *)result : nil;
|
|
103
|
-
}
|
|
104
|
-
if (otaBundlePath && otaBundlePath.length > 0) {
|
|
105
|
-
NSString *filePath = otaBundlePath;
|
|
106
|
-
if ([otaBundlePath hasPrefix:@"file://"]) {
|
|
107
|
-
filePath = [[NSURL URLWithString:otaBundlePath] path];
|
|
108
|
-
}
|
|
109
|
-
NSString *otaRoot = [filePath stringByDeletingLastPathComponent];
|
|
110
|
-
NSString *candidate = [otaRoot stringByAppendingPathComponent:relativePath];
|
|
111
|
-
if ([[NSFileManager defaultManager] fileExistsAtPath:candidate]) {
|
|
112
|
-
absolutePath = candidate;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
203
|
+
// Path traversal guard (#45)
|
|
204
|
+
if ([relativePath containsString:@".."]) {
|
|
205
|
+
reject(@"SPLIT_BUNDLE_INVALID_PATH",
|
|
206
|
+
[NSString stringWithFormat:@"Path traversal rejected: %@", relativePath],
|
|
207
|
+
nil);
|
|
208
|
+
return;
|
|
115
209
|
}
|
|
116
210
|
|
|
117
|
-
|
|
118
|
-
if (!absolutePath) {
|
|
119
|
-
NSString *builtinRoot = [[NSBundle mainBundle] resourcePath];
|
|
120
|
-
NSString *candidate = [builtinRoot stringByAppendingPathComponent:relativePath];
|
|
121
|
-
if ([[NSFileManager defaultManager] fileExistsAtPath:candidate]) {
|
|
122
|
-
absolutePath = candidate;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
211
|
+
NSString *absolutePath = [SplitBundleLoader resolveAbsolutePath:relativePath];
|
|
125
212
|
|
|
126
213
|
if (!absolutePath) {
|
|
127
214
|
reject(@"SPLIT_BUNDLE_NOT_FOUND",
|
|
@@ -130,14 +217,15 @@
|
|
|
130
217
|
return;
|
|
131
218
|
}
|
|
132
219
|
|
|
133
|
-
// Register segment
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
[bridge registerSegmentWithId:@(segId) path:absolutePath];
|
|
220
|
+
// Register segment (#13: supports both bridge and bridgeless)
|
|
221
|
+
NSError *regError = nil;
|
|
222
|
+
if ([SplitBundleLoader registerSegment:segId path:absolutePath error:®Error]) {
|
|
137
223
|
[SBLLogger info:[NSString stringWithFormat:@"Loaded segment %@ (id=%d)", segmentKey, segId]];
|
|
138
224
|
resolve(nil);
|
|
139
225
|
} else {
|
|
140
|
-
reject(@"
|
|
226
|
+
reject(@"SPLIT_BUNDLE_NO_RUNTIME",
|
|
227
|
+
regError.localizedDescription ?: @"Runtime not available",
|
|
228
|
+
regError);
|
|
141
229
|
}
|
|
142
230
|
} @catch (NSException *exception) {
|
|
143
231
|
reject(@"SPLIT_BUNDLE_LOAD_ERROR",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeSplitBundleLoader.ts"],"mappings":";;AAAA,SAASA,mBAAmB,QAAQ,cAAc;
|
|
1
|
+
{"version":3,"names":["TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeSplitBundleLoader.ts"],"mappings":";;AAAA,SAASA,mBAAmB,QAAQ,cAAc;AAsBlD,eAAeA,mBAAmB,CAACC,YAAY,CAAO,mBAAmB,CAAC","ignoreList":[]}
|
|
@@ -9,6 +9,7 @@ export interface Spec extends TurboModule {
|
|
|
9
9
|
bundleVersion?: string;
|
|
10
10
|
}>;
|
|
11
11
|
loadSegment(segmentId: number, segmentKey: string, relativePath: string, sha256: string): Promise<void>;
|
|
12
|
+
resolveSegmentPath(relativePath: string, sha256: string): Promise<string>;
|
|
12
13
|
}
|
|
13
14
|
declare const _default: Spec;
|
|
14
15
|
export default _default;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NativeSplitBundleLoader.d.ts","sourceRoot":"","sources":["../../../src/NativeSplitBundleLoader.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,MAAM,WAAW,IAAK,SAAQ,WAAW;IACvC,uBAAuB,IAAI,OAAO,CAAC;QACjC,WAAW,EAAE,MAAM,CAAC;QACpB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,aAAa,EAAE,MAAM,CAAC;QACtB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,CAAC,CAAC;IACH,WAAW,CACT,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"NativeSplitBundleLoader.d.ts","sourceRoot":"","sources":["../../../src/NativeSplitBundleLoader.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,MAAM,WAAW,IAAK,SAAQ,WAAW;IACvC,uBAAuB,IAAI,OAAO,CAAC;QACjC,WAAW,EAAE,MAAM,CAAC;QACpB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,aAAa,EAAE,MAAM,CAAC;QACtB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,CAAC,CAAC;IACH,WAAW,CACT,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB,kBAAkB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC3E;;AAED,wBAA2E"}
|
package/package.json
CHANGED
|
@@ -17,6 +17,7 @@ export interface Spec extends TurboModule {
|
|
|
17
17
|
relativePath: string,
|
|
18
18
|
sha256: string,
|
|
19
19
|
): Promise<void>;
|
|
20
|
+
resolveSegmentPath(relativePath: string, sha256: string): Promise<string>;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export default TurboModuleRegistry.getEnforcing<Spec>('SplitBundleLoader');
|