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

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.
@@ -404,7 +404,31 @@ object BundleUpdateStoreAndroid {
404
404
  }
405
405
  }
406
406
 
407
+ private val lastSHA256Failure = ThreadLocal<String?>()
408
+
409
+ /**
410
+ * Subtype of the most recent calculateSHA256 failure on this thread, or
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.
416
+ */
417
+ fun lastSHA256FailureReason(): String? = lastSHA256Failure.get()
418
+
407
419
  fun calculateSHA256(filePath: String): String? {
420
+ lastSHA256Failure.set(null)
421
+ val file = File(filePath)
422
+ if (!file.exists()) {
423
+ lastSHA256Failure.set("FILE_NOT_FOUND")
424
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: $filePath")
425
+ return null
426
+ }
427
+ if (file.length() == 0L) {
428
+ lastSHA256Failure.set("FILE_EMPTY")
429
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: file empty: $filePath")
430
+ return null
431
+ }
408
432
  return try {
409
433
  val digest = MessageDigest.getInstance("SHA-256")
410
434
  BufferedInputStream(FileInputStream(filePath)).use { bis ->
@@ -415,8 +439,29 @@ object BundleUpdateStoreAndroid {
415
439
  }
416
440
  }
417
441
  bytesToHex(digest.digest())
442
+ } catch (e: java.io.FileNotFoundException) {
443
+ lastSHA256Failure.set("FILE_DISAPPEARED")
444
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: file disappeared during read: ${e.message}")
445
+ null
446
+ } catch (e: java.io.EOFException) {
447
+ lastSHA256Failure.set("FILE_TRUNCATED")
448
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: truncated file: ${e.message}")
449
+ null
450
+ } catch (e: SecurityException) {
451
+ lastSHA256Failure.set("PERMISSION_DENIED")
452
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: permission denied: ${e.message}")
453
+ null
454
+ } catch (e: OutOfMemoryError) {
455
+ lastSHA256Failure.set("OOM")
456
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: OutOfMemoryError on ${file.length()} bytes")
457
+ null
458
+ } catch (e: java.io.IOException) {
459
+ lastSHA256Failure.set("IO_${e.javaClass.simpleName}")
460
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: ${e.javaClass.simpleName}: ${e.message}")
461
+ null
418
462
  } catch (e: Exception) {
419
- OneKeyLog.error("BundleUpdate", "Error calculating SHA256: ${e.message}")
463
+ lastSHA256Failure.set("UNEXPECTED_${e.javaClass.simpleName}")
464
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: ${e.javaClass.simpleName}: ${e.message}")
420
465
  null
421
466
  }
422
467
  }
@@ -455,8 +500,13 @@ object BundleUpdateStoreAndroid {
455
500
  }
456
501
  }
457
502
  } catch (e: Exception) {
458
- OneKeyLog.error("BundleUpdate", "Error parsing metadata JSON: ${e.message}")
459
- throw Exception("Failed to parse metadata.json: ${e.message}")
503
+ // org.json's exception messages occasionally embed file paths or
504
+ // partial JSON content. Keep the rich detail in OneKeyLog (local
505
+ // only), but throw a class-tag-only message so the JS analytics
506
+ // layer cannot reflect arbitrary inner content. Mirrors the
507
+ // SHA256_<reason>/IO_<class> convention used elsewhere.
508
+ OneKeyLog.error("BundleUpdate", "Error parsing metadata JSON: ${e.javaClass.simpleName}: ${e.message}")
509
+ throw Exception("Failed to parse metadata.json: IO_${e.javaClass.simpleName}")
460
510
  }
461
511
  if (metadata.isEmpty()) {
462
512
  throw Exception("metadata.json is empty or contains no file entries")
@@ -1201,6 +1251,35 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1201
1251
  }
1202
1252
  }
1203
1253
 
1254
+ /**
1255
+ * Returns a low-cardinality, path-free tag describing why a download
1256
+ * failed. Used in the JS-facing `update/error` event so listeners
1257
+ * never observe Android's `/data/data/<pkg>/...` or `/data/user/<u>/<pkg>/...`
1258
+ * paths that FileNotFoundException / IOException embed in `e.message`.
1259
+ *
1260
+ * Recognized payloads (same shape extractUpdateErrorCode parses):
1261
+ * - "Bundle SHA256 verification failed: <REASON>" → preserved verbatim;
1262
+ * JS extractor maps to SHA256_<REASON>.
1263
+ * - "HTTP <code>" / "HTTP 416 ..." → preserved verbatim; maps to HTTP_<code>.
1264
+ * - "Already downloading" / "Invalid version string format" /
1265
+ * "Bundle download URL must use HTTPS" → preserved verbatim; the
1266
+ * hooks unrecoverable-list matches them by exact substring.
1267
+ * - Anything else → "IO_<exceptionClassName>" so the JS extractor
1268
+ * splits the bucket on exception class without leaking the message.
1269
+ */
1270
+ private fun sanitizeErrorMessageForEvent(e: Exception): String {
1271
+ val msg = e.message ?: return "IO_${e.javaClass.simpleName}"
1272
+ if (msg.startsWith("Bundle SHA256 verification failed:")) return msg
1273
+ if (msg.startsWith("HTTP ")) return msg
1274
+ if (msg == "Already downloading" ||
1275
+ msg == "Invalid version string format" ||
1276
+ msg == "Bundle download URL must use HTTPS" ||
1277
+ msg == "Empty response body" ||
1278
+ msg == "Failed to finalize download"
1279
+ ) return msg
1280
+ return "IO_${e.javaClass.simpleName}"
1281
+ }
1282
+
1204
1283
  override fun addDownloadListener(callback: (BundleDownloadEvent) -> Unit): Double {
1205
1284
  val id = nextListenerId.getAndIncrement().toDouble()
1206
1285
  listeners.add(BundleListener(id, callback))
@@ -1274,6 +1353,11 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1274
1353
 
1275
1354
  val fileName = "$appVersion-$bundleVersion.zip"
1276
1355
  val filePath = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context), fileName).absolutePath
