@onekeyfe/react-native-split-bundle-loader 0.1.1 → 1.1.50
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.
|
@@ -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,6 +77,9 @@ 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
|
}
|
|
@@ -92,7 +99,7 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
92
99
|
try {
|
|
93
100
|
val segId = segmentId.toInt()
|
|
94
101
|
|
|
95
|
-
val absolutePath = resolveSegmentPath(relativePath)
|
|
102
|
+
val absolutePath = resolveSegmentPath(relativePath, sha256)
|
|
96
103
|
if (absolutePath == null) {
|
|
97
104
|
promise.reject(
|
|
98
105
|
"SPLIT_BUNDLE_NOT_FOUND",
|
|
@@ -101,28 +108,56 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
101
108
|
return
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
//
|
|
111
|
+
// #19: Try CatalystInstance first (bridge mode), fall back to
|
|
112
|
+
// ReactHost registerSegment if available (bridgeless / new arch).
|
|
105
113
|
val reactContext = reactApplicationContext
|
|
106
114
|
if (reactContext.hasCatalystInstance()) {
|
|
107
115
|
reactContext.catalystInstance.registerSegment(segId, absolutePath)
|
|
108
116
|
SBLLogger.info("Loaded segment $segmentKey (id=$segId)")
|
|
109
117
|
promise.resolve(null)
|
|
110
118
|
} else {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
// Bridgeless: try ReactHost via reflection
|
|
120
|
+
val registered = tryRegisterViaBridgeless(segId, absolutePath)
|
|
121
|
+
if (registered) {
|
|
122
|
+
SBLLogger.info("Loaded segment $segmentKey (id=$segId) via bridgeless")
|
|
123
|
+
promise.resolve(null)
|
|
124
|
+
} else {
|
|
125
|
+
promise.reject(
|
|
126
|
+
"SPLIT_BUNDLE_NO_INSTANCE",
|
|
127
|
+
"Neither CatalystInstance nor ReactHost available"
|
|
128
|
+
)
|
|
129
|
+
}
|
|
115
130
|
}
|
|
116
131
|
} catch (e: Exception) {
|
|
117
132
|
promise.reject("SPLIT_BUNDLE_LOAD_ERROR", e.message, e)
|
|
118
133
|
}
|
|
119
134
|
}
|
|
120
135
|
|
|
136
|
+
// -----------------------------------------------------------------------
|
|
137
|
+
// Bridgeless support (#19)
|
|
138
|
+
// -----------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
private fun tryRegisterViaBridgeless(segmentId: Int, path: String): Boolean {
|
|
141
|
+
return try {
|
|
142
|
+
val appContext = reactApplicationContext.applicationContext
|
|
143
|
+
val appClass = appContext.javaClass
|
|
144
|
+
val hostMethod = appClass.getMethod("getReactHost")
|
|
145
|
+
val host = hostMethod.invoke(appContext) ?: return false
|
|
146
|
+
val registerMethod = host.javaClass.getMethod(
|
|
147
|
+
"registerSegment", Int::class.java, String::class.java
|
|
148
|
+
)
|
|
149
|
+
registerMethod.invoke(host, segmentId, path)
|
|
150
|
+
true
|
|
151
|
+
} catch (_: Exception) {
|
|
152
|
+
false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
121
156
|
// -----------------------------------------------------------------------
|
|
122
157
|
// Path resolution helpers
|
|
123
158
|
// -----------------------------------------------------------------------
|
|
124
159
|
|
|
125
|
-
private fun resolveSegmentPath(relativePath: String): String? {
|
|
160
|
+
private fun resolveSegmentPath(relativePath: String, expectedSha256: String): String? {
|
|
126
161
|
// 1. Try OTA bundle directory first
|
|
127
162
|
val otaBundlePath = getOtaBundlePath()
|
|
128
163
|
if (!otaBundlePath.isNullOrEmpty()) {
|
|
@@ -136,14 +171,17 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
136
171
|
}
|
|
137
172
|
|
|
138
173
|
// 2. Try builtin: extract from assets if needed
|
|
139
|
-
return extractBuiltinSegmentIfNeeded(relativePath)
|
|
174
|
+
return extractBuiltinSegmentIfNeeded(relativePath, expectedSha256)
|
|
140
175
|
}
|
|
141
176
|
|
|
142
177
|
/**
|
|
143
178
|
* For Android builtin segments, APK assets can't be passed directly as file paths.
|
|
144
179
|
* Extract the asset to the extract cache directory on first access.
|
|
180
|
+
*
|
|
181
|
+
* #16: Validates extracted file size against the asset to detect truncated extractions.
|
|
182
|
+
* #18: Uses semaphore to limit concurrent extractions.
|
|
145
183
|
*/
|
|
146
|
-
private fun extractBuiltinSegmentIfNeeded(relativePath: String): String? {
|
|
184
|
+
private fun extractBuiltinSegmentIfNeeded(relativePath: String, expectedSha256: String): String? {
|
|
147
185
|
val context = reactApplicationContext
|
|
148
186
|
val nativeVersion = try {
|
|
149
187
|
context.packageManager
|
|
@@ -155,32 +193,101 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
|
|
|
155
193
|
val extractDir = File(context.filesDir, "$BUILTIN_EXTRACT_DIR/$nativeVersion")
|
|
156
194
|
val extractedFile = File(extractDir, relativePath)
|
|
157
195
|
|
|
158
|
-
//
|
|
196
|
+
// #16: If file exists, verify it's not truncated by checking size against asset
|
|
159
197
|
if (extractedFile.exists()) {
|
|
160
|
-
|
|
198
|
+
val assetSize = getAssetSize(context.assets, relativePath)
|
|
199
|
+
if (assetSize >= 0 && extractedFile.length() == assetSize) {
|
|
200
|
+
return extractedFile.absolutePath
|
|
201
|
+
}
|
|
202
|
+
// Truncated or size mismatch — delete and re-extract
|
|
203
|
+
SBLLogger.warn("Extracted file size mismatch for $relativePath, re-extracting")
|
|
204
|
+
extractedFile.delete()
|
|
161
205
|
}
|
|
162
206
|
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
207
|
+
// #18: Limit concurrent extractions
|
|
208
|
+
extractSemaphore.acquire()
|
|
209
|
+
try {
|
|
210
|
+
// Double-check after acquiring semaphore (another thread may have extracted)
|
|
211
|
+
if (extractedFile.exists()) {
|
|
212
|
+
return extractedFile.absolutePath
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
val assets: AssetManager = context.assets
|
|
216
|
+
return try {
|
|
217
|
+
// Extract to temp file first, then atomically rename
|
|
218
|
+
val tempFile = File(extractedFile.parentFile, "${extractedFile.name}.tmp")
|
|
219
|
+
assets.open(relativePath).use { input ->
|
|
220
|
+
extractedFile.parentFile?.let { parent ->
|
|
221
|
+
if (!parent.exists()) parent.mkdirs()
|
|
222
|
+
}
|
|
223
|
+
FileOutputStream(tempFile).use { output ->
|
|
224
|
+
val buffer = ByteArray(8192)
|
|
225
|
+
var len: Int
|
|
226
|
+
while (input.read(buffer).also { len = it } != -1) {
|
|
227
|
+
output.write(buffer, 0, len)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
169
230
|
}
|
|
170
|
-
|
|
231
|
+
// Atomic rename prevents partial file observation
|
|
232
|
+
if (tempFile.renameTo(extractedFile)) {
|
|
233
|
+
extractedFile.absolutePath
|
|
234
|
+
} else {
|
|
235
|
+
tempFile.delete()
|
|
236
|
+
null
|
|
237
|
+
}
|
|
238
|
+
} catch (_: IOException) {
|
|
239
|
+
null
|
|
240
|
+
}
|
|
241
|
+
} finally {
|
|
242
|
+
extractSemaphore.release()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Returns the size of an asset file, or -1 if it can't be determined.
|
|
248
|
+
*/
|
|
249
|
+
private fun getAssetSize(assets: AssetManager, assetPath: String): Long {
|
|
250
|
+
return try {
|
|
251
|
+
assets.openFd(assetPath).use { it.length }
|
|
252
|
+
} catch (_: IOException) {
|
|
253
|
+
// Asset may be compressed; fall back to reading the stream
|
|
254
|
+
try {
|
|
255
|
+
assets.open(assetPath).use { input ->
|
|
256
|
+
var size = 0L
|
|
171
257
|
val buffer = ByteArray(8192)
|
|
172
258
|
var len: Int
|
|
173
259
|
while (input.read(buffer).also { len = it } != -1) {
|
|
174
|
-
|
|
260
|
+
size += len
|
|
175
261
|
}
|
|
262
|
+
size
|
|
176
263
|
}
|
|
264
|
+
} catch (_: IOException) {
|
|
265
|
+
-1
|
|
177
266
|
}
|
|
178
|
-
extractedFile.absolutePath
|
|
179
|
-
} catch (_: IOException) {
|
|
180
|
-
null
|
|
181
267
|
}
|
|
182
268
|
}
|
|
183
269
|
|
|
270
|
+
/**
|
|
271
|
+
* #17: Asynchronously clean up extract directories from previous native versions.
|
|
272
|
+
*/
|
|
273
|
+
private fun cleanupOldExtractDirs(context: Context, currentVersion: String) {
|
|
274
|
+
Thread {
|
|
275
|
+
try {
|
|
276
|
+
val baseDir = File(context.filesDir, BUILTIN_EXTRACT_DIR)
|
|
277
|
+
if (!baseDir.exists() || !baseDir.isDirectory) return@Thread
|
|
278
|
+
val dirs = baseDir.listFiles() ?: return@Thread
|
|
279
|
+
for (dir in dirs) {
|
|
280
|
+
if (dir.isDirectory && dir.name != currentVersion) {
|
|
281
|
+
SBLLogger.info("Cleaning up old extract dir: ${dir.name}")
|
|
282
|
+
dir.deleteRecursively()
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch (e: Exception) {
|
|
286
|
+
SBLLogger.warn("Failed to cleanup old extract dirs: ${e.message}")
|
|
287
|
+
}
|
|
288
|
+
}.start()
|
|
289
|
+
}
|
|
290
|
+
|
|
184
291
|
private fun getOtaBundlePath(): String? {
|
|
185
292
|
return try {
|
|
186
293
|
val bundleUpdateStore = Class.forName(
|
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,73 @@
|
|
|
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
|
+
+ (BOOL)registerSegment:(int)segmentId path:(NSString *)path error:(NSError **)outError
|
|
70
|
+
{
|
|
71
|
+
// Try legacy bridge first
|
|
72
|
+
RCTBridge *bridge = [RCTBridge currentBridge];
|
|
73
|
+
if (bridge && [bridge respondsToSelector:@selector(registerSegmentWithId:path:)]) {
|
|
74
|
+
[bridge registerSegmentWithId:@(segmentId) path:path];
|
|
75
|
+
return YES;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Try bridgeless RCTHost via AppDelegate
|
|
79
|
+
id<UIApplicationDelegate> appDelegate = [UIApplication sharedApplication].delegate;
|
|
80
|
+
if ([appDelegate respondsToSelector:NSSelectorFromString(@"reactHost")]) {
|
|
81
|
+
id host = [appDelegate performSelector:NSSelectorFromString(@"reactHost")];
|
|
82
|
+
if (host && [host respondsToSelector:@selector(registerSegmentWithId:path:)]) {
|
|
83
|
+
[host registerSegmentWithId:@(segmentId) path:path];
|
|
84
|
+
return YES;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (outError) {
|
|
89
|
+
*outError = [NSError errorWithDomain:@"SplitBundleLoader"
|
|
90
|
+
code:1
|
|
91
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Neither RCTBridge nor RCTHost available for segment registration"}];
|
|
92
|
+
}
|
|
93
|
+
return NO;
|
|
94
|
+
}
|
|
95
|
+
|
|
24
96
|
// MARK: - getRuntimeBundleContext
|
|
25
97
|
|
|
26
98
|
- (void)getRuntimeBundleContext:(RCTPromiseResolveBlock)resolve
|
|
@@ -33,31 +105,12 @@
|
|
|
33
105
|
NSString *nativeVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"";
|
|
34
106
|
NSString *bundleVersion = @"";
|
|
35
107
|
|
|
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
|
-
}
|
|
108
|
+
NSString *otaPath = [SplitBundleLoader otaBundlePath];
|
|
109
|
+
if (otaPath && [[NSFileManager defaultManager] fileExistsAtPath:otaPath]) {
|
|
110
|
+
sourceKind = @"ota";
|
|
111
|
+
bundleRoot = [otaPath stringByDeletingLastPathComponent];
|
|
58
112
|
}
|
|
59
113
|
|
|
60
|
-
// Builtin: use main bundle resource path
|
|
61
114
|
if ([sourceKind isEqualToString:@"builtin"]) {
|
|
62
115
|
bundleRoot = [[NSBundle mainBundle] resourcePath] ?: @"";
|
|
63
116
|
}
|
|
@@ -85,32 +138,15 @@
|
|
|
85
138
|
{
|
|
86
139
|
@try {
|
|
87
140
|
int segId = (int)segmentId;
|
|
88
|
-
|
|
89
|
-
// Resolve absolute path
|
|
90
141
|
NSString *absolutePath = nil;
|
|
91
142
|
|
|
92
143
|
// 1. Try OTA bundle root first
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
NSString *
|
|
96
|
-
|
|
97
|
-
if ([
|
|
98
|
-
|
|
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
|
-
}
|
|
144
|
+
NSString *otaPath = [SplitBundleLoader otaBundlePath];
|
|
145
|
+
if (otaPath) {
|
|
146
|
+
NSString *otaRoot = [otaPath stringByDeletingLastPathComponent];
|
|
147
|
+
NSString *candidate = [otaRoot stringByAppendingPathComponent:relativePath];
|
|
148
|
+
if ([[NSFileManager defaultManager] fileExistsAtPath:candidate]) {
|
|
149
|
+
absolutePath = candidate;
|
|
114
150
|
}
|
|
115
151
|
}
|
|
116
152
|
|
|
@@ -130,14 +166,15 @@
|
|
|
130
166
|
return;
|
|
131
167
|
}
|
|
132
168
|
|
|
133
|
-
// Register segment
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
[bridge registerSegmentWithId:@(segId) path:absolutePath];
|
|
169
|
+
// Register segment (#13: supports both bridge and bridgeless)
|
|
170
|
+
NSError *regError = nil;
|
|
171
|
+
if ([SplitBundleLoader registerSegment:segId path:absolutePath error:®Error]) {
|
|
137
172
|
[SBLLogger info:[NSString stringWithFormat:@"Loaded segment %@ (id=%d)", segmentKey, segId]];
|
|
138
173
|
resolve(nil);
|
|
139
174
|
} else {
|
|
140
|
-
reject(@"
|
|
175
|
+
reject(@"SPLIT_BUNDLE_NO_RUNTIME",
|
|
176
|
+
regError.localizedDescription ?: @"Runtime not available",
|
|
177
|
+
regError);
|
|
141
178
|
}
|
|
142
179
|
} @catch (NSException *exception) {
|
|
143
180
|
reject(@"SPLIT_BUNDLE_LOAD_ERROR",
|