@onekeyfe/react-native-bundle-update 3.0.29 → 3.0.31

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.
@@ -409,10 +409,20 @@ object BundleUpdateStoreAndroid {
409
409
  /**
410
410
  * Subtype of the most recent calculateSHA256 failure on this thread, or
411
411
  * null if the last call succeeded. Surfaces the specific reason —
412
- * FILE_NOT_FOUND / FILE_EMPTY / FILE_TRUNCATED / OOM / IO_<class> /
413
- * UNEXPECTED_<class> — so analytics can split the previously opaque
414
- * "Failed to calculate SHA256" bucket (mixpanel: 91.3 percent of
415
- * verifyPackage failures) into actionable categories.
412
+ * FILE_NOT_FOUND / FILE_DISAPPEARED / FILE_TRUNCATED /
413
+ * PERMISSION_DENIED / OOM / IO_<class> / UNEXPECTED_<class> — so
414
+ * analytics can split the previously opaque "Failed to calculate
415
+ * SHA256" bucket (mixpanel: 91.3 percent of verifyPackage failures)
416
+ * into actionable categories. Keep this list in sync with the
417
+ * lastSHA256Failure.set(...) call sites in calculateSHA256 below.
418
+ *
419
+ * Note: 0-byte files are NOT treated as a failure. They hash to the
420
+ * well-known empty-content SHA-256 and the caller's expected/actual
421
+ * comparison handles legitimate vs. corrupt-empty cases. Rejecting
422
+ * empty files here would make any OTA bundle that legitimately
423
+ * contains a 0-byte file (touched marker, blank locale fallback)
424
+ * fail validateAllFilesInDir / validateWebEmbedSha256 / launch entry
425
+ * verification — all of which share this calculator.
416
426
  */
417
427
  fun lastSHA256FailureReason(): String? = lastSHA256Failure.get()
418
428
 
@@ -424,11 +434,6 @@ object BundleUpdateStoreAndroid {
424
434
  OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: $filePath")
425
435
  return null
426
436
  }
427
- if (file.length() == 0L) {
428
- lastSHA256Failure.set("FILE_EMPTY")
429
- OneKeyLog.error("BundleUpdate", "calculateSHA256: file empty: $filePath")
430
- return null
431
- }
432
437
  return try {
433
438
  val digest = MessageDigest.getInstance("SHA-256")
434
439
  BufferedInputStream(FileInputStream(filePath)).use { bis ->
@@ -1460,8 +1465,10 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1460
1465
  }
1461
1466
  OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt")
1462
1467
  if (partialFile.exists()) partialFile.delete()
1463
- sendEvent("update/error", message = "HTTP 416 (range not satisfiable)")
1464
- throw Exception("HTTP 416")
1468
+ // Don't pre-emit update/error here; the outer catch is the
1469
+ // single source of error events. sanitizeErrorMessageForEvent
1470
+ // recognizes "HTTP " prefix and forwards this string verbatim.
1471
+ throw Exception("HTTP 416 (range not satisfiable)")
1465
1472
  }
1466
1473
 
1467
1474
  val expectsResume = partialBytes > 0
@@ -1470,7 +1477,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1470
1477
  if (!response.isSuccessful || (response.code != 200 && response.code != 206)) {
1471
1478
  OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}")
1472
1479
  response.close()
1473
- sendEvent("update/error", message = "HTTP ${response.code}")
1480
+ // outer catch is the single source of update/error events.
1474
1481
  throw Exception("HTTP ${response.code}")
1475
1482
  }
1476
1483
 
@@ -1542,7 +1549,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1542
1549
  if (downloadedFile.exists()) downloadedFile.delete()
1543
1550
  if (!partialAfter.renameTo(downloadedFile)) {
1544
1551
  OneKeyLog.error("BundleUpdate", "downloadBundle: rename .partial -> final failed")
1545
- sendEvent("update/error", message = "rename .partial failed")
1552
+ // outer catch is the single source of update/error events;
1553
+ // "Failed to finalize download" is in the verbatim allowlist.
1546
1554
  throw Exception("Failed to finalize download")
1547
1555
  }
1548
1556
 
@@ -1551,7 +1559,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1551
1559
  val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH"
1552
1560
  File(filePath).delete()
1553
1561
  OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=$reason")
