@onekeyfe/react-native-bundle-update 3.0.28 → 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.
package/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
|
1307
|
-
val
|
|
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
|
|
1317
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1513
|
+
FileOutputStream(partialFilePath, appendMode).use { outputStream ->
|
|
1329
1514
|
val buffer = ByteArray(8192)
|
|
1330
1515
|
var bytesRead: Int
|
|
1331
1516
|
|
|
1332
|
-
var prevProgress =
|
|
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 (
|
|
1337
|
-
val progress = ((totalBytesRead * 100) /
|
|
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/$
|
|
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
|
|
1349
|
-
OneKeyLog.info(
|
|
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 = "
|
|
1354
|
-
throw Exception("Bundle
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 {
|
|
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
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
1541
|
+
OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP \(httpResponse.statusCode), moving to destination...")
|
|
1279
1542
|
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
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: \(
|
|
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)
|