From 30dec837d3587003749ae02bd13e4bbaba6c1416 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 8 May 2026 20:14:46 +0800 Subject: [PATCH 01/13] feat(bundle-update): resume downloads + per-failure SHA256 subtypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS — persist URLSession resumeData on failure - DownloadDelegate captures NSURLSessionDownloadTaskResumeData from the failed task's userInfo; the previous code threw it away even though iOS attaches ~11KB of partial-transfer state on every NSURL -1005 / -1001 (~5,940 mixpanel users / 64% of all download failures). - downloadBundle persists the blob to .resume on error, then on the next call passes it to session.downloadTask(withResumeData:) so the OS finishes from the cut point instead of byte 0. - Outcome wrapped in a Result switch so the failure branch can persist resumeData before re-throwing, and the success branch can unlink the stale blob — keeping the .resume sidecar in lockstep with the bundle. Android — Range-based resume via .partial sidecar - Download streams to .partial; rename to only after SHA256 verifies, mirroring the Desktop implementation. A corrupt full file can no longer poison the "exists -> already valid" fast path. - 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. SHA256 failure subtypes (both platforms) - ThreadLocal/Thread.threadDictionary stamp records FILE_NOT_FOUND / FILE_EMPTY / FILE_DISAPPEARED / FILE_TRUNCATED / PERMISSION_DENIED / OOM / IO_ / UNEXPECTED_; 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. - Download flow attaches the subtype to its update/error event payload ("SHA256_FILE_TRUNCATED", "SHA256_OOM", …) so the previously opaque 91.3% Android verifyPackage bucket can be split in mixpanel. --- .../ReactNativeBundleUpdate.kt | 180 +++++++++++-- .../ios/ReactNativeBundleUpdate.swift | 253 ++++++++++++++---- 2 files changed, 360 insertions(+), 73 deletions(-) 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..44c1e20a 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,31 @@ 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_EMPTY / FILE_TRUNCATED / 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. + */ + 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 + } + if (file.length() == 0L) { + lastSHA256Failure.set("FILE_EMPTY") + OneKeyLog.error("BundleUpdate", "calculateSHA256: file empty: $filePath") + return null + } return try { val digest = MessageDigest.getInstance("SHA-256") BufferedInputStream(FileInputStream(filePath)).use { bis -> @@ -415,8 +439,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 } } @@ -1274,6 +1319,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 +1336,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 +1348,112 @@ 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. Larger-than-expected partials are + // garbage from a prior schema; nuke and start over. + 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.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: our partial offset is past the file + // length on the server. Either the partial is corrupt, or the + // bundle changed underneath us. Wipe and bubble up — next caller + // attempt starts clean. + if (response.code == 416) { + response.close() + OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt") + if (partialFile.exists()) partialFile.delete() + sendEvent("update/error", message = "HTTP 416 (range not satisfiable)") + throw Exception("HTTP 416") + } - 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}") + response.close() sendEvent("update/error", message = "HTTP ${response.code}") throw Exception("HTTP ${response.code}") } + // 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 + } + 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...") + 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,13 +1461,29 @@ 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") + sendEvent("update/error", message = "rename .partial failed") + 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") + sendEvent("update/error", message = "SHA256_$reason") + throw Exception("Bundle SHA256 verification failed: $reason") } sendEvent("update/complete") @@ -1367,10 +1499,18 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } } + /** + * 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) diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index bf5551d3..6eed79c6 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -312,23 +312,91 @@ 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_EMPTY / + /// FILE_DISAPPEARED / IO_ / UNEXPECTED 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). + 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 + } + let attrs = (try? fm.attributesOfItem(atPath: filePath)) ?? [:] + let fileSize = (attrs[.size] as? NSNumber)?.int64Value ?? -1 + if fileSize == 0 { + setSHA256Failure("FILE_EMPTY") + OneKeyLog.error("BundleUpdate", "calculateSHA256: file empty: \(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 + do { + var context = CC_SHA256_CTX() + CC_SHA256_Init(&context) + var threwError: Error? + // Wrap reads in try/catch via NSException bridge: FileHandle.readData + // can raise on read failure (NSFileHandleOperationException); + // ObjCRuntime catches those when bridged through NSObject methods. + 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 + setSHA256Failure("IO_\(nsErr.domain)_\(nsErr.code)") + OneKeyLog.error("BundleUpdate", "calculateSHA256: read failed: \(nsErr.domain) \(nsErr.code) \(nsErr.localizedDescription)") + return nil } - 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() + 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 +1104,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 +1151,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 +1190,7 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate { tempFileURL = nil onProgress = nil prevProgress = -1 + lastResumeData = nil } } @@ -1202,6 +1288,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 +1308,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 +1317,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 +1333,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 +1357,95 @@ 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() - } + 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)") + throw error + + 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"]) + } - // 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"]) - } + // 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)"]) + } - 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)"]) - } + OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP \(httpResponse.statusCode), moving to destination...") - OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP 200, moving to destination...") + // 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)) - // 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)) + // 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)"]) + } - // 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"]) + self.sendEvent(type: "update/complete") + OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)") + return result } - - self.sendEvent(type: "update/complete") - OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)") - return result } } From 3bcbcd516451e0c236440cdfb421a4ef857326f2 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 8 May 2026 20:44:04 +0800 Subject: [PATCH 02/13] fix(bundle-update): drop inner exception text from thrown messages to prevent path leakage Two thrown messages embedded the lower-level exception's text directly: iOS verifyBundleASC (unzip catch) Before: throw NSError(... "Failed to unzip bundle: \(error.localizedDescription)") After: throw NSError(... "Failed to unzip bundle: IO_\(nsErr.code)") SSZipArchive's localizedDescription frequently embeds the failing file path which, on a real iOS device, contains the install UUID under /var/mobile/Containers/Data/Application//. The full description still flows to OneKeyLog (local-only) for support diagnostics; only an IO_ tag escapes into the Promise rejection that JS analytics observes. Android getMetadata (JSON parse catch) Before: throw Exception("Failed to parse metadata.json: ${e.message}") After: throw Exception("Failed to parse metadata.json: IO_") org.json's parse-failure messages can carry partial JSON contents or local file paths from the underlying reader. Same split: rich detail in OneKeyLog, IO_ tag in the thrown message that becomes errorMessage upstream. Both new shapes (`IO_` / `IO_`) match the SHA256_ convention introduced in the prior commit so extractUpdateErrorCode in hooks.tsx classifies them into the same `IO_*` mixpanel bucket without a separate parser. The companion sanitizer in UpdateReminder/hooks.tsx (sanitizeUpdateErrorMessage) provides a redaction safety net that would have caught these too, but trimming at the source means we never have to count on the safety net for the codepaths we own. --- .../ReactNativeBundleUpdate.kt | 9 +++++++-- .../ios/ReactNativeBundleUpdate.swift | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) 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 44c1e20a..50077617 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 @@ -500,8 +500,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") diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index 6eed79c6..ae8e6cee 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -1524,9 +1524,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) From 28b8b98bfc30cfac9b5c9843ee6a951344247a88 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 8 May 2026 20:55:41 +0800 Subject: [PATCH 03/13] feat(bundle-update-ios): snapshot resumeData on app backgrounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS force-quit (user swipes the app off the App Switcher) cannot fire URLSession's didCompleteWithError — SIGKILL leaves no time for callbacks, so the resume blob never reaches disk. The kill is, however, *always* preceded by the app transitioning to the background. Memory-pressure kills follow the same chain because iOS only reaps backgrounded apps under memory pressure. So a hook on UIApplication.didEnterBackground- Notification gives us the only deterministic snapshot point before termination. Implementation: - ReactNativeBundleUpdate.init registers an observer for didEnterBackgroundNotification (paired removal in deinit). On fire: beginBackgroundTask extends the ~5s of guaranteed background runtime to ~30s; we walk URLSession.getAllTasks and call cancel(byProducingResumeData:) on every running download task. The cancel callback hands us the resume blob synchronously, so even if iOS suspends right after endBackgroundTask the blob is already at .resume on disk. - A new private activeDownloadFilePath property anchors the snapshot to the right .resume sidecar; downloadBundle sets it BEFORE task.resume() (so a foreground→background race on the very first bytes still has a path) and clears it in defer (so a stray didEnterBackground after completion doesn't try to cancel a finished task). - Snapshot guards on isDownloading + activeDownloadFilePath, so observers fired outside an active download are no-ops. Companion change in app-monorepo's UpdateReminder/hooks.tsx (C2 / C1) will retrigger downloadPackage on AppState.active so the next call picks up the freshly snapshotted blob via downloadTask(withResumeData:). Android and Desktop don't need an equivalent hook: they write incrementally to .partial via FileOutputStream / WriteStream on each chunk, so SIGKILL only loses the last <8KB OS pagecache buffer. The next attempt's Range: bytes=- finds whatever has been flushed. --- .../ios/ReactNativeBundleUpdate.swift | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index ae8e6cee..8fda6321 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 = """ @@ -1220,9 +1221,97 @@ 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) + } + } + + /// 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 call + /// `cancel(byProducingResumeData:)` synchronously, so the resume blob + /// is on disk before the app can possibly be terminated. Memory-pressure + /// kills follow the same chain (the OS only reaps backgrounded apps + /// under memory pressure), so this also covers OOM termination. + /// + /// `beginBackgroundTask` extends the ~5s of guaranteed background runtime + /// to ~30s, which is plenty for a few-KB blob write — but we don't rely + /// on the extension to *complete* the snapshot; the `cancel` callback + /// returns the data synchronously, so even if iOS suspends us right + /// after `endBackgroundTask` the resume blob is already persisted. + 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 } + let stillDownloading = self.stateQueue.sync { self.isDownloading } + guard stillDownloading, let filePath = self.activeDownloadFilePath else { return } + + let resumeDataPath = "\(filePath).resume" + let bgTaskName = "BundleUpdateResumeSnapshot" + var bgTaskId: UIBackgroundTaskIdentifier = .invalid + bgTaskId = UIApplication.shared.beginBackgroundTask(withName: bgTaskName) { + // Expiration handler — system is reclaiming us. Best-effort end. + if bgTaskId != .invalid { + UIApplication.shared.endBackgroundTask(bgTaskId) + bgTaskId = .invalid + } + } + 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) { + if bgTaskId != .invalid { + UIApplication.shared.endBackgroundTask(bgTaskId) + bgTaskId = .invalid + } + } + } } private func sendEvent(type: String, progress: Int = 0, message: String = "") { @@ -1267,7 +1356,12 @@ 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 { + self.stateQueue.sync { self.isDownloading = false } + // Clear the snapshot anchor so a stray didEnterBackground + // after this point doesn't try to cancel a finished task. + self.activeDownloadFilePath = nil + } let appVersion = params.latestVersion let bundleVersion = params.bundleVersion @@ -1357,6 +1451,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { self?.sendEvent(type: "update/downloading", progress: progress) } + // 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. + self.activeDownloadFilePath = filePath + let downloadOutcome: Result<(URL, URLResponse), Error> do { let value = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(URL, URLResponse), Error>) in From 61f8b006a4afe98795f2cd6b1e8f4d1ae7b38e22 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 8 May 2026 22:36:58 +0800 Subject: [PATCH 04/13] fix(bundle-update): native correctness from audit (queue sync, partial recovery, verify-stage SHA reason) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit (Codex) on the prior commits surfaced four native-side issues. iOS — protect activeDownloadFilePath under stateQueue Previously isDownloading was read inside the stateQueue but activeDownloadFilePath was read outside it, so a foreground/background race could observe a torn state (isDownloading=true with a stale or nil filePath, or vice versa). Read both inside the same sync block, and pair the writes (set on entry, clear in defer) with the same queue. Mirrors the protection level isDownloading already had. iOS + Android — 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 the same Mixpanel bucket. Read the side-channel reason (FILE_TRUNCATED / OOM / IO_ / MISMATCH) and throw the same shape the download stage uses ("Bundle SHA256 verification failed: ") so the JS extractor splits the bucket end-to-end. 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. Android — recover from 416 when Content-Range says we have the full file HTTP 416 with `Content-Range: bytes */` where total == partialBytes means our local partial IS the complete bundle (server's Range request was correctly rejected because we'd want byte N+1 of an N-byte file). Parse the header, attempt promote+verify before falling through to the wipe branch; otherwise behavior is unchanged. Companion app-monorepo audit fixes (privacy + concurrency) land in the wallet repo. --- .../ReactNativeBundleUpdate.kt | 60 +++++++++++++++---- .../ios/ReactNativeBundleUpdate.swift | 48 ++++++++++----- 2 files changed, 84 insertions(+), 24 deletions(-) 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 50077617..4dba3412 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 @@ -1360,15 +1360,32 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // 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. Larger-than-expected partials are - // garbage from a prior schema; nuke and start over. + // 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.warn("BundleUpdate", "downloadBundle: stale partial (>=expected), discarding: $partialSize/$expectedSize") + 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 -> { @@ -1388,12 +1405,30 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } val response = httpClient.newCall(requestBuilder.build()).execute() - // 416 Range Not Satisfiable: our partial offset is past the file - // length on the server. Either the partial is corrupt, or the - // bundle changed underneath us. Wipe and bubble up — next caller - // attempt starts clean. + // 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() sendEvent("update/error", message = "HTTP 416 (range not satisfiable)") @@ -1557,8 +1592,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 8fda6321..0fc29c4d 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -1267,8 +1267,15 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { private func snapshotResumeDataForBackgrounding() { guard let session = self.urlSession else { return } - let stillDownloading = self.stateQueue.sync { self.isDownloading } - guard stillDownloading, let filePath = self.activeDownloadFilePath else { return } + // Read both isDownloading AND activeDownloadFilePath inside the + // same stateQueue.sync block so a concurrent downloadBundle entry + // (which writes filePath BEFORE flipping isDownloading) cannot + // produce a torn read where isDownloading=true but filePath=nil. + 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" @@ -1357,10 +1364,14 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Already downloading"]) } defer { - self.stateQueue.sync { self.isDownloading = false } - // Clear the snapshot anchor so a stray didEnterBackground - // after this point doesn't try to cancel a finished task. - self.activeDownloadFilePath = nil + // 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 @@ -1451,10 +1462,13 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { self?.sendEvent(type: "update/downloading", progress: progress) } - // 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. - self.activeDownloadFilePath = filePath + // 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 } let downloadOutcome: Result<(URL, URLResponse), Error> do { @@ -1593,10 +1607,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 { From ef315ecafaf1e7e6bb95cf904a3c150c84da7c6c Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 8 May 2026 23:52:50 +0800 Subject: [PATCH 05/13] fix(bundle-update): sanitize Android update/error payload and invalidate iOS URLSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Android: 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_. - iOS: call urlSession?.invalidateAndCancel() in deinit. URLSession retains its delegate strongly until invalidated, so without this the session and its DownloadDelegate would outlive the module — relevant on dev hot-reload and any future test harness with multiple module instances. --- .../ReactNativeBundleUpdate.kt | 39 ++++++++++++++++++- .../ios/ReactNativeBundleUpdate.swift | 5 +++ 2 files changed, 43 insertions(+), 1 deletion(-) 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 4dba3412..5e106607 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 @@ -1251,6 +1251,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)) @@ -1530,8 +1559,16 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { 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 a + // domain+code shape; mirrors the iOS sendEvent payload at + // ReactNativeBundleUpdate.swift's "update/error" sites. OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}") - sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}") + val codeTag = sanitizeErrorMessageForEvent(e) + sendEvent("update/error", message = "${e.javaClass.simpleName}: $codeTag") throw e } finally { isDownloading.set(false) diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index 0fc29c4d..fa748ea1 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -1239,6 +1239,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { 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 From ca254a4fa059f8311873f4dc09293558483486ac Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 9 May 2026 01:04:08 +0800 Subject: [PATCH 06/13] 3.0.29 --- native-modules/native-logger/package.json | 2 +- native-modules/react-native-aes-crypto/package.json | 2 +- native-modules/react-native-app-update/package.json | 2 +- native-modules/react-native-async-storage/package.json | 2 +- native-modules/react-native-background-thread/package.json | 4 ++-- native-modules/react-native-bundle-update/package.json | 2 +- .../react-native-check-biometric-auth-changed/package.json | 2 +- native-modules/react-native-cloud-fs/package.json | 2 +- native-modules/react-native-cloud-kit-module/package.json | 2 +- native-modules/react-native-device-utils/package.json | 2 +- native-modules/react-native-dns-lookup/package.json | 2 +- native-modules/react-native-get-random-values/package.json | 2 +- native-modules/react-native-keychain-module/package.json | 2 +- native-modules/react-native-lite-card/package.json | 2 +- native-modules/react-native-network-info/package.json | 2 +- native-modules/react-native-pbkdf2/package.json | 2 +- native-modules/react-native-perf-memory/package.json | 2 +- native-modules/react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 4 ++-- native-modules/react-native-tcp-socket/package.json | 2 +- native-modules/react-native-zip-archive/package.json | 2 +- native-views/react-native-auto-size-input/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 4 ++-- 29 files changed, 32 insertions(+), 32 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 9b157015..9717a8b4 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.29", "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..784a9735 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.29", "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..fdee0469 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.29", "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..f006b674 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.29", "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..ee058bb9 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.29", "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.29", "react": "*", "react-native": "*" }, diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index a3f741b0..06d0e7c7 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.29", "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..dd4a727b 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.29", "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..705a856e 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.29", "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..a1c741de 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.29", "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..d4d3d52c 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.29", "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..846de071 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.29", "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..03886a57 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.29", "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..4c84b331 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.29", "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..f83dac1c 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.29", "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..be171101 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.29", "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..9fc1606a 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.29", "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..43b3e6e8 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.29", "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..150b0c88 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.29", "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..29927b20 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.29", "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..f9001cd9 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.29", "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..40c8400c 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.29", "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.29", "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..ba46013a 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.29", "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..f39ccba1 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.29", "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..f48fe4a3 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.29", "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..6270d46f 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.29", "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..5d1fab84 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.29", "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..805489fd 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.29", "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..9d03f3a1 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.29", "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..1378fb2f 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.29" 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.29" react: "*" react-native: "*" languageName: unknown From 4ef7869d2df1dbf2c3b3f0293e9f3f195f24864b Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 9 May 2026 01:07:52 +0800 Subject: [PATCH 07/13] Update CHANGELOG.md --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaff0f55..cac2a4aa 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.29] - 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 hands us the blob synchronously, so even if iOS suspends right after `endBackgroundTask` the data is already on disk. 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 `FILE_NOT_FOUND` / `FILE_EMPTY` / `FILE_DISAPPEARED` / `FILE_TRUNCATED` / `PERMISSION_DENIED` / `OOM` / `IO_` / `UNEXPECTED_`; `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. + +### 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.29. + +## [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 From 60eb8bc50defa2c8f71fedbc8db2460eab1107b0 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 9 May 2026 10:08:34 +0800 Subject: [PATCH 08/13] fix(bundle-update): address PR #53 review threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Allow legitimate 0-byte files in calculateSHA256 on both platforms (validateAllFilesInDir / validateWebEmbedSha256 / launch entry verify share this calculator; rejecting empty files broke valid bundles). - iOS: keep IO_ failure tag low-cardinality to match doc and the analytics bucket cap (drop NSError domain from the tag; full detail still goes to OneKeyLog). - iOS: serialize bgTaskId in snapshotResumeDataForBackgrounding via a small holder + dedicated queue so the expiration handler, getAllTasks completion (off-main), and group.notify cannot double-end or leak the background task. - iOS: correct doc on background snapshot — getAllTasks and cancel(byProducingResumeData:) deliver via async closures, not synchronously; the begin/endBackgroundTask window is what gives the closures time to run before suspension. - Android: emit update/error from the outer catch only; remove the pre-emit at HTTP 416 / non-2xx HTTP / rename failure / SHA256 verify failure paths to avoid double-fired events with divergent payload shapes. - Android: drop the \`\${e.javaClass.simpleName}: \` prefix from the emitted update/error message so the verbatim payloads documented on sanitizeErrorMessageForEvent (HTTP / SHA256 / allowlist strings) actually arrive verbatim at JS listeners. --- .../ReactNativeBundleUpdate.kt | 40 +++++---- .../ios/ReactNativeBundleUpdate.swift | 86 ++++++++++++------- 2 files changed, 81 insertions(+), 45 deletions(-) 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 5e106607..83a234bd 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 @@ -409,10 +409,18 @@ object BundleUpdateStoreAndroid { /** * 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_EMPTY / FILE_TRUNCATED / OOM / IO_ / + * FILE_NOT_FOUND / FILE_TRUNCATED / 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. + * + * 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() @@ -424,11 +432,6 @@ object BundleUpdateStoreAndroid { OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: $filePath") return null } - if (file.length() == 0L) { - lastSHA256Failure.set("FILE_EMPTY") - OneKeyLog.error("BundleUpdate", "calculateSHA256: file empty: $filePath") - return null - } return try { val digest = MessageDigest.getInstance("SHA-256") BufferedInputStream(FileInputStream(filePath)).use { bis -> @@ -1460,8 +1463,10 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt") if (partialFile.exists()) partialFile.delete() - sendEvent("update/error", message = "HTTP 416 (range not satisfiable)") - throw Exception("HTTP 416") + // 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 expectsResume = partialBytes > 0 @@ -1470,7 +1475,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { if (!response.isSuccessful || (response.code != 200 && response.code != 206)) { OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}") response.close() - sendEvent("update/error", message = "HTTP ${response.code}") + // outer catch is the single source of update/error events. throw Exception("HTTP ${response.code}") } @@ -1542,7 +1547,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { if (downloadedFile.exists()) downloadedFile.delete() if (!partialAfter.renameTo(downloadedFile)) { OneKeyLog.error("BundleUpdate", "downloadBundle: rename .partial -> final failed") - sendEvent("update/error", message = "rename .partial 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") } @@ -1551,7 +1557,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH" File(filePath).delete() OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=$reason") - sendEvent("update/error", message = "SHA256_$reason") + // outer catch emits the verbatim "Bundle SHA256 verification + // failed: " payload (recognized by sanitize/JS). throw Exception("Bundle SHA256 verification failed: $reason") } @@ -1563,12 +1570,13 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // 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 a - // domain+code shape; mirrors the iOS sendEvent payload at - // ReactNativeBundleUpdate.swift's "update/error" sites. + // 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}") - val codeTag = sanitizeErrorMessageForEvent(e) - sendEvent("update/error", message = "${e.javaClass.simpleName}: $codeTag") + sendEvent("update/error", message = sanitizeErrorMessageForEvent(e)) throw e } finally { isDownloading.set(false) diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index fa748ea1..9e96f901 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -314,12 +314,20 @@ public class BundleUpdateStore: NSObject { } /// Subtype of the most recent calculateSHA256 failure on this thread, or - /// nil if the last call succeeded. Surfaces FILE_NOT_FOUND / FILE_EMPTY / + /// nil if the last call succeeded. Surfaces FILE_NOT_FOUND / /// FILE_DISAPPEARED / IO_ / UNEXPECTED 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). + /// + /// 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 } @@ -340,13 +348,6 @@ public class BundleUpdateStore: NSObject { OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: \(filePath)") return nil } - let attrs = (try? fm.attributesOfItem(atPath: filePath)) ?? [:] - let fileSize = (attrs[.size] as? NSNumber)?.int64Value ?? -1 - if fileSize == 0 { - setSHA256Failure("FILE_EMPTY") - OneKeyLog.error("BundleUpdate", "calculateSHA256: file empty: \(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)") @@ -376,7 +377,11 @@ public class BundleUpdateStore: NSObject { }) {} if let err = threwError { let nsErr = err as NSError - setSHA256Failure("IO_\(nsErr.domain)_\(nsErr.code)") + // 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 } @@ -1249,17 +1254,22 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { /// 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 call - /// `cancel(byProducingResumeData:)` synchronously, so the resume blob - /// is on disk before the app can possibly be terminated. Memory-pressure - /// kills follow the same chain (the OS only reaps backgrounded apps - /// under memory pressure), so this also covers OOM termination. + /// 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. /// - /// `beginBackgroundTask` extends the ~5s of guaranteed background runtime - /// to ~30s, which is plenty for a few-KB blob write — but we don't rely - /// on the extension to *complete* the snapshot; the `cancel` callback - /// returns the data synchronously, so even if iOS suspends us right - /// after `endBackgroundTask` the resume blob is already persisted. + /// 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, @@ -1284,14 +1294,35 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { let resumeDataPath = "\(filePath).resume" let bgTaskName = "BundleUpdateResumeSnapshot" - var bgTaskId: UIBackgroundTaskIdentifier = .invalid - bgTaskId = UIApplication.shared.beginBackgroundTask(withName: bgTaskName) { - // Expiration handler — system is reclaiming us. Best-effort end. - if bgTaskId != .invalid { - UIApplication.shared.endBackgroundTask(bgTaskId) - bgTaskId = .invalid + + // 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 @@ -1318,10 +1349,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { }) } group.notify(queue: .main) { - if bgTaskId != .invalid { - UIApplication.shared.endBackgroundTask(bgTaskId) - bgTaskId = .invalid - } + bgTask.endOnce() } } } From 7de58bd52339d6b188bc60daca2b9caa560adfcf Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 9 May 2026 12:39:57 +0800 Subject: [PATCH 09/13] 3.0.30 --- native-modules/native-logger/package.json | 2 +- native-modules/react-native-aes-crypto/package.json | 2 +- native-modules/react-native-app-update/package.json | 2 +- native-modules/react-native-async-storage/package.json | 2 +- native-modules/react-native-background-thread/package.json | 4 ++-- native-modules/react-native-bundle-update/package.json | 2 +- .../react-native-check-biometric-auth-changed/package.json | 2 +- native-modules/react-native-cloud-fs/package.json | 2 +- native-modules/react-native-cloud-kit-module/package.json | 2 +- native-modules/react-native-device-utils/package.json | 2 +- native-modules/react-native-dns-lookup/package.json | 2 +- native-modules/react-native-get-random-values/package.json | 2 +- native-modules/react-native-keychain-module/package.json | 2 +- native-modules/react-native-lite-card/package.json | 2 +- native-modules/react-native-network-info/package.json | 2 +- native-modules/react-native-pbkdf2/package.json | 2 +- native-modules/react-native-perf-memory/package.json | 2 +- native-modules/react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 4 ++-- native-modules/react-native-tcp-socket/package.json | 2 +- native-modules/react-native-zip-archive/package.json | 2 +- native-views/react-native-auto-size-input/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 4 ++-- 29 files changed, 32 insertions(+), 32 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 9717a8b4..1d2ac2c7 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.29", + "version": "3.0.30", "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 784a9735..737de2d1 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.29", + "version": "3.0.30", "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 fdee0469..69da16bb 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.29", + "version": "3.0.30", "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 f006b674..ced970c3 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.29", + "version": "3.0.30", "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 ee058bb9..09a8161e 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.29", + "version": "3.0.30", "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.29", + "@onekeyfe/react-native-bundle-update": ">=3.0.30", "react": "*", "react-native": "*" }, diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 06d0e7c7..7cba8a09 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.29", + "version": "3.0.30", "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 dd4a727b..822c0cae 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.29", + "version": "3.0.30", "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 705a856e..db69681b 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.29", + "version": "3.0.30", "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 a1c741de..ffe54ab5 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.29", + "version": "3.0.30", "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 d4d3d52c..3405f24e 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.29", + "version": "3.0.30", "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 846de071..9c4d4fbd 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.29", + "version": "3.0.30", "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 03886a57..e52378f7 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.29", + "version": "3.0.30", "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 4c84b331..d6bc7d87 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.29", + "version": "3.0.30", "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 f83dac1c..9e28968f 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.29", + "version": "3.0.30", "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 be171101..56205e83 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.29", + "version": "3.0.30", "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 9fc1606a..2885b758 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.29", + "version": "3.0.30", "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 43b3e6e8..06476902 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.29", + "version": "3.0.30", "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 150b0c88..094b5977 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.29", + "version": "3.0.30", "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 29927b20..2dff2c96 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.29", + "version": "3.0.30", "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 f9001cd9..9f3c4c83 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.29", + "version": "3.0.30", "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 40c8400c..df0ac644 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.29", + "version": "3.0.30", "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.29", + "@onekeyfe/react-native-bundle-update": ">=3.0.30", "react": "*", "react-native": "*" }, diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index ba46013a..34953d5e 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.29", + "version": "3.0.30", "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 f39ccba1..25791259 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.29", + "version": "3.0.30", "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 f48fe4a3..1f301b66 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.29", + "version": "3.0.30", "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 6270d46f..375f43b7 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.29", + "version": "3.0.30", "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 5d1fab84..82d795e3 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.29", + "version": "3.0.30", "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 805489fd..23a5b575 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.29", + "version": "3.0.30", "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 9d03f3a1..50f14938 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.29", + "version": "3.0.30", "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 1378fb2f..1640fe73 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.29" + "@onekeyfe/react-native-bundle-update": ">=3.0.30" 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.29" + "@onekeyfe/react-native-bundle-update": ">=3.0.30" react: "*" react-native: "*" languageName: unknown From adc12da5c5727a547fdd97ee437293e9f1f2a472 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 9 May 2026 14:18:32 +0800 Subject: [PATCH 10/13] Update package-publish.yml --- .github/workflows/package-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' From c622974111d348572c4fde085bf7633aca0c23b8 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 9 May 2026 15:57:06 +0800 Subject: [PATCH 11/13] fix(bundle-update): address PR #53 second-round review threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: align version to 3.0.30 (was 3.0.29), correct cancel(byProducingResumeData:) wording from synchronous to asynchronous-via-closure, drop FILE_EMPTY from the SHA256 taxonomy (no code path emits it after the empty-file early return was removed), and split the per-platform reason lists. - iOS: rewrite the snapshotResumeDataForBackgrounding ordering comment to match the actual sequence (isDownloading flips before activeDownloadFilePath is written) and call out the defer-unwind race; on download failure rethrow a sanitized NSError carrying only " " so URLSession localizedDescription cannot re-leak temp file paths through the Promise rejection — full detail still goes to OneKeyLog. - Android: extend lastSHA256FailureReason() doc to include FILE_DISAPPEARED / PERMISSION_DENIED (already emitted by calculateSHA256); rewrap the downloadBundle catch as Exception(sanitized, e) so the Promise rejection message matches the sanitized update/error payload (verbatim allowlist preserved, everything else collapses to IO_) instead of leaking /data/user/// paths via FileNotFoundException.message. Original exception is attached as cause so crash reporters keep the full chain. --- CHANGELOG.md | 8 +++--- .../ReactNativeBundleUpdate.kt | 22 +++++++++++----- .../ios/ReactNativeBundleUpdate.swift | 25 ++++++++++++++++--- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cac2a4aa..a005b865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,13 @@ All notable changes to this project will be documented in this file. -## [3.0.29] - 2026-05-09 +## [3.0.30] - 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 hands us the blob synchronously, so even if iOS suspends right after `endBackgroundTask` the data is already on disk. Anchored to `activeDownloadFilePath`, set before `task.resume()` and cleared in `defer` so observers fired outside an active download are no-ops. +- **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 `FILE_NOT_FOUND` / `FILE_EMPTY` / `FILE_DISAPPEARED` / `FILE_TRUNCATED` / `PERMISSION_DENIED` / `OOM` / `IO_` / `UNEXPECTED_`; `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. +- **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. @@ -21,7 +21,7 @@ All notable changes to this project will be documented in this file. - **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.29. +- Bump all packages to 3.0.30. ## [3.0.28] - 2026-05-08 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 83a234bd..52e143fe 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 @@ -409,10 +409,12 @@ object BundleUpdateStoreAndroid { /** * 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_TRUNCATED / 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. + * 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 @@ -1576,8 +1578,16 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // matches the verbatim guarantees documented on // sanitizeErrorMessageForEvent. OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}") - sendEvent("update/error", message = sanitizeErrorMessageForEvent(e)) - 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) } diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index 9e96f901..c0ca0780 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -1283,9 +1283,14 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { private func snapshotResumeDataForBackgrounding() { guard let session = self.urlSession else { return } // Read both isDownloading AND activeDownloadFilePath inside the - // same stateQueue.sync block so a concurrent downloadBundle entry - // (which writes filePath BEFORE flipping isDownloading) cannot - // produce a torn read where isDownloading=true but filePath=nil. + // 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) } @@ -1542,7 +1547,19 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { 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)") - throw error + // 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)"] + ) case .success(let (tempURL, response)): // Successful completion ⇒ no longer need the resume blob. From d1bd40e45b85603545e869e255e4aefbeccb16c6 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 9 May 2026 16:06:46 +0800 Subject: [PATCH 12/13] 3.0.31 --- native-modules/native-logger/package.json | 2 +- native-modules/react-native-aes-crypto/package.json | 2 +- native-modules/react-native-app-update/package.json | 2 +- native-modules/react-native-async-storage/package.json | 2 +- native-modules/react-native-background-thread/package.json | 4 ++-- native-modules/react-native-bundle-update/package.json | 2 +- .../react-native-check-biometric-auth-changed/package.json | 2 +- native-modules/react-native-cloud-fs/package.json | 2 +- native-modules/react-native-cloud-kit-module/package.json | 2 +- native-modules/react-native-device-utils/package.json | 2 +- native-modules/react-native-dns-lookup/package.json | 2 +- native-modules/react-native-get-random-values/package.json | 2 +- native-modules/react-native-keychain-module/package.json | 2 +- native-modules/react-native-lite-card/package.json | 2 +- native-modules/react-native-network-info/package.json | 2 +- native-modules/react-native-pbkdf2/package.json | 2 +- native-modules/react-native-perf-memory/package.json | 2 +- native-modules/react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 4 ++-- native-modules/react-native-tcp-socket/package.json | 2 +- native-modules/react-native-zip-archive/package.json | 2 +- native-views/react-native-auto-size-input/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 4 ++-- 29 files changed, 32 insertions(+), 32 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 1d2ac2c7..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.30", + "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 737de2d1..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.30", + "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 69da16bb..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.30", + "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 ced970c3..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.30", + "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 09a8161e..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.30", + "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.30", + "@onekeyfe/react-native-bundle-update": ">=3.0.31", "react": "*", "react-native": "*" }, diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 7cba8a09..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.30", + "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 822c0cae..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.30", + "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 db69681b..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.30", + "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 ffe54ab5..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.30", + "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 3405f24e..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.30", + "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 9c4d4fbd..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.30", + "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 e52378f7..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.30", + "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 d6bc7d87..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.30", + "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 9e28968f..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.30", + "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 56205e83..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.30", + "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 2885b758..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.30", + "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 06476902..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.30", + "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 094b5977..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.30", + "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 2dff2c96..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.30", + "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 9f3c4c83..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.30", + "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 df0ac644..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.30", + "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.30", + "@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 34953d5e..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.30", + "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 25791259..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.30", + "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 1f301b66..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.30", + "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 375f43b7..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.30", + "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 82d795e3..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.30", + "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 23a5b575..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.30", + "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 50f14938..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.30", + "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 1640fe73..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.30" + "@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.30" + "@onekeyfe/react-native-bundle-update": ">=3.0.31" react: "*" react-native: "*" languageName: unknown From af4bf5a0f88878762e7802d0ebf76d477cabecc9 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 9 May 2026 16:39:55 +0800 Subject: [PATCH 13/13] fix(bundle-update): address PR #53 third-round review threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: align release header and chores line to 3.0.31 (matches the package.json bump on this PR). - iOS lastSHA256FailureReason() doc: drop UNEXPECTED from the taxonomy — calculateSHA256 only emits FILE_NOT_FOUND / FILE_DISAPPEARED / IO_; called out the iOS vs. Android reason-set split so the doc stays in sync. - iOS calculateSHA256 read comment: drop the misleading "NSException bridge" wording. read(upToCount:) on iOS 13.4+ throws Swift NSError (catchable here); the pre-13.4 fallback uses readData(ofLength:) which raises NSException — Swift cannot catch that, so legacy OSes will still abort. - Android downloadBundle: close the OkHttp response before throwing on a null body. Without the explicit close, the response (which holds the connection) leaked because the byteStream() consumption path that would otherwise return the connection to the pool is skipped by the throw. --- CHANGELOG.md | 4 +-- .../ReactNativeBundleUpdate.kt | 9 ++++++- .../ios/ReactNativeBundleUpdate.swift | 26 +++++++++++++------ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a005b865..b39dea6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [3.0.30] - 2026-05-09 +## [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). @@ -21,7 +21,7 @@ All notable changes to this project will be documented in this file. - **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.30. +- Bump all packages to 3.0.31. ## [3.0.28] - 2026-05-08 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 52e143fe..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 @@ -1489,7 +1489,14 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { partialBytes = 0L } - val body = response.body ?: throw Exception("Empty response body") + // 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. diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index c0ca0780..0f7c7ad1 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -315,11 +315,15 @@ public class BundleUpdateStore: NSObject { /// Subtype of the most recent calculateSHA256 failure on this thread, or /// nil if the last call succeeded. Surfaces FILE_NOT_FOUND / - /// FILE_DISAPPEARED / IO_ / UNEXPECTED 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). + /// 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 @@ -359,9 +363,15 @@ public class BundleUpdateStore: NSObject { var context = CC_SHA256_CTX() CC_SHA256_Init(&context) var threwError: Error? - // Wrap reads in try/catch via NSException bridge: FileHandle.readData - // can raise on read failure (NSFileHandleOperationException); - // ObjCRuntime catches those when bridged through NSObject methods. + // 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)