1356
+ // Resume support: download to <filename>.partial; rename to <filename>
1357
+ // only after the full transfer + SHA256 verify pass. Mirrors the
1358
+ // Desktop convention so a corrupt completion can never poison the
1359
+ // "exists at filePath -> already valid" cache check above.
1360
+ val partialFilePath = "$filePath.partial"
1277
1361
 
1278
1362
  val result = BundleDownloadResult(
1279
1363
  downloadedFile = filePath,
@@ -1286,6 +1370,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1286
1370
  OneKeyLog.info("BundleUpdate", "downloadBundle: filePath=$filePath")
1287
1371
 
1288
1372
  val downloadedFile = File(filePath)
1373
+ val partialFile = File(partialFilePath)
1289
1374
  if (downloadedFile.exists()) {
1290
1375
  OneKeyLog.info("BundleUpdate", "downloadBundle: file already exists, verifying SHA256...")
1291
1376
  if (verifyBundleSHA256(filePath, sha256)) {
@@ -1297,47 +1382,147 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1297
1382
  } else {
1298
1383
  OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading")
1299
1384
  downloadedFile.delete()
1385
+ // Stale completed file invalidates any partial too.
1386
+ if (partialFile.exists()) partialFile.delete()
1387
+ }
1388
+ }
1389
+
1390
+ // Resume: if a partial from a previous run exists and is smaller
1391
+ // than the expected size, send `Range: bytes=<offset>-` so the
1392
+ // server fills in the rest. If the partial is exactly the
1393
+ // expected size it's a process-killed-just-before-rename case
1394
+ // (full body on disk but never promoted): try SHA verify
1395
+ // before discarding so we save a full re-download.
1396
+ val expectedSize = if (params.fileSize > 0) params.fileSize.toLong() else 0L
1397
+ var partialBytes = 0L
1398
+ if (partialFile.exists()) {
1399
+ val partialSize = partialFile.length()
1400
+ when {
1401
+ expectedSize > 0 && partialSize == expectedSize -> {
1402
+ OneKeyLog.info("BundleUpdate", "downloadBundle: partial matches expected size ($partialSize), trying promote+verify")
1403
+ if (downloadedFile.exists()) downloadedFile.delete()
1404
+ if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) {
1405
+ OneKeyLog.info("BundleUpdate", "downloadBundle: recovered crashed-before-rename bundle, skipping download")
1406
+ isDownloading.set(false)
1407
+ Thread.sleep(1000)
1408
+ sendEvent("update/complete")
1409
+ return@async result
1410
+ } else {
1411
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: promote+verify failed, discarding both files")
1412
+ if (downloadedFile.exists()) downloadedFile.delete()
1413
+ // partialFile is gone (renamed); nothing to delete
1414
+ }
1415
+ }
1416
+ expectedSize > 0 && partialSize > expectedSize -> {
1417
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: stale partial (>expected), discarding: $partialSize/$expectedSize")
1418
+ partialFile.delete()
1419
+ }
1420
+ partialSize > 0 -> {
1421
+ partialBytes = partialSize
1422
+ OneKeyLog.info("BundleUpdate", "downloadBundle: resuming from $partialBytes bytes (expected=$expectedSize)")
1423
+ }
1424
+ else -> partialFile.delete()
1300
1425
  }
1301
1426
  }
1302
1427
 
1303
1428
  sendEvent("update/start")