1554
- sendEvent("update/error", message = "SHA256_$reason")
1562
+ // outer catch emits the verbatim "Bundle SHA256 verification
1563
+ // failed: <REASON>" payload (recognized by sanitize/JS).
1555
1564
  throw Exception("Bundle SHA256 verification failed: $reason")
1556
1565
  }
1557
1566
 
@@ -1563,13 +1572,22 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1563
1572
  // event channel must NOT carry e.message verbatim — Android
1564
1573
  // FileNotFoundException etc. embed the full /data/user/.../
1565
1574
  // path including the package identifier, and downstream
1566
- // listeners would forward that into analytics. Emit only a
1567
- // domain+code shape; mirrors the iOS sendEvent payload at
1568
- // ReactNativeBundleUpdate.swift's "update/error" sites.
1575
+ // listeners would forward that into analytics. Emit only the
1576
+ // sanitized tag; mirrors the iOS sendEvent payload at
1577
+ // ReactNativeBundleUpdate.swift's "update/error" sites and
1578
+ // matches the verbatim guarantees documented on
1579
+ // sanitizeErrorMessageForEvent.
1569
1580
  OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}")
1570
- val codeTag = sanitizeErrorMessageForEvent(e)
1571
- sendEvent("update/error", message = "${e.javaClass.simpleName}: $codeTag")
1572
- throw e
1581
+ val sanitized = sanitizeErrorMessageForEvent(e)
1582
+ sendEvent("update/error", message = sanitized)
1583
+ // Rethrow with the same sanitized message so the Promise
1584
+ // rejection surfacing to JS carries no /data/user/<u>/<pkg>/
1585
+ // paths. Without this rewrap, FileNotFoundException etc.
1586
+ // would re-leak via Promise.reject's message channel even
1587
+ // though the event payload was already sanitized. Keep
1588
+ // the original exception as `cause` so OneKeyLog (and any
1589
+ // native crash reporter) still sees the full chain.
1590
+ throw Exception(sanitized, e)
1573
1591
  } finally {
1574
1592
  isDownloading.set(false)
1575
1593
  }
@@ -314,12 +314,20 @@ public class BundleUpdateStore: NSObject {
314
314
  }
315
315
 
316
316
  /// Subtype of the most recent calculateSHA256 failure on this thread, or
317
- /// nil if the last call succeeded. Surfaces FILE_NOT_FOUND / FILE_EMPTY /
317
+ /// nil if the last call succeeded. Surfaces FILE_NOT_FOUND /
318
318
  /// FILE_DISAPPEARED / IO_<NSError code> / UNEXPECTED so analytics can
319
319
  /// split the previously opaque "Failed to calculate SHA256" bucket
320
320
  /// (mixpanel: 91.3% of verifyPackage failures are Android; iOS shares
321
321
  /// the calculator and inherits the same blind spot for its 14 ASC +
322
322
  /// 2 verifyPackage Promise-destroyed cases).
323
+ ///
324
+ /// Note: 0-byte files are NOT treated as a failure. They hash to the
325
+ /// well-known empty-content SHA256 and the caller's expected/actual
326
+ /// comparison handles legitimate vs. corrupt-empty cases. Rejecting
327
+ /// empty files here would make any OTA bundle that legitimately
328
+ /// contains a 0-byte file (touched marker, blank locale fallback)
329
+ /// fail validateAllFilesInDir / validateWebEmbedSha256 / launch entry
330
+ /// verification — all of which share this calculator.
323
331
  public static func lastSHA256FailureReason() -> String? {
324
332
  return Thread.current.threadDictionary[kSHA256FailureKey] as? String
325
333
  }
@@ -340,13 +348,6 @@ public class BundleUpdateStore: NSObject {
340
348
  OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: \(filePath)")
341
349
  return nil
342
350
  }
343
- let attrs = (try? fm.attributesOfItem(atPath: filePath)) ?? [:]
344
- let fileSize = (attrs[.size] as? NSNumber)?.int64Value ?? -1
345
- if fileSize == 0 {
346
- setSHA256Failure("FILE_EMPTY")
347
- OneKeyLog.error("BundleUpdate", "calculateSHA256: file empty: \(filePath)")
348
- return nil
349
- }
350
351
  guard let fileHandle = FileHandle(forReadingAtPath: filePath) else {
351
352
  setSHA256Failure("FILE_DISAPPEARED")
352
353
  OneKeyLog.error("BundleUpdate", "calculateSHA256: open failed (file disappeared between stat and open): \(filePath)")
@@ -376,7 +377,11 @@ public class BundleUpdateStore: NSObject {
376
377
  }) {}
