diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index c7b144e7..15c18360 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -6,7 +6,7 @@ jobs: package-publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: '24.x' diff --git a/CHANGELOG.md b/CHANGELOG.md index eaff0f55..b39dea6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,55 @@ All notable changes to this project will be documented in this file. +## [3.0.31] - 2026-05-09 + +### Features +- **bundle-update (iOS)**: Persist `URLSession` resume data on download failure. `DownloadDelegate` now captures `NSURLSessionDownloadTaskResumeData` from the failed task's `userInfo` and `downloadBundle` writes it to a `.resume` sidecar; on the next attempt, `session.downloadTask(withResumeData:)` finishes from the OS-recorded cut point instead of restarting from byte 0. The success branch unlinks the stale blob so the sidecar stays in lockstep with the bundle. Previously the ~11KB resume blob iOS attaches to every `NSURL -1005` / `-1001` failure was discarded — the same two error codes that account for ~64% of all download failures (~5,940 mixpanel users). +- **bundle-update (iOS)**: Snapshot resume data on `UIApplication.didEnterBackgroundNotification`. Force-quit (user swipes off App Switcher) and memory-pressure kills both SIGKILL the app, leaving no time for `URLSession` `didCompleteWithError` callbacks to write the resume blob. Both kill paths are deterministically preceded by a background transition, so `init` registers a notification observer (paired removal in `deinit`); on fire, `beginBackgroundTask` extends the ~5s of guaranteed background runtime to ~30s while we walk `URLSession.getAllTasks` and call `cancel(byProducingResumeData:)` on every running download task — the cancel callback delivers the blob asynchronously via its closure, so the background-task window exists precisely to give that closure (and `getAllTasks`'s own completion handler) time to run before suspension; persistence is best-effort. Anchored to `activeDownloadFilePath`, set before `task.resume()` and cleared in `defer` so observers fired outside an active download are no-ops. +- **bundle-update (Android)**: Range-based resume via `.partial` sidecar. Mirrors the Desktop implementation — download streams to the partial file, rename to the final filename happens only after SHA256 verifies. On retry, `.partial` size becomes the `Range: bytes=-` header; server-side 206 with parsed `Content-Range` gives the true total, 200 on a Range request transparently restarts, 416 wipes the partial and bubbles up so the next attempt starts clean. A corrupt full file can no longer poison the "exists → already valid" fast path. +- **bundle-update**: Per-failure SHA256 subtype tagging. A `ThreadLocal` (Android) / `Thread.threadDictionary` (iOS) side-channel stamp records the specific reason — Android emits `FILE_NOT_FOUND` / `FILE_DISAPPEARED` / `FILE_TRUNCATED` / `PERMISSION_DENIED` / `OOM` / `IO_` / `UNEXPECTED_`, iOS emits `FILE_NOT_FOUND` / `FILE_DISAPPEARED` / `IO_`; `verifyBundleSHA256` reads it back so a hash-mismatch (reason==null) is distinguishable from a computation failure. iOS replaces the raise-prone `readData(ofLength:)` with a throwing `read(upToCount:)` wrapper so disk I/O failures become catchable Swift errors instead of `NSException` surface. The download flow attaches the subtype to its `update/error` event payload (`SHA256_FILE_TRUNCATED`, `SHA256_OOM`, …) so the previously opaque 91.3% Android `verifyPackage` mixpanel bucket can be split end-to-end. 0-byte files are intentionally NOT tagged — they hash to the well-known empty-content SHA-256 so legitimate empty marker / locale-fallback files in OTA bundles continue to pass `validateAllFilesInDir` / `validateWebEmbedSha256` / launch entry verification. + +### Bug Fixes +- **bundle-update**: Drop inner exception text from thrown messages to prevent path leakage. iOS `verifyBundleASC` previously embedded `error.localizedDescription` from `SSZipArchive`, which on a real device frequently contains the install UUID under `/var/mobile/Containers/Data/Application//`; now throws `Failed to unzip bundle: IO_`. Android `getMetadata` previously embedded `e.message` from `org.json` parse failures, which can carry partial JSON contents or local file paths from the underlying reader; now throws `Failed to parse metadata.json: IO_`. The full description still flows to OneKeyLog (local-only) for support diagnostics; only the `IO_*` tag escapes into the Promise rejection that JS analytics observes. Tag shape matches the `SHA256_` convention so `extractUpdateErrorCode` classifies them into the same `IO_*` mixpanel bucket without a separate parser. +- **bundle-update**: Verify-stage SHA failures now propagate the subtype. `verifyBundleASC`'s SHA256 guard threw `Bundle signature verification failed`, opaque to `extractUpdateErrorCode`, which collapsed every verify-stage SHA failure into one bucket. Now reads the side-channel reason and throws `Bundle SHA256 verification failed: ` matching the download-stage shape so the JS extractor splits the bucket end-to-end. +- **bundle-update (iOS)**: Protect `activeDownloadFilePath` under `stateQueue`. Previously `isDownloading` was read inside `stateQueue` but `activeDownloadFilePath` was read outside it, so a foreground/background race could observe a torn state (`isDownloading=true` with a stale or nil path, or vice versa). Read both inside the same sync block, and pair the writes with the same queue. +- **bundle-update (iOS)**: Invalidate `URLSession` in `deinit`. `URLSession` retains its delegate strongly until invalidated, so without `urlSession?.invalidateAndCancel()` the session and its `DownloadDelegate` would outlive the module — relevant on dev hot-reload and any future test harness with multiple module instances. +- **bundle-update (Android)**: Recover crashed-before-rename `.partial` via promote+verify. If the JVM was killed between the last byte being written to `.partial` and the rename to the final filename, the previous code unconditionally deleted the partial when its size hit `expectedSize`, forcing a full re-download of an already-complete bundle. Now: when `partialSize == expectedSize`, attempt rename + SHA verify first; if it passes, treat the bundle as complete and skip the download entirely. Only deletes on verify failure. +- **bundle-update (Android)**: Recover from HTTP 416 when `Content-Range` indicates the partial IS the complete bundle. `Content-Range: bytes */` where `total == partialBytes` means the local partial is byte-complete and the server correctly rejected our `Range` request. Parse the header, attempt promote+verify before falling through to the wipe branch; otherwise behavior is unchanged. +- **bundle-update (Android)**: Sanitize `update/error` event payloads. Route exception messages through `sanitizeErrorMessageForEvent` before emitting `update/error`, so `FileNotFoundException` / `IOException` payloads never leak `/data/user///...` paths to JS listeners or downstream analytics. Known low-cardinality reasons (SHA256 verify, HTTP ``, guard messages) are preserved verbatim; everything else collapses to `IO_`. +- **perf-stats (Android)**: Prevent overlay leak on Activity destroy. The window-manager-attached overlay was not removed when the host Activity was destroyed, leaking the `View` and its `WindowManager` token across configuration changes / Activity recreation. + +### Chores +- Bump all packages to 3.0.31. + +## [3.0.28] - 2026-05-08 + +### Features +- **perf-stats**: Add UI FPS and JS FPS metrics. `PerfSample` gains `uiFps` (native — Android `Choreographer` / iOS `CADisplayLink`, counted on the main thread) and `jsFps` (JS-side `requestAnimationFrame` count pushed via `setJsFpsHint`). Native owns UI FPS read-and-reset on each periodic tick, then caches the value so one-shot `sample()` calls don't steal frames from the next interval. JS FPS hints stale out after 2s so the overlay doesn't surface a frozen number when the JS-side tracker has been stopped. Anomaly logging extends to `ui_fps <= 45 (and > 0)` and `js_fps <= 30 (and > 0)`, each with the same 5-sample sustain and 30s cooldown already used for CPU/RSS. Overlay renders four lines. +- **perf-stats**: Auto-manage JS FPS tracker from `start` / `stop`. `ReactNativePerfStats.start` now also runs `startJsFpsTracker` with the sampler's interval, and `.stop` tears it down — callers no longer have to wire the `rAF` lifecycle separately for `jsFps` to populate. `startJsFpsTracker` / `stopJsFpsTracker` remain exported as escape hatches; restarting `start` with a different `reportIntervalMs` while the loop is already running cleanly restarts the rAF reporter. + +### Bug Fixes +- **perf-stats (iOS)**: Keep overlay above modal-presented controllers. The overlay `UILabel` was attached directly to the host app's key `UIWindow`, so any UIKit-modal-presented controller (RN ``, native action sheets, etc.) rendered above it regardless of subview z-order — modal presentation works above the entire root window's view hierarchy, so no amount of `bringSubviewToFront` would help. Switch to a dedicated `UIWindow` at `windowLevel = .alert + 1`, backed by `OverlayPassthroughWindow`, a `UIWindow` subclass that forwards every touch outside the label to the windows below via `hitTest` so the overlay never swallows taps. + +### Chores +- Bump all packages to 3.0.28. + +## [3.0.25] - 2026-05-07 + +### Features +- **perf-stats**: New `@onekeyfe/react-native-perf-stats` Nitro module — periodic CPU% and RSS sampler with a debug overlay, wired through `react-native-nitro-modules` with Android (Kotlin/JNI) and iOS (Swift) bindings. +- **perf-stats**: Log sustained CPU/RSS anomalies to native-logger. Periodic sampler emits `OneKeyLog.warn` when CPU >= 150% or RSS >= 800 MB for 5 consecutive samples, with a per-category 30s cooldown to avoid log flooding. One-shot `sample()` calls do not trip this path; counters live on the sampler thread (Android `HandlerThread`, iOS serial queue) so they need no extra locking, while the cooldown clock persists across stop/start cycles. + +### Bug Fixes +- **perf-stats**: Close `start` / `stop` race that stranded the sampler. Move `running=true` and handler lifecycle into a single synchronized block, plus a generation token so any in-flight tick whose scheduler has been stopped drops itself instead of rescheduling on a quitting (or freshly-recreated) handler. Previously `running=true` was `post()`ed onto the handler thread; if `stop()` landed between the post and its execution, the lambda resurrected `running=true` after `stop` had nulled the handler and quit the looper, leaving `running=true` with no live scheduler — the next `start()` then early-returned on the running check and the sampler never recovered. +- **perf-stats (Android)**: Use literal `1005` for `ABOVE_SUB_PANEL` window type. `WindowManager.LayoutParams.TYPE_APPLICATION_ABOVE_SUB_PANEL` is `@hide` in the public Android SDK, so referencing it by name fails to compile against a non-internal `android.jar`. Runtime still honours the value (`FIRST_SUB_WINDOW + 5 == 1005`), so use the literal directly via a private const and document the intent. + +### Documentation +- **perf-stats**: Rewrite README with the real API and the scoped package name. + +### Chores +- Bump all packages to 3.0.25. + ## [3.0.24] - 2026-04-28 ### Bug Fixes diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 9b157015..1e07ba38 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index cf056b88..c3a250fc 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 0116618c..9fbc9f56 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index 7fedd3c7..e9d5f53a 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index ca841e33..22e07bdf 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -84,7 +84,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-bundle-update": ">=3.0.28", + "@onekeyfe/react-native-bundle-update": ">=3.0.31", "react": "*", "react-native": "*" }, diff --git a/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt index abe9e1f2..eb2eca32 100644 --- a/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt +++ b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt @@ -404,7 +404,36 @@ object BundleUpdateStoreAndroid { } } + private val lastSHA256Failure = ThreadLocal() + + /** + * Subtype of the most recent calculateSHA256 failure on this thread, or + * null if the last call succeeded. Surfaces the specific reason — + * FILE_NOT_FOUND / FILE_DISAPPEARED / FILE_TRUNCATED / + * PERMISSION_DENIED / OOM / IO_ / UNEXPECTED_ — so + * analytics can split the previously opaque "Failed to calculate + * SHA256" bucket (mixpanel: 91.3 percent of verifyPackage failures) + * into actionable categories. Keep this list in sync with the + * lastSHA256Failure.set(...) call sites in calculateSHA256 below. + * + * Note: 0-byte files are NOT treated as a failure. They hash to the + * well-known empty-content SHA-256 and the caller's expected/actual + * comparison handles legitimate vs. corrupt-empty cases. Rejecting + * empty files here would make any OTA bundle that legitimately + * contains a 0-byte file (touched marker, blank locale fallback) + * fail validateAllFilesInDir / validateWebEmbedSha256 / launch entry + * verification — all of which share this calculator. + */ + fun lastSHA256FailureReason(): String? = lastSHA256Failure.get() + fun calculateSHA256(filePath: String): String? { + lastSHA256Failure.set(null) + val file = File(filePath) + if (!file.exists()) { + lastSHA256Failure.set("FILE_NOT_FOUND") + OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: $filePath") + return null + } return try { val digest = MessageDigest.getInstance("SHA-256") BufferedInputStream(FileInputStream(filePath)).use { bis -> @@ -415,8 +444,29 @@ object BundleUpdateStoreAndroid { } } bytesToHex(digest.digest()) + } catch (e: java.io.FileNotFoundException) { + lastSHA256Failure.set("FILE_DISAPPEARED") + OneKeyLog.error("BundleUpdate", "calculateSHA256: file disappeared during read: ${e.message}") + null + } catch (e: java.io.EOFException) { + lastSHA256Failure.set("FILE_TRUNCATED") + OneKeyLog.error("BundleUpdate", "calculateSHA256: truncated file: ${e.message}") + null + } catch (e: SecurityException) { + lastSHA256Failure.set("PERMISSION_DENIED") + OneKeyLog.error("BundleUpdate", "calculateSHA256: permission denied: ${e.message}") + null + } catch (e: OutOfMemoryError) { + lastSHA256Failure.set("OOM") + OneKeyLog.error("BundleUpdate", "calculateSHA256: OutOfMemoryError on ${file.length()} bytes") + null + } catch (e: java.io.IOException) { + lastSHA256Failure.set("IO_${e.javaClass.simpleName}") + OneKeyLog.error("BundleUpdate", "calculateSHA256: ${e.javaClass.simpleName}: ${e.message}") + null } catch (e: Exception) { - OneKeyLog.error("BundleUpdate", "Error calculating SHA256: ${e.message}") + lastSHA256Failure.set("UNEXPECTED_${e.javaClass.simpleName}") + OneKeyLog.error("BundleUpdate", "calculateSHA256: ${e.javaClass.simpleName}: ${e.message}") null } } @@ -455,8 +505,13 @@ object BundleUpdateStoreAndroid { } } } catch (e: Exception) { - OneKeyLog.error("BundleUpdate", "Error parsing metadata JSON: ${e.message}") - throw Exception("Failed to parse metadata.json: ${e.message}") + // org.json's exception messages occasionally embed file paths or + // partial JSON content. Keep the rich detail in OneKeyLog (local + // only), but throw a class-tag-only message so the JS analytics + // layer cannot reflect arbitrary inner content. Mirrors the + // SHA256_/IO_ convention used elsewhere. + OneKeyLog.error("BundleUpdate", "Error parsing metadata JSON: ${e.javaClass.simpleName}: ${e.message}") + throw Exception("Failed to parse metadata.json: IO_${e.javaClass.simpleName}") } if (metadata.isEmpty()) { throw Exception("metadata.json is empty or contains no file entries") @@ -1201,6 +1256,35 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } } + /** + * Returns a low-cardinality, path-free tag describing why a download + * failed. Used in the JS-facing `update/error` event so listeners + * never observe Android's `/data/data//...` or `/data/user///...` + * paths that FileNotFoundException / IOException embed in `e.message`. + * + * Recognized payloads (same shape extractUpdateErrorCode parses): + * - "Bundle SHA256 verification failed: " → preserved verbatim; + * JS extractor maps to SHA256_. + * - "HTTP " / "HTTP 416 ..." → preserved verbatim; maps to HTTP_. + * - "Already downloading" / "Invalid version string format" / + * "Bundle download URL must use HTTPS" → preserved verbatim; the + * hooks unrecoverable-list matches them by exact substring. + * - Anything else → "IO_" so the JS extractor + * splits the bucket on exception class without leaking the message. + */ + private fun sanitizeErrorMessageForEvent(e: Exception): String { + val msg = e.message ?: return "IO_${e.javaClass.simpleName}" + if (msg.startsWith("Bundle SHA256 verification failed:")) return msg + if (msg.startsWith("HTTP ")) return msg + if (msg == "Already downloading" || + msg == "Invalid version string format" || + msg == "Bundle download URL must use HTTPS" || + msg == "Empty response body" || + msg == "Failed to finalize download" + ) return msg + return "IO_${e.javaClass.simpleName}" + } + override fun addDownloadListener(callback: (BundleDownloadEvent) -> Unit): Double { val id = nextListenerId.getAndIncrement().toDouble() listeners.add(BundleListener(id, callback)) @@ -1274,6 +1358,11 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { val fileName = "$appVersion-$bundleVersion.zip" val filePath = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context), fileName).absolutePath + // Resume support: download to .partial; rename to + // only after the full transfer + SHA256 verify pass. Mirrors the + // Desktop convention so a corrupt completion can never poison the + // "exists at filePath -> already valid" cache check above. + val partialFilePath = "$filePath.partial" val result = BundleDownloadResult( downloadedFile = filePath, @@ -1286,6 +1375,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { OneKeyLog.info("BundleUpdate", "downloadBundle: filePath=$filePath") val downloadedFile = File(filePath) + val partialFile = File(partialFilePath) if (downloadedFile.exists()) { OneKeyLog.info("BundleUpdate", "downloadBundle: file already exists, verifying SHA256...") if (verifyBundleSHA256(filePath, sha256)) { @@ -1297,47 +1387,156 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } else { OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading") downloadedFile.delete() + // Stale completed file invalidates any partial too. + if (partialFile.exists()) partialFile.delete() + } + } + + // Resume: if a partial from a previous run exists and is smaller + // than the expected size, send `Range: bytes=-` so the + // server fills in the rest. If the partial is exactly the + // expected size it's a process-killed-just-before-rename case + // (full body on disk but never promoted): try SHA verify + // before discarding so we save a full re-download. + val expectedSize = if (params.fileSize > 0) params.fileSize.toLong() else 0L + var partialBytes = 0L + if (partialFile.exists()) { + val partialSize = partialFile.length() + when { + expectedSize > 0 && partialSize == expectedSize -> { + OneKeyLog.info("BundleUpdate", "downloadBundle: partial matches expected size ($partialSize), trying promote+verify") + if (downloadedFile.exists()) downloadedFile.delete() + if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) { + OneKeyLog.info("BundleUpdate", "downloadBundle: recovered crashed-before-rename bundle, skipping download") + isDownloading.set(false) + Thread.sleep(1000) + sendEvent("update/complete") + return@async result + } else { + OneKeyLog.warn("BundleUpdate", "downloadBundle: promote+verify failed, discarding both files") + if (downloadedFile.exists()) downloadedFile.delete() + // partialFile is gone (renamed); nothing to delete + } + } + expectedSize > 0 && partialSize > expectedSize -> { + OneKeyLog.warn("BundleUpdate", "downloadBundle: stale partial (>expected), discarding: $partialSize/$expectedSize") + partialFile.delete() + } + partialSize > 0 -> { + partialBytes = partialSize + OneKeyLog.info("BundleUpdate", "downloadBundle: resuming from $partialBytes bytes (expected=$expectedSize)") + } + else -> partialFile.delete() } } sendEvent("update/start") - OneKeyLog.info("BundleUpdate", "downloadBundle: starting download...") + OneKeyLog.info("BundleUpdate", "downloadBundle: starting download (resume=${partialBytes > 0})...") + + val requestBuilder = Request.Builder().url(downloadUrl) + if (partialBytes > 0) { + requestBuilder.addHeader("Range", "bytes=$partialBytes-") + } + val response = httpClient.newCall(requestBuilder.build()).execute() + + // 416 Range Not Satisfiable: server says our offset is past the + // file length. Two sub-cases distinguishable from + // `Content-Range: bytes */`: + // (a) total == partialBytes → file is exactly complete on + // server; our partial is the whole bundle and just + // needs SHA verify + rename. Recover instead of wipe. + // (b) anything else → partial is corrupt or bundle changed. + // Wipe and bubble up. + if (response.code == 416) { + val contentRange = response.header("Content-Range") + response.close() + val totalFromHeader = contentRange + ?.let { Regex("""bytes\s+\*\s*/\s*(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } + if (totalFromHeader != null && totalFromHeader == partialBytes && partialFile.exists()) { + OneKeyLog.info("BundleUpdate", "downloadBundle: HTTP 416 with total==$totalFromHeader matches partial, attempting promote+verify") + if (downloadedFile.exists()) downloadedFile.delete() + if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) { + OneKeyLog.info("BundleUpdate", "downloadBundle: 416 recovery succeeded, skipping download") + sendEvent("update/complete") + return@async result + } + OneKeyLog.warn("BundleUpdate", "downloadBundle: 416 recovery failed verify, discarding") + if (downloadedFile.exists()) downloadedFile.delete() + } + OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt") + if (partialFile.exists()) partialFile.delete() + // Don't pre-emit update/error here; the outer catch is the + // single source of error events. sanitizeErrorMessageForEvent + // recognizes "HTTP " prefix and forwards this string verbatim. + throw Exception("HTTP 416 (range not satisfiable)") + } - val request = Request.Builder().url(downloadUrl).build() - val response = httpClient.newCall(request).execute() + val expectsResume = partialBytes > 0 + val isPartialResponse = response.code == 206 - if (!response.isSuccessful) { + if (!response.isSuccessful || (response.code != 200 && response.code != 206)) { OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}") - sendEvent("update/error", message = "HTTP ${response.code}") + response.close() + // outer catch is the single source of update/error events. throw Exception("HTTP ${response.code}") } - val body = response.body ?: throw Exception("Empty response body") - val fileSize = if (params.fileSize > 0) params.fileSize.toLong() else body.contentLength() - OneKeyLog.info("BundleUpdate", "downloadBundle: HTTP 200, contentLength=$fileSize, downloading...") + // Server can ignore `Range` and return the full body with 200; in + // that case our partial is meaningless — restart fresh. + if (expectsResume && !isPartialResponse) { + OneKeyLog.warn("BundleUpdate", "downloadBundle: requested Range but server returned 200, restarting from scratch") + if (partialFile.exists()) partialFile.delete() + partialBytes = 0L + } + + // Close the response before throwing on a null body — OkHttp + // holds connection resources on the response wrapper itself, + // and `throw` here exits the function before any byteStream() + // consumption would close it for us. + val body = response.body ?: run { + response.close() + throw Exception("Empty response body") + } + val contentLength = body.contentLength() + // Total size of the whole resource (not the slice). On 206 prefer + // Content-Range's "/total" tail; fall back to partial+contentLength. + val totalSize: Long = if (isPartialResponse) { + val contentRange = response.header("Content-Range") + val parsedTotal = contentRange + ?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } + parsedTotal ?: (partialBytes + contentLength.coerceAtLeast(0L)) + } else { + if (contentLength > 0) contentLength else expectedSize + } + OneKeyLog.info( + "BundleUpdate", + "downloadBundle: HTTP ${response.code}, contentLength=$contentLength, totalSize=$totalSize, partialBytes=$partialBytes, downloading..." + ) // Ensure parent directory exists before writing - val parentDir = File(filePath).parentFile + val parentDir = File(partialFilePath).parentFile if (parentDir != null && !parentDir.exists()) { parentDir.mkdirs() OneKeyLog.info("BundleUpdate", "downloadBundle: created parent directory: ${parentDir.absolutePath}") } - var totalBytesRead = 0L + // Append iff server granted us a 206 partial; otherwise overwrite. + val appendMode = isPartialResponse + var totalBytesRead = if (isPartialResponse) partialBytes else 0L body.byteStream().use { inputStream -> - FileOutputStream(filePath).use { outputStream -> + FileOutputStream(partialFilePath, appendMode).use { outputStream -> val buffer = ByteArray(8192) var bytesRead: Int - var prevProgress = 0 + var prevProgress = -1 while (inputStream.read(buffer).also { bytesRead = it } != -1) { outputStream.write(buffer, 0, bytesRead) totalBytesRead += bytesRead - if (fileSize > 0) { - val progress = ((totalBytesRead * 100) / fileSize).toInt() + if (totalSize > 0) { + val progress = ((totalBytesRead * 100) / totalSize).toInt().coerceIn(0, 100) if (progress != prevProgress) { sendEvent("update/downloading", progress = progress) - OneKeyLog.info("BundleUpdate", "download progress: $progress% ($totalBytesRead/$fileSize)") + OneKeyLog.info("BundleUpdate", "download progress: $progress% ($totalBytesRead/$totalSize)") prevProgress = progress } } @@ -1345,32 +1544,75 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } } - val downloadedFileAfter = File(filePath) - OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, totalBytesRead=$totalBytesRead, fileExists=${downloadedFileAfter.exists()}, fileSize=${if (downloadedFileAfter.exists()) downloadedFileAfter.length() else -1}, verifying SHA256...") + val partialAfter = File(partialFilePath) + OneKeyLog.info( + "BundleUpdate", + "downloadBundle: download finished, totalBytesRead=$totalBytesRead, partialExists=${partialAfter.exists()}, partialSize=${if (partialAfter.exists()) partialAfter.length() else -1}, finalizing..." + ) + + // Promote .partial to final ONLY after the full transfer; renaming + // first means a SHA256 mismatch leaves no half-baked filePath that + // the next call would mistake for a cached good bundle. + if (downloadedFile.exists()) downloadedFile.delete() + if (!partialAfter.renameTo(downloadedFile)) { + OneKeyLog.error("BundleUpdate", "downloadBundle: rename .partial -> final failed") + // outer catch is the single source of update/error events; + // "Failed to finalize download" is in the verbatim allowlist. + throw Exception("Failed to finalize download") + } + + OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...") if (!verifyBundleSHA256(filePath, sha256)) { + val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH" File(filePath).delete() - OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download") - sendEvent("update/error", message = "Bundle signature verification failed") - throw Exception("Bundle signature verification failed") + OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=$reason") + // outer catch emits the verbatim "Bundle SHA256 verification + // failed: " payload (recognized by sanitize/JS). + throw Exception("Bundle SHA256 verification failed: $reason") } sendEvent("update/complete") OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=$appVersion, bundleVersion=$bundleVersion") result } catch (e: Exception) { + // Keep the rich detail in OneKeyLog (local-only). The JS + // event channel must NOT carry e.message verbatim — Android + // FileNotFoundException etc. embed the full /data/user/.../ + // path including the package identifier, and downstream + // listeners would forward that into analytics. Emit only the + // sanitized tag; mirrors the iOS sendEvent payload at + // ReactNativeBundleUpdate.swift's "update/error" sites and + // matches the verbatim guarantees documented on + // sanitizeErrorMessageForEvent. OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}") - sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}") - throw e + val sanitized = sanitizeErrorMessageForEvent(e) + sendEvent("update/error", message = sanitized) + // Rethrow with the same sanitized message so the Promise + // rejection surfacing to JS carries no /data/user/// + // paths. Without this rewrap, FileNotFoundException etc. + // would re-leak via Promise.reject's message channel even + // though the event payload was already sanitized. Keep + // the original exception as `cause` so OneKeyLog (and any + // native crash reporter) still sees the full chain. + throw Exception(sanitized, e) } finally { isDownloading.set(false) } } } + /** + * Returns true on hash match. On false, callers may inspect + * BundleUpdateStoreAndroid.lastSHA256FailureReason() to distinguish a + * computation failure (FILE_TRUNCATED / OOM / IO_*) from a clean hash + * mismatch (reason == null). + */ private fun verifyBundleSHA256(bundlePath: String, sha256: String): Boolean { val calculated = BundleUpdateStoreAndroid.calculateSHA256(bundlePath) if (calculated == null) { - OneKeyLog.error("BundleUpdate", "verifyBundleSHA256: failed to calculate SHA256 for: $bundlePath") + val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "UNKNOWN" + val fileSize = try { File(bundlePath).length() } catch (_: Exception) { -1L } + OneKeyLog.error("BundleUpdate", "verifyBundleSHA256: failed to calculate SHA256 for: $bundlePath, reason=$reason, fileSize=$fileSize") return false } val isValid = BundleUpdateStoreAndroid.secureCompare(calculated, sha256) @@ -1412,8 +1654,13 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { if (!skipGPG) { OneKeyLog.info("BundleUpdate", "verifyBundleASC: verifying SHA256 of downloaded file...") if (!verifyBundleSHA256(filePath, sha256)) { - OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=$filePath") - throw Exception("Bundle signature verification failed") + // Promote the SHA256 subtype (FILE_TRUNCATED / OOM / + // IO_ / MISMATCH) into the thrown message so + // extractUpdateErrorCode in the JS layer can split + // this bucket the same way the download stage does. + val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH" + OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=$filePath, reason=$reason") + throw Exception("Bundle SHA256 verification failed: $reason") } OneKeyLog.info("BundleUpdate", "verifyBundleASC: SHA256 verified OK") } else { diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index bf5551d3..0f7c7ad1 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -5,6 +5,7 @@ import CommonCrypto import Gopenpgp import SSZipArchive import MMKV +import UIKit // OneKey GPG public key for signature verification private let GPG_PUBLIC_KEY = """ @@ -312,23 +313,106 @@ public class BundleUpdateStore: NSObject { return true } + /// Subtype of the most recent calculateSHA256 failure on this thread, or + /// nil if the last call succeeded. Surfaces FILE_NOT_FOUND / + /// FILE_DISAPPEARED / IO_ so analytics can split the + /// previously opaque "Failed to calculate SHA256" bucket (mixpanel: + /// 91.3% of verifyPackage failures are Android; iOS shares the + /// calculator and inherits the same blind spot for its 14 ASC + 2 + /// verifyPackage Promise-destroyed cases). Keep this list in sync + /// with the setSHA256Failure(...) call sites in calculateSHA256 + /// below — Android's wider taxonomy (FILE_TRUNCATED / OOM / + /// UNEXPECTED_) does not apply here because the iOS reader + /// surfaces every disk error as a single `IO_`. + /// + /// Note: 0-byte files are NOT treated as a failure. They hash to the + /// well-known empty-content SHA256 and the caller's expected/actual + /// comparison handles legitimate vs. corrupt-empty cases. Rejecting + /// empty files here would make any OTA bundle that legitimately + /// contains a 0-byte file (touched marker, blank locale fallback) + /// fail validateAllFilesInDir / validateWebEmbedSha256 / launch entry + /// verification — all of which share this calculator. + public static func lastSHA256FailureReason() -> String? { + return Thread.current.threadDictionary[kSHA256FailureKey] as? String + } + private static let kSHA256FailureKey = "so.onekey.bundleupdate.sha256.failure" + private static func setSHA256Failure(_ reason: String?) { + if let reason = reason { + Thread.current.threadDictionary[kSHA256FailureKey] = reason + } else { + Thread.current.threadDictionary.removeObject(forKey: kSHA256FailureKey) + } + } + public static func calculateSHA256(_ filePath: String) -> String? { - guard let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil } + setSHA256Failure(nil) + let fm = FileManager.default + if !fm.fileExists(atPath: filePath) { + setSHA256Failure("FILE_NOT_FOUND") + OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: \(filePath)") + return nil + } + guard let fileHandle = FileHandle(forReadingAtPath: filePath) else { + setSHA256Failure("FILE_DISAPPEARED") + OneKeyLog.error("BundleUpdate", "calculateSHA256: open failed (file disappeared between stat and open): \(filePath)") + return nil + } defer { fileHandle.closeFile() } - var context = CC_SHA256_CTX() - CC_SHA256_Init(&context) - while autoreleasepool(invoking: { - let data = fileHandle.readData(ofLength: 8192) - if data.count > 0 { - data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) } - return true - } - return false - }) {} - var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - CC_SHA256_Final(&hash, &context) - return hash.map { String(format: "%02x", $0) }.joined() + do { + var context = CC_SHA256_CTX() + CC_SHA256_Init(&context) + var threwError: Error? + // safeRead routes reads through FileHandle.read(upToCount:) on + // iOS 13.4+ — that variant surfaces disk failures as throwing + // NSError, which we catch here as Swift `Error`. On pre-13.4 + // OS versions safeRead falls back to readData(ofLength:), + // which raises NSFileHandleOperationException; Swift cannot + // catch ObjC NSExceptions via try/catch, so on those legacy + // versions a read failure will still abort the process. We + // accept that on the floor since 13.4+ has been the deployment + // target for years. + while autoreleasepool(invoking: { + do { + let data = try Self.safeRead(fileHandle: fileHandle, length: 8192) + if data.count > 0 { + data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) } + return true + } + return false + } catch { + threwError = error + return false + } + }) {} + if let err = threwError { + let nsErr = err as NSError + // Keep the failure tag low-cardinality (`IO_`) so it + // matches the doc on lastSHA256FailureReason and stays under + // the analytics bucket cap. The full `domain code description` + // detail is still logged below for local debugging. + setSHA256Failure("IO_\(nsErr.code)") + OneKeyLog.error("BundleUpdate", "calculateSHA256: read failed: \(nsErr.domain) \(nsErr.code) \(nsErr.localizedDescription)") + return nil + } + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + CC_SHA256_Final(&hash, &context) + return hash.map { String(format: "%02x", $0) }.joined() + } + } + + /// Raises throwing wrapper around FileHandle.read(upToCount:) so disk + /// I/O failures (truncated file, unmounted volume) become catchable Swift + /// errors rather than NSFileHandleOperationException. + private static func safeRead(fileHandle: FileHandle, length: Int) throws -> Data { + if #available(iOS 13.4, macOS 10.15.4, *) { + return try fileHandle.read(upToCount: length) ?? Data() + } else { + // Pre-iOS 13.4 fallback: classic readData(ofLength:) does not + // throw, but raises NSException; we cannot bridge that here so + // accept the legacy behavior on these old OS versions only. + return fileHandle.readData(ofLength: length) + } } public static func getNativeVersion() -> String? { @@ -1036,6 +1120,10 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate { /// Continuation to bridge delegate callbacks → async/await private var continuation: CheckedContinuation<(URL, URLResponse), Error>? private var tempFileURL: URL? + /// Resume data captured from the most recent failure so the caller can + /// persist it for the next attempt. Populated in didCompleteWithError + /// when the system supplies NSURLSessionDownloadTaskResumeData. + private(set) var lastResumeData: Data? private let lock = NSLock() func setContinuation(_ cont: CheckedContinuation<(URL, URLResponse), Error>) { @@ -1079,10 +1167,23 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate { lock.unlock() if let error = error { + // iOS attaches partial download bytes via userInfo so the next + // attempt can finish from the cut point instead of byte 0. Mixpanel + // shows ~5,940 of our failures (NSURL -1005 / -1001) carry ~11KB of + // resume data each — previously discarded. + let nsError = error as NSError + if let resumeData = nsError.userInfo[NSURLSessionDownloadTaskResumeData] as? Data, resumeData.count > 0 { + self.lastResumeData = resumeData + OneKeyLog.info("BundleUpdate", "download error captured resumeData: \(resumeData.count) bytes") + } else { + self.lastResumeData = nil + } cont?.resume(throwing: error) } else if let tempURL = tempFileURL, let response = task.response { + self.lastResumeData = nil cont?.resume(returning: (tempURL, response)) } else { + self.lastResumeData = nil cont?.resume(throwing: NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download completed without file"])) } @@ -1105,6 +1206,7 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate { tempFileURL = nil onProgress = nil prevProgress = -1 + lastResumeData = nil } } @@ -1134,9 +1236,137 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } + /// Path of the bundle being downloaded right now (mirrors filePath in + /// downloadBundle). Set on entry, cleared on exit. Single value because + /// isDownloading enforces at most one in-flight download per process. + /// Read by the background-snapshot handler so it knows where to drop the + /// `.resume` sidecar. + private var activeDownloadFilePath: String? + private var didEnterBackgroundObserver: NSObjectProtocol? + override init() { super.init() urlSession = createURLSession() + registerBackgroundSnapshotObserver() + } + + deinit { + if let token = didEnterBackgroundObserver { + NotificationCenter.default.removeObserver(token) + } + // URLSession retains its delegate strongly until invalidated. + // Without this call the session (and its DownloadDelegate) would + // leak past module deallocation — relevant in dev hot-reload and + // any future test harness that spins up multiple module instances. + urlSession?.invalidateAndCancel() + } + + /// On iOS, force-quit (user swipes the app off the App Switcher) cannot + /// fire `URLSession`'s `didCompleteWithError` — SIGKILL leaves no time + /// for callbacks. The kill is, however, *always* preceded by the app + /// transitioning to the background. We hook that transition and kick + /// off `cancel(byProducingResumeData:)` for any in-flight downloads so + /// the resume blob lands on disk before the app can be terminated. + /// Memory-pressure kills follow the same chain (the OS only reaps + /// backgrounded apps under memory pressure), so this also covers OOM + /// termination. + /// + /// Both `URLSession.getAllTasks(_:)` and `cancel(byProducingResumeData:)` + /// deliver their results *asynchronously* via a closure — the resume + /// data does not pop out synchronously. We wrap the work in a + /// `beginBackgroundTask` window that extends the ~5s of guaranteed + /// background runtime to ~30s so the closures actually have time to + /// fire and write the few-KB blob before suspension. Persistence is + /// best-effort: if iOS reaps us before the writer closure runs (very + /// short background windows, expiration), the next launch simply + /// re-downloads from scratch — a resume miss is correct, just slower. + private func registerBackgroundSnapshotObserver() { + didEnterBackgroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.snapshotResumeDataForBackgrounding() + } + } + + private func snapshotResumeDataForBackgrounding() { + guard let session = self.urlSession else { return } + // Read both isDownloading AND activeDownloadFilePath inside the + // same stateQueue.sync block. A concurrent downloadBundle entry + // flips isDownloading=true at the start of the Promise body but + // only writes activeDownloadFilePath later (after the early + // guards / version + URL validation). Without this paired read, + // didEnterBackground could observe isDownloading=true while + // activeDownloadFilePath is still nil from a previous run, or — + // on the unwind via defer — see isDownloading=false alongside a + // not-yet-cleared path. + let snapshot: (Bool, String?) = self.stateQueue.sync { + (self.isDownloading, self.activeDownloadFilePath) + } + let (stillDownloading, snapshotPath) = snapshot + guard stillDownloading, let filePath = snapshotPath else { return } + + let resumeDataPath = "\(filePath).resume" + let bgTaskName = "BundleUpdateResumeSnapshot" + + // bgTaskId is mutated from three escaping closures: the + // beginBackgroundTask expiration handler (called on main), + // session.getAllTasks's completion (NOT guaranteed to be main), + // and group.notify on .main. Wrap reads/writes in a tiny holder + // serialized through a dedicated queue so we cannot double-end + // the background task or leak it. `endOnce` guarantees endBackgroundTask + // is invoked exactly once across whichever closure reaches it first. + final class BgTaskHolder { + private let q = DispatchQueue(label: "so.onekey.bundleupdate.bgtask") + private var id: UIBackgroundTaskIdentifier = .invalid + func set(_ newId: UIBackgroundTaskIdentifier) { + q.sync { id = newId } + } + func endOnce() { + q.sync { + if id != .invalid { + UIApplication.shared.endBackgroundTask(id) + id = .invalid + } + } + } + } + let bgTask = BgTaskHolder() + let started = UIApplication.shared.beginBackgroundTask(withName: bgTaskName) { + // Expiration handler — system is reclaiming us. Best-effort end. + bgTask.endOnce() + } + bgTask.set(started) + OneKeyLog.info("BundleUpdate", "didEnterBackground: snapshotting resumeData for \(filePath)") + + session.getAllTasks { tasks in + let group = DispatchGroup() + for task in tasks { + guard let dl = task as? URLSessionDownloadTask, dl.state == .running else { continue } + group.enter() + dl.cancel(byProducingResumeData: { data in + if let data = data, data.count > 0 { + do { + let dir = (resumeDataPath as NSString).deletingLastPathComponent + if !FileManager.default.fileExists(atPath: dir) { + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + try data.write(to: URL(fileURLWithPath: resumeDataPath), options: .atomic) + OneKeyLog.info("BundleUpdate", "didEnterBackground: persisted resumeData (\(data.count) bytes) at \(resumeDataPath)") + } catch { + OneKeyLog.warn("BundleUpdate", "didEnterBackground: failed to persist resumeData: \(error)") + } + } else { + OneKeyLog.info("BundleUpdate", "didEnterBackground: cancel produced no resumeData (task may have just completed)") + } + group.leave() + }) + } + group.notify(queue: .main) { + bgTask.endOnce() + } + } } private func sendEvent(type: String, progress: Int = 0, message: String = "") { @@ -1181,7 +1411,16 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { OneKeyLog.warn("BundleUpdate", "downloadBundle: rejected, already downloading") throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Already downloading"]) } - defer { self.stateQueue.sync { self.isDownloading = false } } + defer { + // Clear isDownloading + the snapshot anchor under the same + // queue so didEnterBackground's paired read can't observe + // a half-cleared state (isDownloading=true while + // activeDownloadFilePath=nil, or vice versa). + self.stateQueue.sync { + self.isDownloading = false + self.activeDownloadFilePath = nil + } + } let appVersion = params.latestVersion let bundleVersion = params.bundleVersion @@ -1202,6 +1441,10 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { let fileName = "\(appVersion)-\(bundleVersion).zip" let filePath = (BundleUpdateStore.downloadBundleDir() as NSString).appendingPathComponent(fileName) + // Persisted resume blob from a previous failed attempt. Lives next + // to the bundle so it shares fate (delete-with-bundle) without + // polluting the bundle dir's cache lookup. + let resumeDataPath = "\(filePath).resume" let result = BundleDownloadResult( downloadedFile: filePath, @@ -1218,6 +1461,8 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { OneKeyLog.info("BundleUpdate", "downloadBundle: file already exists, verifying SHA256...") if self.verifyBundleSHA256(filePath, sha256: sha256) { OneKeyLog.info("BundleUpdate", "downloadBundle: existing file SHA256 valid, skipping download") + // A valid completed bundle invalidates any stale resume blob. + try? FileManager.default.removeItem(atPath: resumeDataPath) DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.sendEvent(type: "update/complete") } @@ -1225,6 +1470,8 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { } else { OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading") try? FileManager.default.removeItem(atPath: filePath) + // Hash-failed bundle means the resume blob is also poisoned. + try? FileManager.default.removeItem(atPath: resumeDataPath) } } @@ -1239,8 +1486,17 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "URLSession not initialized"]) } + // Resume blob from a prior failed attempt; iOS rebuilds the byte + // offset internally so we never need a Range header on this path. + let persistedResumeData: Data? = { + guard FileManager.default.fileExists(atPath: resumeDataPath), + let data = try? Data(contentsOf: URL(fileURLWithPath: resumeDataPath)), + data.count > 0 else { return nil } + return data + }() + self.sendEvent(type: "update/start") - OneKeyLog.info("BundleUpdate", "downloadBundle: starting download...") + OneKeyLog.info("BundleUpdate", "downloadBundle: starting download (resumeBytes=\(persistedResumeData?.count ?? 0))...") let request = URLRequest(url: url) @@ -1254,51 +1510,115 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { self?.sendEvent(type: "update/downloading", progress: progress) } - let (tempURL, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(URL, URLResponse), Error>) in - delegate.setContinuation(continuation) - let task = session.downloadTask(with: request) - task.resume() - } + // Anchor for the background-snapshot handler. Set BEFORE + // task.resume() so a foreground→background transition that + // races the very first bytes still finds a path to write the + // resume blob to. Pair the write with the same stateQueue the + // snapshot reader uses, so isDownloading=true and a non-nil + // activeDownloadFilePath always go together. + self.stateQueue.sync { self.activeDownloadFilePath = filePath } - // Verify HTTPS was maintained (no HTTP redirect) - if let httpResponse = response as? HTTPURLResponse, - let responseUrl = httpResponse.url, - responseUrl.scheme?.lowercased() != "https" { - OneKeyLog.error("BundleUpdate", "downloadBundle: redirected to non-HTTPS URL: \(responseUrl)") - throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download was redirected to non-HTTPS URL"]) - } + let downloadOutcome: Result<(URL, URLResponse), Error> + do { + let value = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(URL, URLResponse), Error>) in + delegate.setContinuation(continuation) + let task: URLSessionDownloadTask + if let resumeData = persistedResumeData { + task = session.downloadTask(withResumeData: resumeData) + } else { + task = session.downloadTask(with: request) + } + task.resume() + } + downloadOutcome = .success(value) + } catch { + downloadOutcome = .failure(error) + } + + switch downloadOutcome { + case .failure(let error): + // Persist the freshly captured resume blob for the next call. + // If iOS gave us nothing usable, clear any stale blob so a + // future attempt isn't held back by an unresumable cut point. + if let resumeData = delegate.lastResumeData { + do { + let dir = (resumeDataPath as NSString).deletingLastPathComponent + if !FileManager.default.fileExists(atPath: dir) { + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + try resumeData.write(to: URL(fileURLWithPath: resumeDataPath), options: .atomic) + OneKeyLog.info("BundleUpdate", "downloadBundle: persisted resumeData (\(resumeData.count) bytes) at \(resumeDataPath)") + } catch { + OneKeyLog.warn("BundleUpdate", "downloadBundle: failed to persist resumeData: \(error)") + } + } else { + try? FileManager.default.removeItem(atPath: resumeDataPath) + } + let nsError = error as NSError + OneKeyLog.error("BundleUpdate", "downloadBundle: download failed: \(nsError.domain) \(nsError.code) \(nsError.localizedDescription)") + self.sendEvent(type: "update/error", message: "\(nsError.domain) \(nsError.code)") + // Rethrow a sanitized NSError. The original `error` is a + // URLSession/system error whose `localizedDescription` may + // include the temp file path (e.g. NSURLErrorFailingURLString + // userInfo, or the ~/Library/.../CFNetworkDownload_*.tmp + // path on cancel-with-resume) — Promise rejections surface + // that string to JS, which would re-leak the paths the + // event-payload sanitization just stripped. Keep the full + // detail in OneKeyLog (above) and emit only `domain code`. + throw NSError( + domain: nsError.domain, + code: nsError.code, + userInfo: [NSLocalizedDescriptionKey: "Download failed: \(nsError.domain) \(nsError.code)"] + ) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 - OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=\(statusCode)") - self.sendEvent(type: "update/error", message: "HTTP error \(statusCode)") - throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP \(statusCode)"]) - } + case .success(let (tempURL, response)): + // Successful completion ⇒ no longer need the resume blob. + try? FileManager.default.removeItem(atPath: resumeDataPath) + + // Verify HTTPS was maintained (no HTTP redirect) + if let httpResponse = response as? HTTPURLResponse, + let responseUrl = httpResponse.url, + responseUrl.scheme?.lowercased() != "https" { + OneKeyLog.error("BundleUpdate", "downloadBundle: redirected to non-HTTPS URL: \(responseUrl)") + throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download was redirected to non-HTTPS URL"]) + } - OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP 200, moving to destination...") + // 206 is acceptable when the OS finished a Range-resumed task on + // our behalf; everything else non-200 is a real error. + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=\(statusCode)") + self.sendEvent(type: "update/error", message: "HTTP error \(statusCode)") + throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP \(statusCode)"]) + } - // Move downloaded file to destination - let destDir = (filePath as NSString).deletingLastPathComponent - if !FileManager.default.fileExists(atPath: destDir) { - try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true) - } - if FileManager.default.fileExists(atPath: filePath) { - try FileManager.default.removeItem(atPath: filePath) - } - try FileManager.default.moveItem(at: tempURL, to: URL(fileURLWithPath: filePath)) + OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP \(httpResponse.statusCode), moving to destination...") - // Verify SHA256 - OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...") - if !self.verifyBundleSHA256(filePath, sha256: sha256) { - try? FileManager.default.removeItem(atPath: filePath) - OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download") - self.sendEvent(type: "update/error", message: "Bundle signature verification failed") - throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle signature verification failed"]) - } + // Move downloaded file to destination + let destDir = (filePath as NSString).deletingLastPathComponent + if !FileManager.default.fileExists(atPath: destDir) { + try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true) + } + if FileManager.default.fileExists(atPath: filePath) { + try FileManager.default.removeItem(atPath: filePath) + } + try FileManager.default.moveItem(at: tempURL, to: URL(fileURLWithPath: filePath)) - self.sendEvent(type: "update/complete") - OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)") - return result + // Verify SHA256 + OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...") + if !self.verifyBundleSHA256(filePath, sha256: sha256) { + let reason = BundleUpdateStore.lastSHA256FailureReason() ?? "MISMATCH" + try? FileManager.default.removeItem(atPath: filePath) + OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=\(reason)") + self.sendEvent(type: "update/error", message: "SHA256_\(reason)") + throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"]) + } + + self.sendEvent(type: "update/complete") + OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)") + return result + } } } @@ -1347,10 +1667,16 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { if !skipGPG { OneKeyLog.info("BundleUpdate", "verifyBundleASC: verifying SHA256 of downloaded file...") - guard let calculated = BundleUpdateStore.calculateSHA256(filePath), - calculated.secureCompare(sha256) else { - OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=\(filePath)") - throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle signature verification failed"]) + let calculated = BundleUpdateStore.calculateSHA256(filePath) + let isValid = calculated != nil && calculated!.secureCompare(sha256) + if !isValid { + // Promote the SHA256 subtype (FILE_TRUNCATED / OOM / + // IO_ / MISMATCH) into the thrown message so + // analytics' extractUpdateErrorCode can split this + // bucket the same way the download stage already does. + let reason = BundleUpdateStore.lastSHA256FailureReason() ?? "MISMATCH" + OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=\(filePath), reason=\(reason)") + throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"]) } OneKeyLog.info("BundleUpdate", "verifyBundleASC: SHA256 verified OK") } else { @@ -1377,9 +1703,19 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { try SSZipArchive.unzipFile(atPath: filePath, toDestination: destination, overwrite: true, password: nil) OneKeyLog.info("BundleUpdate", "verifyBundleASC: extraction completed") } catch { - OneKeyLog.error("BundleUpdate", "verifyBundleASC: unzip failed: \(error.localizedDescription)") + // SSZipArchive's NSError.localizedDescription often embeds the + // failing file path which on iOS includes the install UUID + // (/var/mobile/Containers/Data/Application//...). Keep + // the rich detail in OneKeyLog (local-only), but expose only + // a code-shaped tag in the thrown message so the JS-side + // analytics layer can never reflect that path back to the + // server. Subtypes intentionally low-cardinality so + // extractUpdateErrorCode picks them up cleanly: + // IO_ + let nsErr = error as NSError + OneKeyLog.error("BundleUpdate", "verifyBundleASC: unzip failed: domain=\(nsErr.domain) code=\(nsErr.code) desc=\(nsErr.localizedDescription)") try? FileManager.default.removeItem(atPath: destination) - throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to unzip bundle: \(error.localizedDescription)"]) + throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to unzip bundle: IO_\(nsErr.code)"]) } // Validate extracted paths (symlinks, path traversal) diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index a3f741b0..812bd3dc 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index 52b6a45c..0532ef9b 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 7a432b49..a9276168 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index f46453d8..6904c3ec 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index d2c49476..00ad1905 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index 6d13c16a..c664a863 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index fdfdae96..71f4f16b 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index 56deae0d..bbdcc423 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index e6b6ecf5..2cdd7ab1 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.28", + "version": "3.0.31", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index 848bef3f..1ac3eb12 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index 02837047..b895cc1f 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index 926b3d11..e65fd1b4 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index 13a5ffc2..70667b7e 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index 9d9ebf13..3462dfcb 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index 37799c7f..b61c83a7 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index 2d965979..67637a02 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -82,7 +82,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-bundle-update": ">=3.0.28", + "@onekeyfe/react-native-bundle-update": ">=3.0.31", "react": "*", "react-native": "*" }, diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 15f674da..567a6436 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 6e7f9f87..3d601840 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-zip-archive TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 7d45a183..555d8626 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.28", + "version": "3.0.31", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 28213bb5..74be568b 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.28", + "version": "3.0.31", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 1ae4b418..2af829b6 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.28", + "version": "3.0.31", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 8e5d79be..e4862e2c 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.28", + "version": "3.0.31", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index afa05d18..99fa5f9e 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.28", + "version": "3.0.31", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/yarn.lock b/yarn.lock index fdba9b5b..b0edd5ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2993,7 +2993,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-bundle-update": ">=3.0.28" + "@onekeyfe/react-native-bundle-update": ">=3.0.31" react: "*" react-native: "*" languageName: unknown @@ -3619,7 +3619,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-bundle-update": ">=3.0.28" + "@onekeyfe/react-native-bundle-update": ">=3.0.31" react: "*" react-native: "*" languageName: unknown