1304
- OneKeyLog.info("BundleUpdate", "downloadBundle: starting download...")
1429
+ OneKeyLog.info("BundleUpdate", "downloadBundle: starting download (resume=${partialBytes > 0})...")
1430
+
1431
+ val requestBuilder = Request.Builder().url(downloadUrl)
1432
+ if (partialBytes > 0) {
1433
+ requestBuilder.addHeader("Range", "bytes=$partialBytes-")
1434
+ }
1435
+ val response = httpClient.newCall(requestBuilder.build()).execute()
1436
+
1437
+ // 416 Range Not Satisfiable: server says our offset is past the
1438
+ // file length. Two sub-cases distinguishable from
1439
+ // `Content-Range: bytes */<total>`:
1440
+ // (a) total == partialBytes → file is exactly complete on
1441
+ // server; our partial is the whole bundle and just
1442
+ // needs SHA verify + rename. Recover instead of wipe.
1443
+ // (b) anything else → partial is corrupt or bundle changed.
1444
+ // Wipe and bubble up.
1445
+ if (response.code == 416) {
1446
+ val contentRange = response.header("Content-Range")
1447
+ response.close()
1448
+ val totalFromHeader = contentRange
1449
+ ?.let { Regex("""bytes\s+\*\s*/\s*(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
1450
+ if (totalFromHeader != null && totalFromHeader == partialBytes && partialFile.exists()) {
1451
+ OneKeyLog.info("BundleUpdate", "downloadBundle: HTTP 416 with total==$totalFromHeader matches partial, attempting promote+verify")
1452
+ if (downloadedFile.exists()) downloadedFile.delete()
1453
+ if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) {
1454
+ OneKeyLog.info("BundleUpdate", "downloadBundle: 416 recovery succeeded, skipping download")
1455
+ sendEvent("update/complete")
1456
+ return@async result
1457
+ }
1458
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: 416 recovery failed verify, discarding")
1459
+ if (downloadedFile.exists()) downloadedFile.delete()
1460
+ }
1461
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt")
1462
+ if (partialFile.exists()) partialFile.delete()
1463
+ sendEvent("update/error", message = "HTTP 416 (range not satisfiable)")
1464
+ throw Exception("HTTP 416")
1465
+ }
1305
1466
 
1306
- val request = Request.Builder().url(downloadUrl).build()
1307
- val response = httpClient.newCall(request).execute()
1467
+ val expectsResume = partialBytes > 0
1468
+ val isPartialResponse = response.code == 206
1308
1469
 
1309
- if (!response.isSuccessful) {
1470
+ if (!response.isSuccessful || (response.code != 200 && response.code != 206)) {
1310
1471
  OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}")
1472
+ response.close()
1311
1473
  sendEvent("update/error", message = "HTTP ${response.code}")
1312
1474
  throw Exception("HTTP ${response.code}")
1313
1475
  }
1314
1476
 
1477
+ // Server can ignore `Range` and return the full body with 200; in
1478
+ // that case our partial is meaningless — restart fresh.
1479
+ if (expectsResume && !isPartialResponse) {
1480
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: requested Range but server returned 200, restarting from scratch")
1481
+ if (partialFile.exists()) partialFile.delete()
1482
+ partialBytes = 0L
1483
+ }
1484
+
1315
1485
  val body = response.body ?: throw Exception("Empty response body")
1316
- val fileSize = if (params.fileSize > 0) params.fileSize.toLong() else body.contentLength()
1317
- OneKeyLog.info("BundleUpdate", "downloadBundle: HTTP 200, contentLength=$fileSize, downloading...")
1486
+ val contentLength = body.contentLength()
1487
+ // Total size of the whole resource (not the slice). On 206 prefer
1488
+ // Content-Range's "/total" tail; fall back to partial+contentLength.
1489
+ val totalSize: Long = if (isPartialResponse) {
1490
+ val contentRange = response.header("Content-Range")
1491
+ val parsedTotal = contentRange
1492
+ ?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
1493
+ parsedTotal ?: (partialBytes + contentLength.coerceAtLeast(0L))
1494
+ } else {
1495
+ if (contentLength > 0) contentLength else expectedSize
1496
+ }
1497
+ OneKeyLog.info(
1498
+ "BundleUpdate",
1499
+ "downloadBundle: HTTP ${response.code}, contentLength=$contentLength, totalSize=$totalSize, partialBytes=$partialBytes, downloading..."
1500
+ )
1318
1501
 
1319
1502
  // Ensure parent directory exists before writing
1320
- val parentDir = File(filePath).parentFile
1503
+ val parentDir = File(partialFilePath).parentFile
1321
1504
  if (parentDir != null && !parentDir.exists()) {
1322
1505
  parentDir.mkdirs()
1323
1506
  OneKeyLog.info("BundleUpdate", "downloadBundle: created parent directory: ${parentDir.absolutePath}")
1324
1507
  }
1325
1508
 
1326
- var totalBytesRead = 0L
1509
+ // Append iff server granted us a 206 partial; otherwise overwrite.
1510
+ val appendMode = isPartialResponse
1511
+ var totalBytesRead = if (isPartialResponse) partialBytes else 0L
1327
1512
  body.byteStream().use { inputStream ->
1328
- FileOutputStream(filePath).use { outputStream ->
1513
+ FileOutputStream(partialFilePath, appendMode).use { outputStream ->
1329
1514
  val buffer = ByteArray(8192)
1330
1515
  var bytesRead: Int
1331
1516
 
1332
- var prevProgress = 0
1517
+ var prevProgress = -1
1333
1518
  while (inputStream.read(buffer).also { bytesRead = it } != -1) {
1334
1519
  outputStream.write(buffer, 0, bytesRead)
1335
1520
  totalBytesRead += bytesRead
1336
- if (fileSize > 0) {
1337
- val progress = ((totalBytesRead * 100) / fileSize).toInt()
1521
+ if (totalSize > 0) {
1522
+ val progress = ((totalBytesRead * 100) / totalSize).toInt().coerceIn(0, 100)
1338
1523
  if (progress != prevProgress) {
1339
1524
  sendEvent("update/downloading", progress = progress)
1340
- OneKeyLog.info("BundleUpdate", "download progress: $progress% ($totalBytesRead/$fileSize)")
1525
+ OneKeyLog.info("BundleUpdate", "download progress: $progress% ($totalBytesRead/$totalSize)")
1341
1526
  prevProgress = progress
1342
1527
  }
1343
1528
  }
@@ -1345,21 +1530,45 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1345
1530
  }
1346
1531
  }
1347
1532
 
1348
- val downloadedFileAfter = File(filePath)
1349
- OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, totalBytesRead=$totalBytesRead, fileExists=${downloadedFileAfter.exists()}, fileSize=${if (downloadedFileAfter.exists()) downloadedFileAfter.length() else -1}, verifying SHA256...")
1533
+ val partialAfter = File(partialFilePath)
1534
+ OneKeyLog.info(
1535
+ "BundleUpdate",
1536
+ "downloadBundle: download finished, totalBytesRead=$totalBytesRead, partialExists=${partialAfter.exists()}, partialSize=${if (partialAfter.exists()) partialAfter.length() else -1}, finalizing..."
1537
+ )
1538
+
1539
+ // Promote .partial to final ONLY after the full transfer; renaming
1540
+ // first means a SHA256 mismatch leaves no half-baked filePath that
1541
+ // the next call would mistake for a cached good bundle.
1542
+ if (downloadedFile.exists()) downloadedFile.delete()
1543
+ if (!partialAfter.renameTo(downloadedFile)) {
1544
+ OneKeyLog.error("BundleUpdate", "downloadBundle: rename .partial -> final failed")
1545
+ sendEvent("update/error", message = "rename .partial failed")
1546
+ throw Exception("Failed to finalize download")
1547
+ }
1548
+
1549
+ OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...")
1350
1550
  if (!verifyBundleSHA256(filePath, sha256)) {
1551
+ val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH"
1351
1552
  File(filePath).delete()
1352
- OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download")
1353
- sendEvent("update/error", message = "Bundle signature verification failed")
1354
- throw Exception("Bundle signature verification failed")
1553
+ OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=$reason")
1554
+ sendEvent("update/error", message = "SHA256_$reason")
1555
+ throw Exception("Bundle SHA256 verification failed: $reason")
1355
1556
  }
1356
1557
 
1357
1558
  sendEvent("update/complete")
1358
1559
  OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=$appVersion, bundleVersion=$bundleVersion")
1359
1560
  result
1360
1561
  } catch (e: Exception) {
1562
+ // Keep the rich detail in OneKeyLog (local-only). The JS
1563
+ // event channel must NOT carry e.message verbatim — Android
1564
+ // FileNotFoundException etc. embed the full /data/user/.../
1565
+ // 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.
1361
1569
  OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}")
1362
- sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}")
1570
+ val codeTag = sanitizeErrorMessageForEvent(e)
1571
+ sendEvent("update/error", message = "${e.javaClass.simpleName}: $codeTag")
1363
1572
  throw e
1364
1573
  } finally {
1365
1574
  isDownloading.set(false)
@@ -1367,10 +1576,18 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1367
1576
  }
1368
1577
  }
1369
1578
 
1579
+ /**
1580
+ * Returns true on hash match. On false, callers may inspect
1581
+ * BundleUpdateStoreAndroid.lastSHA256FailureReason() to distinguish a
1582
+ * computation failure (FILE_TRUNCATED / OOM / IO_*) from a clean hash
1583
+ * mismatch (reason == null).
1584
+ */
1370
1585
  private fun verifyBundleSHA256(bundlePath: String, sha256: String): Boolean {
1371
1586
  val calculated = BundleUpdateStoreAndroid.calculateSHA256(bundlePath)
1372
1587
  if (calculated == null) {
1373
- OneKeyLog.error("BundleUpdate", "verifyBundleSHA256: failed to calculate SHA256 for: $bundlePath")
1588
+ val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "UNKNOWN"
1589
+ val fileSize = try { File(bundlePath).length() } catch (_: Exception) { -1L }
1590
+ OneKeyLog.error("BundleUpdate", "verifyBundleSHA256: failed to calculate SHA256 for: $bundlePath, reason=$reason, fileSize=$fileSize")
1374
1591
  return false
1375
1592
  }
1376
1593
  val isValid = BundleUpdateStoreAndroid.secureCompare(calculated, sha256)
@@ -1412,8 +1629,13 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1412
1629
  if (!skipGPG) {
1413
1630
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: verifying SHA256 of downloaded file...")
1414
1631
  if (!verifyBundleSHA256(filePath, sha256)) {
1415
- OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=$filePath")
1416
- throw Exception("Bundle signature verification failed")
1632
+ // Promote the SHA256 subtype (FILE_TRUNCATED / OOM /
1633
+ // IO_<class> / MISMATCH) into the thrown message so
1634
+ // extractUpdateErrorCode in the JS layer can split
1635
+ // this bucket the same way the download stage does.
1636
+ val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH"
1637
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=$filePath, reason=$reason")
1638
+ throw Exception("Bundle SHA256 verification failed: $reason")
1417
1639
  }
1418
1640
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: SHA256 verified OK")
1419
1641
  } else {
@@ -5,6 +5,7 @@ import CommonCrypto
5
5
  import Gopenpgp
6
6
  import SSZipArchive
7
7
  import MMKV
8
+ import UIKit
8
9
 
9
10
  // OneKey GPG public key for signature verification
10
11
  private let GPG_PUBLIC_KEY = """
@@ -312,23 +313,91 @@ public class BundleUpdateStore: NSObject {
312
313
  return true
313
314
  }
314
315
 
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 /
318
+ /// FILE_DISAPPEARED / IO_<NSError code> / UNEXPECTED so analytics can
319
+ /// split the previously opaque "Failed to calculate SHA256" bucket
320
+ /// (mixpanel: 91.3% of verifyPackage failures are Android; iOS shares
321
+ /// the calculator and inherits the same blind spot for its 14 ASC +
322
+ /// 2 verifyPackage Promise-destroyed cases).
323
+ public static func lastSHA256FailureReason() -> String? {
324
+ return Thread.current.threadDictionary[kSHA256FailureKey] as? String
325
+ }
326
+ private static let kSHA256FailureKey = "so.onekey.bundleupdate.sha256.failure"
327
+ private static func setSHA256Failure(_ reason: String?) {
328
+ if let reason = reason {
329
+ Thread.current.threadDictionary[kSHA256FailureKey] = reason
330
+ } else {
331
+ Thread.current.threadDictionary.removeObject(forKey: kSHA256FailureKey)
332
+ }
333
+ }
334
+
315
335
  public static func calculateSHA256(_ filePath: String) -> String? {
316
- guard let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil }
336
+ setSHA256Failure(nil)
337
+ let fm = FileManager.default
338
+ if !fm.fileExists(atPath: filePath) {
339
+ setSHA256Failure("FILE_NOT_FOUND")
340
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: \(filePath)")
341
+ return nil
342
+ }
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
+ guard let fileHandle = FileHandle(forReadingAtPath: filePath) else {
351
+ setSHA256Failure("FILE_DISAPPEARED")
352
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: open failed (file disappeared between stat and open): \(filePath)")
353
+ return nil
354
+ }
317
355
  defer { fileHandle.closeFile() }
318
356
 
319
- var context = CC_SHA256_CTX()
320
- CC_SHA256_Init(&context)
321
- while autoreleasepool(invoking: {
322
- let data = fileHandle.readData(ofLength: 8192)
323
- if data.count > 0 {
324
- data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) }
325
- return true
357
+ do {
358
+ var context = CC_SHA256_CTX()
359
+ CC_SHA256_Init(&context)
360
+ var threwError: Error?
361
+ // Wrap reads in try/catch via NSException bridge: FileHandle.readData
362
+ // can raise on read failure (NSFileHandleOperationException);
363
+ // ObjCRuntime catches those when bridged through NSObject methods.
364
+ while autoreleasepool(invoking: {
365
+ do {
366
+ let data = try Self.safeRead(fileHandle: fileHandle, length: 8192)
367
+ if data.count > 0 {
368
+ data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) }
369
+ return true
370
+ }
371
+ return false
372
+ } catch {
373
+ threwError = error
374
+ return false
375
+ }
376
+ }) {}
377
+ if let err = threwError {
378
+ let nsErr = err as NSError
379
+ setSHA256Failure("IO_\(nsErr.domain)_\(nsErr.code)")
380
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: read failed: \(nsErr.domain) \(nsErr.code) \(nsErr.localizedDescription)")
381
+ return nil
326
382
  }
327
- return false
328
- }) {}
329
- var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
330
- CC_SHA256_Final(&hash, &context)
331
- return hash.map { String(format: "%02x", $0) }.joined()
383
+ var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
384
+ CC_SHA256_Final(&hash, &context)
385
+ return hash.map { String(format: "%02x", $0) }.joined()
386
+ }
387
+ }
388
+
389
+ /// Raises throwing wrapper around FileHandle.read(upToCount:) so disk
390
+ /// I/O failures (truncated file, unmounted volume) become catchable Swift
391
+ /// errors rather than NSFileHandleOperationException.
392
+ private static func safeRead(fileHandle: FileHandle, length: Int) throws -> Data {
393
+ if #available(iOS 13.4, macOS 10.15.4, *) {
394
+ return try fileHandle.read(upToCount: length) ?? Data()
395
+ } else {
396
+ // Pre-iOS 13.4 fallback: classic readData(ofLength:) does not
397
+ // throw, but raises NSException; we cannot bridge that here so
398
+ // accept the legacy behavior on these old OS versions only.
399
+ return fileHandle.readData(ofLength: length)
400
+ }
332
401
  }