377
378
  if let err = threwError {
378
379
  let nsErr = err as NSError
379
- setSHA256Failure("IO_\(nsErr.domain)_\(nsErr.code)")
380
+ // Keep the failure tag low-cardinality (`IO_<code>`) so it
381
+ // matches the doc on lastSHA256FailureReason and stays under
382
+ // the analytics bucket cap. The full `domain code description`
383
+ // detail is still logged below for local debugging.
384
+ setSHA256Failure("IO_\(nsErr.code)")
380
385
  OneKeyLog.error("BundleUpdate", "calculateSHA256: read failed: \(nsErr.domain) \(nsErr.code) \(nsErr.localizedDescription)")
381
386
  return nil
382
387
  }
@@ -1249,17 +1254,22 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1249
1254
  /// On iOS, force-quit (user swipes the app off the App Switcher) cannot
1250
1255
  /// fire `URLSession`'s `didCompleteWithError` — SIGKILL leaves no time
1251
1256
  /// for callbacks. The kill is, however, *always* preceded by the app
1252
- /// transitioning to the background. We hook that transition and call
1253
- /// `cancel(byProducingResumeData:)` synchronously, so the resume blob
1254
- /// is on disk before the app can possibly be terminated. Memory-pressure
1255
- /// kills follow the same chain (the OS only reaps backgrounded apps
1256
- /// under memory pressure), so this also covers OOM termination.
1257
+ /// transitioning to the background. We hook that transition and kick
1258
+ /// off `cancel(byProducingResumeData:)` for any in-flight downloads so
1259
+ /// the resume blob lands on disk before the app can be terminated.
1260
+ /// Memory-pressure kills follow the same chain (the OS only reaps
1261
+ /// backgrounded apps under memory pressure), so this also covers OOM
1262
+ /// termination.
1257
1263
  ///
