@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
- // Register segment via CatalystInstance
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
- promise.reject(
112
- "SPLIT_BUNDLE_NO_INSTANCE",
113
- "CatalystInstance not available"
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
- // Already extracted
196
+ // #16: If file exists, verify it's not truncated by checking size against asset
159
197
  if (extractedFile.exists()) {
160
- return extractedFile.absolutePath
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
- // Extract from assets
164
- val assets: AssetManager = context.assets
165
- return try {
166
- assets.open(relativePath).use { input ->
167
- extractedFile.parentFile?.let { parent ->
168
- if (!parent.exists()) parent.mkdirs()
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
- FileOutputStream(extractedFile).use { output ->
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
- output.write(buffer, 0, len)
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(
@@ -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
- // Check if OTA bundle is active via BundleUpdateStore
37
- Class bundleUpdateStoreClass = NSClassFromString(@"ReactNativeBundleUpdate.BundleUpdateStore");
38
- if (bundleUpdateStoreClass) {
39
- NSString *otaBundlePath = nil;
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
- Class bundleUpdateStoreClass = NSClassFromString(@"ReactNativeBundleUpdate.BundleUpdateStore");
94
- if (bundleUpdateStoreClass) {
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
- }
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 via RCTBridge
134
- RCTBridge *bridge = [RCTBridge currentBridge];
135
- if (bridge) {
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:&regError]) {
137
172
  [SBLLogger info:[NSString stringWithFormat:@"Loaded segment %@ (id=%d)", segmentKey, segId]];
138
173
  resolve(nil);
139
174
  } else {
140
- reject(@"SPLIT_BUNDLE_NO_BRIDGE", @"RCTBridge not available", nil);
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-split-bundle-loader",
3
- "version": "0.1.1",
3
+ "version": "1.1.50",
4
4
  "description": "react-native-split-bundle-loader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",