333
402
 
334
403
  public static func getNativeVersion() -> String? {
@@ -1036,6 +1105,10 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
1036
1105
  /// Continuation to bridge delegate callbacks → async/await
1037
1106
  private var continuation: CheckedContinuation<(URL, URLResponse), Error>?
1038
1107
  private var tempFileURL: URL?
1108
+ /// Resume data captured from the most recent failure so the caller can
1109
+ /// persist it for the next attempt. Populated in didCompleteWithError
1110
+ /// when the system supplies NSURLSessionDownloadTaskResumeData.
1111
+ private(set) var lastResumeData: Data?
1039
1112
  private let lock = NSLock()
1040
1113
 
1041
1114
  func setContinuation(_ cont: CheckedContinuation<(URL, URLResponse), Error>) {
@@ -1079,10 +1152,23 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
1079
1152
  lock.unlock()
1080
1153
 
1081
1154
  if let error = error {
1155
+ // iOS attaches partial download bytes via userInfo so the next
1156
+ // attempt can finish from the cut point instead of byte 0. Mixpanel
1157
+ // shows ~5,940 of our failures (NSURL -1005 / -1001) carry ~11KB of
1158
+ // resume data each — previously discarded.
1159
+ let nsError = error as NSError
1160
+ if let resumeData = nsError.userInfo[NSURLSessionDownloadTaskResumeData] as? Data, resumeData.count > 0 {
1161
+ self.lastResumeData = resumeData
1162
+ OneKeyLog.info("BundleUpdate", "download error captured resumeData: \(resumeData.count) bytes")
1163
+ } else {
1164
+ self.lastResumeData = nil
1165
+ }
1082
1166
  cont?.resume(throwing: error)
1083
1167
  } else if let tempURL = tempFileURL, let response = task.response {
1168
+ self.lastResumeData = nil
1084
1169
  cont?.resume(returning: (tempURL, response))
1085
1170
  } else {
1171
+ self.lastResumeData = nil
1086
1172
  cont?.resume(throwing: NSError(domain: "BundleUpdate", code: -1,
1087
1173
  userInfo: [NSLocalizedDescriptionKey: "Download completed without file"]))
1088
1174
  }
@@ -1105,6 +1191,7 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
1105
1191
  tempFileURL = nil
1106
1192
  onProgress = nil
1107
1193
  prevProgress = -1
1194
+ lastResumeData = nil
1108
1195
  }
1109
1196
  }