1258
- /// `beginBackgroundTask` extends the ~5s of guaranteed background runtime
1259
- /// to ~30s, which is plenty for a few-KB blob write but we don't rely
1260
- /// on the extension to *complete* the snapshot; the `cancel` callback
1261
- /// returns the data synchronously, so even if iOS suspends us right
1262
- /// after `endBackgroundTask` the resume blob is already persisted.
1264
+ /// Both `URLSession.getAllTasks(_:)` and `cancel(byProducingResumeData:)`
1265
+ /// deliver their results *asynchronously* via a closurethe resume
1266
+ /// data does not pop out synchronously. We wrap the work in a
1267
+ /// `beginBackgroundTask` window that extends the ~5s of guaranteed
1268
+ /// background runtime to ~30s so the closures actually have time to
1269
+ /// fire and write the few-KB blob before suspension. Persistence is
1270
+ /// best-effort: if iOS reaps us before the writer closure runs (very
1271
+ /// short background windows, expiration), the next launch simply
1272
+ /// re-downloads from scratch — a resume miss is correct, just slower.
1263
1273
  private func registerBackgroundSnapshotObserver() {
1264
1274
  didEnterBackgroundObserver = NotificationCenter.default.addObserver(
1265
1275
  forName: UIApplication.didEnterBackgroundNotification,
@@ -1273,9 +1283,14 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1273
1283
  private func snapshotResumeDataForBackgrounding() {
1274
1284
  guard let session = self.urlSession else { return }
1275
1285
  // Read both isDownloading AND activeDownloadFilePath inside the
1276
- // same stateQueue.sync block so a concurrent downloadBundle entry
1277
- // (which writes filePath BEFORE flipping isDownloading) cannot
1278
- // produce a torn read where isDownloading=true but filePath=nil.
1286
+ // same stateQueue.sync block. A concurrent downloadBundle entry
1287
+ // flips isDownloading=true at the start of the Promise body but
1288
+ // only writes activeDownloadFilePath later (after the early
1289
+ // guards / version + URL validation). Without this paired read,
1290
+ // didEnterBackground could observe isDownloading=true while
1291
+ // activeDownloadFilePath is still nil from a previous run, or —
1292
+ // on the unwind via defer — see isDownloading=false alongside a
1293
+ // not-yet-cleared path.
1279
1294
  let snapshot: (Bool, String?) = self.stateQueue.sync {
1280
1295
  (self.isDownloading, self.activeDownloadFilePath)
1281
1296
  }
@@ -1284,14 +1299,35 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1284
1299
 
1285
1300
  let resumeDataPath = "\(filePath).resume"
1286
1301
  let bgTaskName = "BundleUpdateResumeSnapshot"
1287
- var bgTaskId: UIBackgroundTaskIdentifier = .invalid
1288
- bgTaskId = UIApplication.shared.beginBackgroundTask(withName: bgTaskName) {
1289
- // Expiration handler system is reclaiming us. Best-effort end.
1290
- if bgTaskId != .invalid {
1291
- UIApplication.shared.endBackgroundTask(bgTaskId)
1292
- bgTaskId = .invalid
1302
+
1303
+ // bgTaskId is mutated from three escaping closures: the
1304
+ // beginBackgroundTask expiration handler (called on main),
1305
+ // session.getAllTasks's completion (NOT guaranteed to be main),
1306
+ // and group.notify on .main. Wrap reads/writes in a tiny holder
1307
+ // serialized through a dedicated queue so we cannot double-end
1308
+ // the background task or leak it. `endOnce` guarantees endBackgroundTask
1309
+ // is invoked exactly once across whichever closure reaches it first.
1310
+ final class BgTaskHolder {
1311
+ private let q = DispatchQueue(label: "so.onekey.bundleupdate.bgtask")
1312
+ private var id: UIBackgroundTaskIdentifier = .invalid
1313
+ func set(_ newId: UIBackgroundTaskIdentifier) {
1314
+ q.sync { id = newId }
1315
+ }
1316
+ func endOnce() {
1317
+ q.sync {
1318
+ if id != .invalid {
1319
+ UIApplication.shared.endBackgroundTask(id)
1320
+ id = .invalid
1321
+ }
1322
+ }
1293
1323
  }
1294
1324
  }
1325
+ let bgTask = BgTaskHolder()
1326
+ let started = UIApplication.shared.beginBackgroundTask(withName: bgTaskName) {
1327
+ // Expiration handler — system is reclaiming us. Best-effort end.
1328
+ bgTask.endOnce()
1329
+ }
1330
+ bgTask.set(started)
1295
1331
  OneKeyLog.info("BundleUpdate", "didEnterBackground: snapshotting resumeData for \(filePath)")
1296
1332
 
1297
1333
  session.getAllTasks { tasks in
@@ -1318,10 +1354,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1318
1354
  })
1319
1355
  }
1320
1356
  group.notify(queue: .main) {
1321
- if bgTaskId != .invalid {
1322
- UIApplication.shared.endBackgroundTask(bgTaskId)
1323
- bgTaskId = .invalid
1324
- }
1357
+ bgTask.endOnce()
1325
1358
  }
1326
1359
  }
1327
1360
  }
@@ -1514,7 +1547,19 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1514
1547
  let nsError = error as NSError
1515
1548
  OneKeyLog.error("BundleUpdate", "downloadBundle: download failed: \(nsError.domain) \(nsError.code) \(nsError.localizedDescription)")
1516
1549
  self.sendEvent(type: "update/error", message: "\(nsError.domain) \(nsError.code)")
1517
- throw error
1550
+ // Rethrow a sanitized NSError. The original `error` is a
1551
+ // URLSession/system error whose `localizedDescription` may
1552
+ // include the temp file path (e.g. NSURLErrorFailingURLString
1553
+ // userInfo, or the ~/Library/.../CFNetworkDownload_*.tmp
1554
+ // path on cancel-with-resume) — Promise rejections surface
1555
+ // that string to JS, which would re-leak the paths the
1556
+ // event-payload sanitization just stripped. Keep the full
1557
+ // detail in OneKeyLog (above) and emit only `domain code`.
1558
+ throw NSError(
1559
+ domain: nsError.domain,
1560
+ code: nsError.code,
1561
+ userInfo: [NSLocalizedDescriptionKey: "Download failed: \(nsError.domain) \(nsError.code)"]
1562
+ )
1518
1563
 
1519
1564
  case .success(let (tempURL, response)):
1520
1565
  // Successful completion ⇒ no longer need the resume blob.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-bundle-update",
3
- "version": "3.0.29",
3
+ "version": "3.0.31",
4
4
  "description": "react-native-bundle-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",