1110
1197
 
@@ -1134,9 +1221,109 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1134
1221
  return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
1135
1222
  }
1136
1223
 
1224
+ /// Path of the bundle being downloaded right now (mirrors filePath in
1225
+ /// downloadBundle). Set on entry, cleared on exit. Single value because
1226
+ /// isDownloading enforces at most one in-flight download per process.
1227
+ /// Read by the background-snapshot handler so it knows where to drop the
1228
+ /// `.resume` sidecar.
1229
+ private var activeDownloadFilePath: String?
1230
+ private var didEnterBackgroundObserver: NSObjectProtocol?
1231
+
1137
1232
  override init() {
1138
1233
  super.init()
1139
1234
  urlSession = createURLSession()
1235
+ registerBackgroundSnapshotObserver()
1236
+ }
1237
+
1238
+ deinit {
1239
+ if let token = didEnterBackgroundObserver {
1240
+ NotificationCenter.default.removeObserver(token)
1241
+ }
1242
+ // URLSession retains its delegate strongly until invalidated.
1243
+ // Without this call the session (and its DownloadDelegate) would
1244
+ // leak past module deallocation — relevant in dev hot-reload and
1245
+ // any future test harness that spins up multiple module instances.
1246
+ urlSession?.invalidateAndCancel()
1247
+ }
1248
+
1249
+ /// On iOS, force-quit (user swipes the app off the App Switcher) cannot
1250
+ /// fire `URLSession`'s `didCompleteWithError` — SIGKILL leaves no time
1251
+ /// 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
+ ///
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.
1263
+ private func registerBackgroundSnapshotObserver() {
1264
+ didEnterBackgroundObserver = NotificationCenter.default.addObserver(
1265
+ forName: UIApplication.didEnterBackgroundNotification,
1266
+ object: nil,
1267
+ queue: .main
1268
+ ) { [weak self] _ in
1269
+ self?.snapshotResumeDataForBackgrounding()
1270
+ }
1271
+ }
1272
+
1273
+ private func snapshotResumeDataForBackgrounding() {
1274
+ guard let session = self.urlSession else { return }
1275
+ // 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.
1279
+ let snapshot: (Bool, String?) = self.stateQueue.sync {
1280
+ (self.isDownloading, self.activeDownloadFilePath)
1281
+ }
1282
+ let (stillDownloading, snapshotPath) = snapshot
1283
+ guard stillDownloading, let filePath = snapshotPath else { return }
1284
+
1285
+ let resumeDataPath = "\(filePath).resume"
1286
+ 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
1293
+ }
1294
+ }
1295
+ OneKeyLog.info("BundleUpdate", "didEnterBackground: snapshotting resumeData for \(filePath)")
1296
+
1297
+ session.getAllTasks { tasks in
1298
+ let group = DispatchGroup()
1299
+ for task in tasks {
1300
+ guard let dl = task as? URLSessionDownloadTask, dl.state == .running else { continue }
1301
+ group.enter()
1302
+ dl.cancel(byProducingResumeData: { data in
1303
+ if let data = data, data.count > 0 {
1304
+ do {
1305
+ let dir = (resumeDataPath as NSString).deletingLastPathComponent
1306
+ if !FileManager.default.fileExists(atPath: dir) {
1307
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
1308
+ }
1309
+ try data.write(to: URL(fileURLWithPath: resumeDataPath), options: .atomic)
1310
+ OneKeyLog.info("BundleUpdate", "didEnterBackground: persisted resumeData (\(data.count) bytes) at \(resumeDataPath)")
1311
+ } catch {
1312
+ OneKeyLog.warn("BundleUpdate", "didEnterBackground: failed to persist resumeData: \(error)")
1313
+ }
1314
+ } else {
1315
+ OneKeyLog.info("BundleUpdate", "didEnterBackground: cancel produced no resumeData (task may have just completed)")
1316
+ }
1317
+ group.leave()
1318
+ })
1319
+ }
1320
+ group.notify(queue: .main) {
1321
+ if bgTaskId != .invalid {
1322
+ UIApplication.shared.endBackgroundTask(bgTaskId)
1323
+ bgTaskId = .invalid
1324
+ }
1325
+ }
1326
+ }
1140
1327
  }
1141
1328
 
1142
1329
  private func sendEvent(type: String, progress: Int = 0, message: String = "") {
@@ -1181,7 +1368,16 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1181
1368
  OneKeyLog.warn("BundleUpdate", "downloadBundle: rejected, already downloading")
1182
1369
  throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Already downloading"])
1183
1370
  }
1184
- defer { self.stateQueue.sync { self.isDownloading = false } }
1371
+ defer {
1372
+ // Clear isDownloading + the snapshot anchor under the same
1373
+ // queue so didEnterBackground's paired read can't observe
1374
+ // a half-cleared state (isDownloading=true while
1375
+ // activeDownloadFilePath=nil, or vice versa).
1376
+ self.stateQueue.sync {
1377
+ self.isDownloading = false
1378
+ self.activeDownloadFilePath = nil
1379
+ }
1380
+ }
1185
1381
 
1186
1382
  let appVersion = params.latestVersion
1187
1383
  let bundleVersion = params.bundleVersion
@@ -1202,6 +1398,10 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1202
1398
 
1203
1399
  let fileName = "\(appVersion)-\(bundleVersion).zip"
1204
1400
  let filePath = (BundleUpdateStore.downloadBundleDir() as NSString).appendingPathComponent(fileName)
1401
+ // Persisted resume blob from a previous failed attempt. Lives next
1402
+ // to the bundle so it shares fate (delete-with-bundle) without
1403
+ // polluting the bundle dir's cache lookup.
1404
+ let resumeDataPath = "\(filePath).resume"
1205
1405
 
1206
1406
  let result = BundleDownloadResult(
1207
1407
  downloadedFile: filePath,
@@ -1218,6 +1418,8 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1218
1418
  OneKeyLog.info("BundleUpdate", "downloadBundle: file already exists, verifying SHA256...")
1219
1419
  if self.verifyBundleSHA256(filePath, sha256: sha256) {
1220
1420
  OneKeyLog.info("BundleUpdate", "downloadBundle: existing file SHA256 valid, skipping download")
1421
+ // A valid completed bundle invalidates any stale resume blob.
1422
+ try? FileManager.default.removeItem(atPath: resumeDataPath)
1221
1423
  DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
1222
1424
  self?.sendEvent(type: "update/complete")
1223
1425
  }
@@ -1225,6 +1427,8 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1225
1427
  } else {
1226
1428
  OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading")
1227
1429
  try? FileManager.default.removeItem(atPath: filePath)
1430
+ // Hash-failed bundle means the resume blob is also poisoned.
1431
+ try? FileManager.default.removeItem(atPath: resumeDataPath)
1228
1432
  }
1229
1433
  }
1230
1434
 
@@ -1239,8 +1443,17 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1239
1443
  throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "URLSession not initialized"])
1240
1444
  }
1241
1445
 
1446
+ // Resume blob from a prior failed attempt; iOS rebuilds the byte
1447
+ // offset internally so we never need a Range header on this path.
1448
+ let persistedResumeData: Data? = {
1449
+ guard FileManager.default.fileExists(atPath: resumeDataPath),
1450
+ let data = try? Data(contentsOf: URL(fileURLWithPath: resumeDataPath)),
1451
+ data.count > 0 else { return nil }
1452
+ return data
1453
+ }()
1454
+
1242
1455
  self.sendEvent(type: "update/start")
1243
- OneKeyLog.info("BundleUpdate", "downloadBundle: starting download...")
1456
+ OneKeyLog.info("BundleUpdate", "downloadBundle: starting download (resumeBytes=\(persistedResumeData?.count ?? 0))...")
1244
1457
 
1245
1458
  let request = URLRequest(url: url)
1246
1459
 
@@ -1254,51 +1467,103 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1254
1467
  self?.sendEvent(type: "update/downloading", progress: progress)
1255
1468
  }
1256
1469
 
1257
- let (tempURL, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(URL, URLResponse), Error>) in
1258
- delegate.setContinuation(continuation)
1259
- let task = session.downloadTask(with: request)
1260
- task.resume()
1261
- }
1470
+ // Anchor for the background-snapshot handler. Set BEFORE
1471
+ // task.resume() so a foreground→background transition that
1472
+ // races the very first bytes still finds a path to write the
1473
+ // resume blob to. Pair the write with the same stateQueue the
1474
+ // snapshot reader uses, so isDownloading=true and a non-nil
1475
+ // activeDownloadFilePath always go together.
1476
+ self.stateQueue.sync { self.activeDownloadFilePath = filePath }
1262
1477
 
1263
- // Verify HTTPS was maintained (no HTTP redirect)
1264
- if let httpResponse = response as? HTTPURLResponse,
1265
- let responseUrl = httpResponse.url,
1266
- responseUrl.scheme?.lowercased() != "https" {
1267
- OneKeyLog.error("BundleUpdate", "downloadBundle: redirected to non-HTTPS URL: \(responseUrl)")
1268
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download was redirected to non-HTTPS URL"])
1269
- }
1478
+ let downloadOutcome: Result<(URL, URLResponse), Error>
1479
+ do {
1480
+ let value = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(URL, URLResponse), Error>) in
1481
+ delegate.setContinuation(continuation)
1482
+ let task: URLSessionDownloadTask
1483
+ if let resumeData = persistedResumeData {
1484
+ task = session.downloadTask(withResumeData: resumeData)
1485
+ } else {
1486
+ task = session.downloadTask(with: request)
1487
+ }
1488
+ task.resume()
1489
+ }
1490
+ downloadOutcome = .success(value)
1491
+ } catch {
1492
+ downloadOutcome = .failure(error)
1493
+ }
1494
+
1495
+ switch downloadOutcome {
1496
+ case .failure(let error):
1497
+ // Persist the freshly captured resume blob for the next call.
1498
+ // If iOS gave us nothing usable, clear any stale blob so a
1499
+ // future attempt isn't held back by an unresumable cut point.
1500
+ if let resumeData = delegate.lastResumeData {
1501
+ do {
1502
+ let dir = (resumeDataPath as NSString).deletingLastPathComponent
1503
+ if !FileManager.default.fileExists(atPath: dir) {
1504
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
1505
+ }
1506
+ try resumeData.write(to: URL(fileURLWithPath: resumeDataPath), options: .atomic)
1507
+ OneKeyLog.info("BundleUpdate", "downloadBundle: persisted resumeData (\(resumeData.count) bytes) at \(resumeDataPath)")
1508
+ } catch {
1509
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: failed to persist resumeData: \(error)")
1510
+ }
1511
+ } else {
1512
+ try? FileManager.default.removeItem(atPath: resumeDataPath)
1513
+ }
1514
+ let nsError = error as NSError
1515
+ OneKeyLog.error("BundleUpdate", "downloadBundle: download failed: \(nsError.domain) \(nsError.code) \(nsError.localizedDescription)")
1516
+ self.sendEvent(type: "update/error", message: "\(nsError.domain) \(nsError.code)")
1517
+ throw error
1518
+
1519
+ case .success(let (tempURL, response)):
1520
+ // Successful completion ⇒ no longer need the resume blob.
1521
+ try? FileManager.default.removeItem(atPath: resumeDataPath)
1522
+
1523
+ // Verify HTTPS was maintained (no HTTP redirect)
1524
+ if let httpResponse = response as? HTTPURLResponse,
1525
+ let responseUrl = httpResponse.url,
1526
+ responseUrl.scheme?.lowercased() != "https" {
1527
+ OneKeyLog.error("BundleUpdate", "downloadBundle: redirected to non-HTTPS URL: \(responseUrl)")
1528
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download was redirected to non-HTTPS URL"])
1529
+ }
1270
1530
 
1271
- guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
1272
- let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
1273
- OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=\(statusCode)")
1274
- self.sendEvent(type: "update/error", message: "HTTP error \(statusCode)")
1275
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP \(statusCode)"])
1276
- }
1531
+ // 206 is acceptable when the OS finished a Range-resumed task on
1532
+ // our behalf; everything else non-200 is a real error.
1533
+ guard let httpResponse = response as? HTTPURLResponse,
1534
+ httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else {
1535
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
1536
+ OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=\(statusCode)")
1537
+ self.sendEvent(type: "update/error", message: "HTTP error \(statusCode)")
1538
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP \(statusCode)"])
1539
+ }
1277
1540
 
1278
- OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP 200, moving to destination...")
1541
+ OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP \(httpResponse.statusCode), moving to destination...")
1279
1542
 
1280
- // Move downloaded file to destination
1281
- let destDir = (filePath as NSString).deletingLastPathComponent
1282
- if !FileManager.default.fileExists(atPath: destDir) {
1283
- try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true)
1284
- }
1285
- if FileManager.default.fileExists(atPath: filePath) {
1286
- try FileManager.default.removeItem(atPath: filePath)
1287
- }
1288
- try FileManager.default.moveItem(at: tempURL, to: URL(fileURLWithPath: filePath))
1543
+ // Move downloaded file to destination
1544
+ let destDir = (filePath as NSString).deletingLastPathComponent
1545
+ if !FileManager.default.fileExists(atPath: destDir) {
1546
+ try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true)
1547
+ }
1548
+ if FileManager.default.fileExists(atPath: filePath) {
1549
+ try FileManager.default.removeItem(atPath: filePath)
1550
+ }
1551
+ try FileManager.default.moveItem(at: tempURL, to: URL(fileURLWithPath: filePath))
1289
1552
 
1290
- // Verify SHA256
1291
- OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...")
1292
- if !self.verifyBundleSHA256(filePath, sha256: sha256) {
1293
- try? FileManager.default.removeItem(atPath: filePath)
1294
- OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download")
1295
- self.sendEvent(type: "update/error", message: "Bundle signature verification failed")
1296
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle signature verification failed"])
1297
- }
1553
+ // Verify SHA256
1554
+ OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...")
1555
+ if !self.verifyBundleSHA256(filePath, sha256: sha256) {
1556
+ let reason = BundleUpdateStore.lastSHA256FailureReason() ?? "MISMATCH"
1557
+ try? FileManager.default.removeItem(atPath: filePath)
1558
+ OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=\(reason)")
1559
+ self.sendEvent(type: "update/error", message: "SHA256_\(reason)")
1560
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"])
1561
+ }
1298
1562
 
1299
- self.sendEvent(type: "update/complete")
1300
- OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
1301
- return result
1563
+ self.sendEvent(type: "update/complete")
1564
+ OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
1565
+ return result
1566
+ }
1302
1567
  }
1303
1568
  }
1304
1569
 
@@ -1347,10 +1612,16 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1347
1612
 
1348
1613
  if !skipGPG {
1349
1614
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: verifying SHA256 of downloaded file...")
1350
- guard let calculated = BundleUpdateStore.calculateSHA256(filePath),
1351
- calculated.secureCompare(sha256) else {
1352
- OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=\(filePath)")
1353
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle signature verification failed"])
1615
+ let calculated = BundleUpdateStore.calculateSHA256(filePath)
1616
+ let isValid = calculated != nil && calculated!.secureCompare(sha256)
1617
+ if !isValid {
1618
+ // Promote the SHA256 subtype (FILE_TRUNCATED / OOM /
1619
+ // IO_<class> / MISMATCH) into the thrown message so
1620
+ // analytics' extractUpdateErrorCode can split this
1621
+ // bucket the same way the download stage already does.
1622
+ let reason = BundleUpdateStore.lastSHA256FailureReason() ?? "MISMATCH"
1623
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=\(filePath), reason=\(reason)")
1624
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"])
1354
1625
  }
1355
1626
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: SHA256 verified OK")
1356
1627
  } else {
@@ -1377,9 +1648,19 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1377
1648
  try SSZipArchive.unzipFile(atPath: filePath, toDestination: destination, overwrite: true, password: nil)
1378
1649
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: extraction completed")
1379
1650
  } catch {
1380
- OneKeyLog.error("BundleUpdate", "verifyBundleASC: unzip failed: \(error.localizedDescription)")
1651
+ // SSZipArchive's NSError.localizedDescription often embeds the
1652
+ // failing file path which on iOS includes the install UUID
1653
+ // (/var/mobile/Containers/Data/Application/<UUID>/...). Keep
1654
+ // the rich detail in OneKeyLog (local-only), but expose only
1655
+ // a code-shaped tag in the thrown message so the JS-side
1656
+ // analytics layer can never reflect that path back to the
1657
+ // server. Subtypes intentionally low-cardinality so
1658
+ // extractUpdateErrorCode picks them up cleanly:
1659
+ // IO_<NSError code>
1660
+ let nsErr = error as NSError
1661
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: unzip failed: domain=\(nsErr.domain) code=\(nsErr.code) desc=\(nsErr.localizedDescription)")
1381
1662
  try? FileManager.default.removeItem(atPath: destination)
1382
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to unzip bundle: \(error.localizedDescription)"])
1663
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to unzip bundle: IO_\(nsErr.code)"])
1383
1664
  }
1384
1665
 
1385
1666
  // Validate extracted paths (symlinks, path traversal)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-bundle-update",
3
- "version": "3.0.27",
3
+ "version": "3.0.29",
4
4
  "description": "react-native-bundle-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",