From e1f96acb0b98be4e95ba7833e6a76af84af5468e Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 27 Jul 2026 11:09:05 +0800 Subject: [PATCH 01/27] feat: add firmware artifact storage --- .../ReactNativeRangeDownloader.podspec | 8 + .../android/build.gradle | 1 + .../FirmwareArchiveRules.kt | 284 ++++ .../FirmwareArtifactStore.kt | 776 +++++++++++ .../ReactNativeRangeDownloader.kt | 140 ++ .../FirmwareArchiveRulesTest.kt | 75 ++ .../ios/FirmwareArchiveMinizipBridge.h | 33 + .../ios/FirmwareArchiveMinizipBridge.mm | 213 +++ .../ios/FirmwareArtifactStore.swift | 1166 +++++++++++++++++ ...FirmwareBackgroundSessionEventRouter.swift | 17 + .../ios/ReactNativeRangeDownloader.swift | 574 +++++++- .../package.json | 3 +- .../src/ReactNativeRangeDownloader.nitro.ts | 128 +- .../src/index.tsx | 4 +- .../java/com/sniconnect/SniConnectModule.kt | 51 +- .../java/com/sniconnect/SniPinnedTransport.kt | 58 + .../ios/SniConnectClient.swift | 47 +- .../ios/SniConnectPinnedTransport.swift | 85 ++ .../react-native-sni-connect/package.json | 2 +- yarn.lock | 1 + 20 files changed, 3575 insertions(+), 91 deletions(-) create mode 100644 native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt create mode 100644 native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt create mode 100644 native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt create mode 100644 native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.h create mode 100644 native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.mm create mode 100644 native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift create mode 100644 native-modules/react-native-range-downloader/ios/FirmwareBackgroundSessionEventRouter.swift create mode 100644 native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt create mode 100644 native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift diff --git a/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec b/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec index c5ed2197..87760c3d 100644 --- a/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec +++ b/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec @@ -15,6 +15,7 @@ Pod::Spec.new do |s| s.source_files = [ "ios/**/*.{swift}", + "ios/**/*.{h}", "ios/**/*.{m,mm}", "cpp/**/*.{hpp,cpp}", ] @@ -22,6 +23,13 @@ Pod::Spec.new do |s| s.dependency 'React-jsi' s.dependency 'React-callinvoker' s.dependency 'ReactNativeNativeLogger' + s.dependency 'SniConnect', package["version"] + s.public_header_files = "ios/FirmwareArchiveMinizipBridge.h" + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"', + } + + s.dependency 'SSZipArchive', '>= 2.5.4' load 'nitrogen/generated/ios/ReactNativeRangeDownloader+autolinking.rb' add_nitrogen_files(s) diff --git a/native-modules/react-native-range-downloader/android/build.gradle b/native-modules/react-native-range-downloader/android/build.gradle index 7ab18d64..c6f428de 100644 --- a/native-modules/react-native-range-downloader/android/build.gradle +++ b/native-modules/react-native-range-downloader/android/build.gradle @@ -127,6 +127,7 @@ dependencies { implementation project(":react-native-nitro-modules") implementation project(":onekeyfe_react-native-native-logger") + implementation project(":onekeyfe_react-native-sni-connect") implementation "com.squareup.okhttp3:okhttp:4.12.0" diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt new file mode 100644 index 00000000..b704948b --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt @@ -0,0 +1,284 @@ +package com.margelo.nitro.reactnativerangedownloader + +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets +import java.text.Normalizer +import java.util.Locale + +internal data class FirmwareArchiveCentralEntry( + val name: String, + val compressedSize: Long, + val uncompressedSize: Long, + val crc32: Long, + val flags: Int, + val compressionMethod: Int, + val versionMadeBy: Int, + val externalAttributes: Long, + val diskNumber: Int, +) + +internal object FirmwareArchiveRules { + private const val MAX_EOCD_SEARCH_BYTES = 65_557 + private const val EOCD_SIGNATURE = 0x06054b50L + private const val CENTRAL_ENTRY_SIGNATURE = 0x02014b50L + private const val MAX_ARCHIVE_ENTRIES = 4096 + private const val MAX_ARCHIVE_ENTRY_BYTES = 128L * 1024 * 1024 + private const val MAX_ARCHIVE_EXPANDED_BYTES = 512L * 1024 * 1024 + private val nestedArchiveExtensions = setOf( + ".zip", + ".7z", + ".rar", + ".tar", + ".gz", + ".tgz", + ) + private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$") + + fun validateRequirements( + expectedEntries: Array, + ): List { + require(expectedEntries.isNotEmpty() && expectedEntries.size <= MAX_ARCHIVE_ENTRIES) { + "Firmware archive expected entry count is invalid" + } + val names = mutableSetOf() + val canonicalNames = mutableSetOf() + var totalSize = 0L + expectedEntries.forEach { entry -> + require( + entry.artifactId.isNotEmpty() && + entry.artifactId.length <= 160 && + entry.artifactId.all { it.isLetterOrDigit() || it in "._-" } + ) { + "Firmware archive artifactId is invalid" + } + require( + names.add(entry.entryName) && + validatePortableName(entry.entryName, canonicalNames) + ) { + "Firmware archive expected entry name is invalid" + } + val expectedSize = entry.expectedSize.toExactPositiveLong("entry size") + require(expectedSize <= MAX_ARCHIVE_ENTRY_BYTES) { + "Firmware archive entry exceeds size limits" + } + totalSize = Math.addExact(totalSize, expectedSize) + require(totalSize <= MAX_ARCHIVE_EXPANDED_BYTES) { + "Firmware archive expected entries exceed size limits" + } + require(sha256Pattern.matches(entry.expectedSha256)) { + "Firmware archive entry SHA-256 is invalid" + } + } + return expectedEntries.toList() + } + + fun validateCentralDirectory( + file: File, + requirements: List, + ): List { + val entries = scanCentralDirectory(file) + require(entries.size == requirements.size) { + "Firmware archive has missing or extra entries" + } + val requirementsByName = requirements.associateBy { it.entryName } + val names = mutableSetOf() + val canonicalNames = mutableSetOf() + entries.forEach { entry -> + val requirement = requirementsByName[entry.name] + ?: error("Firmware archive contains an unexpected entry") + val expectedSize = requirement.expectedSize.toLong() + require( + names.add(entry.name) && + validatePortableName(entry.name, canonicalNames) && + entry.uncompressedSize == expectedSize && + entry.uncompressedSize > 0 && + entry.compressedSize in 0..MAX_ARCHIVE_EXPANDED_BYTES && + entry.uncompressedSize <= + Math.multiplyExact(entry.compressedSize.coerceAtLeast(1), 1000) && + entry.diskNumber == 0 && + entry.flags and 1 == 0 && + (entry.compressionMethod == 0 || entry.compressionMethod == 8) && + isRegularEntry(entry) + ) { + "Firmware archive entry metadata is invalid" + } + } + return entries + } + + private fun scanCentralDirectory(file: File): List { + require(file.isFile && file.length() >= 22) { + "Firmware archive is invalid" + } + RandomAccessFile(file, "r").use { archive -> + val tailSize = minOf(file.length(), MAX_EOCD_SEARCH_BYTES.toLong()).toInt() + val tail = ByteArray(tailSize) + archive.seek(file.length() - tailSize) + archive.readFully(tail) + val eocdIndex = findSignatureBackwards(tail, EOCD_SIGNATURE) + require(eocdIndex >= 0 && eocdIndex + 22 <= tail.size) { + "Firmware archive EOCD is missing" + } + val eocd = ByteBuffer.wrap(tail, eocdIndex, tail.size - eocdIndex) + .order(ByteOrder.LITTLE_ENDIAN) + require(eocd.int.toUnsignedLong() == EOCD_SIGNATURE) { + "Firmware archive EOCD is invalid" + } + val diskNumber = eocd.short.toUnsignedInt() + val centralDisk = eocd.short.toUnsignedInt() + val entriesOnDisk = eocd.short.toUnsignedInt() + val totalEntries = eocd.short.toUnsignedInt() + val centralSize = eocd.int.toUnsignedLong() + val centralOffset = eocd.int.toUnsignedLong() + val commentLength = eocd.short.toUnsignedInt() + require( + diskNumber == 0 && + centralDisk == 0 && + entriesOnDisk == totalEntries && + totalEntries in 1..MAX_ARCHIVE_ENTRIES && + totalEntries != 0xffff && + centralSize != 0xffff_ffffL && + centralOffset != 0xffff_ffffL && + eocdIndex + 22 + commentLength == tail.size && + centralOffset <= file.length() - centralSize && + centralOffset + centralSize <= file.length() - tailSize + eocdIndex + ) { + "Firmware archive is multi-disk, ZIP64, or malformed" + } + + archive.seek(centralOffset) + val entries = ArrayList(totalEntries) + repeat(totalEntries) { + val header = ByteArray(46) + archive.readFully(header) + val buffer = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN) + require(buffer.int.toUnsignedLong() == CENTRAL_ENTRY_SIGNATURE) { + "Firmware archive central entry is malformed" + } + val versionMadeBy = buffer.short.toUnsignedInt() + buffer.short + val flags = buffer.short.toUnsignedInt() + val method = buffer.short.toUnsignedInt() + buffer.position(buffer.position() + 4) + val crc32 = buffer.int.toUnsignedLong() + val compressedSize = buffer.int.toUnsignedLong() + val uncompressedSize = buffer.int.toUnsignedLong() + val nameLength = buffer.short.toUnsignedInt() + val extraLength = buffer.short.toUnsignedInt() + val entryCommentLength = buffer.short.toUnsignedInt() + val entryDisk = buffer.short.toUnsignedInt() + buffer.short + val externalAttributes = buffer.int.toUnsignedLong() + val localHeaderOffset = buffer.int.toUnsignedLong() + require( + nameLength in 1..4096 && + compressedSize != 0xffff_ffffL && + uncompressedSize != 0xffff_ffffL && + entryDisk != 0xffff && + localHeaderOffset != 0xffff_ffffL && + localHeaderOffset < centralOffset + ) { + "Firmware archive ZIP64 entry is not supported" + } + val nameBytes = ByteArray(nameLength) + archive.readFully(nameBytes) + val name = decodeEntryName(nameBytes, flags) + val skipLength = Math.addExact(extraLength, entryCommentLength) + archive.seek(Math.addExact(archive.filePointer, skipLength.toLong())) + entries += FirmwareArchiveCentralEntry( + name = name, + compressedSize = compressedSize, + uncompressedSize = uncompressedSize, + crc32 = crc32, + flags = flags, + compressionMethod = method, + versionMadeBy = versionMadeBy, + externalAttributes = externalAttributes, + diskNumber = entryDisk, + ) + } + require(archive.filePointer == centralOffset + centralSize) { + "Firmware archive central directory size mismatch" + } + return entries + } + } + + private fun decodeEntryName(bytes: ByteArray, flags: Int): String { + if (flags and (1 shl 11) == 0) { + require(bytes.all { it.toInt() and 0xff < 0x80 }) { + "Firmware archive entry name must be UTF-8 or ASCII" + } + return String(bytes, StandardCharsets.US_ASCII) + } + val decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + return decoder.decode(ByteBuffer.wrap(bytes)).toString() + } + + private fun validatePortableName( + name: String, + canonicalNames: MutableSet, + ): Boolean { + val normalized = Normalizer.normalize(name, Normalizer.Form.NFC) + val folded = normalized.lowercase(Locale.ROOT) + val components = name.split('/') + return name.isNotEmpty() && + name.length <= 512 && + name == normalized && + !name.startsWith("/") && + !name.startsWith("\\") && + '\\' !in name && + ':' !in name && + name.none { it.code < 0x20 || it.code == 0x7f } && + components.all { + it.isNotEmpty() && + it != "." && + it != ".." && + !it.endsWith(".") && + !it.endsWith(" ") + } && + nestedArchiveExtensions.none { folded.endsWith(it) } && + canonicalNames.add(folded) + } + + private fun isRegularEntry(entry: FirmwareArchiveCentralEntry): Boolean { + val hostSystem = entry.versionMadeBy ushr 8 + if (hostSystem == 3 || hostSystem == 19) { + val fileType = (entry.externalAttributes ushr 16) and 0xf000 + return fileType == 0L || fileType == 0x8000L + } + return entry.externalAttributes and 0x10 == 0L + } + + private fun findSignatureBackwards(bytes: ByteArray, signature: Long): Int { + for (index in bytes.size - 4 downTo 0) { + val value = (bytes[index].toLong() and 0xff) or + ((bytes[index + 1].toLong() and 0xff) shl 8) or + ((bytes[index + 2].toLong() and 0xff) shl 16) or + ((bytes[index + 3].toLong() and 0xff) shl 24) + if (value == signature) return index + } + return -1 + } + + private fun Int.toUnsignedLong(): Long = toLong() and 0xffff_ffffL + + private fun Short.toUnsignedInt(): Int = toInt() and 0xffff + + private fun Double.toExactPositiveLong(label: String): Long { + require(isFinite() && this > 0 && this <= Long.MAX_VALUE.toDouble()) { + "Invalid firmware archive $label" + } + val converted = toLong() + require(converted.toDouble() == this) { + "Invalid firmware archive $label" + } + return converted + } +} diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt new file mode 100644 index 00000000..5b10b6bc --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt @@ -0,0 +1,776 @@ +package com.margelo.nitro.reactnativerangedownloader + +import android.system.Os +import com.margelo.nitro.NitroModules +import com.sniconnect.SniPinnedTransport +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.zip.ZipInputStream +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.json.JSONArray +import org.json.JSONObject + +internal data class StoredFirmwareArtifact( + val artifactRef: String, + val size: Long, + val sha256: String, + val file: File, +) + +internal data class StoredFirmwareArchiveEntry( + val entryName: String, + val artifact: StoredFirmwareArtifact, +) + +private data class StagedFirmwareArchiveEntry( + val entryName: String, + val size: Long, + val sha256: String, + val file: File, +) + +internal object FirmwareArtifactStore { + const val MAX_READ_BYTES = 256 * 1024 + + private const val MAX_ARTIFACT_BYTES = 512L * 1024 * 1024 + private const val MAX_LEASE_METADATA_BYTES = 1024L * 1024 + private const val MAX_TOTAL_LEASE_REFS = 8192 + private const val FINAL_ARTIFACT_GRACE_MS = 24L * 60 * 60 * 1000 + private const val PARTIAL_ARTIFACT_GRACE_MS = 7L * 24 * 60 * 60 * 1000 + private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$") + private val artifactRefPattern = Regex("^fw:[a-f0-9]{64}$") + private val leaseRefPattern = Regex("^fwlease:[a-f0-9-]{36}$") + private val identifierPattern = Regex("^[A-Za-z0-9._:-]{1,160}$") + private val downloadLocks = ConcurrentHashMap() + private val activeDownloadLock = Any() + private val activeDownloadCounts = mutableMapOf() + private val leaseLock = Any() + private val readerLock = Any() + private val readers = mutableMapOf() + + private data class LeaseState( + val transactionId: String, + val artifactRefs: MutableSet, + ) + + private data class OpenReader( + val file: File, + val handle: RandomAccessFile, + val size: Long, + ) + + private val root: File + get() { + val context = NitroModules.applicationContext + ?: error("Application context is not available") + return File(context.filesDir, "onekey-firmware-artifacts").also { + check(it.exists() || it.mkdirs()) { + "Firmware artifact directory cannot be created" + } + } + } + + fun download(params: FirmwareArtifactDownloadParams): StoredFirmwareArtifact { + val validated = validateDownloadParams(params) + retainExpectedArtifact( + leaseRef = params.leaseRef, + transactionId = params.transactionId, + artifactRef = "fw:${validated.expectedSha256}", + ) + val lock = downloadLocks.computeIfAbsent(validated.expectedSha256) { Any() } + markDownloadActive(validated.expectedSha256, 1) + try { + return synchronized(lock) { + downloadLocked(params, validated) + } + } finally { + markDownloadActive(validated.expectedSha256, -1) + } + } + + fun discard(artifactRef: String) { + val file = resolveArtifactFile(artifactRef) + synchronized(leaseLock) { + require(loadLeasesLocked().values.none { artifactRef in it.artifactRefs }) { + "ARTIFACT_LEASED: firmware artifact is retained" + } + } + synchronized(readerLock) { + require(readers.none { it.value.file == file }) { + "ARTIFACT_BUSY: firmware artifact has an open reader" + } + } + if (file.exists() && !file.delete()) { + error("Firmware artifact cannot be discarded") + } + } + + fun open(artifactRef: String): Pair { + val file = resolveArtifactFile(artifactRef) + val size = file.length() + require(size > 0) { "Firmware artifact is empty" } + require(hashFile(file) == artifactRef.removePrefix("fw:")) { + "Firmware artifact SHA-256 mismatch" + } + val readerId = UUID.randomUUID().toString() + synchronized(readerLock) { + readers[readerId] = OpenReader(file, RandomAccessFile(file, "r"), size) + } + return readerId to size + } + + fun read(readerId: String, offset: Long, length: Int): ByteArray { + require(offset >= 0 && length in 1..MAX_READ_BYTES) { + "Invalid firmware artifact read" + } + return synchronized(readerLock) { + val reader = readers[readerId] ?: error("Firmware artifact reader is invalid") + require(offset <= reader.size - length) { + "Firmware artifact read is out of bounds" + } + val data = ByteArray(length) + reader.handle.seek(offset) + reader.handle.readFully(data) + data + } + } + + fun close(readerId: String) { + synchronized(readerLock) { + readers.remove(readerId)?.handle?.close() + } + } + + fun materializeArchive( + leaseRef: String, + artifactRef: String, + expectedEntries: Array, + ): List { + requireLease(leaseRef) + val archiveFile = resolveArtifactFile(artifactRef) + val requirements = FirmwareArchiveRules.validateRequirements(expectedEntries) + val centralEntries = FirmwareArchiveRules.validateCentralDirectory( + archiveFile, + requirements, + ) + val requirementsByName = requirements.associateBy { it.entryName } + val centralNames = centralEntries.mapTo(mutableSetOf()) { it.name } + val scratchDir = File(root, "archive-${UUID.randomUUID()}") + check(scratchDir.mkdirs()) { "Firmware archive scratch directory cannot be created" } + try { + val staged = mutableListOf() + val entryNames = mutableSetOf() + ZipInputStream(BufferedInputStream(FileInputStream(archiveFile))).use { zip -> + while (true) { + val zipEntry = zip.nextEntry ?: break + require(!zipEntry.isDirectory) { + "Firmware archive contains an unexpected directory" + } + val requirement = requirementsByName[zipEntry.name] + ?: error("Firmware archive contains an unexpected entry") + require( + centralNames.contains(zipEntry.name) && + entryNames.add(zipEntry.name) + ) { + "Firmware archive contains a duplicate or mismatched entry" + } + val expectedSize = requirement.expectedSize.toLong() + val expectedSha256 = requirement.expectedSha256.lowercase() + val scratchFile = File(scratchDir, "${staged.size}.entry") + val digest = MessageDigest.getInstance("SHA-256") + var entrySize = 0L + RandomAccessFile(scratchFile, "rw").use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val count = zip.read(buffer) + if (count < 0) break + if (count == 0) continue + entrySize = Math.addExact(entrySize, count.toLong()) + require(entrySize <= expectedSize) { + "Firmware archive entry exceeds its expected size" + } + output.write(buffer, 0, count) + digest.update(buffer, 0, count) + } + output.fd.sync() + } + val sha256 = digest.digest().toHex() + require(entrySize == expectedSize && sha256 == expectedSha256) { + "Firmware archive entry integrity mismatch" + } + staged += StagedFirmwareArchiveEntry( + entryName = requirement.entryName, + size = entrySize, + sha256 = sha256, + file = scratchFile, + ) + zip.closeEntry() + } + } + require(entryNames == centralNames) { + "Firmware archive has missing or extra entries" + } + return staged.map { entry -> + val destination = artifactFile(entry.sha256) + val stored = validateStoredArtifactOrNull( + destination, + entry.size, + entry.sha256, + ) ?: run { + promoteAtomically(entry.file, destination) + StoredFirmwareArtifact( + "fw:${entry.sha256}", + entry.size, + entry.sha256, + destination, + ) + } + retainExpectedArtifact( + leaseRef = leaseRef, + transactionId = null, + artifactRef = stored.artifactRef, + ) + StoredFirmwareArchiveEntry(entry.entryName, stored) + } + } finally { + scratchDir.deleteRecursively() + } + } + + private data class ValidatedDownload( + val expectedSize: Long, + val maxBytes: Long, + val expectedSha256: String, + val hostname: String, + ) + + private fun validateDownloadParams( + params: FirmwareArtifactDownloadParams, + ): ValidatedDownload { + require( + params.taskId.isNotEmpty() && + params.taskId.length <= 100 && + params.taskId.all { it.isLetterOrDigit() || it in "._-" } + ) { + "Invalid firmware taskId" + } + require(identifierPattern.matches(params.transactionId)) { + "Invalid firmware transactionId" + } + require(identifierPattern.matches(params.artifactId)) { + "Invalid firmware artifactId" + } + require(leaseRefPattern.matches(params.leaseRef)) { + "Invalid firmware leaseRef" + } + val url = params.url.toHttpUrlOrNull() + ?: throw IllegalArgumentException("Invalid firmware URL") + require( + url.isHttps && + url.port == 443 && + url.username.isEmpty() && + url.password.isEmpty() && + url.fragment == null + ) { + "Firmware URL must use HTTPS port 443" + } + val expectedSize = params.expectedSize.toExactPositiveLong("expectedSize") + val maxBytes = params.maxBytes.toExactPositiveLong("maxBytes") + require(maxBytes == expectedSize && maxBytes <= MAX_ARTIFACT_BYTES) { + "Invalid firmware maxBytes" + } + require(sha256Pattern.matches(params.expectedSha256)) { + "Invalid firmware artifact SHA-256" + } + require(params.routeType == "domain" || params.routeType == "pinnedIp") { + "Invalid firmware route type" + } + if (params.routeType == "pinnedIp") { + require(!params.resolvedIp.isNullOrEmpty()) { + "Pinned route requires resolvedIp" + } + } else { + require(params.resolvedIp == null) { + "Domain route must not include resolvedIp" + } + } + return ValidatedDownload( + expectedSize = expectedSize, + maxBytes = maxBytes, + expectedSha256 = params.expectedSha256.lowercase(), + hostname = url.host, + ) + } + + private fun downloadLocked( + params: FirmwareArtifactDownloadParams, + validated: ValidatedDownload, + ): StoredFirmwareArtifact { + val finalFile = artifactFile(validated.expectedSha256) + validateStoredArtifactOrNull( + finalFile, + validated.expectedSize, + validated.expectedSha256, + )?.let { return it } + + val partialFile = File( + root, + "${validated.expectedSha256}.${params.taskId}.partial", + ) + if (partialFile.length() > validated.expectedSize) { + check(partialFile.delete()) { "Invalid firmware partial cannot be removed" } + } + if (partialFile.length() == validated.expectedSize) { + validateStoredArtifactOrNull( + partialFile, + validated.expectedSize, + validated.expectedSha256, + )?.let { + promoteAtomically(partialFile, finalFile) + return StoredFirmwareArtifact( + it.artifactRef, + it.size, + it.sha256, + finalFile, + ) + } + check(partialFile.delete()) { "Invalid firmware partial cannot be removed" } + } + + val resumeOffset = partialFile.length() + val requestBuilder = Request.Builder() + .url(params.url) + .header("Accept-Encoding", "identity") + if (resumeOffset > 0) { + requestBuilder.header("Range", "bytes=$resumeOffset-") + } + + val client = if (params.routeType == "pinnedIp") { + SniPinnedTransport.createClient( + ip = checkNotNull(params.resolvedIp), + hostname = validated.hostname, + ) + } else { + OkHttpClient.Builder() + .protocols(listOf(Protocol.HTTP_1_1)) + .followRedirects(false) + .followSslRedirects(false) + .build() + } + val call = client.newCall(requestBuilder.build()) + params.overallDeadlineSeconds?.let { deadline -> + require(deadline.isFinite() && deadline > 0) { + "Invalid firmware download deadline" + } + call.timeout().timeout(deadline.toLong().coerceAtLeast(1), TimeUnit.SECONDS) + } + + val response = try { + call.execute() + } catch (error: IOException) { + throw IllegalStateException( + "ARTIFACT_NETWORK_FAILED: firmware request failed", + error, + ) + } + response.use { + writeResponseToPartial( + it, + partialFile, + resumeOffset, + validated.expectedSize, + validated.maxBytes, + ) + } + + val artifact = try { + validateStoredArtifact( + partialFile, + validated.expectedSize, + validated.expectedSha256, + ) + } catch (error: Throwable) { + partialFile.delete() + throw error + } + promoteAtomically(partialFile, finalFile) + return StoredFirmwareArtifact( + artifact.artifactRef, + artifact.size, + artifact.sha256, + finalFile, + ) + } + + private fun writeResponseToPartial( + response: Response, + partialFile: File, + resumeOffset: Long, + expectedSize: Long, + maxBytes: Long, + ) { + require(response.code == 200 || response.code == 206) { + "ARTIFACT_HTTP_${response.code}: firmware request failed" + } + val append = resumeOffset > 0 && response.code == 206 + if (response.code == 206) { + require( + validateContentRange( + response.header("Content-Range"), + if (append) resumeOffset else 0, + expectedSize, + ) + ) { + "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid" + } + } + val body = response.body ?: error("Firmware response has no body") + RandomAccessFile(partialFile, "rw").use { output -> + if (append) { + output.seek(resumeOffset) + } else { + output.setLength(0) + } + var written = if (append) resumeOffset else 0L + val source = body.source() + val buffer = ByteArray(64 * 1024) + while (true) { + val count = source.read(buffer) + if (count < 0) break + if (count == 0) continue + written += count + require(written <= maxBytes) { + "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" + } + output.write(buffer, 0, count) + } + output.fd.sync() + } + } + + private fun validateContentRange( + value: String?, + expectedStart: Long, + expectedTotal: Long, + ): Boolean { + val match = value + ?.lowercase() + ?.let { Regex("^bytes ([0-9]+)-([0-9]+)/([0-9]+)$").matchEntire(it) } + ?: return false + val start = match.groupValues[1].toLongOrNull() ?: return false + val end = match.groupValues[2].toLongOrNull() ?: return false + val total = match.groupValues[3].toLongOrNull() ?: return false + return start == expectedStart && + end >= start && + end < total && + total == expectedTotal + } + + private fun validateStoredArtifactOrNull( + file: File, + expectedSize: Long, + expectedSha256: String, + ): StoredFirmwareArtifact? = try { + validateStoredArtifact(file, expectedSize, expectedSha256) + } catch (_: Throwable) { + null + } + + private fun validateStoredArtifact( + file: File, + expectedSize: Long, + expectedSha256: String, + ): StoredFirmwareArtifact { + require(file.isFile && file.length() == expectedSize) { + "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch" + } + val sha256 = hashFile(file) + require(sha256 == expectedSha256) { + "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch" + } + return StoredFirmwareArtifact("fw:$sha256", expectedSize, sha256, file) + } + + private fun resolveArtifactFile(artifactRef: String): File { + require(artifactRefPattern.matches(artifactRef)) { + "Invalid firmware artifactRef" + } + val file = artifactFile(artifactRef.removePrefix("fw:")) + require(file.isFile) { "Firmware artifact not found" } + return file + } + + private fun artifactFile(sha256: String): File = File(root, "$sha256.bin") + + fun createLease(transactionId: String): String { + require(identifierPattern.matches(transactionId)) { + "Invalid firmware transactionId" + } + return synchronized(leaseLock) { + val leases = loadLeasesLocked() + require(leases.size < 32) { + "Too many firmware artifact leases" + } + val leaseRef = "fwlease:${UUID.randomUUID()}" + leases[leaseRef] = LeaseState(transactionId, mutableSetOf()) + saveLeasesLocked(leases) + leaseRef + } + } + + fun retain(leaseRef: String, artifactRef: String) { + resolveArtifactFile(artifactRef) + retainExpectedArtifact( + leaseRef = leaseRef, + transactionId = null, + artifactRef = artifactRef, + ) + } + + fun releaseLease(leaseRef: String, disposition: String) { + require( + disposition == "completed" || + disposition == "safeCancelled" || + disposition == "safeAbandoned" + ) { + "Invalid firmware lease disposition" + } + synchronized(leaseLock) { + val leases = loadLeasesLocked() + require(leases.remove(validateLeaseRef(leaseRef)) != null) { + "Firmware artifact lease is unavailable" + } + saveLeasesLocked(leases) + } + } + + fun reconcileLeases(activeLeaseRefs: Array) { + require(activeLeaseRefs.size <= 32) { + "Too many active firmware artifact leases" + } + val active = activeLeaseRefs.mapTo(mutableSetOf()) { + validateLeaseRef(it) + } + require(active.size == activeLeaseRefs.size) { + "Duplicate active firmware artifact lease" + } + synchronized(leaseLock) { + val leases = loadLeasesLocked() + require(active.all { leases.containsKey(it) }) { + "Firmware artifact lease reconciliation is incomplete" + } + leases.keys.retainAll(active) + saveLeasesLocked(leases) + } + } + + fun sweepOrphans(): Pair { + val retainedSha256 = synchronized(leaseLock) { + loadLeasesLocked().values + .flatMap { it.artifactRefs } + .mapTo(mutableSetOf()) { it.removePrefix("fw:") } + } + val activeSha256 = synchronized(activeDownloadLock) { + activeDownloadCounts.filterValues { it > 0 }.keys.toSet() + } + val openFiles = synchronized(readerLock) { + readers.values.mapTo(mutableSetOf()) { it.file.absolutePath } + } + val now = System.currentTimeMillis() + var deletedFiles = 0 + var deletedBytes = 0L + root.listFiles()?.forEach { file -> + if (!file.isFile || file.name == "leases.json") return@forEach + val sha256 = file.name.take(64) + if ( + !sha256Pattern.matches(sha256) || + sha256 in retainedSha256 || + sha256 in activeSha256 || + file.absolutePath in openFiles + ) { + return@forEach + } + val grace = if (file.name.endsWith(".bin")) { + FINAL_ARTIFACT_GRACE_MS + } else if (file.name.endsWith(".partial")) { + PARTIAL_ARTIFACT_GRACE_MS + } else { + return@forEach + } + if (now - file.lastModified() < grace) return@forEach + val size = file.length() + if (file.delete()) { + deletedFiles += 1 + deletedBytes += size + } + } + return deletedFiles to deletedBytes + } + + private fun requireLease(leaseRef: String) { + synchronized(leaseLock) { + require(loadLeasesLocked().containsKey(validateLeaseRef(leaseRef))) { + "Firmware artifact lease is unavailable" + } + } + } + + private fun retainExpectedArtifact( + leaseRef: String, + transactionId: String?, + artifactRef: String, + ) { + require(artifactRefPattern.matches(artifactRef)) { + "Invalid firmware artifactRef" + } + synchronized(leaseLock) { + val leases = loadLeasesLocked() + val lease = leases[validateLeaseRef(leaseRef)] + ?: error("Firmware artifact lease is unavailable") + if (transactionId != null) { + require(lease.transactionId == transactionId) { + "Firmware artifact lease transaction mismatch" + } + } + if (lease.artifactRefs.add(artifactRef)) { + saveLeasesLocked(leases) + } + } + } + + private fun validateLeaseRef(leaseRef: String): String { + require(leaseRefPattern.matches(leaseRef)) { + "Invalid firmware leaseRef" + } + return leaseRef + } + + private fun loadLeasesLocked(): MutableMap { + val file = File(root, "leases.json") + if (!file.exists()) return mutableMapOf() + require(file.length() in 1..MAX_LEASE_METADATA_BYTES) { + "Firmware lease metadata is too large" + } + val envelope = JSONObject(file.readText(Charsets.UTF_8)) + require(envelope.optInt("schemaVersion") == 1) { + "Unsupported firmware lease schema" + } + val result = mutableMapOf() + val jsonLeases = envelope.getJSONObject("leases") + val keys = jsonLeases.keys() + while (keys.hasNext()) { + val leaseRef = validateLeaseRef(keys.next()) + val jsonLease = jsonLeases.getJSONObject(leaseRef) + val transactionId = jsonLease.getString("transactionId") + require(identifierPattern.matches(transactionId)) { + "Invalid persisted firmware transactionId" + } + val jsonRefs = jsonLease.getJSONArray("artifactRefs") + require(jsonRefs.length() <= 4096) { + "Too many persisted firmware artifact refs" + } + val refs = mutableSetOf() + for (index in 0 until jsonRefs.length()) { + val artifactRef = jsonRefs.getString(index) + require(artifactRefPattern.matches(artifactRef) && refs.add(artifactRef)) { + "Invalid persisted firmware artifact ref" + } + } + result[leaseRef] = LeaseState(transactionId, refs) + } + require(result.size <= 32) { + "Too many persisted firmware artifact leases" + } + require(result.values.sumOf { it.artifactRefs.size } <= MAX_TOTAL_LEASE_REFS) { + "Too many persisted firmware artifact refs" + } + return result + } + + private fun saveLeasesLocked(leases: Map) { + require( + leases.size <= 32 && + leases.values.sumOf { it.artifactRefs.size } <= MAX_TOTAL_LEASE_REFS + ) { + "Firmware lease metadata is too large" + } + val jsonLeases = JSONObject() + leases.toSortedMap().forEach { (leaseRef, lease) -> + jsonLeases.put( + leaseRef, + JSONObject() + .put("transactionId", lease.transactionId) + .put("artifactRefs", JSONArray(lease.artifactRefs.sorted())), + ) + } + val bytes = JSONObject() + .put("schemaVersion", 1) + .put("leases", jsonLeases) + .toString() + .toByteArray(Charsets.UTF_8) + require(bytes.size.toLong() <= MAX_LEASE_METADATA_BYTES) { + "Firmware lease metadata is too large" + } + val destination = File(root, "leases.json") + val temporary = File(root, ".leases-${UUID.randomUUID()}.tmp") + FileOutputStream(temporary).use { output -> + output.write(bytes) + output.fd.sync() + } + Os.rename(temporary.absolutePath, destination.absolutePath) + } + + private fun markDownloadActive(sha256: String, delta: Int) { + synchronized(activeDownloadLock) { + val count = (activeDownloadCounts[sha256] ?: 0) + delta + if (count <= 0) { + activeDownloadCounts.remove(sha256) + } else { + activeDownloadCounts[sha256] = count + } + } + } + + private fun hashFile(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + BufferedInputStream(FileInputStream(file)).use { input -> + val buffer = ByteArray(MAX_READ_BYTES) + while (true) { + val count = input.read(buffer) + if (count < 0) break + if (count > 0) digest.update(buffer, 0, count) + } + } + return digest.digest().toHex() + } + + private fun promoteAtomically(source: File, destination: File) { + destination.parentFile?.mkdirs() + Os.rename(source.absolutePath, destination.absolutePath) + } + + private fun Double.toExactPositiveLong(label: String): Long { + require(isFinite() && this > 0 && this <= Long.MAX_VALUE.toDouble()) { + "Invalid firmware $label" + } + val converted = toLong() + require(converted.toDouble() == this) { "Invalid firmware $label" } + return converted + } + + private fun ByteArray.toHex(): String = joinToString("") { + "%02x".format(it) + } +} diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt index 8835e4c2..57d029e2 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt @@ -2,6 +2,7 @@ package com.margelo.nitro.reactnativerangedownloader import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.ArrayBuffer import com.margelo.nitro.core.Promise import com.margelo.nitro.nativelogger.OneKeyLog import java.io.File @@ -265,6 +266,145 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { return ctx.cacheDir.absolutePath } + override fun getFirmwareArtifactCapabilities(): FirmwareArtifactCapabilities { + return FirmwareArtifactCapabilities( + firmwareArtifactProtocolVersion = 1.0, + supportedRouteTypes = arrayOf("domain", "pinnedIp"), + supportsArchiveMaterialization = true, + maxReadBytes = FirmwareArtifactStore.MAX_READ_BYTES.toDouble(), + ) + } + + override fun downloadFirmwareArtifact( + params: FirmwareArtifactDownloadParams, + ): Promise { + return Promise.async { + val artifact = FirmwareArtifactStore.download(params) + FirmwareArtifactReceipt( + artifactRef = artifact.artifactRef, + size = artifact.size.toDouble(), + sha256 = artifact.sha256, + ) + } + } + + override fun discardFirmwareArtifact( + params: FirmwareArtifactRefParams, + ): Promise { + return Promise.async { + FirmwareArtifactStore.discard(params.artifactRef) + } + } + + override fun openFirmwareArtifact( + params: FirmwareArtifactRefParams, + ): Promise { + return Promise.async { + val (readerId, size) = FirmwareArtifactStore.open(params.artifactRef) + FirmwareArtifactReaderInfo(readerId = readerId, size = size.toDouble()) + } + } + + override fun readFirmwareArtifact( + params: FirmwareArtifactReaderReadParams, + ): Promise { + return Promise.async { + require( + params.offset.isFinite() && + params.offset >= 0 && + params.offset.toLong().toDouble() == params.offset && + params.length.isFinite() && + params.length > 0 && + params.length.toInt().toDouble() == params.length + ) { + "Invalid firmware artifact read" + } + ArrayBuffer.copy( + FirmwareArtifactStore.read( + readerId = params.readerId, + offset = params.offset.toLong(), + length = params.length.toInt(), + ), + ) + } + } + + override fun closeFirmwareArtifact( + params: FirmwareArtifactReaderCloseParams, + ): Promise { + return Promise.async { + FirmwareArtifactStore.close(params.readerId) + } + } + + override fun materializeFirmwareArchive( + params: FirmwareArchiveMaterializeParams, + ): Promise { + return Promise.async { + val artifacts = FirmwareArtifactStore.materializeArchive( + params.leaseRef, + params.archiveArtifactRef, + params.expectedEntries, + ) + FirmwareArchiveMaterializeResult( + artifacts = artifacts.map { entry -> + FirmwareArchiveMaterializedArtifact( + entryName = entry.entryName, + receipt = FirmwareArtifactReceipt( + artifactRef = entry.artifact.artifactRef, + size = entry.artifact.size.toDouble(), + sha256 = entry.artifact.sha256, + ), + ) + }.toTypedArray(), + ) + } + } + + override fun createFirmwareArtifactLease( + params: FirmwareArtifactLeaseCreateParams, + ): Promise { + return Promise.async { + FirmwareArtifactLease( + leaseRef = FirmwareArtifactStore.createLease(params.transactionId), + ) + } + } + + override fun retainFirmwareArtifact( + params: FirmwareArtifactLeaseRetainParams, + ): Promise { + return Promise.async { + FirmwareArtifactStore.retain(params.leaseRef, params.artifactRef) + } + } + + override fun releaseFirmwareArtifactLease( + params: FirmwareArtifactLeaseReleaseParams, + ): Promise { + return Promise.async { + FirmwareArtifactStore.releaseLease(params.leaseRef, params.disposition) + } + } + + override fun reconcileFirmwareArtifactLeases( + params: FirmwareArtifactLeaseReconcileParams, + ): Promise { + return Promise.async { + FirmwareArtifactStore.reconcileLeases(params.activeLeaseRefs) + } + } + + override fun sweepFirmwareArtifactOrphans(): Promise { + return Promise.async { + val (deletedFiles, deletedBytes) = FirmwareArtifactStore.sweepOrphans() + FirmwareArtifactSweepResult( + deletedFiles = deletedFiles.toDouble(), + deletedBytes = deletedBytes.toDouble(), + ) + } + } + // Broadcast one event to every registered listener. Listeners filter by // channel/taskId on their side (shared registry, per the design). private fun sendEvent( diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt new file mode 100644 index 00000000..04195465 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt @@ -0,0 +1,75 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class FirmwareArchiveRulesTest { + private fun entry( + artifactId: String = "resource-entry", + entryName: String = "assets/icon.png", + expectedSize: Double = 128.0, + expectedSha256: String = "a".repeat(64), + ) = FirmwareArchiveExpectedEntry( + artifactId = artifactId, + entryName = entryName, + expectedSize = expectedSize, + expectedSha256 = expectedSha256, + ) + + @Test + fun acceptsAnExactPortableAllowlist() { + val requirements = FirmwareArchiveRules.validateRequirements( + arrayOf( + entry(), + entry( + artifactId = "resource-entry-2", + entryName = "assets/sub/icon-2.png", + expectedSha256 = "b".repeat(64), + ), + ), + ) + + assertEquals(2, requirements.size) + } + + @Test + fun rejectsTraversalAndNestedArchives() { + for (entryName in listOf("../icon.png", "assets/../icon.png", "assets.zip")) { + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateRequirements( + arrayOf(entry(entryName = entryName)), + ) + } + } + } + + @Test + fun rejectsCaseFoldedDuplicateNames() { + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateRequirements( + arrayOf( + entry(entryName = "assets/Icon.png"), + entry( + artifactId = "resource-entry-2", + entryName = "assets/icon.png", + ), + ), + ) + } + } + + @Test + fun rejectsNonIntegralSizesAndInvalidDigests() { + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateRequirements( + arrayOf(entry(expectedSize = 1.5)), + ) + } + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateRequirements( + arrayOf(entry(expectedSha256 = "not-a-digest")), + ) + } + } +} diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.h b/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.h new file mode 100644 index 00000000..a5cc3d38 --- /dev/null +++ b/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.h @@ -0,0 +1,33 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FirmwareArchiveEntryInfo : NSObject + +@property(nonatomic, readonly) NSString *name; +@property(nonatomic, readonly) int64_t compressedSize; +@property(nonatomic, readonly) int64_t uncompressedSize; +@property(nonatomic, readonly) uint32_t crc32; +@property(nonatomic, readonly) uint16_t flags; +@property(nonatomic, readonly) uint16_t compressionMethod; +@property(nonatomic, readonly) uint16_t versionMadeBy; +@property(nonatomic, readonly) uint32_t externalAttributes; +@property(nonatomic, readonly) uint32_t diskNumber; +@property(nonatomic, readonly, nullable) NSString *linkName; + +@end + +@interface FirmwareArchiveMinizipBridge : NSObject + ++ (nullable NSArray *)scanArchiveAtPath: + (NSString *)path + error:(NSError * _Nullable * _Nullable)error; + ++ (BOOL)extractEntryNamed:(NSString *)entryName + archivePath:(NSString *)archivePath + consumer:(BOOL (^)(NSData *chunk))consumer + error:(NSError * _Nullable * _Nullable)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.mm b/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.mm new file mode 100644 index 00000000..7351f520 --- /dev/null +++ b/native-modules/react-native-range-downloader/ios/FirmwareArchiveMinizipBridge.mm @@ -0,0 +1,213 @@ +#import "FirmwareArchiveMinizipBridge.h" + +#import "mz.h" +#import "mz_strm.h" +#import "mz_zip.h" +#import "mz_zip_rw.h" + +#include + +static NSString *const FirmwareArchiveBridgeErrorDomain = + @"so.onekey.firmware.archive.minizip"; + +static NSError *FirmwareArchiveBridgeError(NSString *message, int32_t code) { + return [NSError errorWithDomain:FirmwareArchiveBridgeErrorDomain + code:code + userInfo:@{NSLocalizedDescriptionKey : message}]; +} + +@interface FirmwareArchiveEntryInfo () + +@property(nonatomic, readwrite) NSString *name; +@property(nonatomic, readwrite) int64_t compressedSize; +@property(nonatomic, readwrite) int64_t uncompressedSize; +@property(nonatomic, readwrite) uint32_t crc32; +@property(nonatomic, readwrite) uint16_t flags; +@property(nonatomic, readwrite) uint16_t compressionMethod; +@property(nonatomic, readwrite) uint16_t versionMadeBy; +@property(nonatomic, readwrite) uint32_t externalAttributes; +@property(nonatomic, readwrite) uint32_t diskNumber; +@property(nonatomic, readwrite, nullable) NSString *linkName; + +@end + +@implementation FirmwareArchiveEntryInfo +@end + +@implementation FirmwareArchiveMinizipBridge + ++ (nullable NSArray *)scanArchiveAtPath: + (NSString *)path + error:(NSError * _Nullable * _Nullable)error { + void *reader = nullptr; + mz_zip_reader_create(&reader); + if (reader == nullptr) { + if (error != nullptr) { + *error = FirmwareArchiveBridgeError(@"Archive reader allocation failed", + MZ_MEM_ERROR); + } + return nil; + } + + int32_t result = mz_zip_reader_open_file(reader, path.fileSystemRepresentation); + if (result != MZ_OK) { + mz_zip_reader_delete(&reader); + if (error != nullptr) { + *error = FirmwareArchiveBridgeError(@"Archive open failed", result); + } + return nil; + } + + NSMutableArray *entries = + [NSMutableArray array]; + result = mz_zip_reader_goto_first_entry(reader); + while (result == MZ_OK) { + mz_zip_file *fileInfo = nullptr; + result = mz_zip_reader_entry_get_info(reader, &fileInfo); + if (result != MZ_OK || fileInfo == nullptr || + fileInfo->filename == nullptr) { + result = result == MZ_OK ? MZ_FORMAT_ERROR : result; + break; + } + const size_t filenameLength = std::strlen(fileInfo->filename); + if (filenameLength == 0 || filenameLength > 4096) { + result = MZ_FORMAT_ERROR; + break; + } + if ((fileInfo->flag & (1 << 11)) == 0) { + const unsigned char *filenameBytes = + reinterpret_cast(fileInfo->filename); + bool isAscii = true; + for (size_t index = 0; index < filenameLength; index += 1) { + if (filenameBytes[index] >= 0x80) { + isAscii = false; + break; + } + } + if (!isAscii) { + result = MZ_FORMAT_ERROR; + break; + } + } + NSString *name = [NSString stringWithUTF8String:fileInfo->filename]; + if (name == nil) { + result = MZ_FORMAT_ERROR; + break; + } + NSString *linkName = nil; + if (fileInfo->linkname != nullptr) { + linkName = [NSString stringWithUTF8String:fileInfo->linkname]; + if (linkName == nil) { + result = MZ_FORMAT_ERROR; + break; + } + } + + FirmwareArchiveEntryInfo *entry = + [[FirmwareArchiveEntryInfo alloc] init]; + entry.name = name; + entry.compressedSize = fileInfo->compressed_size; + entry.uncompressedSize = fileInfo->uncompressed_size; + entry.crc32 = fileInfo->crc; + entry.flags = fileInfo->flag; + entry.compressionMethod = fileInfo->compression_method; + entry.versionMadeBy = fileInfo->version_madeby; + entry.externalAttributes = fileInfo->external_fa; + entry.diskNumber = fileInfo->disk_number; + entry.linkName = linkName; + [entries addObject:entry]; + if (entries.count > 4096) { + result = MZ_FORMAT_ERROR; + break; + } + result = mz_zip_reader_goto_next_entry(reader); + } + + if (result == MZ_END_OF_LIST) { + result = MZ_OK; + } + int32_t closeResult = mz_zip_reader_close(reader); + mz_zip_reader_delete(&reader); + if (result != MZ_OK || closeResult != MZ_OK) { + if (error != nullptr) { + *error = FirmwareArchiveBridgeError(@"Archive scan failed", + result != MZ_OK ? result + : closeResult); + } + return nil; + } + return entries; +} + ++ (BOOL)extractEntryNamed:(NSString *)entryName + archivePath:(NSString *)archivePath + consumer:(BOOL (^)(NSData *chunk))consumer + error:(NSError * _Nullable * _Nullable)error { + void *reader = nullptr; + bool archiveOpened = false; + bool entryOpened = false; + mz_zip_reader_create(&reader); + if (reader == nullptr) { + if (error != nullptr) { + *error = FirmwareArchiveBridgeError(@"Archive reader allocation failed", + MZ_MEM_ERROR); + } + return NO; + } + + int32_t result = + mz_zip_reader_open_file(reader, archivePath.fileSystemRepresentation); + if (result == MZ_OK) { + archiveOpened = true; + } + if (result == MZ_OK) { + result = mz_zip_reader_locate_entry( + reader, entryName.UTF8String, 0); + } + if (result == MZ_OK) { + result = mz_zip_reader_entry_open(reader); + if (result == MZ_OK) { + entryOpened = true; + } + } + + uint8_t buffer[64 * 1024]; + while (result == MZ_OK) { + int32_t count = + mz_zip_reader_entry_read(reader, buffer, (int32_t)sizeof(buffer)); + if (count < 0) { + result = count; + break; + } + if (count == 0) { + break; + } + NSData *chunk = [NSData dataWithBytes:buffer length:(NSUInteger)count]; + if (!consumer(chunk)) { + result = MZ_WRITE_ERROR; + break; + } + } + + int32_t entryCloseResult = + entryOpened ? mz_zip_reader_entry_close(reader) : MZ_OK; + int32_t archiveCloseResult = + archiveOpened ? mz_zip_reader_close(reader) : MZ_OK; + mz_zip_reader_delete(&reader); + if (result != MZ_OK || entryCloseResult != MZ_OK || + archiveCloseResult != MZ_OK) { + if (error != nullptr) { + int32_t failure = result != MZ_OK + ? result + : (entryCloseResult != MZ_OK + ? entryCloseResult + : archiveCloseResult); + *error = FirmwareArchiveBridgeError( + @"Archive entry extraction or CRC validation failed", failure); + } + return NO; + } + return YES; +} + +@end diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift new file mode 100644 index 00000000..72535664 --- /dev/null +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -0,0 +1,1166 @@ +import CryptoKit +import Foundation +import SniConnect + +enum FirmwareArtifactStoreError: Error { + case invalidInput(String) + case downloadFailed(String) + case integrityMismatch(String) + case readerInvalid(String) + case archiveInvalid(String) +} + +struct StoredFirmwareArtifact: Sendable { + let artifactRef: String + let size: Int64 + let sha256: String + let fileURL: URL +} + +struct StoredFirmwareArchiveEntry { + let entryName: String + let artifact: StoredFirmwareArtifact +} + +private struct StagedFirmwareArchiveEntry { + let entryName: String + let size: Int64 + let sha256: String + let stagingURL: URL +} + +private final class FirmwareArtifactRedirectDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} + +private actor FirmwareArtifactDownloadCoordinator { + private var tasks: [String: Task] = [:] + + func run( + key: String, + operation: @escaping () async throws -> StoredFirmwareArtifact + ) async throws -> StoredFirmwareArtifact { + if let task = tasks[key] { + return try await task.value + } + let task = Task { + try await operation() + } + tasks[key] = task + defer { tasks[key] = nil } + return try await task.value + } +} + +final class FirmwareArtifactStore { + static let shared = FirmwareArtifactStore() + static let maxReadBytes = 256 * 1024 + private static let maxLeaseMetadataBytes: Int64 = 1024 * 1024 + private static let maxTotalLeaseRefs = 8192 + private static let finalArtifactGrace: TimeInterval = 24 * 60 * 60 + private static let partialArtifactGrace: TimeInterval = 7 * 24 * 60 * 60 + + private struct OpenReader { + let handle: FileHandle + let fileURL: URL + let size: Int64 + } + + private struct LeaseState: Codable { + let transactionId: String + var artifactRefs: Set + } + + private struct LeaseEnvelope: Codable { + let schemaVersion: Int + var leases: [String: LeaseState] + } + + private let fileManager = FileManager.default + private let downloadCoordinator = FirmwareArtifactDownloadCoordinator() + private let leaseLock = NSLock() + private let activeDownloadLock = NSLock() + private var activeDownloadCounts: [String: Int] = [:] + private let readerLock = NSLock() + private var readers: [String: OpenReader] = [:] + + private lazy var rootURL: URL = { + let base = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + let root = base.appendingPathComponent("onekey-firmware-artifacts", isDirectory: true) + try? fileManager.createDirectory(at: root, withIntermediateDirectories: true) + return root + }() + + private init() {} + + static func validateDownloadParams(_ params: FirmwareArtifactDownloadParams) throws { + guard + !params.taskId.isEmpty, + params.taskId.count <= 100, + params.taskId.allSatisfy({ $0.isLetter || $0.isNumber || "._-".contains($0) }) + else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware taskId") + } + guard + isSafeIdentifier(params.transactionId), + isSafeIdentifier(params.artifactId), + isValidLeaseRef(params.leaseRef) + else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware transaction or lease identity" + ) + } + guard + let url = URL(string: params.url), + url.scheme?.lowercased() == "https", + url.host?.isEmpty == false, + url.user == nil, + url.password == nil, + url.fragment == nil, + url.port == nil || url.port == 443 + else { + throw FirmwareArtifactStoreError.invalidInput("Firmware URL must use HTTPS port 443") + } + guard + params.expectedSize.isFinite, + params.expectedSize > 0, + params.expectedSize <= Double(Int64.max), + params.expectedSize.rounded() == params.expectedSize, + params.maxBytes.isFinite, + params.maxBytes == params.expectedSize, + params.maxBytes <= Double(512 * 1024 * 1024) + else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact size") + } + guard params.expectedSha256.range( + of: "^[a-fA-F0-9]{64}$", + options: .regularExpression + ) != nil else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact SHA-256") + } + guard params.routeType == "domain" || params.routeType == "pinnedIp" else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware route type") + } + if params.routeType == "pinnedIp" { + guard let resolvedIp = params.resolvedIp, !resolvedIp.isEmpty else { + throw FirmwareArtifactStoreError.invalidInput("Pinned route requires resolvedIp") + } + } else if params.resolvedIp != nil { + throw FirmwareArtifactStoreError.invalidInput("Domain route must not include resolvedIp") + } + } + + func download(_ params: FirmwareArtifactDownloadParams) async throws -> StoredFirmwareArtifact { + try Self.validateDownloadParams(params) + let expectedSha256 = params.expectedSha256.lowercased() + try retainExpectedArtifact( + leaseRef: params.leaseRef, + transactionId: params.transactionId, + artifactRef: "fw:\(expectedSha256)" + ) + let key = "\(expectedSha256):\(Int64(params.expectedSize))" + markDownloadActive(expectedSha256, delta: 1) + do { + let artifact = try await downloadCoordinator.run(key: key) { [self] in + try await downloadLocked(params, expectedSha256: expectedSha256) + } + markDownloadActive(expectedSha256, delta: -1) + return artifact + } catch { + markDownloadActive(expectedSha256, delta: -1) + throw error + } + } + + private func downloadLocked( + _ params: FirmwareArtifactDownloadParams, + expectedSha256: String + ) async throws -> StoredFirmwareArtifact { + let finalURL = artifactURL(sha256: expectedSha256) + if let existing = try? validateStoredArtifact( + fileURL: finalURL, + expectedSize: Int64(params.expectedSize), + expectedSha256: expectedSha256 + ) { + return existing + } + + if params.routeType == "domain" { + return try await RangeDownloader.shared.downloadFirmwareArtifact( + params: params + ) + } + + let partialURL = rootURL.appendingPathComponent( + "\(expectedSha256).\(params.taskId).partial", + isDirectory: false + ) + if !fileManager.fileExists(atPath: partialURL.path) { + fileManager.createFile(atPath: partialURL.path, contents: nil) + } + var currentSize = try fileSize(partialURL) + if currentSize > Int64(params.expectedSize) { + try fileManager.removeItem(at: partialURL) + fileManager.createFile(atPath: partialURL.path, contents: nil) + currentSize = 0 + } + if currentSize == Int64(params.expectedSize) { + if let completed = try? validateStoredArtifact( + fileURL: partialURL, + expectedSize: Int64(params.expectedSize), + expectedSha256: expectedSha256 + ) { + try promote(source: partialURL, destination: finalURL) + return StoredFirmwareArtifact( + artifactRef: completed.artifactRef, + size: completed.size, + sha256: completed.sha256, + fileURL: finalURL + ) + } + try fileManager.removeItem(at: partialURL) + fileManager.createFile(atPath: partialURL.path, contents: nil) + currentSize = 0 + } + + try await streamDownload( + params, + partialURL: partialURL, + resumeOffset: min(currentSize, Int64(params.expectedSize)) + ) + let artifact: StoredFirmwareArtifact + do { + artifact = try validateStoredArtifact( + fileURL: partialURL, + expectedSize: Int64(params.expectedSize), + expectedSha256: expectedSha256 + ) + } catch { + try? fileManager.removeItem(at: partialURL) + throw error + } + try promote(source: partialURL, destination: finalURL) + return StoredFirmwareArtifact( + artifactRef: artifact.artifactRef, + size: artifact.size, + sha256: artifact.sha256, + fileURL: finalURL + ) + } + + func acceptBackgroundDownload( + temporaryURL: URL, + leaseRef: String, + transactionId: String, + expectedSize: Int64, + expectedSha256: String + ) throws -> StoredFirmwareArtifact { + let normalizedExpectedSha256 = expectedSha256.lowercased() + try retainExpectedArtifact( + leaseRef: leaseRef, + transactionId: transactionId, + artifactRef: "fw:\(normalizedExpectedSha256)" + ) + let finalURL = artifactURL(sha256: normalizedExpectedSha256) + if let existing = try? validateStoredArtifact( + fileURL: finalURL, + expectedSize: expectedSize, + expectedSha256: normalizedExpectedSha256 + ) { + return existing + } + let artifact = try validateStoredArtifact( + fileURL: temporaryURL, + expectedSize: expectedSize, + expectedSha256: normalizedExpectedSha256 + ) + try promote(source: temporaryURL, destination: finalURL) + return StoredFirmwareArtifact( + artifactRef: artifact.artifactRef, + size: artifact.size, + sha256: artifact.sha256, + fileURL: finalURL + ) + } + + func storedArtifact( + expectedSize: Int64, + expectedSha256: String + ) throws -> StoredFirmwareArtifact? { + let finalURL = artifactURL(sha256: expectedSha256) + guard fileManager.fileExists(atPath: finalURL.path) else { + return nil + } + return try validateStoredArtifact( + fileURL: finalURL, + expectedSize: expectedSize, + expectedSha256: expectedSha256 + ) + } + + func discard(artifactRef: String) throws { + let fileURL = try resolveArtifactURL(artifactRef) + leaseLock.lock() + let isRetained: Bool + do { + isRetained = try loadLeasesLocked().leases.values.contains { + $0.artifactRefs.contains(artifactRef) + } + } catch { + leaseLock.unlock() + throw error + } + leaseLock.unlock() + guard !isRetained else { + throw FirmwareArtifactStoreError.invalidInput( + "ARTIFACT_LEASED: firmware artifact is retained" + ) + } + readerLock.lock() + let hasMatchingReader = readers.values.contains { + $0.fileURL == fileURL + } + readerLock.unlock() + guard !hasMatchingReader else { + throw FirmwareArtifactStoreError.readerInvalid( + "ARTIFACT_BUSY: firmware artifact has an open reader" + ) + } + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } + } + + func open(artifactRef: String) throws -> (readerId: String, size: Int64) { + let fileURL = try resolveArtifactURL(artifactRef) + let size = try fileSize(fileURL) + guard size > 0 else { + throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact is empty") + } + let expectedSha256 = String(artifactRef.dropFirst(3)) + guard try hashFile(fileURL) == expectedSha256 else { + throw FirmwareArtifactStoreError.integrityMismatch( + "Firmware artifact SHA-256 mismatch" + ) + } + let readerId = UUID().uuidString + let reader = OpenReader( + handle: try FileHandle(forReadingFrom: fileURL), + fileURL: fileURL, + size: size + ) + readerLock.lock() + readers[readerId] = reader + readerLock.unlock() + return (readerId, size) + } + + func read(readerId: String, offset: Int64, length: Int) throws -> Data { + guard length > 0, length <= Self.maxReadBytes, offset >= 0 else { + throw FirmwareArtifactStoreError.readerInvalid("Invalid firmware artifact read") + } + readerLock.lock() + defer { readerLock.unlock() } + guard + let reader = readers[readerId], + offset <= reader.size - Int64(length) + else { + throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact read is out of bounds") + } + try reader.handle.seek(toOffset: UInt64(offset)) + let data = try reader.handle.read(upToCount: length) ?? Data() + guard data.count == length else { + throw FirmwareArtifactStoreError.readerInvalid("Firmware artifact returned a short read") + } + return data + } + + func close(readerId: String) throws { + readerLock.lock() + let reader = readers.removeValue(forKey: readerId) + readerLock.unlock() + guard let reader else { + return + } + try reader.handle.close() + } + + func materializeArchive( + leaseRef: String, + artifactRef: String, + expectedEntries: [FirmwareArchiveExpectedEntry] + ) throws -> [StoredFirmwareArchiveEntry] { + try requireLease(leaseRef) + let archiveURL = try resolveArtifactURL(artifactRef) + let scratchURL = rootURL.appendingPathComponent( + "archive-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: scratchURL, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: scratchURL) } + + let requirements = try validateArchiveRequirements(expectedEntries) + let archiveEntries = try FirmwareArchiveMinizipBridge.scanArchive( + atPath: archiveURL.path + ) + try validateArchiveEntries( + archiveEntries, + requirements: requirements + ) + + var staged: [StagedFirmwareArchiveEntry] = [] + for (index, requirement) in requirements.enumerated() { + let stagingURL = scratchURL.appendingPathComponent( + "\(index).entry", + isDirectory: false + ) + fileManager.createFile(atPath: stagingURL.path, contents: nil) + let handle = try FileHandle(forWritingTo: stagingURL) + var hasher = SHA256() + var actualSize: Int64 = 0 + var consumerError: Error? + do { + try FirmwareArchiveMinizipBridge.extractEntryNamed( + requirement.entryName, + archivePath: archiveURL.path + ) { chunk in + guard consumerError == nil else { + return false + } + do { + actualSize += Int64(chunk.count) + guard actualSize <= Int64(requirement.expectedSize) else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive entry exceeds its expected size" + ) + } + hasher.update(data: chunk) + try handle.write(contentsOf: chunk) + return true + } catch { + consumerError = error + return false + } + } + if let consumerError { + throw consumerError + } + try handle.synchronize() + try handle.close() + } catch { + try? handle.close() + throw error + } + let sha256 = hasher.finalize().map { + String(format: "%02x", $0) + }.joined() + guard + actualSize == Int64(requirement.expectedSize), + sha256 == requirement.expectedSha256.lowercased() + else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive entry integrity mismatch" + ) + } + staged.append( + StagedFirmwareArchiveEntry( + entryName: requirement.entryName, + size: actualSize, + sha256: sha256, + stagingURL: stagingURL + ) + ) + } + + return try staged.map { entry in + let destination = artifactURL(sha256: entry.sha256) + if (try? validateStoredArtifact( + fileURL: destination, + expectedSize: entry.size, + expectedSha256: entry.sha256 + )) == nil { + try promote(source: entry.stagingURL, destination: destination) + } + let stored = StoredFirmwareArtifact( + artifactRef: "fw:\(entry.sha256)", + size: entry.size, + sha256: entry.sha256, + fileURL: destination + ) + try retainExpectedArtifact( + leaseRef: leaseRef, + transactionId: nil, + artifactRef: stored.artifactRef + ) + return StoredFirmwareArchiveEntry( + entryName: entry.entryName, + artifact: stored + ) + } + } + + func createLease(transactionId: String) throws -> String { + guard Self.isSafeIdentifier(transactionId) else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware transactionId" + ) + } + leaseLock.lock() + defer { leaseLock.unlock() } + var envelope = try loadLeasesLocked() + guard envelope.leases.count < 32 else { + throw FirmwareArtifactStoreError.invalidInput( + "Too many firmware artifact leases" + ) + } + let leaseRef = "fwlease:\(UUID().uuidString.lowercased())" + envelope.leases[leaseRef] = LeaseState( + transactionId: transactionId, + artifactRefs: [] + ) + try saveLeasesLocked(envelope) + return leaseRef + } + + func retain(leaseRef: String, artifactRef: String) throws { + _ = try resolveArtifactURL(artifactRef) + try retainExpectedArtifact( + leaseRef: leaseRef, + transactionId: nil, + artifactRef: artifactRef + ) + } + + func releaseLease(leaseRef: String, disposition: String) throws { + guard + disposition == "completed" || + disposition == "safeCancelled" || + disposition == "safeAbandoned" + else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware lease disposition" + ) + } + leaseLock.lock() + defer { leaseLock.unlock() } + var envelope = try loadLeasesLocked() + guard envelope.leases.removeValue(forKey: try validateLeaseRef(leaseRef)) != nil else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease is unavailable" + ) + } + try saveLeasesLocked(envelope) + } + + func reconcileLeases(activeLeaseRefs: [String]) throws { + guard activeLeaseRefs.count <= 32 else { + throw FirmwareArtifactStoreError.invalidInput( + "Too many active firmware artifact leases" + ) + } + let active = try Set(activeLeaseRefs.map(validateLeaseRef)) + guard active.count == activeLeaseRefs.count else { + throw FirmwareArtifactStoreError.invalidInput( + "Duplicate active firmware artifact lease" + ) + } + leaseLock.lock() + defer { leaseLock.unlock() } + var envelope = try loadLeasesLocked() + guard active.allSatisfy({ envelope.leases[$0] != nil }) else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease reconciliation is incomplete" + ) + } + envelope.leases = envelope.leases.filter { active.contains($0.key) } + try saveLeasesLocked(envelope) + } + + func sweepOrphans() throws -> (deletedFiles: Int, deletedBytes: Int64) { + leaseLock.lock() + let retained: Set + do { + retained = Set( + try loadLeasesLocked().leases.values + .flatMap(\.artifactRefs) + .map { String($0.dropFirst(3)) } + ) + } catch { + leaseLock.unlock() + throw error + } + leaseLock.unlock() + activeDownloadLock.lock() + let active = Set(activeDownloadCounts.filter { $0.value > 0 }.keys) + activeDownloadLock.unlock() + readerLock.lock() + let openPaths = Set(readers.values.map(\.fileURL.path)) + readerLock.unlock() + + let now = Date() + var deletedFiles = 0 + var deletedBytes: Int64 = 0 + let files = try fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) + for fileURL in files { + let name = fileURL.lastPathComponent + guard name != "leases.json", name.count >= 64 else { continue } + let sha256 = String(name.prefix(64)) + guard + sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil, + !retained.contains(sha256), + !active.contains(sha256), + !openPaths.contains(fileURL.path) + else { + continue + } + let grace: TimeInterval + if name.hasSuffix(".bin") { + grace = Self.finalArtifactGrace + } else if name.hasSuffix(".partial") { + grace = Self.partialArtifactGrace + } else { + continue + } + let values = try fileURL.resourceValues( + forKeys: [.contentModificationDateKey, .fileSizeKey] + ) + guard + let modifiedAt = values.contentModificationDate, + now.timeIntervalSince(modifiedAt) >= grace + else { + continue + } + let size = Int64(values.fileSize ?? 0) + try fileManager.removeItem(at: fileURL) + deletedFiles += 1 + deletedBytes += size + } + return (deletedFiles, deletedBytes) + } + + private func requireLease(_ leaseRef: String) throws { + leaseLock.lock() + defer { leaseLock.unlock() } + guard try loadLeasesLocked().leases[validateLeaseRef(leaseRef)] != nil else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease is unavailable" + ) + } + } + + private func retainExpectedArtifact( + leaseRef: String, + transactionId: String?, + artifactRef: String + ) throws { + guard artifactRef.range( + of: "^fw:[a-f0-9]{64}$", + options: .regularExpression + ) != nil else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware artifactRef" + ) + } + leaseLock.lock() + defer { leaseLock.unlock() } + var envelope = try loadLeasesLocked() + let validatedLeaseRef = try validateLeaseRef(leaseRef) + guard var lease = envelope.leases[validatedLeaseRef] else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease is unavailable" + ) + } + if let transactionId, lease.transactionId != transactionId { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease transaction mismatch" + ) + } + if lease.artifactRefs.insert(artifactRef).inserted { + envelope.leases[validatedLeaseRef] = lease + try saveLeasesLocked(envelope) + } + } + + private func validateLeaseRef(_ leaseRef: String) throws -> String { + guard Self.isValidLeaseRef(leaseRef) else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware leaseRef" + ) + } + return leaseRef + } + + private static func isValidLeaseRef(_ value: String) -> Bool { + value.range( + of: "^fwlease:[a-f0-9-]{36}$", + options: .regularExpression + ) != nil + } + + private static func isSafeIdentifier(_ value: String) -> Bool { + value.range( + of: "^[A-Za-z0-9._:-]{1,160}$", + options: .regularExpression + ) != nil + } + + private func loadLeasesLocked() throws -> LeaseEnvelope { + let url = rootURL.appendingPathComponent("leases.json", isDirectory: false) + guard fileManager.fileExists(atPath: url.path) else { + return LeaseEnvelope(schemaVersion: 1, leases: [:]) + } + let fileSize = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard fileSize > 0, Int64(fileSize) <= Self.maxLeaseMetadataBytes else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware lease metadata is too large" + ) + } + let envelope = try JSONDecoder().decode( + LeaseEnvelope.self, + from: Data(contentsOf: url) + ) + guard envelope.schemaVersion == 1, envelope.leases.count <= 32 else { + throw FirmwareArtifactStoreError.invalidInput( + "Unsupported firmware lease schema" + ) + } + guard + envelope.leases.values.reduce(0, { $0 + $1.artifactRefs.count }) + <= Self.maxTotalLeaseRefs + else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware lease metadata is too large" + ) + } + for (leaseRef, lease) in envelope.leases { + guard + Self.isValidLeaseRef(leaseRef), + Self.isSafeIdentifier(lease.transactionId), + lease.artifactRefs.count <= 4096, + lease.artifactRefs.allSatisfy({ + $0.range( + of: "^fw:[a-f0-9]{64}$", + options: .regularExpression + ) != nil + }) + else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid persisted firmware lease" + ) + } + } + return envelope + } + + private func saveLeasesLocked(_ envelope: LeaseEnvelope) throws { + guard + envelope.schemaVersion == 1, + envelope.leases.count <= 32, + envelope.leases.values.allSatisfy({ + $0.artifactRefs.count <= 4096 + }), + envelope.leases.values.reduce(0, { + $0 + $1.artifactRefs.count + }) <= Self.maxTotalLeaseRefs + else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware lease metadata is too large" + ) + } + let data = try JSONEncoder().encode(envelope) + guard Int64(data.count) <= Self.maxLeaseMetadataBytes else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware lease metadata is too large" + ) + } + let url = rootURL.appendingPathComponent("leases.json", isDirectory: false) + try data.write(to: url, options: [.atomic]) + } + + private func markDownloadActive(_ sha256: String, delta: Int) { + activeDownloadLock.lock() + let next = (activeDownloadCounts[sha256] ?? 0) + delta + if next <= 0 { + activeDownloadCounts.removeValue(forKey: sha256) + } else { + activeDownloadCounts[sha256] = next + } + activeDownloadLock.unlock() + } + + private func validateArchiveRequirements( + _ expectedEntries: [FirmwareArchiveExpectedEntry] + ) throws -> [FirmwareArchiveExpectedEntry] { + guard !expectedEntries.isEmpty, expectedEntries.count <= 4096 else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive expected entry count is invalid" + ) + } + var names = Set() + var canonicalNames = Set() + var totalSize: Int64 = 0 + for entry in expectedEntries { + guard + !entry.artifactId.isEmpty, + entry.artifactId.count <= 160, + entry.artifactId.allSatisfy({ + $0.isLetter || $0.isNumber || "._-".contains($0) + }), + isPortableArchiveEntryName( + entry.entryName, + canonicalNames: &canonicalNames + ), + names.insert(entry.entryName).inserted, + entry.expectedSize.isFinite, + entry.expectedSize > 0, + entry.expectedSize <= Double(128 * 1024 * 1024), + entry.expectedSize.rounded() == entry.expectedSize, + entry.expectedSha256.range( + of: "^[a-fA-F0-9]{64}$", + options: .regularExpression + ) != nil + else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive expected entry is invalid" + ) + } + totalSize += Int64(entry.expectedSize) + guard totalSize <= 512 * 1024 * 1024 else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive expected entries exceed limits" + ) + } + } + return expectedEntries + } + + private func validateArchiveEntries( + _ entries: [FirmwareArchiveEntryInfo], + requirements: [FirmwareArchiveExpectedEntry] + ) throws { + guard entries.count == requirements.count else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive has missing or extra entries" + ) + } + let requirementsByName = Dictionary( + uniqueKeysWithValues: requirements.map { ($0.entryName, $0) } + ) + var names = Set() + var canonicalNames = Set() + for entry in entries { + guard + names.insert(entry.name).inserted, + isPortableArchiveEntryName( + entry.name, + canonicalNames: &canonicalNames + ), + let requirement = requirementsByName[entry.name], + entry.uncompressedSize == Int64(requirement.expectedSize), + entry.uncompressedSize > 0, + entry.compressedSize >= 0, + entry.compressedSize <= 512 * 1024 * 1024, + entry.uncompressedSize <= max(entry.compressedSize, 1) * 1000, + entry.diskNumber == 0, + entry.flags & 1 == 0, + entry.compressionMethod == 0 || entry.compressionMethod == 8, + entry.linkName?.isEmpty != false, + isRegularArchiveEntry(entry) + else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive entry metadata is invalid" + ) + } + } + } + + private func isPortableArchiveEntryName( + _ name: String, + canonicalNames: inout Set + ) -> Bool { + let lowered = name.precomposedStringWithCanonicalMapping.lowercased() + let components = name.split( + separator: "/", + omittingEmptySubsequences: false + ) + let nestedArchiveExtensions = [ + ".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", + ] + guard + !name.isEmpty, + name.count <= 512, + name == name.precomposedStringWithCanonicalMapping, + !name.hasPrefix("/"), + !name.hasPrefix("\\"), + !name.contains("\\"), + !name.contains(":"), + name.unicodeScalars.allSatisfy({ + !CharacterSet.controlCharacters.contains($0) + }), + components.allSatisfy({ + !$0.isEmpty && + $0 != "." && + $0 != ".." && + !$0.hasSuffix(".") && + !$0.hasSuffix(" ") + }), + !nestedArchiveExtensions.contains(where: { + lowered.hasSuffix($0) + }), + canonicalNames.insert(lowered).inserted + else { + return false + } + return true + } + + private func isRegularArchiveEntry( + _ entry: FirmwareArchiveEntryInfo + ) -> Bool { + let hostSystem = UInt8(entry.versionMadeBy >> 8) + if hostSystem == 3 || hostSystem == 19 { + let fileType = (entry.externalAttributes >> 16) & 0xF000 + return fileType == 0 || fileType == 0x8000 + } + return entry.externalAttributes & 0x10 == 0 + } + + private func streamDownload( + _ params: FirmwareArtifactDownloadParams, + partialURL: URL, + resumeOffset: Int64 + ) async throws { + guard let url = URL(string: params.url), let hostname = url.host else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware URL") + } + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = params.overallDeadlineSeconds ?? 180 + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + if resumeOffset > 0 { + request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range") + } + + let pinnedSession: SniConnectPinnedSession? + if params.routeType == "pinnedIp" { + pinnedSession = try SniConnectPinnedTransport.makeSession( + hostname: hostname, + ip: params.resolvedIp! + ) + } else { + pinnedSession = nil + } + let domainDelegate = FirmwareArtifactRedirectDelegate() + let session = pinnedSession?.session ?? URLSession( + configuration: .ephemeral, + delegate: domainDelegate, + delegateQueue: nil + ) + defer { + if let pinnedSession { + pinnedSession.close() + } else { + session.finishTasksAndInvalidate() + } + } + + let bytes: URLSession.AsyncBytes + let response: URLResponse + do { + (bytes, response) = try await session.bytes(for: request) + } catch { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_NETWORK_FAILED: firmware request failed" + ) + } + guard let httpResponse = response as? HTTPURLResponse else { + throw FirmwareArtifactStoreError.downloadFailed("Firmware response is not HTTP") + } + guard httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_HTTP_\(httpResponse.statusCode): firmware request failed" + ) + } + let append = resumeOffset > 0 && httpResponse.statusCode == 206 + if httpResponse.statusCode == 206 { + guard + let contentRange = httpResponse.value(forHTTPHeaderField: "Content-Range"), + validateContentRange( + contentRange, + expectedStart: append ? resumeOffset : 0, + expectedTotal: Int64(params.expectedSize) + ) + else { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid" + ) + } + } + let handle = try FileHandle(forWritingTo: partialURL) + defer { try? handle.close() } + if append { + try handle.seekToEnd() + } else { + try handle.truncate(atOffset: 0) + } + + var written = append ? resumeOffset : 0 + var buffer = Data() + buffer.reserveCapacity(64 * 1024) + do { + for try await byte in bytes { + buffer.append(byte) + if buffer.count >= 64 * 1024 { + written += Int64(buffer.count) + guard written <= Int64(params.maxBytes) else { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" + ) + } + try handle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + } + } + } catch let error as FirmwareArtifactStoreError { + throw error + } catch { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_NETWORK_FAILED: firmware response stream failed" + ) + } + if !buffer.isEmpty { + written += Int64(buffer.count) + guard written <= Int64(params.maxBytes) else { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" + ) + } + try handle.write(contentsOf: buffer) + } + try handle.synchronize() + } + + private func validateContentRange( + _ value: String, + expectedStart: Int64, + expectedTotal: Int64 + ) -> Bool { + let pattern = #"^bytes ([0-9]+)-([0-9]+)/([0-9]+)$"# + guard + let expression = try? NSRegularExpression(pattern: pattern), + let match = expression.firstMatch( + in: value.lowercased(), + range: NSRange(value.startIndex..., in: value) + ), + match.range.location != NSNotFound, + let startRange = Range(match.range(at: 1), in: value), + let endRange = Range(match.range(at: 2), in: value), + let totalRange = Range(match.range(at: 3), in: value), + let start = Int64(value[startRange]), + let end = Int64(value[endRange]), + let total = Int64(value[totalRange]) + else { + return false + } + return start == expectedStart && + end >= start && + end < total && + total == expectedTotal + } + + private func validateStoredArtifact( + fileURL: URL, + expectedSize: Int64, + expectedSha256: String + ) throws -> StoredFirmwareArtifact { + let size = try fileSize(fileURL) + guard size == expectedSize else { + throw FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch" + ) + } + let sha256 = try hashFile(fileURL) + guard sha256 == expectedSha256 else { + throw FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch" + ) + } + return StoredFirmwareArtifact( + artifactRef: "fw:\(sha256)", + size: size, + sha256: sha256, + fileURL: fileURL + ) + } + + private func resolveArtifactURL(_ artifactRef: String) throws -> URL { + guard artifactRef.hasPrefix("fw:") else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef") + } + let sha256 = String(artifactRef.dropFirst(3)) + guard sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef") + } + let url = artifactURL(sha256: sha256) + guard fileManager.fileExists(atPath: url.path) else { + throw FirmwareArtifactStoreError.invalidInput("Firmware artifact not found") + } + return url + } + + private func artifactURL(sha256: String) -> URL { + rootURL.appendingPathComponent("\(sha256).bin", isDirectory: false) + } + + private func fileSize(_ url: URL) throws -> Int64 { + let attributes = try fileManager.attributesOfItem(atPath: url.path) + return (attributes[.size] as? NSNumber)?.int64Value ?? 0 + } + + private func hashFile(_ url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + while let data = try handle.read(upToCount: 256 * 1024), !data.isEmpty { + hasher.update(data: data) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private func promote(source: URL, destination: URL) throws { + let stagingURL = destination + .deletingLastPathComponent() + .appendingPathComponent(".promote-\(UUID().uuidString)", isDirectory: false) + try fileManager.moveItem(at: source, to: stagingURL) + defer { + if fileManager.fileExists(atPath: stagingURL.path) { + try? fileManager.removeItem(at: stagingURL) + } + } + if fileManager.fileExists(atPath: destination.path) { + _ = try fileManager.replaceItemAt( + destination, + withItemAt: stagingURL, + backupItemName: nil, + options: [] + ) + } else { + try fileManager.moveItem(at: stagingURL, to: destination) + } + } +} diff --git a/native-modules/react-native-range-downloader/ios/FirmwareBackgroundSessionEventRouter.swift b/native-modules/react-native-range-downloader/ios/FirmwareBackgroundSessionEventRouter.swift new file mode 100644 index 00000000..477fb9ad --- /dev/null +++ b/native-modules/react-native-range-downloader/ios/FirmwareBackgroundSessionEventRouter.swift @@ -0,0 +1,17 @@ +import Foundation + +@objc(FirmwareBackgroundSessionEventRouter) +public final class FirmwareBackgroundSessionEventRouter: NSObject { + @objc(routeEventsForBackgroundURLSession:completionHandler:) + public static func routeEvents( + forBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) -> NSNumber { + NSNumber( + value: RangeDownloader.routeFirmwareBackgroundEvents( + identifier: identifier, + completionHandler: completionHandler + ) + ) + } +} diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index 98e0c650..8a32e8b7 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -95,6 +95,163 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { } return NSTemporaryDirectory() } + + func getFirmwareArtifactCapabilities() throws -> FirmwareArtifactCapabilities { + FirmwareArtifactCapabilities( + firmwareArtifactProtocolVersion: 1, + supportedRouteTypes: ["domain", "pinnedIp"], + supportsArchiveMaterialization: true, + maxReadBytes: Double(FirmwareArtifactStore.maxReadBytes) + ) + } + + func downloadFirmwareArtifact( + params: FirmwareArtifactDownloadParams + ) throws -> Promise { + Promise.async { + let artifact = try await FirmwareArtifactStore.shared.download(params) + return FirmwareArtifactReceipt( + artifactRef: artifact.artifactRef, + size: Double(artifact.size), + sha256: artifact.sha256 + ) + } + } + + func discardFirmwareArtifact( + params: FirmwareArtifactRefParams + ) throws -> Promise { + Promise.async { + try FirmwareArtifactStore.shared.discard(artifactRef: params.artifactRef) + } + } + + func openFirmwareArtifact( + params: FirmwareArtifactRefParams + ) throws -> Promise { + Promise.async { + let reader = try FirmwareArtifactStore.shared.open( + artifactRef: params.artifactRef + ) + return FirmwareArtifactReaderInfo( + readerId: reader.readerId, + size: Double(reader.size) + ) + } + } + + func readFirmwareArtifact( + params: FirmwareArtifactReaderReadParams + ) throws -> Promise { + Promise.async { + guard + params.offset.isFinite, + params.offset >= 0, + params.offset <= Double(Int64.max), + params.offset.rounded() == params.offset, + params.length.isFinite, + params.length > 0, + params.length <= Double(Int.max), + params.length.rounded() == params.length + else { + throw FirmwareArtifactStoreError.readerInvalid( + "Invalid firmware artifact read" + ) + } + let data = try FirmwareArtifactStore.shared.read( + readerId: params.readerId, + offset: Int64(params.offset), + length: Int(params.length) + ) + return try ArrayBuffer.copy(data: data) + } + } + + func closeFirmwareArtifact( + params: FirmwareArtifactReaderCloseParams + ) throws -> Promise { + Promise.async { + try FirmwareArtifactStore.shared.close(readerId: params.readerId) + } + } + + func materializeFirmwareArchive( + params: FirmwareArchiveMaterializeParams + ) throws -> Promise { + Promise.async { + let entries = try FirmwareArtifactStore.shared.materializeArchive( + leaseRef: params.leaseRef, + artifactRef: params.archiveArtifactRef, + expectedEntries: params.expectedEntries + ) + return FirmwareArchiveMaterializeResult( + artifacts: entries.map { entry in + FirmwareArchiveMaterializedArtifact( + entryName: entry.entryName, + receipt: FirmwareArtifactReceipt( + artifactRef: entry.artifact.artifactRef, + size: Double(entry.artifact.size), + sha256: entry.artifact.sha256 + ) + ) + } + ) + } + } + + func createFirmwareArtifactLease( + params: FirmwareArtifactLeaseCreateParams + ) throws -> Promise { + Promise.async { + FirmwareArtifactLease( + leaseRef: try FirmwareArtifactStore.shared.createLease( + transactionId: params.transactionId + ) + ) + } + } + + func retainFirmwareArtifact( + params: FirmwareArtifactLeaseRetainParams + ) throws -> Promise { + Promise.async { + try FirmwareArtifactStore.shared.retain( + leaseRef: params.leaseRef, + artifactRef: params.artifactRef + ) + } + } + + func releaseFirmwareArtifactLease( + params: FirmwareArtifactLeaseReleaseParams + ) throws -> Promise { + Promise.async { + try FirmwareArtifactStore.shared.releaseLease( + leaseRef: params.leaseRef, + disposition: params.disposition + ) + } + } + + func reconcileFirmwareArtifactLeases( + params: FirmwareArtifactLeaseReconcileParams + ) throws -> Promise { + Promise.async { + try FirmwareArtifactStore.shared.reconcileLeases( + activeLeaseRefs: params.activeLeaseRefs + ) + } + } + + func sweepFirmwareArtifactOrphans() throws -> Promise { + Promise.async { + let result = try FirmwareArtifactStore.shared.sweepOrphans() + return FirmwareArtifactSweepResult( + deletedFiles: Double(result.deletedFiles), + deletedBytes: Double(result.deletedBytes) + ) + } + } } // MARK: - RangeDownloader (migrated core) @@ -156,10 +313,76 @@ extension RangeFallbackClass { } } +private struct FirmwareBackgroundTaskDescriptor: Codable, Equatable { + let schemaVersion: Int + let taskId: String + let transactionId: String + let leaseRef: String + let expectedSize: Int64 + let expectedSha256: String + let hostname: String + let deadlineAt: TimeInterval + + var key: String { + "\(leaseRef)|\(taskId)|\(expectedSha256)" + } + + func hasSameArtifactIdentity( + as other: FirmwareBackgroundTaskDescriptor + ) -> Bool { + taskId == other.taskId && + transactionId == other.transactionId && + leaseRef == other.leaseRef && + expectedSize == other.expectedSize && + expectedSha256 == other.expectedSha256 && + hostname == other.hostname + } +} + +private enum FirmwareBackgroundDownloadError: LocalizedError { + case invalidTask + case deadlineExceeded + case redirectRejected + case responseRejected + case sizeRejected + case transferFailed + + var errorDescription: String? { + switch self { + case .invalidTask: + return "ARTIFACT_PROTOCOL_INVALID: firmware background task is invalid" + case .deadlineExceeded: + return "ARTIFACT_DEADLINE_EXCEEDED: firmware download exceeded its deadline" + case .redirectRejected: + return "ARTIFACT_REDIRECT_REJECTED: firmware redirect changed canonical identity" + case .responseRejected: + return "ARTIFACT_PROTOCOL_INVALID: firmware response is invalid" + case .sizeRejected: + return "ARTIFACT_PROTOCOL_INVALID: firmware artifact size is invalid" + case .transferFailed: + return "ARTIFACT_NETWORK_FAILED: firmware background transfer failed" + } + } +} + public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { public static let shared = RangeDownloader() + public static func routeFirmwareBackgroundEvents( + identifier: String, + completionHandler: @escaping () -> Void + ) -> Bool { + guard identifier == sessionIdentifier(for: .firmware) else { + return false + } + shared.attachBackgroundEvents( + identifier: identifier, + completionHandler: completionHandler + ) + return true + } + /// Posted by the AppDelegate from /// application(_:handleEventsForBackgroundURLSession:completionHandler:). /// userInfo: ["identifier": String, "completionHandler": () -> Void]. @@ -196,6 +419,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { /// notification), keyed by session identifier, so we can call each back once /// all its queued background events have been delivered. private var backgroundCompletionHandlers: [String: () -> Void] = [:] + private var firmwareWaiters: [ + String: [CheckedContinuation] + ] = [:] + private var firmwareTaskErrors: [Int: Error] = [:] + private var completedFirmwareTasks: Set = [] // Per-run mutable state. private final class RunState { @@ -326,16 +554,32 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { @objc private func handleBackgroundEventsNotification(_ note: Notification) { guard let identifier = note.userInfo?["identifier"] as? String, Self.channel(forIdentifier: identifier) != nil else { return } - // Re-create the session with this delegate so queued completion events are - // delivered here on a background relaunch. - _ = session(forIdentifier: identifier) if let handler = note.userInfo?["completionHandler"] as? () -> Void { - lock.lock() - backgroundCompletionHandlers[identifier] = handler - lock.unlock() + attachBackgroundEvents( + identifier: identifier, + completionHandler: handler + ) } } + private func attachBackgroundEvents( + identifier: String, + completionHandler: @escaping () -> Void + ) { + let replacedHandler: (() -> Void)? = lock.withLockValue { + backgroundCompletionHandlers.updateValue( + completionHandler, + forKey: identifier + ) + } + // UIKit should provide one live handler per session. Complete an older + // handler instead of leaking it if the callback is unexpectedly repeated. + replacedHandler?() + // Store the handler before creating the session. Delegate delivery can + // begin immediately when a background relaunch reattaches this identifier. + _ = session(forIdentifier: identifier) + } + // MARK: - Session cache private func session(forChannel channel: DownloadChannel, segmentCount: Int) -> URLSession { @@ -437,6 +681,193 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { return (r, decoded.segIndex) } + private static let firmwareTaskDescriptionPrefix = "firmware-v1:" + + private static func encodeFirmwareTaskDescription( + _ descriptor: FirmwareBackgroundTaskDescriptor + ) throws -> String { + let data = try JSONEncoder().encode(descriptor) + return firmwareTaskDescriptionPrefix + data.base64EncodedString() + } + + private static func decodeFirmwareTaskDescription( + _ description: String? + ) -> FirmwareBackgroundTaskDescriptor? { + guard + let description, + description.hasPrefix(firmwareTaskDescriptionPrefix), + let data = Data( + base64Encoded: String( + description.dropFirst(firmwareTaskDescriptionPrefix.count) + ) + ), + data.count <= 2048, + let descriptor = try? JSONDecoder().decode( + FirmwareBackgroundTaskDescriptor.self, + from: data + ), + descriptor.schemaVersion == 1, + descriptor.taskId.range( + of: "^[A-Za-z0-9._-]{1,100}$", + options: .regularExpression + ) != nil, + descriptor.transactionId.range( + of: "^[A-Za-z0-9._:-]{1,160}$", + options: .regularExpression + ) != nil, + descriptor.leaseRef.range( + of: "^fwlease:[a-f0-9-]{36}$", + options: .regularExpression + ) != nil, + descriptor.expectedSize > 0, + descriptor.expectedSize <= 512 * 1024 * 1024, + descriptor.expectedSha256.range( + of: "^[a-f0-9]{64}$", + options: .regularExpression + ) != nil, + !descriptor.hostname.isEmpty, + descriptor.hostname.count <= 253, + descriptor.deadlineAt.isFinite, + descriptor.deadlineAt > 0 + else { + return nil + } + return descriptor + } + + func downloadFirmwareArtifact( + params: FirmwareArtifactDownloadParams + ) async throws -> StoredFirmwareArtifact { + guard + params.routeType == "domain", + let url = URL(string: params.url), + let hostname = url.host?.lowercased() + else { + throw FirmwareBackgroundDownloadError.invalidTask + } + let deadlineSeconds = params.overallDeadlineSeconds ?? 180 + guard + deadlineSeconds.isFinite, + deadlineSeconds > 0, + deadlineSeconds <= 24 * 60 * 60 + else { + throw FirmwareBackgroundDownloadError.invalidTask + } + let descriptor = FirmwareBackgroundTaskDescriptor( + schemaVersion: 1, + taskId: params.taskId, + transactionId: params.transactionId, + leaseRef: params.leaseRef, + expectedSize: Int64(params.expectedSize), + expectedSha256: params.expectedSha256.lowercased(), + hostname: hostname, + deadlineAt: Date().timeIntervalSince1970 + deadlineSeconds + ) + if let stored = try FirmwareArtifactStore.shared.storedArtifact( + expectedSize: descriptor.expectedSize, + expectedSha256: descriptor.expectedSha256 + ) { + return stored + } + + return try await withCheckedThrowingContinuation { continuation in + lock.withLockValue { + firmwareWaiters[descriptor.key, default: []].append(continuation) + } + Task { [weak self] in + await self?.reconcileOrStartFirmwareTask( + descriptor: descriptor, + url: url + ) + } + } + } + + private func reconcileOrStartFirmwareTask( + descriptor: FirmwareBackgroundTaskDescriptor, + url: URL + ) async { + let session = session(forChannel: .firmware, segmentCount: 1) + let tasks = await allTasks(in: session) + var matchingTaskFound = false + for task in tasks { + guard + let candidate = Self.decodeFirmwareTaskDescription( + task.taskDescription + ) + else { + continue + } + if candidate.hasSameArtifactIdentity(as: descriptor) { + matchingTaskFound = true + } else if candidate.taskId == descriptor.taskId { + task.cancel() + } + } + if matchingTaskFound { + return + } + do { + if let stored = try FirmwareArtifactStore.shared.storedArtifact( + expectedSize: descriptor.expectedSize, + expectedSha256: descriptor.expectedSha256 + ) { + finishFirmwareTask( + key: descriptor.key, + result: .success(stored) + ) + return + } + guard Date().timeIntervalSince1970 < descriptor.deadlineAt else { + throw FirmwareBackgroundDownloadError.deadlineExceeded + } + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = max( + 1, + descriptor.deadlineAt - Date().timeIntervalSince1970 + ) + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + let task = session.downloadTask(with: request) + task.taskDescription = try Self.encodeFirmwareTaskDescription( + descriptor + ) + task.resume() + } catch { + finishFirmwareTask( + key: descriptor.key, + result: .failure(error) + ) + } + } + + private func allTasks(in session: URLSession) async -> [URLSessionTask] { + await withCheckedContinuation { continuation in + session.getAllTasks { tasks in + continuation.resume(returning: tasks) + } + } + } + + private func finishFirmwareTask( + key: String, + result: Result + ) { + let continuations: [ + CheckedContinuation + ] = lock.withLockValue { + firmwareWaiters.removeValue(forKey: key) ?? [] + } + for continuation in continuations { + switch result { + case let .success(artifact): + continuation.resume(returning: artifact) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + // MARK: - Public entry /// Downloads [urlString] into [filePath] using concurrent background ranges. @@ -972,6 +1403,28 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + if let descriptor = Self.decodeFirmwareTaskDescription( + downloadTask.taskDescription + ) { + let exceedsBound = + totalBytesWritten > descriptor.expectedSize || + ( + totalBytesExpectedToWrite != NSURLSessionTransferSizeUnknown && + totalBytesExpectedToWrite != descriptor.expectedSize + ) + let expired = + Date().timeIntervalSince1970 >= descriptor.deadlineAt + if exceedsBound || expired { + recordFirmwareTaskError( + taskIdentifier: downloadTask.taskIdentifier, + error: exceedsBound + ? FirmwareBackgroundDownloadError.sizeRejected + : FirmwareBackgroundDownloadError.deadlineExceeded + ) + downloadTask.cancel() + } + return + } guard let desc = downloadTask.taskDescription, let (state, idx) = run(for: desc) else { return } lock.lock() @@ -1002,6 +1455,50 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + if let descriptor = Self.decodeFirmwareTaskDescription( + downloadTask.taskDescription + ) { + guard firmwareTaskError( + taskIdentifier: downloadTask.taskIdentifier + ) == nil else { + return + } + do { + guard + Date().timeIntervalSince1970 < descriptor.deadlineAt, + let response = downloadTask.response as? HTTPURLResponse, + response.statusCode == 200, + response.url?.scheme?.lowercased() == "https", + response.url?.host?.lowercased() == descriptor.hostname, + response.url?.port == nil || response.url?.port == 443 + else { + throw FirmwareBackgroundDownloadError.responseRejected + } + let artifact = try FirmwareArtifactStore.shared.acceptBackgroundDownload( + temporaryURL: location, + leaseRef: descriptor.leaseRef, + transactionId: descriptor.transactionId, + expectedSize: descriptor.expectedSize, + expectedSha256: descriptor.expectedSha256 + ) + lock.withLockValue { + firmwareTaskErrors.removeValue( + forKey: downloadTask.taskIdentifier + ) + completedFirmwareTasks.insert(downloadTask.taskIdentifier) + } + finishFirmwareTask( + key: descriptor.key, + result: .success(artifact) + ) + } catch { + recordFirmwareTaskError( + taskIdentifier: downloadTask.taskIdentifier, + error: error + ) + } + return + } guard let desc = downloadTask.taskDescription, let (state, idx) = run(for: desc) else { return } let ranges = lock.withLockValue { state.ranges } @@ -1100,6 +1597,32 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { } public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let descriptor = Self.decodeFirmwareTaskDescription( + task.taskDescription + ) { + let completion: (completed: Bool, error: Error?) = + lock.withLockValue { + let completed = completedFirmwareTasks.remove( + task.taskIdentifier + ) != nil + let recordedError = firmwareTaskErrors.removeValue( + forKey: task.taskIdentifier + ) + return (completed, recordedError) + } + if completion.completed { + return + } + finishFirmwareTask( + key: descriptor.key, + result: .failure( + completion.error ?? + error ?? + FirmwareBackgroundDownloadError.transferFailed + ) + ) + return + } guard let desc = task.taskDescription, let (state, idx) = run(for: desc) else { return } let ranges = lock.withLockValue { state.ranges } @@ -1275,6 +1798,26 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + if let descriptor = Self.decodeFirmwareTaskDescription( + task.taskDescription + ) { + guard + request.url?.scheme?.lowercased() == "https", + request.url?.host?.lowercased() == descriptor.hostname, + request.url?.port == nil || request.url?.port == 443, + request.url?.user == nil, + request.url?.password == nil + else { + recordFirmwareTaskError( + taskIdentifier: task.taskIdentifier, + error: FirmwareBackgroundDownloadError.redirectRejected + ) + completionHandler(nil) + return + } + completionHandler(request) + return + } if request.url?.scheme?.lowercased() != "https" { OneKeyLog.error("RangeDownloader", "blocked redirect to non-HTTPS URL") if let desc = task.taskDescription, let (state, _) = run(for: desc) { @@ -1286,6 +1829,25 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { } } + private func recordFirmwareTaskError( + taskIdentifier: Int, + error: Error + ) { + lock.withLockValue { + if firmwareTaskErrors[taskIdentifier] == nil { + firmwareTaskErrors[taskIdentifier] = error + } + } + } + + private func firmwareTaskError( + taskIdentifier: Int + ) -> Error? { + lock.withLockValue { + firmwareTaskErrors[taskIdentifier] + } + } + /// Called on the session delegate queue when all background events for this /// session have been delivered after a background relaunch. public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 813abd4b..dbead33d 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.80", + "version": "3.0.81", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,6 +88,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { + "@onekeyfe/react-native-sni-connect": "3.0.81", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts index ddc96d68..6e797271 100644 --- a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts +++ b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts @@ -4,7 +4,7 @@ import type { HybridObject } from 'react-native-nitro-modules'; // The channel decides the iOS background URLSession identifier suffix and the // event-routing key, so different consumers never cross-talk. Closed set per // design decision 10.1 (constant enum, not a bare string). -export type DownloadChannel = 'bundle' | 'apk' | 'chart'; +export type DownloadChannel = 'bundle' | 'apk' | 'chart' | 'firmware'; export interface RangeDownloadParams { channel: DownloadChannel; @@ -68,6 +68,101 @@ export interface RangeDownloadEvent { message: string; } +export interface FirmwareArtifactDownloadParams { + taskId: string; + transactionId: string; + leaseRef: string; + artifactId: string; + url: string; + routeType: string; + resolvedIp?: string; + expectedSize: number; + expectedSha256: string; + maxBytes: number; + overallDeadlineSeconds?: number; +} + +export interface FirmwareArtifactReceipt { + artifactRef: string; + size: number; + sha256: string; +} + +export interface FirmwareArtifactCapabilities { + firmwareArtifactProtocolVersion: number; + supportedRouteTypes: string[]; + supportsArchiveMaterialization: boolean; + maxReadBytes: number; +} + +export interface FirmwareArtifactRefParams { + artifactRef: string; +} + +export interface FirmwareArtifactReaderInfo { + readerId: string; + size: number; +} + +export interface FirmwareArtifactReaderReadParams { + readerId: string; + offset: number; + length: number; +} + +export interface FirmwareArtifactReaderCloseParams { + readerId: string; +} + +export interface FirmwareArchiveMaterializeParams { + leaseRef: string; + archiveArtifactRef: string; + expectedEntries: FirmwareArchiveExpectedEntry[]; +} + +export interface FirmwareArchiveExpectedEntry { + artifactId: string; + entryName: string; + expectedSize: number; + expectedSha256: string; +} + +export interface FirmwareArchiveMaterializedArtifact { + entryName: string; + receipt: FirmwareArtifactReceipt; +} + +export interface FirmwareArchiveMaterializeResult { + artifacts: FirmwareArchiveMaterializedArtifact[]; +} + +export interface FirmwareArtifactLeaseCreateParams { + transactionId: string; +} + +export interface FirmwareArtifactLease { + leaseRef: string; +} + +export interface FirmwareArtifactLeaseRetainParams { + leaseRef: string; + artifactRef: string; +} + +export interface FirmwareArtifactLeaseReleaseParams { + leaseRef: string; + disposition: string; +} + +export interface FirmwareArtifactLeaseReconcileParams { + activeLeaseRefs: string[]; +} + +export interface FirmwareArtifactSweepResult { + deletedFiles: number; + deletedBytes: number; +} + export interface ReactNativeRangeDownloader extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { // Main entry: concurrent ranged download (iOS background session / Android thread pool). @@ -102,4 +197,35 @@ export interface ReactNativeRangeDownloader // not own directory layout for real consumers, but exposes this so demos/simple // callers have a valid absolute destination without hardcoding sandbox paths. getDownloadsDir(): string; + + getFirmwareArtifactCapabilities(): FirmwareArtifactCapabilities; + downloadFirmwareArtifact( + params: FirmwareArtifactDownloadParams + ): Promise; + discardFirmwareArtifact(params: FirmwareArtifactRefParams): Promise; + openFirmwareArtifact( + params: FirmwareArtifactRefParams + ): Promise; + readFirmwareArtifact( + params: FirmwareArtifactReaderReadParams + ): Promise; + closeFirmwareArtifact( + params: FirmwareArtifactReaderCloseParams + ): Promise; + materializeFirmwareArchive( + params: FirmwareArchiveMaterializeParams + ): Promise; + createFirmwareArtifactLease( + params: FirmwareArtifactLeaseCreateParams + ): Promise; + retainFirmwareArtifact( + params: FirmwareArtifactLeaseRetainParams + ): Promise; + releaseFirmwareArtifactLease( + params: FirmwareArtifactLeaseReleaseParams + ): Promise; + reconcileFirmwareArtifactLeases( + params: FirmwareArtifactLeaseReconcileParams + ): Promise; + sweepFirmwareArtifactOrphans(): Promise; } diff --git a/native-modules/react-native-range-downloader/src/index.tsx b/native-modules/react-native-range-downloader/src/index.tsx index eb729725..ea3e6032 100644 --- a/native-modules/react-native-range-downloader/src/index.tsx +++ b/native-modules/react-native-range-downloader/src/index.tsx @@ -6,7 +6,8 @@ const ReactNativeRangeDownloaderHybridObject = 'ReactNativeRangeDownloader' ); -export const ReactNativeRangeDownloader = ReactNativeRangeDownloaderHybridObject; +export const ReactNativeRangeDownloader = + ReactNativeRangeDownloaderHybridObject; // Closed set of download channels (design decision 10.1). The native side builds the // background session identifier as `so.onekey.rangedownloader.bg.`. New channels @@ -15,6 +16,7 @@ export const RangeDownloadChannel = { Bundle: 'bundle', Apk: 'apk', Chart: 'chart', + Firmware: 'firmware', } as const; export type * from './ReactNativeRangeDownloader.nitro'; diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt index 265d61bb..0a0e887b 100644 --- a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt @@ -13,11 +13,9 @@ import okhttp3.Call import okhttp3.Callback import okhttp3.ConnectionPool import okhttp3.Dispatcher -import okhttp3.Dns import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient -import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response @@ -25,7 +23,6 @@ import okhttp3.ResponseBody import okio.Buffer import java.io.IOException import java.io.InterruptedIOException -import java.net.InetAddress import java.net.Proxy import java.net.ProxySelector import java.net.URI @@ -37,7 +34,6 @@ import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLException import javax.net.ssl.SSLPeerUnverifiedException @@ -405,24 +401,12 @@ class SniConnectModule(reactContext: ReactApplicationContext) : synchronized(clientCache) { clientCache[key]?.let { return it } - val client = OkHttpClient.Builder() - .dispatcher(sharedDispatcher) - .connectionPool(sharedConnectionPool) - .proxy(Proxy.NO_PROXY) - .protocols(listOf(Protocol.HTTP_1_1)) - .connectTimeout(0, TimeUnit.MILLISECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) - .writeTimeout(0, TimeUnit.MILLISECONDS) - .callTimeout(0, TimeUnit.MILLISECONDS) - .followRedirects(false) - .followSslRedirects(false) - // TLS is validated normally: cert chain via the default trust manager and - // hostname verification against the REAL hostname (not the pinned IP). - .hostnameVerifier { _, session -> - HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session) - } - .dns(createPinnedDns(config.ip, config.hostname)) - .build() + val client = SniPinnedTransport.createClient( + ip = config.ip, + hostname = config.hostname, + dispatcher = sharedDispatcher, + connectionPool = sharedConnectionPool, + ) clientCache[key] = client SniConnectLogger.info( @@ -442,29 +426,6 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } } - private fun createPinnedDns(ip: String, hostname: String): Dns = - object : Dns { - private val expectedHost = hostname.lowercase(Locale.US) - // Resolve the literal IP once up front (validated; never triggers DNS). - private val pinnedAddress: InetAddress = SniConnectValidation.literalToInetAddress(ip) - - override fun lookup(requestedHost: String): List { - return if (requestedHost.lowercase(Locale.US) == expectedHost) { - listOf(pinnedAddress) - } else { - SniConnectLogger.warn( - SniConnectLogger.event( - "sni_pinned_dns_unexpected_host", - "expectedHost" to expectedHost, - "requestedHostHash" to SniConnectLogger.shortHash(requestedHost.lowercase(Locale.US)), - "result" to "fail_closed", - ), - ) - throw UnknownHostException("Unexpected host for pinned SNI request: $requestedHost") - } - } - } - /** * Build the request. Always `https://` on the implicit port 443 — * `path` has been validated as relative, so scheme/host/port cannot be overridden. diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt new file mode 100644 index 00000000..0491d5fd --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniPinnedTransport.kt @@ -0,0 +1,58 @@ +package com.sniconnect + +import java.net.InetAddress +import java.net.Proxy +import java.net.UnknownHostException +import java.util.Locale +import java.util.concurrent.TimeUnit +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Protocol +import javax.net.ssl.HttpsURLConnection + +object SniPinnedTransport { + @JvmStatic + fun createClient( + ip: String, + hostname: String, + dispatcher: Dispatcher = Dispatcher(), + connectionPool: ConnectionPool = ConnectionPool(), + ): OkHttpClient { + SniConnectValidation.validatePublicIp(ip) + SniConnectValidation.validateHostname(hostname) + val normalizedHostname = hostname.lowercase(Locale.US) + return OkHttpClient.Builder() + .dispatcher(dispatcher) + .connectionPool(connectionPool) + .proxy(Proxy.NO_PROXY) + .protocols(listOf(Protocol.HTTP_1_1)) + .connectTimeout(0, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) + .followRedirects(false) + .followSslRedirects(false) + .hostnameVerifier { _, session -> + HttpsURLConnection.getDefaultHostnameVerifier().verify(normalizedHostname, session) + } + .dns(createPinnedDns(ip, normalizedHostname)) + .build() + } + + private fun createPinnedDns(ip: String, hostname: String): Dns = + object : Dns { + private val pinnedAddress: InetAddress = + SniConnectValidation.literalToInetAddress(ip) + + override fun lookup(requestedHost: String): List { + if (requestedHost.lowercase(Locale.US) == hostname) { + return listOf(pinnedAddress) + } + throw UnknownHostException( + "Unexpected host for pinned SNI request: $requestedHost" + ) + } + } +} diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index f660eed0..db798ede 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -4,13 +4,13 @@ import UIKit import EMASCurl @objc(SniConnectPinnedDNSResolverBase) -private class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver { +class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver { @objc class func resolveDomain(_ domain: String) -> String? { PinnedDNSResolverFactory.resolve(domain: domain, resolverClass: self) } } -private enum PinnedDNSResolverFactory { +enum PinnedDNSResolverFactory { private static let queue = DispatchQueue(label: "com.onekey.sni.connect.pinned-dns-resolvers") private static var nextClassID = 0 private static let registry = SniConnectPinnedResolverRegistry() @@ -54,7 +54,7 @@ private enum PinnedDNSResolverFactory { } } -private final class SniConnectPinnedResolverLease { +final class SniConnectPinnedResolverLease { private let hostname: String private let ip: String private let queue = DispatchQueue(label: "com.onekey.sni.connect.resolver-lease") @@ -90,7 +90,7 @@ private final class SniConnectPinnedResolverLease { } } -private final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDelegate { +final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDelegate { private let hostname: String private let ip: String private let resolverLease: SniConnectPinnedResolverLease @@ -334,33 +334,10 @@ final class SniConnectClient { } private static func makeURLSession(for key: SessionKey) throws -> ManagedSession { - let configuration = URLSessionConfiguration.default - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.urlCache = nil - configuration.httpCookieStorage = nil - configuration.httpShouldSetCookies = false - configuration.connectionProxyDictionary = [:] - configuration.shouldUseExtendedBackgroundIdleMode = false - - let curlConfig = EMASCurlConfiguration.default() - curlConfig.httpVersion = .HTTP1 - curlConfig.connectTimeoutInterval = 2.5 - curlConfig.enableBuiltInGzip = false - curlConfig.enableBuiltInRedirection = false - curlConfig.cacheEnabled = false - - // Enable full certificate validation for security. - // The certificate is validated against the SNI hostname, not the IP, because - // the custom DNS resolver only overrides address resolution — libcurl keeps the - // original hostname for SNI and certificate CN/SAN matching. - curlConfig.certificateValidationEnabled = true - curlConfig.domainNameVerificationEnabled = true - curlConfig.dnsResolver = try PinnedDNSResolverFactory.resolverClass( + let resources = try SniConnectPinnedTransport.makeResources( hostname: key.hostname, ip: key.ip ) - - EMASCurlProtocol.install(into: configuration, with: curlConfig) SniConnectLog.info(SniConnectLog.event("sni_transport_config", [ ("hostname", key.hostname), ("ipHash", SniConnectLog.shortHash(key.ip)), @@ -371,15 +348,13 @@ final class SniConnectClient { ("followRedirects", false), ("cacheEnabled", false), ])) - let resolverLease = SniConnectPinnedResolverLease(hostname: key.hostname, ip: key.ip) - let delegate = SniConnectSessionInvalidationDelegate( - hostname: key.hostname, - ip: key.ip, - resolverLease: resolverLease - ) return ManagedSession( - session: URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil), - resolverLease: resolverLease + session: URLSession( + configuration: resources.configuration, + delegate: resources.delegate, + delegateQueue: nil + ), + resolverLease: resources.resolverLease ) } diff --git a/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift b/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift new file mode 100644 index 00000000..3769d3d0 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift @@ -0,0 +1,85 @@ +import Foundation +import EMASCurl + +struct SniConnectPinnedTransportResources { + let configuration: URLSessionConfiguration + let resolverLease: SniConnectPinnedResolverLease + let delegate: SniConnectSessionInvalidationDelegate +} + +public final class SniConnectPinnedSession { + public let session: URLSession + + init(resources: SniConnectPinnedTransportResources) { + session = URLSession( + configuration: resources.configuration, + delegate: resources.delegate, + delegateQueue: nil + ) + } + + public func close() { + session.finishTasksAndInvalidate() + } + + deinit { + close() + } +} + +public enum SniConnectPinnedTransport { + static func makeResources( + hostname: String, + ip: String + ) throws -> SniConnectPinnedTransportResources { + try SniConnectValidation.validateHostname(hostname) + try SniConnectValidation.validatePublicIP(ip) + + let normalizedHostname = hostname.lowercased() + let configuration = URLSessionConfiguration.default + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.connectionProxyDictionary = [:] + configuration.shouldUseExtendedBackgroundIdleMode = false + + let curlConfig = EMASCurlConfiguration.default() + curlConfig.httpVersion = .HTTP1 + curlConfig.connectTimeoutInterval = 2.5 + curlConfig.enableBuiltInGzip = false + curlConfig.enableBuiltInRedirection = false + curlConfig.cacheEnabled = false + curlConfig.certificateValidationEnabled = true + curlConfig.domainNameVerificationEnabled = true + curlConfig.dnsResolver = try PinnedDNSResolverFactory.resolverClass( + hostname: normalizedHostname, + ip: ip + ) + EMASCurlProtocol.install(into: configuration, with: curlConfig) + + let resolverLease = SniConnectPinnedResolverLease( + hostname: normalizedHostname, + ip: ip + ) + let delegate = SniConnectSessionInvalidationDelegate( + hostname: normalizedHostname, + ip: ip, + resolverLease: resolverLease + ) + return SniConnectPinnedTransportResources( + configuration: configuration, + resolverLease: resolverLease, + delegate: delegate + ) + } + + public static func makeSession( + hostname: String, + ip: String + ) throws -> SniConnectPinnedSession { + SniConnectPinnedSession( + resources: try makeResources(hostname: hostname, ip: ip) + ) + } +} diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 72f9af30..d73e714d 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.80", + "version": "3.0.81", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/yarn.lock b/yarn.lock index b15ec080..3809e188 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,6 +3688,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: + "@onekeyfe/react-native-sni-connect": 3.0.81 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From 3aaea3887cc8e9260aaadcbfb82a5080784c920e Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 27 Jul 2026 11:45:38 +0800 Subject: [PATCH 02/27] feat: cancel firmware artifact downloads --- .../FirmwareArtifactStore.kt | 94 ++++++++++-- .../ReactNativeRangeDownloader.kt | 8 + .../ios/FirmwareArtifactStore.swift | 142 ++++++++++++++++-- .../ios/ReactNativeRangeDownloader.swift | 70 +++++++++ .../src/ReactNativeRangeDownloader.nitro.ts | 7 + 5 files changed, 291 insertions(+), 30 deletions(-) diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt index 5b10b6bc..2c3497e3 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt @@ -14,6 +14,7 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream +import okhttp3.Call import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request @@ -41,6 +42,11 @@ private data class StagedFirmwareArchiveEntry( val file: File, ) +private data class FirmwareDownloadKey( + val transactionId: String, + val expectedSha256: String, +) + internal object FirmwareArtifactStore { const val MAX_READ_BYTES = 256 * 1024 @@ -53,7 +59,11 @@ internal object FirmwareArtifactStore { private val artifactRefPattern = Regex("^fw:[a-f0-9]{64}$") private val leaseRefPattern = Regex("^fwlease:[a-f0-9-]{36}$") private val identifierPattern = Regex("^[A-Za-z0-9._:-]{1,160}$") - private val downloadLocks = ConcurrentHashMap() + private val downloadLocks = ConcurrentHashMap() + private val activeCalls = + ConcurrentHashMap>() + private val cancelledTransactions = + ConcurrentHashMap.newKeySet() private val activeDownloadLock = Any() private val activeDownloadCounts = mutableMapOf() private val leaseLock = Any() @@ -84,15 +94,25 @@ internal object FirmwareArtifactStore { fun download(params: FirmwareArtifactDownloadParams): StoredFirmwareArtifact { val validated = validateDownloadParams(params) + check(!cancelledTransactions.contains(params.transactionId)) { + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + } retainExpectedArtifact( leaseRef = params.leaseRef, transactionId = params.transactionId, artifactRef = "fw:${validated.expectedSha256}", ) - val lock = downloadLocks.computeIfAbsent(validated.expectedSha256) { Any() } + val lockKey = FirmwareDownloadKey( + params.transactionId, + validated.expectedSha256, + ) + val lock = downloadLocks.computeIfAbsent(lockKey) { Any() } markDownloadActive(validated.expectedSha256, 1) try { return synchronized(lock) { + check(!cancelledTransactions.contains(params.transactionId)) { + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + } downloadLocked(params, validated) } } finally { @@ -100,6 +120,16 @@ internal object FirmwareArtifactStore { } } + fun cancelDownloads(transactionId: String) { + require(identifierPattern.matches(transactionId)) { + "Invalid firmware transactionId" + } + cancelledTransactions.add(transactionId) + activeCalls[transactionId] + ?.toList() + ?.forEach { it.cancel() } + } + fun discard(artifactRef: String) { val file = resolveArtifactFile(artifactRef) synchronized(leaseLock) { @@ -377,22 +407,36 @@ internal object FirmwareArtifactStore { call.timeout().timeout(deadline.toLong().coerceAtLeast(1), TimeUnit.SECONDS) } - val response = try { - call.execute() + registerCall(params.transactionId, call) + try { + call.execute().use { + writeResponseToPartial( + it, + partialFile, + resumeOffset, + validated.expectedSize, + validated.maxBytes, + ) + } } catch (error: IOException) { + if ( + call.isCanceled() || + cancelledTransactions.contains(params.transactionId) + ) { + throw IllegalStateException( + "ARTIFACT_CANCELLED: firmware artifact download was cancelled", + error, + ) + } throw IllegalStateException( "ARTIFACT_NETWORK_FAILED: firmware request failed", error, ) + } finally { + unregisterCall(params.transactionId, call) } - response.use { - writeResponseToPartial( - it, - partialFile, - resumeOffset, - validated.expectedSize, - validated.maxBytes, - ) + check(!cancelledTransactions.contains(params.transactionId)) { + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" } val artifact = try { @@ -547,13 +591,17 @@ internal object FirmwareArtifactStore { ) { "Invalid firmware lease disposition" } - synchronized(leaseLock) { + val transactionId = synchronized(leaseLock) { val leases = loadLeasesLocked() - require(leases.remove(validateLeaseRef(leaseRef)) != null) { + val removed = leases.remove(validateLeaseRef(leaseRef)) + require(removed != null) { "Firmware artifact lease is unavailable" } saveLeasesLocked(leases) + removed.transactionId } + cancelledTransactions.remove(transactionId) + downloadLocks.keys.removeIf { it.transactionId == transactionId } } fun reconcileLeases(activeLeaseRefs: Array) { @@ -773,4 +821,22 @@ internal object FirmwareArtifactStore { private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun registerCall(transactionId: String, call: Call) { + val calls = activeCalls.computeIfAbsent(transactionId) { + ConcurrentHashMap.newKeySet() + } + calls.add(call) + if (cancelledTransactions.contains(transactionId)) { + call.cancel() + } + } + + private fun unregisterCall(transactionId: String, call: Call) { + val calls = activeCalls[transactionId] ?: return + calls.remove(call) + if (calls.isEmpty()) { + activeCalls.remove(transactionId, calls) + } + } } diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt index 57d029e2..07d91fbf 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt @@ -288,6 +288,14 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { } } + override fun cancelFirmwareArtifactDownloads( + params: FirmwareArtifactCancelParams, + ): Promise { + return Promise.async { + FirmwareArtifactStore.cancelDownloads(params.transactionId) + } + } + override fun discardFirmwareArtifact( params: FirmwareArtifactRefParams, ): Promise { diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index 72535664..3b912f25 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -42,22 +42,38 @@ private final class FirmwareArtifactRedirectDelegate: NSObject, URLSessionTaskDe } private actor FirmwareArtifactDownloadCoordinator { - private var tasks: [String: Task] = [:] + private struct ActiveDownload { + let transactionId: String + let task: Task + } + + private var downloads: [String: ActiveDownload] = [:] func run( key: String, + transactionId: String, operation: @escaping () async throws -> StoredFirmwareArtifact ) async throws -> StoredFirmwareArtifact { - if let task = tasks[key] { - return try await task.value + if let download = downloads[key] { + return try await download.task.value } let task = Task { try await operation() } - tasks[key] = task - defer { tasks[key] = nil } + downloads[key] = ActiveDownload( + transactionId: transactionId, + task: task + ) + defer { downloads[key] = nil } return try await task.value } + + func cancel(transactionId: String) { + for download in downloads.values + where download.transactionId == transactionId { + download.task.cancel() + } + } } final class FirmwareArtifactStore { @@ -89,6 +105,8 @@ final class FirmwareArtifactStore { private let leaseLock = NSLock() private let activeDownloadLock = NSLock() private var activeDownloadCounts: [String: Int] = [:] + private let cancellationLock = NSLock() + private var cancelledTransactions: Set = [] private let readerLock = NSLock() private var readers: [String: OpenReader] = [:] @@ -163,26 +181,56 @@ final class FirmwareArtifactStore { func download(_ params: FirmwareArtifactDownloadParams) async throws -> StoredFirmwareArtifact { try Self.validateDownloadParams(params) + try rejectIfCancelled(transactionId: params.transactionId) let expectedSha256 = params.expectedSha256.lowercased() try retainExpectedArtifact( leaseRef: params.leaseRef, transactionId: params.transactionId, artifactRef: "fw:\(expectedSha256)" ) - let key = "\(expectedSha256):\(Int64(params.expectedSize))" + let key = + "\(params.transactionId):\(expectedSha256):\(Int64(params.expectedSize))" markDownloadActive(expectedSha256, delta: 1) do { - let artifact = try await downloadCoordinator.run(key: key) { [self] in - try await downloadLocked(params, expectedSha256: expectedSha256) + let artifact = try await downloadCoordinator.run( + key: key, + transactionId: params.transactionId + ) { [self] in + try rejectIfCancelled(transactionId: params.transactionId) + return try await downloadLocked( + params, + expectedSha256: expectedSha256 + ) } markDownloadActive(expectedSha256, delta: -1) return artifact } catch { markDownloadActive(expectedSha256, delta: -1) + if error is CancellationError || + isTransactionCancelled(params.transactionId) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + } throw error } } + func cancelDownloads(transactionId: String) async throws { + guard Self.isSafeIdentifier(transactionId) else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware transactionId" + ) + } + cancellationLock.withFirmwareArtifactLock { + cancelledTransactions.insert(transactionId) + } + await downloadCoordinator.cancel(transactionId: transactionId) + try await RangeDownloader.shared.cancelFirmwareArtifactDownloads( + transactionId: transactionId + ) + } + private func downloadLocked( _ params: FirmwareArtifactDownloadParams, expectedSha256: String @@ -552,15 +600,25 @@ final class FirmwareArtifactStore { "Invalid firmware lease disposition" ) } - leaseLock.lock() - defer { leaseLock.unlock() } - var envelope = try loadLeasesLocked() - guard envelope.leases.removeValue(forKey: try validateLeaseRef(leaseRef)) != nil else { - throw FirmwareArtifactStoreError.invalidInput( - "Firmware artifact lease is unavailable" - ) + let transactionId: String = try { + leaseLock.lock() + defer { leaseLock.unlock() } + var envelope = try loadLeasesLocked() + guard + let lease = envelope.leases.removeValue( + forKey: try validateLeaseRef(leaseRef) + ) + else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease is unavailable" + ) + } + try saveLeasesLocked(envelope) + return lease.transactionId + }() + cancellationLock.withFirmwareArtifactLock { + cancelledTransactions.remove(transactionId) } - try saveLeasesLocked(envelope) } func reconcileLeases(activeLeaseRefs: [String]) throws { @@ -984,6 +1042,14 @@ final class FirmwareArtifactStore { do { (bytes, response) = try await session.bytes(for: request) } catch { + if isCancellationError( + error, + transactionId: params.transactionId + ) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + } throw FirmwareArtifactStoreError.downloadFailed( "ARTIFACT_NETWORK_FAILED: firmware request failed" ) @@ -1039,6 +1105,14 @@ final class FirmwareArtifactStore { } catch let error as FirmwareArtifactStoreError { throw error } catch { + if isCancellationError( + error, + transactionId: params.transactionId + ) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + } throw FirmwareArtifactStoreError.downloadFailed( "ARTIFACT_NETWORK_FAILED: firmware response stream failed" ) @@ -1053,6 +1127,34 @@ final class FirmwareArtifactStore { try handle.write(contentsOf: buffer) } try handle.synchronize() + try rejectIfCancelled(transactionId: params.transactionId) + } + + private func rejectIfCancelled(transactionId: String) throws { + if isTransactionCancelled(transactionId) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + } + } + + func isTransactionCancelled(_ transactionId: String) -> Bool { + cancellationLock.withFirmwareArtifactLock { + cancelledTransactions.contains(transactionId) + } + } + + private func isCancellationError( + _ error: Error, + transactionId: String + ) -> Bool { + if error is CancellationError || + isTransactionCancelled(transactionId) { + return true + } + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && + nsError.code == NSURLErrorCancelled } private func validateContentRange( @@ -1164,3 +1266,11 @@ final class FirmwareArtifactStore { } } } + +private extension NSLock { + func withFirmwareArtifactLock(_ body: () -> T) -> T { + lock() + defer { unlock() } + return body() + } +} diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index 8a32e8b7..7b5289b8 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -118,6 +118,16 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { } } + func cancelFirmwareArtifactDownloads( + params: FirmwareArtifactCancelParams + ) throws -> Promise { + Promise.async { + try await FirmwareArtifactStore.shared.cancelDownloads( + transactionId: params.transactionId + ) + } + } + func discardFirmwareArtifact( params: FirmwareArtifactRefParams ) throws -> Promise { @@ -340,6 +350,7 @@ private struct FirmwareBackgroundTaskDescriptor: Codable, Equatable { } private enum FirmwareBackgroundDownloadError: LocalizedError { + case cancelled case invalidTask case deadlineExceeded case redirectRejected @@ -349,6 +360,8 @@ private enum FirmwareBackgroundDownloadError: LocalizedError { var errorDescription: String? { switch self { + case .cancelled: + return "ARTIFACT_CANCELLED: firmware background download was cancelled" case .invalidTask: return "ARTIFACT_PROTOCOL_INVALID: firmware background task is invalid" case .deadlineExceeded: @@ -763,6 +776,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { hostname: hostname, deadlineAt: Date().timeIntervalSince1970 + deadlineSeconds ) + guard !FirmwareArtifactStore.shared.isTransactionCancelled( + descriptor.transactionId + ) else { + throw FirmwareBackgroundDownloadError.cancelled + } if let stored = try FirmwareArtifactStore.shared.storedArtifact( expectedSize: descriptor.expectedSize, expectedSha256: descriptor.expectedSha256 @@ -783,10 +801,47 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { } } + func cancelFirmwareArtifactDownloads( + transactionId: String + ) async throws { + guard transactionId.range( + of: "^[A-Za-z0-9._:-]{1,160}$", + options: .regularExpression + ) != nil else { + throw FirmwareBackgroundDownloadError.invalidTask + } + let session = session(forChannel: .firmware, segmentCount: 1) + let tasks = await allTasks(in: session) + for task in tasks { + guard + let descriptor = Self.decodeFirmwareTaskDescription( + task.taskDescription + ), + descriptor.transactionId == transactionId + else { + continue + } + recordFirmwareTaskError( + taskIdentifier: task.taskIdentifier, + error: FirmwareBackgroundDownloadError.cancelled + ) + task.cancel() + } + } + private func reconcileOrStartFirmwareTask( descriptor: FirmwareBackgroundTaskDescriptor, url: URL ) async { + guard !FirmwareArtifactStore.shared.isTransactionCancelled( + descriptor.transactionId + ) else { + finishFirmwareTask( + key: descriptor.key, + result: .failure(FirmwareBackgroundDownloadError.cancelled) + ) + return + } let session = session(forChannel: .firmware, segmentCount: 1) let tasks = await allTasks(in: session) var matchingTaskFound = false @@ -821,6 +876,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { guard Date().timeIntervalSince1970 < descriptor.deadlineAt else { throw FirmwareBackgroundDownloadError.deadlineExceeded } + guard !FirmwareArtifactStore.shared.isTransactionCancelled( + descriptor.transactionId + ) else { + throw FirmwareBackgroundDownloadError.cancelled + } var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalCacheData request.timeoutInterval = max( @@ -1465,6 +1525,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { } do { guard + !FirmwareArtifactStore.shared.isTransactionCancelled( + descriptor.transactionId + ), Date().timeIntervalSince1970 < descriptor.deadlineAt, let response = downloadTask.response as? HTTPURLResponse, response.statusCode == 200, @@ -1617,6 +1680,13 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { key: descriptor.key, result: .failure( completion.error ?? + ( + FirmwareArtifactStore.shared.isTransactionCancelled( + descriptor.transactionId + ) + ? FirmwareBackgroundDownloadError.cancelled + : nil + ) ?? error ?? FirmwareBackgroundDownloadError.transferFailed ) diff --git a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts index 6e797271..b11ee442 100644 --- a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts +++ b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts @@ -99,6 +99,10 @@ export interface FirmwareArtifactRefParams { artifactRef: string; } +export interface FirmwareArtifactCancelParams { + transactionId: string; +} + export interface FirmwareArtifactReaderInfo { readerId: string; size: number; @@ -202,6 +206,9 @@ export interface ReactNativeRangeDownloader downloadFirmwareArtifact( params: FirmwareArtifactDownloadParams ): Promise; + cancelFirmwareArtifactDownloads( + params: FirmwareArtifactCancelParams + ): Promise; discardFirmwareArtifact(params: FirmwareArtifactRefParams): Promise; openFirmwareArtifact( params: FirmwareArtifactRefParams From d7c7aa95ead85daf5b0688169aa9a21e2fe6ddc1 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 27 Jul 2026 15:20:11 +0800 Subject: [PATCH 03/27] chore: prepare firmware module alpha packages --- native-modules/react-native-range-downloader/package.json | 2 +- native-modules/react-native-sni-connect/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index dbead33d..c6b0ad2b 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81", + "version": "3.0.81-alpha.0", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index d73e714d..012e1eb5 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81", + "version": "3.0.81-alpha.0", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", From 59259d935960e355fb9c921d5ff802dbc55bbfd6 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 27 Jul 2026 15:57:34 +0800 Subject: [PATCH 04/27] chore: align app module alpha versions --- native-modules/native-logger/package.json | 5 +++-- native-modules/react-native-aes-crypto/package.json | 5 +++-- native-modules/react-native-app-update/package.json | 5 +++-- native-modules/react-native-async-storage/package.json | 5 +++-- native-modules/react-native-background-thread/package.json | 5 +++-- native-modules/react-native-bundle-crypto/package.json | 5 +++-- native-modules/react-native-bundle-update/package.json | 5 +++-- .../react-native-check-biometric-auth-changed/package.json | 5 +++-- native-modules/react-native-cloud-fs/package.json | 5 +++-- native-modules/react-native-cloud-kit-module/package.json | 5 +++-- native-modules/react-native-device-utils/package.json | 5 +++-- native-modules/react-native-dns-lookup/package.json | 5 +++-- native-modules/react-native-get-random-values/package.json | 5 +++-- native-modules/react-native-keychain-module/package.json | 5 +++-- native-modules/react-native-lite-card/package.json | 5 +++-- native-modules/react-native-network-info/package.json | 5 +++-- native-modules/react-native-network-throttle/package.json | 5 +++-- native-modules/react-native-pbkdf2/package.json | 5 +++-- native-modules/react-native-perf-memory/package.json | 5 +++-- native-modules/react-native-perf-stats/package.json | 5 +++-- native-modules/react-native-ping/package.json | 5 +++-- native-modules/react-native-range-downloader/package.json | 3 ++- native-modules/react-native-sni-connect/package.json | 3 ++- native-modules/react-native-splash-screen/package.json | 5 +++-- native-modules/react-native-split-bundle-loader/package.json | 5 +++-- native-modules/react-native-tcp-socket/package.json | 5 +++-- native-modules/react-native-zip-archive/package.json | 5 +++-- native-views/react-native-auto-size-input/package.json | 5 +++-- native-views/react-native-chart-webview/package.json | 5 +++-- native-views/react-native-pager-view/package.json | 5 +++-- native-views/react-native-perp-depth-bar/package.json | 5 +++-- native-views/react-native-scroll-guard/package.json | 5 +++-- native-views/react-native-segment-slider/package.json | 5 +++-- native-views/react-native-skeleton/package.json | 5 +++-- native-views/react-native-tab-view/package.json | 5 +++-- 35 files changed, 103 insertions(+), 68 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 250a0841..7f07ad13 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index a3be31b4..6bf1f022 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -158,5 +158,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 91d1482e..b5be1007 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index f22c9fba..5ee942ba 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -164,5 +164,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index e058e9a3..19f611b7 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -167,5 +167,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 0decf256..482d6984 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.80", + "version": "3.0.81-alpha.0", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -165,5 +165,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index bde1bac5..1bff7b6a 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } 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 694d79cd..9941ed34 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 0a54eed0..69763a80 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -89,5 +89,6 @@ "RNCloudFs": "CloudFs" } } - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 13a83cc3..cd93da37 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index 72b307e7..ea2fec50 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index e2f5d444..42b341c7 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -164,5 +164,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index 1fed74f7..89c75274 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index 6c192c5a..9444d7db 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index af147ac7..89b8e678 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.80", + "version": "3.0.81-alpha.0", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -168,5 +168,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index 575d3ea9..349b99e7 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -164,5 +164,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index 29133d4d..f1953f14 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.80", + "version": "3.0.81-alpha.0", "description": "react-native-network-throttle", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -142,5 +142,6 @@ } } } - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index bb70d030..ef37feeb 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -158,5 +158,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index 269ede92..f9974ec8 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index 89ccf862..68cbfcb9 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -165,5 +165,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index 4402fa8d..986c781f 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -89,5 +89,6 @@ "RNReactNativePing": "Ping" } } - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index c6b0ad2b..d7142446 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 012e1eb5..3c009ca8 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -161,5 +161,6 @@ "languages": "kotlin-objc", "type": "turbo-module", "version": "0.54.8" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index bc5c76cb..d63f00ec 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index 4a6a02d7..114f3db9 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -165,5 +165,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index f391d7a5..679519aa 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -159,5 +159,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 959bd8ed..48ec8aa1 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -94,5 +94,6 @@ } ] ] - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 025a040f..79d10885 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.80", + "version": "3.0.81-alpha.0", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -167,5 +167,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index dd78bf9c..8219238e 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.80", + "version": "3.0.81-alpha.0", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 205b0a27..f19a76c3 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.80", + "version": "3.0.81-alpha.0", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -98,5 +98,6 @@ "RNCViewPager": "RNCPagerViewComponentView" } } - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index a42f3488..a9d70055 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.80", + "version": "3.0.81-alpha.0", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index e47def84..8f2e9af3 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.80", + "version": "3.0.81-alpha.0", "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", @@ -103,5 +103,6 @@ "create-react-native-library": { "type": "nitro-view", "languages": "kotlin-swift" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index aacfab4b..7e35e2b9 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.80", + "version": "3.0.81-alpha.0", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -166,5 +166,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 8cdfa451..2d7c13db 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.80", + "version": "3.0.81-alpha.0", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -167,5 +167,6 @@ "release-it" ], "version": "0.56.0" - } + }, + "stableVersion": "3.0.80" } diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index 0ad46162..573e09ed 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.80", + "version": "3.0.81-alpha.0", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -111,5 +111,6 @@ ] } } - } + }, + "stableVersion": "3.0.80" } From 02a594cd56a90e9bb93a48a9ee23ffcb07cc13ec Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 27 Jul 2026 19:27:10 +0800 Subject: [PATCH 05/27] fix: pass npm dist-tag to package publish --- .github/workflows/package-publish.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index 15c18360..900aa195 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -1,6 +1,16 @@ name: package-publish -on: workflow_dispatch +on: + workflow_dispatch: + inputs: + npm_dist_tag: + description: npm dist-tag + required: true + default: next + type: choice + options: + - next + - latest jobs: package-publish: @@ -9,11 +19,11 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: '24.x' - registry-url: 'https://registry.npmjs.org' + node-version: "24.x" + registry-url: "https://registry.npmjs.org" - name: Install Package run: corepack enable && yarn install - - name: + - name: Publish packages env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: yarn version:publish + run: yarn version:publish --tag "${{ inputs.npm_dist_tag }}" From e119ca6742c697cf19247219ea4ae53479de5788 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 27 Jul 2026 19:44:37 +0800 Subject: [PATCH 06/27] fix: align prerelease app module dependencies --- 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 | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- 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-network-throttle/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-range-downloader/package.json | 4 ++-- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- 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-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 2 +- 36 files changed, 37 insertions(+), 37 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 7f07ad13..96301dc7 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 6bf1f022..9566609c 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 b5be1007..4b6b8cb5 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 5ee942ba..4a67c41a 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 19f611b7..e01caf9a 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.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 482d6984..10684b7a 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 1bff7b6a..1fe9aacc 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 9941ed34..5149adf9 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 69763a80..bf3b5d07 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 cd93da37..764d0df8 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 ea2fec50..e4cd210a 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 42b341c7..1bb518f7 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 89c75274..8cf20861 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 9444d7db..309c515b 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 89b8e678..a246e25a 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 349b99e7..5736a0b5 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.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index f1953f14..f79cdf1d 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-network-throttle", "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 ef37feeb..9b39b5b6 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 f9974ec8..8894b1f5 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 68cbfcb9..fdd34767 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 986c781f..2718f0b6 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index d7142446..191a54eb 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,7 +88,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-sni-connect": "3.0.81", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.1", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 3c009ca8..3413f104 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "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 d63f00ec..1652f4e2 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 114f3db9..e4b8494d 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.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 679519aa..5b71d341 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 48ec8aa1..403a91af 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.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-zip-archive Nitro HybridObject 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 79d10885..e4b344fa 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 8219238e..cb29b66e 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-chart-webview", "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 f19a76c3..b7f352fc 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index a9d70055..5dcaaac8 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 8f2e9af3..ba72643f 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 7e35e2b9..dccee707 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.81-alpha.0", + "version": "3.0.81-alpha.1", "description": "react-native-segment-slider", "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 2d7c13db..8fa260a8 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 573e09ed..8a1b91d8 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.81-alpha.0", + "version": "3.0.81-alpha.1", "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 3809e188..4a14a9e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,7 +3688,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-sni-connect": 3.0.81 + "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.1 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From 4e90e722e223cde4a71079085c1cc3bc69f5cdae Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 28 Jul 2026 10:54:21 +0800 Subject: [PATCH 07/27] fix: harden firmware artifact concurrency --- .../FirmwareArtifactStore.kt | 38 +++++++++++++---- .../eslint.config.mjs | 30 ++++++++++++-- .../ios/FirmwareArtifactStore.swift | 41 ++++++++++++++++++- .../ios/ReactNativeRangeDownloader.swift | 19 +++++---- .../eslint.config.mjs | 27 ++++++++++++ 5 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 native-modules/react-native-sni-connect/eslint.config.mjs diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt index 2c3497e3..b5ffce31 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt @@ -14,6 +14,7 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream +import javax.net.ssl.SSLException import okhttp3.Call import okhttp3.OkHttpClient import okhttp3.Protocol @@ -43,10 +44,14 @@ private data class StagedFirmwareArchiveEntry( ) private data class FirmwareDownloadKey( - val transactionId: String, val expectedSha256: String, ) +private class FirmwareDownloadLock { + val monitor = Any() + var references = 0 +} + internal object FirmwareArtifactStore { const val MAX_READ_BYTES = 256 * 1024 @@ -59,7 +64,8 @@ internal object FirmwareArtifactStore { private val artifactRefPattern = Regex("^fw:[a-f0-9]{64}$") private val leaseRefPattern = Regex("^fwlease:[a-f0-9-]{36}$") private val identifierPattern = Regex("^[A-Za-z0-9._:-]{1,160}$") - private val downloadLocks = ConcurrentHashMap() + private val downloadLocks = + ConcurrentHashMap() private val activeCalls = ConcurrentHashMap>() private val cancelledTransactions = @@ -102,14 +108,15 @@ internal object FirmwareArtifactStore { transactionId = params.transactionId, artifactRef = "fw:${validated.expectedSha256}", ) - val lockKey = FirmwareDownloadKey( - params.transactionId, - validated.expectedSha256, - ) - val lock = downloadLocks.computeIfAbsent(lockKey) { Any() } + val lockKey = FirmwareDownloadKey(validated.expectedSha256) + val downloadLock = downloadLocks.compute(lockKey) { _, current -> + (current ?: FirmwareDownloadLock()).also { + it.references += 1 + } + } ?: error("Firmware artifact lock is unavailable") markDownloadActive(validated.expectedSha256, 1) try { - return synchronized(lock) { + return synchronized(downloadLock.monitor) { check(!cancelledTransactions.contains(params.transactionId)) { "ARTIFACT_CANCELLED: firmware artifact download was cancelled" } @@ -117,6 +124,14 @@ internal object FirmwareArtifactStore { } } finally { markDownloadActive(validated.expectedSha256, -1) + downloadLocks.compute(lockKey) { _, current -> + if (current !== downloadLock) { + current + } else { + current.references -= 1 + current.takeIf { it.references > 0 } + } + } } } @@ -428,6 +443,12 @@ internal object FirmwareArtifactStore { error, ) } + if (generateSequence(error) { it.cause }.any { it is SSLException }) { + throw IllegalStateException( + "ARTIFACT_TLS_FAILED: firmware TLS validation failed", + error, + ) + } throw IllegalStateException( "ARTIFACT_NETWORK_FAILED: firmware request failed", error, @@ -601,7 +622,6 @@ internal object FirmwareArtifactStore { removed.transactionId } cancelledTransactions.remove(transactionId) - downloadLocks.keys.removeIf { it.transactionId == transactionId } } fun reconcileLeases(activeLeaseRefs: Array) { diff --git a/native-modules/react-native-range-downloader/eslint.config.mjs b/native-modules/react-native-range-downloader/eslint.config.mjs index 3416cf5c..4494c016 100644 --- a/native-modules/react-native-range-downloader/eslint.config.mjs +++ b/native-modules/react-native-range-downloader/eslint.config.mjs @@ -1,5 +1,29 @@ -import { createEslintConfig } from '@react-native/eslint-config'; +import { fixupConfigRules } from '@eslint/compat'; +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -export default createEslintConfig({ - extends: ['@react-native/eslint-config'], +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, }); + +export default [ + { + ignores: [ + '**/node_modules', + '**/android/build', + '**/conformance', + '**/ios/build', + '**/lib', + '**/nitrogen', + '**/*.config.js', + '**/*.config.mjs', + ], + }, + ...fixupConfigRules(compat.extends('@react-native', 'prettier')), +]; diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index 3b912f25..5c311e75 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -2,6 +2,33 @@ import CryptoKit import Foundation import SniConnect +func isFirmwareArtifactTLSError(_ error: Error) -> Bool { + var current: NSError? = error as NSError + var visited = Set() + while let candidate = current { + let identifier = ObjectIdentifier(candidate) + guard visited.insert(identifier).inserted else { + break + } + if candidate.domain == NSURLErrorDomain, + [ + NSURLErrorSecureConnectionFailed, + NSURLErrorServerCertificateHasBadDate, + NSURLErrorServerCertificateUntrusted, + NSURLErrorServerCertificateHasUnknownRoot, + NSURLErrorServerCertificateNotYetValid, + NSURLErrorClientCertificateRejected, + NSURLErrorClientCertificateRequired, + NSURLErrorAppTransportSecurityRequiresSecureConnection, + ].contains(candidate.code) + { + return true + } + current = candidate.userInfo[NSUnderlyingErrorKey] as? NSError + } + return false +} + enum FirmwareArtifactStoreError: Error { case invalidInput(String) case downloadFailed(String) @@ -188,8 +215,7 @@ final class FirmwareArtifactStore { transactionId: params.transactionId, artifactRef: "fw:\(expectedSha256)" ) - let key = - "\(params.transactionId):\(expectedSha256):\(Int64(params.expectedSize))" + let key = "\(expectedSha256):\(Int64(params.expectedSize))" markDownloadActive(expectedSha256, delta: 1) do { let artifact = try await downloadCoordinator.run( @@ -202,6 +228,7 @@ final class FirmwareArtifactStore { expectedSha256: expectedSha256 ) } + try rejectIfCancelled(transactionId: params.transactionId) markDownloadActive(expectedSha256, delta: -1) return artifact } catch { @@ -1050,6 +1077,11 @@ final class FirmwareArtifactStore { "ARTIFACT_CANCELLED: firmware artifact download was cancelled" ) } + if isFirmwareArtifactTLSError(error) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_TLS_FAILED: firmware TLS validation failed" + ) + } throw FirmwareArtifactStoreError.downloadFailed( "ARTIFACT_NETWORK_FAILED: firmware request failed" ) @@ -1113,6 +1145,11 @@ final class FirmwareArtifactStore { "ARTIFACT_CANCELLED: firmware artifact download was cancelled" ) } + if isFirmwareArtifactTLSError(error) { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_TLS_FAILED: firmware TLS validation failed" + ) + } throw FirmwareArtifactStoreError.downloadFailed( "ARTIFACT_NETWORK_FAILED: firmware response stream failed" ) diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index 7b5289b8..1e46ca30 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -334,18 +334,14 @@ private struct FirmwareBackgroundTaskDescriptor: Codable, Equatable { let deadlineAt: TimeInterval var key: String { - "\(leaseRef)|\(taskId)|\(expectedSha256)" + "\(expectedSize)|\(expectedSha256)" } func hasSameArtifactIdentity( as other: FirmwareBackgroundTaskDescriptor ) -> Bool { - taskId == other.taskId && - transactionId == other.transactionId && - leaseRef == other.leaseRef && - expectedSize == other.expectedSize && - expectedSha256 == other.expectedSha256 && - hostname == other.hostname + expectedSize == other.expectedSize && + expectedSha256 == other.expectedSha256 } } @@ -356,6 +352,7 @@ private enum FirmwareBackgroundDownloadError: LocalizedError { case redirectRejected case responseRejected case sizeRejected + case tlsRejected case transferFailed var errorDescription: String? { @@ -372,6 +369,8 @@ private enum FirmwareBackgroundDownloadError: LocalizedError { return "ARTIFACT_PROTOCOL_INVALID: firmware response is invalid" case .sizeRejected: return "ARTIFACT_PROTOCOL_INVALID: firmware artifact size is invalid" + case .tlsRejected: + return "ARTIFACT_TLS_FAILED: firmware TLS validation failed" case .transferFailed: return "ARTIFACT_NETWORK_FAILED: firmware background transfer failed" } @@ -1687,7 +1686,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { ? FirmwareBackgroundDownloadError.cancelled : nil ) ?? - error ?? + error.map { + isFirmwareArtifactTLSError($0) + ? FirmwareBackgroundDownloadError.tlsRejected + : FirmwareBackgroundDownloadError.transferFailed + } ?? FirmwareBackgroundDownloadError.transferFailed ) ) diff --git a/native-modules/react-native-sni-connect/eslint.config.mjs b/native-modules/react-native-sni-connect/eslint.config.mjs new file mode 100644 index 00000000..3b9276a8 --- /dev/null +++ b/native-modules/react-native-sni-connect/eslint.config.mjs @@ -0,0 +1,27 @@ +import { fixupConfigRules } from '@eslint/compat'; +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: [ + '**/node_modules', + '**/android/build', + '**/ios/build', + '**/lib', + '**/*.config.js', + '**/*.config.mjs', + ], + }, + ...fixupConfigRules(compat.extends('@react-native', 'prettier')), +]; From 5c8bc317eb351c3ebbef57d73022a507f1d7d84b Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 28 Jul 2026 14:52:33 +0800 Subject: [PATCH 08/27] refactor: remove persistent firmware lease recovery --- .../FirmwareArtifactStore.kt | 118 +------------ .../ReactNativeRangeDownloader.kt | 10 +- .../ios/FirmwareArtifactStore.swift | 160 ++---------------- .../ios/ReactNativeRangeDownloader.swift | 25 +-- .../src/ReactNativeRangeDownloader.nitro.ts | 7 - 5 files changed, 28 insertions(+), 292 deletions(-) diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt index b5ffce31..924c8a59 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt @@ -6,7 +6,6 @@ import com.sniconnect.SniPinnedTransport import java.io.BufferedInputStream import java.io.File import java.io.FileInputStream -import java.io.FileOutputStream import java.io.IOException import java.io.RandomAccessFile import java.security.MessageDigest @@ -21,8 +20,6 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.json.JSONArray -import org.json.JSONObject internal data class StoredFirmwareArtifact( val artifactRef: String, @@ -56,8 +53,6 @@ internal object FirmwareArtifactStore { const val MAX_READ_BYTES = 256 * 1024 private const val MAX_ARTIFACT_BYTES = 512L * 1024 * 1024 - private const val MAX_LEASE_METADATA_BYTES = 1024L * 1024 - private const val MAX_TOTAL_LEASE_REFS = 8192 private const val FINAL_ARTIFACT_GRACE_MS = 24L * 60 * 60 * 1000 private const val PARTIAL_ARTIFACT_GRACE_MS = 7L * 24 * 60 * 60 * 1000 private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$") @@ -75,6 +70,7 @@ internal object FirmwareArtifactStore { private val leaseLock = Any() private val readerLock = Any() private val readers = mutableMapOf() + private val leases = mutableMapOf() private data class LeaseState( val transactionId: String, @@ -148,7 +144,7 @@ internal object FirmwareArtifactStore { fun discard(artifactRef: String) { val file = resolveArtifactFile(artifactRef) synchronized(leaseLock) { - require(loadLeasesLocked().values.none { artifactRef in it.artifactRefs }) { + require(leases.values.none { artifactRef in it.artifactRefs }) { "ARTIFACT_LEASED: firmware artifact is retained" } } @@ -584,13 +580,11 @@ internal object FirmwareArtifactStore { "Invalid firmware transactionId" } return synchronized(leaseLock) { - val leases = loadLeasesLocked() require(leases.size < 32) { "Too many firmware artifact leases" } val leaseRef = "fwlease:${UUID.randomUUID()}" leases[leaseRef] = LeaseState(transactionId, mutableSetOf()) - saveLeasesLocked(leases) leaseRef } } @@ -613,40 +607,18 @@ internal object FirmwareArtifactStore { "Invalid firmware lease disposition" } val transactionId = synchronized(leaseLock) { - val leases = loadLeasesLocked() val removed = leases.remove(validateLeaseRef(leaseRef)) require(removed != null) { "Firmware artifact lease is unavailable" } - saveLeasesLocked(leases) removed.transactionId } cancelledTransactions.remove(transactionId) } - fun reconcileLeases(activeLeaseRefs: Array) { - require(activeLeaseRefs.size <= 32) { - "Too many active firmware artifact leases" - } - val active = activeLeaseRefs.mapTo(mutableSetOf()) { - validateLeaseRef(it) - } - require(active.size == activeLeaseRefs.size) { - "Duplicate active firmware artifact lease" - } - synchronized(leaseLock) { - val leases = loadLeasesLocked() - require(active.all { leases.containsKey(it) }) { - "Firmware artifact lease reconciliation is incomplete" - } - leases.keys.retainAll(active) - saveLeasesLocked(leases) - } - } - fun sweepOrphans(): Pair { val retainedSha256 = synchronized(leaseLock) { - loadLeasesLocked().values + leases.values .flatMap { it.artifactRefs } .mapTo(mutableSetOf()) { it.removePrefix("fw:") } } @@ -660,7 +632,7 @@ internal object FirmwareArtifactStore { var deletedFiles = 0 var deletedBytes = 0L root.listFiles()?.forEach { file -> - if (!file.isFile || file.name == "leases.json") return@forEach + if (!file.isFile) return@forEach val sha256 = file.name.take(64) if ( !sha256Pattern.matches(sha256) || @@ -689,7 +661,7 @@ internal object FirmwareArtifactStore { private fun requireLease(leaseRef: String) { synchronized(leaseLock) { - require(loadLeasesLocked().containsKey(validateLeaseRef(leaseRef))) { + require(leases.containsKey(validateLeaseRef(leaseRef))) { "Firmware artifact lease is unavailable" } } @@ -704,7 +676,6 @@ internal object FirmwareArtifactStore { "Invalid firmware artifactRef" } synchronized(leaseLock) { - val leases = loadLeasesLocked() val lease = leases[validateLeaseRef(leaseRef)] ?: error("Firmware artifact lease is unavailable") if (transactionId != null) { @@ -712,9 +683,7 @@ internal object FirmwareArtifactStore { "Firmware artifact lease transaction mismatch" } } - if (lease.artifactRefs.add(artifactRef)) { - saveLeasesLocked(leases) - } + lease.artifactRefs.add(artifactRef) } } @@ -725,81 +694,6 @@ internal object FirmwareArtifactStore { return leaseRef } - private fun loadLeasesLocked(): MutableMap { - val file = File(root, "leases.json") - if (!file.exists()) return mutableMapOf() - require(file.length() in 1..MAX_LEASE_METADATA_BYTES) { - "Firmware lease metadata is too large" - } - val envelope = JSONObject(file.readText(Charsets.UTF_8)) - require(envelope.optInt("schemaVersion") == 1) { - "Unsupported firmware lease schema" - } - val result = mutableMapOf() - val jsonLeases = envelope.getJSONObject("leases") - val keys = jsonLeases.keys() - while (keys.hasNext()) { - val leaseRef = validateLeaseRef(keys.next()) - val jsonLease = jsonLeases.getJSONObject(leaseRef) - val transactionId = jsonLease.getString("transactionId") - require(identifierPattern.matches(transactionId)) { - "Invalid persisted firmware transactionId" - } - val jsonRefs = jsonLease.getJSONArray("artifactRefs") - require(jsonRefs.length() <= 4096) { - "Too many persisted firmware artifact refs" - } - val refs = mutableSetOf() - for (index in 0 until jsonRefs.length()) { - val artifactRef = jsonRefs.getString(index) - require(artifactRefPattern.matches(artifactRef) && refs.add(artifactRef)) { - "Invalid persisted firmware artifact ref" - } - } - result[leaseRef] = LeaseState(transactionId, refs) - } - require(result.size <= 32) { - "Too many persisted firmware artifact leases" - } - require(result.values.sumOf { it.artifactRefs.size } <= MAX_TOTAL_LEASE_REFS) { - "Too many persisted firmware artifact refs" - } - return result - } - - private fun saveLeasesLocked(leases: Map) { - require( - leases.size <= 32 && - leases.values.sumOf { it.artifactRefs.size } <= MAX_TOTAL_LEASE_REFS - ) { - "Firmware lease metadata is too large" - } - val jsonLeases = JSONObject() - leases.toSortedMap().forEach { (leaseRef, lease) -> - jsonLeases.put( - leaseRef, - JSONObject() - .put("transactionId", lease.transactionId) - .put("artifactRefs", JSONArray(lease.artifactRefs.sorted())), - ) - } - val bytes = JSONObject() - .put("schemaVersion", 1) - .put("leases", jsonLeases) - .toString() - .toByteArray(Charsets.UTF_8) - require(bytes.size.toLong() <= MAX_LEASE_METADATA_BYTES) { - "Firmware lease metadata is too large" - } - val destination = File(root, "leases.json") - val temporary = File(root, ".leases-${UUID.randomUUID()}.tmp") - FileOutputStream(temporary).use { output -> - output.write(bytes) - output.fd.sync() - } - Os.rename(temporary.absolutePath, destination.absolutePath) - } - private fun markDownloadActive(sha256: String, delta: Int) { synchronized(activeDownloadLock) { val count = (activeDownloadCounts[sha256] ?: 0) + delta diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt index 07d91fbf..9deef96e 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt @@ -268,7 +268,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun getFirmwareArtifactCapabilities(): FirmwareArtifactCapabilities { return FirmwareArtifactCapabilities( - firmwareArtifactProtocolVersion = 1.0, + firmwareArtifactProtocolVersion = 2.0, supportedRouteTypes = arrayOf("domain", "pinnedIp"), supportsArchiveMaterialization = true, maxReadBytes = FirmwareArtifactStore.MAX_READ_BYTES.toDouble(), @@ -395,14 +395,6 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { } } - override fun reconcileFirmwareArtifactLeases( - params: FirmwareArtifactLeaseReconcileParams, - ): Promise { - return Promise.async { - FirmwareArtifactStore.reconcileLeases(params.activeLeaseRefs) - } - } - override fun sweepFirmwareArtifactOrphans(): Promise { return Promise.async { val (deletedFiles, deletedBytes) = FirmwareArtifactStore.sweepOrphans() diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index 5c311e75..b166f37c 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -106,8 +106,6 @@ private actor FirmwareArtifactDownloadCoordinator { final class FirmwareArtifactStore { static let shared = FirmwareArtifactStore() static let maxReadBytes = 256 * 1024 - private static let maxLeaseMetadataBytes: Int64 = 1024 * 1024 - private static let maxTotalLeaseRefs = 8192 private static let finalArtifactGrace: TimeInterval = 24 * 60 * 60 private static let partialArtifactGrace: TimeInterval = 7 * 24 * 60 * 60 @@ -117,16 +115,11 @@ final class FirmwareArtifactStore { let size: Int64 } - private struct LeaseState: Codable { + private struct LeaseState { let transactionId: String var artifactRefs: Set } - private struct LeaseEnvelope: Codable { - let schemaVersion: Int - var leases: [String: LeaseState] - } - private let fileManager = FileManager.default private let downloadCoordinator = FirmwareArtifactDownloadCoordinator() private let leaseLock = NSLock() @@ -136,6 +129,7 @@ final class FirmwareArtifactStore { private var cancelledTransactions: Set = [] private let readerLock = NSLock() private var readers: [String: OpenReader] = [:] + private var leases: [String: LeaseState] = [:] private lazy var rootURL: URL = { let base = fileManager.urls( @@ -336,17 +330,12 @@ final class FirmwareArtifactStore { func acceptBackgroundDownload( temporaryURL: URL, - leaseRef: String, transactionId: String, expectedSize: Int64, expectedSha256: String ) throws -> StoredFirmwareArtifact { let normalizedExpectedSha256 = expectedSha256.lowercased() - try retainExpectedArtifact( - leaseRef: leaseRef, - transactionId: transactionId, - artifactRef: "fw:\(normalizedExpectedSha256)" - ) + try rejectIfCancelled(transactionId: transactionId) let finalURL = artifactURL(sha256: normalizedExpectedSha256) if let existing = try? validateStoredArtifact( fileURL: finalURL, @@ -387,14 +376,8 @@ final class FirmwareArtifactStore { func discard(artifactRef: String) throws { let fileURL = try resolveArtifactURL(artifactRef) leaseLock.lock() - let isRetained: Bool - do { - isRetained = try loadLeasesLocked().leases.values.contains { - $0.artifactRefs.contains(artifactRef) - } - } catch { - leaseLock.unlock() - throw error + let isRetained = leases.values.contains { + $0.artifactRefs.contains(artifactRef) } leaseLock.unlock() guard !isRetained else { @@ -593,18 +576,16 @@ final class FirmwareArtifactStore { } leaseLock.lock() defer { leaseLock.unlock() } - var envelope = try loadLeasesLocked() - guard envelope.leases.count < 32 else { + guard leases.count < 32 else { throw FirmwareArtifactStoreError.invalidInput( "Too many firmware artifact leases" ) } let leaseRef = "fwlease:\(UUID().uuidString.lowercased())" - envelope.leases[leaseRef] = LeaseState( + leases[leaseRef] = LeaseState( transactionId: transactionId, artifactRefs: [] ) - try saveLeasesLocked(envelope) return leaseRef } @@ -630,9 +611,8 @@ final class FirmwareArtifactStore { let transactionId: String = try { leaseLock.lock() defer { leaseLock.unlock() } - var envelope = try loadLeasesLocked() guard - let lease = envelope.leases.removeValue( + let lease = leases.removeValue( forKey: try validateLeaseRef(leaseRef) ) else { @@ -640,7 +620,6 @@ final class FirmwareArtifactStore { "Firmware artifact lease is unavailable" ) } - try saveLeasesLocked(envelope) return lease.transactionId }() cancellationLock.withFirmwareArtifactLock { @@ -648,43 +627,13 @@ final class FirmwareArtifactStore { } } - func reconcileLeases(activeLeaseRefs: [String]) throws { - guard activeLeaseRefs.count <= 32 else { - throw FirmwareArtifactStoreError.invalidInput( - "Too many active firmware artifact leases" - ) - } - let active = try Set(activeLeaseRefs.map(validateLeaseRef)) - guard active.count == activeLeaseRefs.count else { - throw FirmwareArtifactStoreError.invalidInput( - "Duplicate active firmware artifact lease" - ) - } - leaseLock.lock() - defer { leaseLock.unlock() } - var envelope = try loadLeasesLocked() - guard active.allSatisfy({ envelope.leases[$0] != nil }) else { - throw FirmwareArtifactStoreError.invalidInput( - "Firmware artifact lease reconciliation is incomplete" - ) - } - envelope.leases = envelope.leases.filter { active.contains($0.key) } - try saveLeasesLocked(envelope) - } - func sweepOrphans() throws -> (deletedFiles: Int, deletedBytes: Int64) { leaseLock.lock() - let retained: Set - do { - retained = Set( - try loadLeasesLocked().leases.values - .flatMap(\.artifactRefs) - .map { String($0.dropFirst(3)) } - ) - } catch { - leaseLock.unlock() - throw error - } + let retained = Set( + leases.values + .flatMap(\.artifactRefs) + .map { String($0.dropFirst(3)) } + ) leaseLock.unlock() activeDownloadLock.lock() let active = Set(activeDownloadCounts.filter { $0.value > 0 }.keys) @@ -703,7 +652,7 @@ final class FirmwareArtifactStore { ) for fileURL in files { let name = fileURL.lastPathComponent - guard name != "leases.json", name.count >= 64 else { continue } + guard name.count >= 64 else { continue } let sha256 = String(name.prefix(64)) guard sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil, @@ -741,7 +690,7 @@ final class FirmwareArtifactStore { private func requireLease(_ leaseRef: String) throws { leaseLock.lock() defer { leaseLock.unlock() } - guard try loadLeasesLocked().leases[validateLeaseRef(leaseRef)] != nil else { + guard leases[try validateLeaseRef(leaseRef)] != nil else { throw FirmwareArtifactStoreError.invalidInput( "Firmware artifact lease is unavailable" ) @@ -763,9 +712,8 @@ final class FirmwareArtifactStore { } leaseLock.lock() defer { leaseLock.unlock() } - var envelope = try loadLeasesLocked() let validatedLeaseRef = try validateLeaseRef(leaseRef) - guard var lease = envelope.leases[validatedLeaseRef] else { + guard var lease = leases[validatedLeaseRef] else { throw FirmwareArtifactStoreError.invalidInput( "Firmware artifact lease is unavailable" ) @@ -776,8 +724,7 @@ final class FirmwareArtifactStore { ) } if lease.artifactRefs.insert(artifactRef).inserted { - envelope.leases[validatedLeaseRef] = lease - try saveLeasesLocked(envelope) + leases[validatedLeaseRef] = lease } } @@ -804,79 +751,6 @@ final class FirmwareArtifactStore { ) != nil } - private func loadLeasesLocked() throws -> LeaseEnvelope { - let url = rootURL.appendingPathComponent("leases.json", isDirectory: false) - guard fileManager.fileExists(atPath: url.path) else { - return LeaseEnvelope(schemaVersion: 1, leases: [:]) - } - let fileSize = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 - guard fileSize > 0, Int64(fileSize) <= Self.maxLeaseMetadataBytes else { - throw FirmwareArtifactStoreError.invalidInput( - "Firmware lease metadata is too large" - ) - } - let envelope = try JSONDecoder().decode( - LeaseEnvelope.self, - from: Data(contentsOf: url) - ) - guard envelope.schemaVersion == 1, envelope.leases.count <= 32 else { - throw FirmwareArtifactStoreError.invalidInput( - "Unsupported firmware lease schema" - ) - } - guard - envelope.leases.values.reduce(0, { $0 + $1.artifactRefs.count }) - <= Self.maxTotalLeaseRefs - else { - throw FirmwareArtifactStoreError.invalidInput( - "Firmware lease metadata is too large" - ) - } - for (leaseRef, lease) in envelope.leases { - guard - Self.isValidLeaseRef(leaseRef), - Self.isSafeIdentifier(lease.transactionId), - lease.artifactRefs.count <= 4096, - lease.artifactRefs.allSatisfy({ - $0.range( - of: "^fw:[a-f0-9]{64}$", - options: .regularExpression - ) != nil - }) - else { - throw FirmwareArtifactStoreError.invalidInput( - "Invalid persisted firmware lease" - ) - } - } - return envelope - } - - private func saveLeasesLocked(_ envelope: LeaseEnvelope) throws { - guard - envelope.schemaVersion == 1, - envelope.leases.count <= 32, - envelope.leases.values.allSatisfy({ - $0.artifactRefs.count <= 4096 - }), - envelope.leases.values.reduce(0, { - $0 + $1.artifactRefs.count - }) <= Self.maxTotalLeaseRefs - else { - throw FirmwareArtifactStoreError.invalidInput( - "Firmware lease metadata is too large" - ) - } - let data = try JSONEncoder().encode(envelope) - guard Int64(data.count) <= Self.maxLeaseMetadataBytes else { - throw FirmwareArtifactStoreError.invalidInput( - "Firmware lease metadata is too large" - ) - } - let url = rootURL.appendingPathComponent("leases.json", isDirectory: false) - try data.write(to: url, options: [.atomic]) - } - private func markDownloadActive(_ sha256: String, delta: Int) { activeDownloadLock.lock() let next = (activeDownloadCounts[sha256] ?? 0) + delta diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index 1e46ca30..849c199c 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -98,7 +98,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func getFirmwareArtifactCapabilities() throws -> FirmwareArtifactCapabilities { FirmwareArtifactCapabilities( - firmwareArtifactProtocolVersion: 1, + firmwareArtifactProtocolVersion: 2, supportedRouteTypes: ["domain", "pinnedIp"], supportsArchiveMaterialization: true, maxReadBytes: Double(FirmwareArtifactStore.maxReadBytes) @@ -243,16 +243,6 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { } } - func reconcileFirmwareArtifactLeases( - params: FirmwareArtifactLeaseReconcileParams - ) throws -> Promise { - Promise.async { - try FirmwareArtifactStore.shared.reconcileLeases( - activeLeaseRefs: params.activeLeaseRefs - ) - } - } - func sweepFirmwareArtifactOrphans() throws -> Promise { Promise.async { let result = try FirmwareArtifactStore.shared.sweepOrphans() @@ -327,7 +317,6 @@ private struct FirmwareBackgroundTaskDescriptor: Codable, Equatable { let schemaVersion: Int let taskId: String let transactionId: String - let leaseRef: String let expectedSize: Int64 let expectedSha256: String let hostname: String @@ -693,7 +682,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { return (r, decoded.segIndex) } - private static let firmwareTaskDescriptionPrefix = "firmware-v1:" + private static let firmwareTaskDescriptionPrefix = "firmware-v2:" private static func encodeFirmwareTaskDescription( _ descriptor: FirmwareBackgroundTaskDescriptor @@ -718,7 +707,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { FirmwareBackgroundTaskDescriptor.self, from: data ), - descriptor.schemaVersion == 1, + descriptor.schemaVersion == 2, descriptor.taskId.range( of: "^[A-Za-z0-9._-]{1,100}$", options: .regularExpression @@ -727,10 +716,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { of: "^[A-Za-z0-9._:-]{1,160}$", options: .regularExpression ) != nil, - descriptor.leaseRef.range( - of: "^fwlease:[a-f0-9-]{36}$", - options: .regularExpression - ) != nil, descriptor.expectedSize > 0, descriptor.expectedSize <= 512 * 1024 * 1024, descriptor.expectedSha256.range( @@ -766,10 +751,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { throw FirmwareBackgroundDownloadError.invalidTask } let descriptor = FirmwareBackgroundTaskDescriptor( - schemaVersion: 1, + schemaVersion: 2, taskId: params.taskId, transactionId: params.transactionId, - leaseRef: params.leaseRef, expectedSize: Int64(params.expectedSize), expectedSha256: params.expectedSha256.lowercased(), hostname: hostname, @@ -1538,7 +1522,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { } let artifact = try FirmwareArtifactStore.shared.acceptBackgroundDownload( temporaryURL: location, - leaseRef: descriptor.leaseRef, transactionId: descriptor.transactionId, expectedSize: descriptor.expectedSize, expectedSha256: descriptor.expectedSha256 diff --git a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts index b11ee442..7ae22363 100644 --- a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts +++ b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts @@ -158,10 +158,6 @@ export interface FirmwareArtifactLeaseReleaseParams { disposition: string; } -export interface FirmwareArtifactLeaseReconcileParams { - activeLeaseRefs: string[]; -} - export interface FirmwareArtifactSweepResult { deletedFiles: number; deletedBytes: number; @@ -231,8 +227,5 @@ export interface ReactNativeRangeDownloader releaseFirmwareArtifactLease( params: FirmwareArtifactLeaseReleaseParams ): Promise; - reconcileFirmwareArtifactLeases( - params: FirmwareArtifactLeaseReconcileParams - ): Promise; sweepFirmwareArtifactOrphans(): Promise; } From 4df1274339ea645f2d4e618829ecb2c3bc090cbe Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 28 Jul 2026 15:15:57 +0800 Subject: [PATCH 09/27] chore: bump app modules prerelease --- 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 | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- 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-network-throttle/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-range-downloader/package.json | 4 ++-- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- 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-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 2 +- 36 files changed, 37 insertions(+), 37 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 96301dc7..13e9eba1 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 9566609c..1ae7d953 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 4b6b8cb5..c7a24c6c 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 4a67c41a..ccb819e7 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 e01caf9a..210de1f0 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.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 10684b7a..75830298 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 1fe9aacc..c02387c3 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 5149adf9..4656006f 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 bf3b5d07..0822d437 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 764d0df8..666fd10e 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 e4cd210a..89decbe4 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 1bb518f7..304a6094 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 8cf20861..9f12ecb1 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 309c515b..7f751871 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 a246e25a..a9c4a758 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 5736a0b5..7e0b7738 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.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index f79cdf1d..a8c6afd9 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-network-throttle", "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 9b39b5b6..a61becd0 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 8894b1f5..e8fd7e9b 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 fdd34767..83adce9b 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 2718f0b6..d5b7ee7a 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 191a54eb..9482d214 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,7 +88,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.1", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.2", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 3413f104..46ae3408 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "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 1652f4e2..4d07ed64 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 e4b8494d..15170b91 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.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 5b71d341..70c6e40d 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 403a91af..6122550f 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.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-zip-archive Nitro HybridObject 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 e4b344fa..db9638bf 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index cb29b66e..25b93c47 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-chart-webview", "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 b7f352fc..0f8f29fb 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 5dcaaac8..243caf47 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index ba72643f..509eeaff 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index dccee707..971698d2 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.81-alpha.1", + "version": "3.0.81-alpha.2", "description": "react-native-segment-slider", "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 8fa260a8..047f518f 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 8a1b91d8..b62603b0 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.81-alpha.1", + "version": "3.0.81-alpha.2", "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 4a14a9e6..03356c60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,7 +3688,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.1 + "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.2 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From 56a98dc8b0a75755a02bfa9c26acc3f51cfc57b1 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 28 Jul 2026 16:38:10 +0800 Subject: [PATCH 10/27] fix: bound firmware artifact downloads --- .../FirmwareArtifactStore.kt | 30 +++++++++++++++---- .../FirmwareArtifactDeadlineTest.kt | 26 ++++++++++++++++ .../ios/FirmwareArtifactStore.swift | 13 +++++++- .../src/ReactNativeRangeDownloader.nitro.ts | 2 +- 4 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt index 924c8a59..a75574e3 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt @@ -14,6 +14,7 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream import javax.net.ssl.SSLException +import kotlin.math.ceil import okhttp3.Call import okhttp3.OkHttpClient import okhttp3.Protocol @@ -21,6 +22,21 @@ import okhttp3.Request import okhttp3.Response import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +private const val DEFAULT_FIRMWARE_DOWNLOAD_DEADLINE_SECONDS = 180.0 +private const val MAX_FIRMWARE_DOWNLOAD_DEADLINE_SECONDS = 24.0 * 60 * 60 + +internal fun validateFirmwareDownloadDeadlineSeconds(value: Double?): Double { + val deadline = value ?: DEFAULT_FIRMWARE_DOWNLOAD_DEADLINE_SECONDS + require( + deadline.isFinite() && + deadline > 0 && + deadline <= MAX_FIRMWARE_DOWNLOAD_DEADLINE_SECONDS + ) { + "Invalid firmware download deadline" + } + return deadline +} + internal data class StoredFirmwareArtifact( val artifactRef: String, val size: Long, @@ -295,6 +311,7 @@ internal object FirmwareArtifactStore { val maxBytes: Long, val expectedSha256: String, val hostname: String, + val overallDeadlineSeconds: Double, ) private fun validateDownloadParams( @@ -335,6 +352,8 @@ internal object FirmwareArtifactStore { require(sha256Pattern.matches(params.expectedSha256)) { "Invalid firmware artifact SHA-256" } + val overallDeadlineSeconds = + validateFirmwareDownloadDeadlineSeconds(params.overallDeadlineSeconds) require(params.routeType == "domain" || params.routeType == "pinnedIp") { "Invalid firmware route type" } @@ -352,6 +371,7 @@ internal object FirmwareArtifactStore { maxBytes = maxBytes, expectedSha256 = params.expectedSha256.lowercase(), hostname = url.host, + overallDeadlineSeconds = overallDeadlineSeconds, ) } @@ -411,12 +431,10 @@ internal object FirmwareArtifactStore { .build() } val call = client.newCall(requestBuilder.build()) - params.overallDeadlineSeconds?.let { deadline -> - require(deadline.isFinite() && deadline > 0) { - "Invalid firmware download deadline" - } - call.timeout().timeout(deadline.toLong().coerceAtLeast(1), TimeUnit.SECONDS) - } + call.timeout().timeout( + ceil(validated.overallDeadlineSeconds * 1000).toLong(), + TimeUnit.MILLISECONDS, + ) registerCall(params.transactionId, call) try { diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt new file mode 100644 index 00000000..d9f74f8a --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt @@ -0,0 +1,26 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class FirmwareArtifactDeadlineTest { + @Test + fun defaultsToABoundedThreeMinuteDeadline() { + assertEquals(180.0, validateFirmwareDownloadDeadlineSeconds(null), 0.0) + } + + @Test + fun preservesFractionalDeadlines() { + assertEquals(2.75, validateFirmwareDownloadDeadlineSeconds(2.75), 0.0) + } + + @Test + fun rejectsUnboundedOrInvalidDeadlines() { + listOf(0.0, -1.0, Double.NaN, Double.POSITIVE_INFINITY, 86_400.1).forEach { + assertThrows(IllegalArgumentException::class.java) { + validateFirmwareDownloadDeadlineSeconds(it) + } + } + } +} diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index b166f37c..b4c13c12 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -106,6 +106,8 @@ private actor FirmwareArtifactDownloadCoordinator { final class FirmwareArtifactStore { static let shared = FirmwareArtifactStore() static let maxReadBytes = 256 * 1024 + private static let defaultDownloadDeadline: TimeInterval = 180 + private static let maxDownloadDeadline: TimeInterval = 24 * 60 * 60 private static let finalArtifactGrace: TimeInterval = 24 * 60 * 60 private static let partialArtifactGrace: TimeInterval = 7 * 24 * 60 * 60 @@ -191,6 +193,14 @@ final class FirmwareArtifactStore { guard params.routeType == "domain" || params.routeType == "pinnedIp" else { throw FirmwareArtifactStoreError.invalidInput("Invalid firmware route type") } + let deadline = params.overallDeadlineSeconds ?? defaultDownloadDeadline + guard + deadline.isFinite, + deadline > 0, + deadline <= maxDownloadDeadline + else { + throw FirmwareArtifactStoreError.invalidInput("Invalid firmware download deadline") + } if params.routeType == "pinnedIp" { guard let resolvedIp = params.resolvedIp, !resolvedIp.isEmpty else { throw FirmwareArtifactStoreError.invalidInput("Pinned route requires resolvedIp") @@ -909,7 +919,8 @@ final class FirmwareArtifactStore { } var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalCacheData - request.timeoutInterval = params.overallDeadlineSeconds ?? 180 + request.timeoutInterval = + params.overallDeadlineSeconds ?? Self.defaultDownloadDeadline request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") if resumeOffset > 0 { request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range") diff --git a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts index 7ae22363..6472ddb3 100644 --- a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts +++ b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts @@ -79,7 +79,7 @@ export interface FirmwareArtifactDownloadParams { expectedSize: number; expectedSha256: string; maxBytes: number; - overallDeadlineSeconds?: number; + overallDeadlineSeconds?: number; // defaults to 180; must be > 0 and <= 24 hours } export interface FirmwareArtifactReceipt { From 97f05457c4644bdd9ef6cb77cdf9760f29951207 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 28 Jul 2026 23:51:41 +0800 Subject: [PATCH 11/27] fix: harden firmware artifact failover --- .../ReactNativeRangeDownloader.kt | 26 +++--- .../ios/FirmwareArtifactStore.swift | 83 +++++++++++++------ .../ios/RangeDownloadLogic.swift | 71 ++++++++++++++++ .../ios/ReactNativeRangeDownloader.swift | 50 +++-------- .../RangeDownloadLogicTests.swift | 44 ++++++++++ .../ios/SniConnectClient.swift | 12 ++- 6 files changed, 212 insertions(+), 74 deletions(-) diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt index 9deef96e..09cf1314 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt @@ -9,6 +9,9 @@ import java.io.File import java.security.MessageDigest import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob // P1: Nitro adapter for the Android concurrent multi-range downloader. // @@ -34,6 +37,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { private val listeners = CopyOnWriteArrayList() private val nextListenerId = AtomicLong(1) + private val firmwareArtifactScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // Active downloads keyed by "channel|taskId" so cancel/discardArtifacts can // flip the abort flag + stop the worker pool BEFORE deleting files, instead of @@ -278,7 +282,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun downloadFirmwareArtifact( params: FirmwareArtifactDownloadParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { val artifact = FirmwareArtifactStore.download(params) FirmwareArtifactReceipt( artifactRef = artifact.artifactRef, @@ -291,7 +295,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun cancelFirmwareArtifactDownloads( params: FirmwareArtifactCancelParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { FirmwareArtifactStore.cancelDownloads(params.transactionId) } } @@ -299,7 +303,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun discardFirmwareArtifact( params: FirmwareArtifactRefParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { FirmwareArtifactStore.discard(params.artifactRef) } } @@ -307,7 +311,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun openFirmwareArtifact( params: FirmwareArtifactRefParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { val (readerId, size) = FirmwareArtifactStore.open(params.artifactRef) FirmwareArtifactReaderInfo(readerId = readerId, size = size.toDouble()) } @@ -316,7 +320,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun readFirmwareArtifact( params: FirmwareArtifactReaderReadParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { require( params.offset.isFinite() && params.offset >= 0 && @@ -340,7 +344,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun closeFirmwareArtifact( params: FirmwareArtifactReaderCloseParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { FirmwareArtifactStore.close(params.readerId) } } @@ -348,7 +352,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun materializeFirmwareArchive( params: FirmwareArchiveMaterializeParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { val artifacts = FirmwareArtifactStore.materializeArchive( params.leaseRef, params.archiveArtifactRef, @@ -372,7 +376,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun createFirmwareArtifactLease( params: FirmwareArtifactLeaseCreateParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { FirmwareArtifactLease( leaseRef = FirmwareArtifactStore.createLease(params.transactionId), ) @@ -382,7 +386,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun retainFirmwareArtifact( params: FirmwareArtifactLeaseRetainParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { FirmwareArtifactStore.retain(params.leaseRef, params.artifactRef) } } @@ -390,13 +394,13 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun releaseFirmwareArtifactLease( params: FirmwareArtifactLeaseReleaseParams, ): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { FirmwareArtifactStore.releaseLease(params.leaseRef, params.disposition) } } override fun sweepFirmwareArtifactOrphans(): Promise { - return Promise.async { + return Promise.async(firmwareArtifactScope) { val (deletedFiles, deletedBytes) = FirmwareArtifactStore.sweepOrphans() FirmwareArtifactSweepResult( deletedFiles = deletedFiles.toDouble(), diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index b4c13c12..bb2d4cb1 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -219,7 +219,11 @@ final class FirmwareArtifactStore { transactionId: params.transactionId, artifactRef: "fw:\(expectedSha256)" ) - let key = "\(expectedSha256):\(Int64(params.expectedSize))" + let key = firmwareArtifactDownloadKey( + transactionId: params.transactionId, + expectedSize: Int64(params.expectedSize), + expectedSha256: expectedSha256 + ) markDownloadActive(expectedSha256, delta: 1) do { let artifact = try await downloadCoordinator.run( @@ -913,6 +917,29 @@ final class FirmwareArtifactStore { _ params: FirmwareArtifactDownloadParams, partialURL: URL, resumeOffset: Int64 + ) async throws { + do { + try await FirmwareArtifactWallClockDeadline.run( + timeoutSeconds: + params.overallDeadlineSeconds ?? Self.defaultDownloadDeadline + ) { [self] in + try await streamDownloadWithinDeadline( + params, + partialURL: partialURL, + resumeOffset: resumeOffset + ) + } + } catch FirmwareArtifactDeadlineError.exceeded { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_DEADLINE_EXCEEDED: firmware download exceeded its deadline" + ) + } + } + + private func streamDownloadWithinDeadline( + _ params: FirmwareArtifactDownloadParams, + partialURL: URL, + resumeOffset: Int64 ) async throws { guard let url = URL(string: params.url), let hostname = url.host else { throw FirmwareArtifactStoreError.invalidInput("Invalid firmware URL") @@ -949,10 +976,10 @@ final class FirmwareArtifactStore { } } - let bytes: URLSession.AsyncBytes + let downloadedURL: URL let response: URLResponse do { - (bytes, response) = try await session.bytes(for: request) + (downloadedURL, response) = try await session.download(for: request) } catch { if isCancellationError( error, @@ -971,9 +998,20 @@ final class FirmwareArtifactStore { "ARTIFACT_NETWORK_FAILED: firmware request failed" ) } + defer { try? fileManager.removeItem(at: downloadedURL) } guard let httpResponse = response as? HTTPURLResponse else { throw FirmwareArtifactStoreError.downloadFailed("Firmware response is not HTTP") } + guard + let responseURL = httpResponse.url, + responseURL.scheme?.lowercased() == "https", + responseURL.host?.lowercased() == hostname.lowercased(), + responseURL.port == nil || responseURL.port == 443 + else { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity" + ) + } guard httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else { throw FirmwareArtifactStoreError.downloadFailed( "ARTIFACT_HTTP_\(httpResponse.statusCode): firmware request failed" @@ -1003,21 +1041,25 @@ final class FirmwareArtifactStore { } var written = append ? resumeOffset : 0 - var buffer = Data() - buffer.reserveCapacity(64 * 1024) + let source = try FileHandle(forReadingFrom: downloadedURL) + defer { try? source.close() } do { - for try await byte in bytes { - buffer.append(byte) - if buffer.count >= 64 * 1024 { - written += Int64(buffer.count) - guard written <= Int64(params.maxBytes) else { - throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" - ) - } - try handle.write(contentsOf: buffer) - buffer.removeAll(keepingCapacity: true) + while true { + try Task.checkCancellation() + try rejectIfCancelled(transactionId: params.transactionId) + guard + let buffer = try source.read(upToCount: 64 * 1024), + !buffer.isEmpty + else { + break } + written += Int64(buffer.count) + guard written <= Int64(params.maxBytes) else { + throw FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" + ) + } + try handle.write(contentsOf: buffer) } } catch let error as FirmwareArtifactStoreError { throw error @@ -1039,15 +1081,6 @@ final class FirmwareArtifactStore { "ARTIFACT_NETWORK_FAILED: firmware response stream failed" ) } - if !buffer.isEmpty { - written += Int64(buffer.count) - guard written <= Int64(params.maxBytes) else { - throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" - ) - } - try handle.write(contentsOf: buffer) - } try handle.synchronize() try rejectIfCancelled(transactionId: params.transactionId) } diff --git a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift index 4c0ea0db..990554c4 100644 --- a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift +++ b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift @@ -1,6 +1,77 @@ import Foundation import CommonCrypto +enum FirmwareArtifactDeadlineError: Error { + case exceeded +} + +enum FirmwareArtifactWallClockDeadline { + static func run( + timeoutSeconds: TimeInterval, + operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + let timeoutNanoseconds = UInt64(max(0.001, timeoutSeconds) * 1_000_000_000.0) + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(nanoseconds: timeoutNanoseconds) + throw FirmwareArtifactDeadlineError.exceeded + } + defer { group.cancelAll() } + guard let result = try await group.next() else { + throw FirmwareArtifactDeadlineError.exceeded + } + return result + } + } +} + +func firmwareArtifactDownloadKey( + transactionId: String, + expectedSize: Int64, + expectedSha256: String +) -> String { + "\(transactionId)|\(expectedSize)|\(expectedSha256)" +} + +enum FirmwareBackgroundDownloadError: LocalizedError, CustomStringConvertible { + case cancelled + case invalidTask + case deadlineExceeded + case redirectRejected + case responseRejected + case sizeRejected + case tlsRejected + case transferFailed + + var errorDescription: String? { + switch self { + case .cancelled: + return "ARTIFACT_CANCELLED: firmware background download was cancelled" + case .invalidTask: + return "ARTIFACT_PROTOCOL_INVALID: firmware background task is invalid" + case .deadlineExceeded: + return "ARTIFACT_DEADLINE_EXCEEDED: firmware download exceeded its deadline" + case .redirectRejected: + return "ARTIFACT_REDIRECT_REJECTED: firmware redirect changed canonical identity" + case .responseRejected: + return "ARTIFACT_PROTOCOL_INVALID: firmware response is invalid" + case .sizeRejected: + return "ARTIFACT_PROTOCOL_INVALID: firmware artifact size is invalid" + case .tlsRejected: + return "ARTIFACT_TLS_FAILED: firmware TLS validation failed" + case .transferFailed: + return "ARTIFACT_NETWORK_FAILED: firmware background transfer failed" + } + } + + var description: String { + errorDescription ?? "ARTIFACT_NETWORK_FAILED: firmware background transfer failed" + } +} + // MARK: - Dependency-free RangeDownloader logic (OCDS §4 / §5) // // This file holds the DETERMINISTIC, dependency-light pieces of the range diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index 849c199c..bca3b900 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -323,49 +323,22 @@ private struct FirmwareBackgroundTaskDescriptor: Codable, Equatable { let deadlineAt: TimeInterval var key: String { - "\(expectedSize)|\(expectedSha256)" + firmwareArtifactDownloadKey( + transactionId: transactionId, + expectedSize: expectedSize, + expectedSha256: expectedSha256 + ) } - func hasSameArtifactIdentity( + func hasSameDownloadIdentity( as other: FirmwareBackgroundTaskDescriptor ) -> Bool { - expectedSize == other.expectedSize && + transactionId == other.transactionId && + expectedSize == other.expectedSize && expectedSha256 == other.expectedSha256 } } -private enum FirmwareBackgroundDownloadError: LocalizedError { - case cancelled - case invalidTask - case deadlineExceeded - case redirectRejected - case responseRejected - case sizeRejected - case tlsRejected - case transferFailed - - var errorDescription: String? { - switch self { - case .cancelled: - return "ARTIFACT_CANCELLED: firmware background download was cancelled" - case .invalidTask: - return "ARTIFACT_PROTOCOL_INVALID: firmware background task is invalid" - case .deadlineExceeded: - return "ARTIFACT_DEADLINE_EXCEEDED: firmware download exceeded its deadline" - case .redirectRejected: - return "ARTIFACT_REDIRECT_REJECTED: firmware redirect changed canonical identity" - case .responseRejected: - return "ARTIFACT_PROTOCOL_INVALID: firmware response is invalid" - case .sizeRejected: - return "ARTIFACT_PROTOCOL_INVALID: firmware artifact size is invalid" - case .tlsRejected: - return "ARTIFACT_TLS_FAILED: firmware TLS validation failed" - case .transferFailed: - return "ARTIFACT_NETWORK_FAILED: firmware background transfer failed" - } - } -} - public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { public static let shared = RangeDownloader() @@ -836,9 +809,12 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { else { continue } - if candidate.hasSameArtifactIdentity(as: descriptor) { + if candidate.hasSameDownloadIdentity(as: descriptor) { matchingTaskFound = true - } else if candidate.taskId == descriptor.taskId { + } else if + candidate.transactionId == descriptor.transactionId && + candidate.taskId == descriptor.taskId + { task.cancel() } } diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift index f101c318..790ea601 100644 --- a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift @@ -26,6 +26,50 @@ import Foundation // ─────────────────────────────────────────────────────────────────────────── final class RangeDownloadLogicTests: XCTestCase { + func testFirmwareArtifactDownloadKeyIsolatesTransactions() { + let first = firmwareArtifactDownloadKey( + transactionId: "fwtx:first", + expectedSize: 42, + expectedSha256: String(repeating: "a", count: 64) + ) + let second = firmwareArtifactDownloadKey( + transactionId: "fwtx:second", + expectedSize: 42, + expectedSha256: String(repeating: "a", count: 64) + ) + + XCTAssertNotEqual(first, second) + } + + func testFirmwareArtifactWallClockDeadlineRejectsSlowOperation() async { + do { + _ = try await FirmwareArtifactWallClockDeadline.run( + timeoutSeconds: 0.01 + ) { + try await Task.sleep(nanoseconds: 1_000_000_000) + return true + } + XCTFail("Expected the wall-clock deadline to reject") + } catch FirmwareArtifactDeadlineError.exceeded { + // Expected. + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testFirmwareBackgroundErrorsKeepArtifactCodesInDescriptions() { + XCTAssertTrue( + String( + describing: FirmwareBackgroundDownloadError.transferFailed + ).contains("ARTIFACT_NETWORK_FAILED") + ) + XCTAssertTrue( + String( + describing: FirmwareBackgroundDownloadError.deadlineExceeded + ).contains("ARTIFACT_DEADLINE_EXCEEDED") + ) + } + // MARK: §4 — HTTP status classification // // OCDS §4. Mirrors Android's IsPermanentHttpStatusTest matrix. Asserts BOTH the diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index db798ede..00f82b8e 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -90,7 +90,7 @@ final class SniConnectPinnedResolverLease { } } -final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDelegate { +final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionTaskDelegate { private let hostname: String private let ip: String private let resolverLease: SniConnectPinnedResolverLease @@ -110,6 +110,16 @@ final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDelegate ("success", error == nil), ])) } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } } /// Core HTTPS client that enforces IP direct connection with SNI. From 1989d3f1e963a74e312dcd880bda3650a4996b42 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 28 Jul 2026 23:57:46 +0800 Subject: [PATCH 12/27] chore: bump app modules prerelease --- 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 | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- 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-network-throttle/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-range-downloader/package.json | 4 ++-- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- 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-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 2 +- 36 files changed, 37 insertions(+), 37 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 13e9eba1..fc0e4503 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 1ae7d953..944de05d 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 c7a24c6c..7e045709 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 ccb819e7..000fbda4 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 210de1f0..cb819c06 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.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 75830298..50d1f19e 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index c02387c3..f0d450df 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 4656006f..90c32ffd 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 0822d437..ebc06e5a 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 666fd10e..ba8ac91b 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 89decbe4..7ff64103 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 304a6094..2c28ea48 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 9f12ecb1..61f6667f 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 7f751871..8177062c 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 a9c4a758..73697e2e 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 7e0b7738..403c09ff 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.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index a8c6afd9..dd6ad9bc 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-network-throttle", "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 a61becd0..8cff4501 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 e8fd7e9b..03c6bff6 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 83adce9b..9c41a40c 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 d5b7ee7a..2d580531 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 9482d214..ed8797b1 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,7 +88,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.2", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.3", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 46ae3408..c839ddce 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "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 4d07ed64..8a53552f 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 15170b91..23e7f37b 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.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 70c6e40d..c2cb35b7 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 6122550f..23e81e2e 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.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-zip-archive Nitro HybridObject 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 db9638bf..8a6fb74b 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 25b93c47..30ca8f46 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-chart-webview", "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 0f8f29fb..66da3531 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 243caf47..386f643d 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 509eeaff..b30033ad 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 971698d2..2c34caf6 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.81-alpha.2", + "version": "3.0.81-alpha.3", "description": "react-native-segment-slider", "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 047f518f..52d6b5ee 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 b62603b0..2388834e 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.81-alpha.2", + "version": "3.0.81-alpha.3", "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 03356c60..306abd85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,7 +3688,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.2 + "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.3 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From defd57679cff9564959564bc49f9c93f5e188cb7 Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 29 Jul 2026 01:10:36 +0800 Subject: [PATCH 13/27] fix: stream bounded iOS firmware artifacts --- .../ios/FirmwareArtifactStore.swift | 420 +++++++++++++----- .../ios/RangeDownloadLogic.swift | 49 ++ .../RangeDownloadLogicTests.swift | 73 +++ .../ios/SniConnectClient.swift | 61 ++- .../ios/SniConnectPinnedTransport.swift | 15 +- 5 files changed, 492 insertions(+), 126 deletions(-) diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index bb2d4cb1..bca078cf 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -56,7 +56,100 @@ private struct StagedFirmwareArchiveEntry { let stagingURL: URL } -private final class FirmwareArtifactRedirectDelegate: NSObject, URLSessionTaskDelegate { +private final class FirmwareArtifactStreamDelegate: NSObject, URLSessionDataDelegate { + private let partialURL: URL + private let hostname: String + private let resumeOffset: Int64 + private let expectedSize: Int64 + private let maxBytes: Int64 + private let isCancelled: () -> Bool + private let stateLock = NSLock() + private var continuation: CheckedContinuation? + private var dataTask: URLSessionDataTask? + private var cancellationRequested = false + private var completed = false + private var responseAccepted = false + private var handle: FileHandle? + private var written: Int64 = 0 + + init( + partialURL: URL, + hostname: String, + resumeOffset: Int64, + expectedSize: Int64, + maxBytes: Int64, + isCancelled: @escaping () -> Bool + ) { + self.partialURL = partialURL + self.hostname = hostname + self.resumeOffset = resumeOffset + self.expectedSize = expectedSize + self.maxBytes = maxBytes + self.isCancelled = isCancelled + } + + func run(session: URLSession, request: URLRequest) async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + let task = session.dataTask(with: request) + stateLock.lock() + if cancellationRequested || completed { + stateLock.unlock() + task.cancel() + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + dataTask = task + stateLock.unlock() + task.resume() + } + } onCancel: { + self.cancel() + } + } + + private func cancel() { + stateLock.lock() + cancellationRequested = true + let task = dataTask + stateLock.unlock() + task?.cancel() + } + + private func finish(_ result: Result) { + stateLock.lock() + guard !completed else { + stateLock.unlock() + return + } + completed = true + let continuation = continuation + self.continuation = nil + dataTask = nil + let handle = handle + self.handle = nil + stateLock.unlock() + + try? handle?.close() + continuation?.resume(with: result) + } + + private func reject( + _ dataTask: URLSessionDataTask, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void, + message: String + ) { + completionHandler(.cancel) + fail(dataTask, message: message) + } + + private func fail(_ task: URLSessionTask, message: String) { + task.cancel() + finish(.failure(FirmwareArtifactStoreError.downloadFailed(message))) + } + func urlSession( _ session: URLSession, task: URLSessionTask, @@ -65,6 +158,192 @@ private final class FirmwareArtifactRedirectDelegate: NSObject, URLSessionTaskDe completionHandler: @escaping (URLRequest?) -> Void ) { completionHandler(nil) + fail( + task, + message: + "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity" + ) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + guard !isCancelled() else { + reject( + dataTask, + completionHandler: completionHandler, + message: "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + reject( + dataTask, + completionHandler: completionHandler, + message: "Firmware response is not HTTP" + ) + return + } + guard + let responseURL = httpResponse.url, + responseURL.scheme?.lowercased() == "https", + responseURL.host?.lowercased() == hostname.lowercased(), + responseURL.port == nil || responseURL.port == 443 + else { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity" + ) + return + } + guard httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_HTTP_\(httpResponse.statusCode): firmware request failed" + ) + return + } + + let append = resumeOffset > 0 && httpResponse.statusCode == 206 + if httpResponse.statusCode == 206 { + guard + let contentRange = httpResponse.value( + forHTTPHeaderField: "Content-Range" + ), + firmwareArtifactContentRangeIsValid( + contentRange, + expectedStart: append ? resumeOffset : 0, + expectedTotal: expectedSize + ) + else { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid" + ) + return + } + } + + let baseOffset = append ? resumeOffset : 0 + guard + firmwareArtifactResponseFits( + expectedContentLength: httpResponse.expectedContentLength, + baseOffset: baseOffset, + maxBytes: maxBytes + ) + else { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" + ) + return + } + + do { + let handle = try FileHandle(forWritingTo: partialURL) + if append { + try handle.seekToEnd() + } else { + try handle.truncate(atOffset: 0) + } + self.handle = handle + written = baseOffset + responseAccepted = true + completionHandler(.allow) + } catch { + reject( + dataTask, + completionHandler: completionHandler, + message: + "ARTIFACT_NETWORK_FAILED: firmware partial file could not be opened" + ) + } + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + guard responseAccepted, let handle else { + fail( + dataTask, + message: + "ARTIFACT_PROTOCOL_INVALID: firmware response stream was not accepted" + ) + return + } + guard !isCancelled() else { + fail( + dataTask, + message: "ARTIFACT_CANCELLED: firmware artifact download was cancelled" + ) + return + } + guard + firmwareArtifactResponseFits( + expectedContentLength: Int64(data.count), + baseOffset: written, + maxBytes: maxBytes + ) + else { + fail( + dataTask, + message: + "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" + ) + return + } + do { + try handle.write(contentsOf: data) + written += Int64(data.count) + } catch { + fail( + dataTask, + message: + "ARTIFACT_NETWORK_FAILED: firmware response stream could not be persisted" + ) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + if let error { + finish(.failure(error)) + return + } + guard responseAccepted, let handle else { + finish( + .failure(FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_PROTOCOL_INVALID: firmware response stream was not accepted" + )) + ) + return + } + do { + try handle.synchronize() + finish(.success(())) + } catch { + finish( + .failure(FirmwareArtifactStoreError.downloadFailed( + "ARTIFACT_NETWORK_FAILED: firmware partial file could not be synchronized" + )) + ) + } } } @@ -286,7 +565,11 @@ final class FirmwareArtifactStore { } let partialURL = rootURL.appendingPathComponent( - "\(expectedSha256).\(params.taskId).partial", + firmwareArtifactPartialFileName( + transactionId: params.transactionId, + taskId: params.taskId, + expectedSha256: expectedSha256 + ), isDirectory: false ) if !fileManager.fileExists(atPath: partialURL.path) { @@ -953,19 +1236,30 @@ final class FirmwareArtifactStore { request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range") } + let streamDelegate = FirmwareArtifactStreamDelegate( + partialURL: partialURL, + hostname: hostname, + resumeOffset: resumeOffset, + expectedSize: Int64(params.expectedSize), + maxBytes: Int64(params.maxBytes), + isCancelled: { [weak self] in + Task.isCancelled || + self?.isTransactionCancelled(params.transactionId) == true + } + ) let pinnedSession: SniConnectPinnedSession? if params.routeType == "pinnedIp" { pinnedSession = try SniConnectPinnedTransport.makeSession( hostname: hostname, - ip: params.resolvedIp! + ip: params.resolvedIp!, + dataDelegate: streamDelegate ) } else { pinnedSession = nil } - let domainDelegate = FirmwareArtifactRedirectDelegate() let session = pinnedSession?.session ?? URLSession( configuration: .ephemeral, - delegate: domainDelegate, + delegate: streamDelegate, delegateQueue: nil ) defer { @@ -976,91 +1270,8 @@ final class FirmwareArtifactStore { } } - let downloadedURL: URL - let response: URLResponse - do { - (downloadedURL, response) = try await session.download(for: request) - } catch { - if isCancellationError( - error, - transactionId: params.transactionId - ) { - throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_CANCELLED: firmware artifact download was cancelled" - ) - } - if isFirmwareArtifactTLSError(error) { - throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_TLS_FAILED: firmware TLS validation failed" - ) - } - throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_NETWORK_FAILED: firmware request failed" - ) - } - defer { try? fileManager.removeItem(at: downloadedURL) } - guard let httpResponse = response as? HTTPURLResponse else { - throw FirmwareArtifactStoreError.downloadFailed("Firmware response is not HTTP") - } - guard - let responseURL = httpResponse.url, - responseURL.scheme?.lowercased() == "https", - responseURL.host?.lowercased() == hostname.lowercased(), - responseURL.port == nil || responseURL.port == 443 - else { - throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_REDIRECT_REJECTED: firmware response changed canonical identity" - ) - } - guard httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else { - throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_HTTP_\(httpResponse.statusCode): firmware request failed" - ) - } - let append = resumeOffset > 0 && httpResponse.statusCode == 206 - if httpResponse.statusCode == 206 { - guard - let contentRange = httpResponse.value(forHTTPHeaderField: "Content-Range"), - validateContentRange( - contentRange, - expectedStart: append ? resumeOffset : 0, - expectedTotal: Int64(params.expectedSize) - ) - else { - throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid" - ) - } - } - let handle = try FileHandle(forWritingTo: partialURL) - defer { try? handle.close() } - if append { - try handle.seekToEnd() - } else { - try handle.truncate(atOffset: 0) - } - - var written = append ? resumeOffset : 0 - let source = try FileHandle(forReadingFrom: downloadedURL) - defer { try? source.close() } do { - while true { - try Task.checkCancellation() - try rejectIfCancelled(transactionId: params.transactionId) - guard - let buffer = try source.read(upToCount: 64 * 1024), - !buffer.isEmpty - else { - break - } - written += Int64(buffer.count) - guard written <= Int64(params.maxBytes) else { - throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes" - ) - } - try handle.write(contentsOf: buffer) - } + try await streamDelegate.run(session: session, request: request) } catch let error as FirmwareArtifactStoreError { throw error } catch { @@ -1078,10 +1289,9 @@ final class FirmwareArtifactStore { ) } throw FirmwareArtifactStoreError.downloadFailed( - "ARTIFACT_NETWORK_FAILED: firmware response stream failed" + "ARTIFACT_NETWORK_FAILED: firmware request failed" ) } - try handle.synchronize() try rejectIfCancelled(transactionId: params.transactionId) } @@ -1112,34 +1322,6 @@ final class FirmwareArtifactStore { nsError.code == NSURLErrorCancelled } - private func validateContentRange( - _ value: String, - expectedStart: Int64, - expectedTotal: Int64 - ) -> Bool { - let pattern = #"^bytes ([0-9]+)-([0-9]+)/([0-9]+)$"# - guard - let expression = try? NSRegularExpression(pattern: pattern), - let match = expression.firstMatch( - in: value.lowercased(), - range: NSRange(value.startIndex..., in: value) - ), - match.range.location != NSNotFound, - let startRange = Range(match.range(at: 1), in: value), - let endRange = Range(match.range(at: 2), in: value), - let totalRange = Range(match.range(at: 3), in: value), - let start = Int64(value[startRange]), - let end = Int64(value[endRange]), - let total = Int64(value[totalRange]) - else { - return false - } - return start == expectedStart && - end >= start && - end < total && - total == expectedTotal - } - private func validateStoredArtifact( fileURL: URL, expectedSize: Int64, diff --git a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift index 990554c4..3727e1fa 100644 --- a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift +++ b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift @@ -36,6 +36,55 @@ func firmwareArtifactDownloadKey( "\(transactionId)|\(expectedSize)|\(expectedSha256)" } +func firmwareArtifactPartialFileName( + transactionId: String, + taskId: String, + expectedSha256: String +) -> String { + let transactionData = Data(transactionId.utf8) + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + transactionData.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(transactionData.count), &hash) + } + let transactionToken = hash + .prefix(8) + .map { String(format: "%02x", $0) } + .joined() + return "\(expectedSha256).\(taskId).\(transactionToken).partial" +} + +func firmwareArtifactContentRangeIsValid( + _ value: String, + expectedStart: Int64, + expectedTotal: Int64 +) -> Bool { + guard value.lowercased().hasPrefix("bytes ") else { + return false + } + guard + let bounds = RangeDownloadLogic.parseContentRangeBounds(value), + let total = RangeDownloadLogic.parseContentRangeTotal(value) + else { + return false + } + return bounds.start == expectedStart && + bounds.end >= bounds.start && + bounds.end < total && + total == expectedTotal +} + +func firmwareArtifactResponseFits( + expectedContentLength: Int64, + baseOffset: Int64, + maxBytes: Int64 +) -> Bool { + guard baseOffset >= 0, baseOffset <= maxBytes else { + return false + } + return expectedContentLength < 0 || + expectedContentLength <= maxBytes - baseOffset +} + enum FirmwareBackgroundDownloadError: LocalizedError, CustomStringConvertible { case cancelled case invalidTask diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift index 790ea601..8a46d305 100644 --- a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift @@ -41,6 +41,79 @@ final class RangeDownloadLogicTests: XCTestCase { XCTAssertNotEqual(first, second) } + func testFirmwareArtifactPartialFileNameIsolatesTransactions() { + let sha256 = String(repeating: "a", count: 64) + let first = firmwareArtifactPartialFileName( + transactionId: "fwtx:first", + taskId: "firmware", + expectedSha256: sha256 + ) + let second = firmwareArtifactPartialFileName( + transactionId: "fwtx:second", + taskId: "firmware", + expectedSha256: sha256 + ) + + XCTAssertNotEqual(first, second) + XCTAssertTrue(first.hasPrefix("\(sha256).firmware.")) + XCTAssertTrue(first.hasSuffix(".partial")) + } + + func testFirmwareArtifactContentRangeValidation() { + XCTAssertTrue( + firmwareArtifactContentRangeIsValid( + "bytes 128-255/256", + expectedStart: 128, + expectedTotal: 256 + ) + ) + XCTAssertFalse( + firmwareArtifactContentRangeIsValid( + "bytes 0-255/256", + expectedStart: 128, + expectedTotal: 256 + ) + ) + XCTAssertFalse( + firmwareArtifactContentRangeIsValid( + "bytes 128-256/256", + expectedStart: 128, + expectedTotal: 256 + ) + ) + XCTAssertFalse( + firmwareArtifactContentRangeIsValid( + "items 128-255/256", + expectedStart: 128, + expectedTotal: 256 + ) + ) + } + + func testFirmwareArtifactResponseSizeIsBoundedBeforeStreaming() { + XCTAssertTrue( + firmwareArtifactResponseFits( + expectedContentLength: 128, + baseOffset: 128, + maxBytes: 256 + ) + ) + XCTAssertFalse( + firmwareArtifactResponseFits( + expectedContentLength: 129, + baseOffset: 128, + maxBytes: 256 + ) + ) + XCTAssertTrue( + firmwareArtifactResponseFits( + expectedContentLength: -1, + baseOffset: 128, + maxBytes: 256 + ) + ) + } + func testFirmwareArtifactWallClockDeadlineRejectsSlowOperation() async { do { _ = try await FirmwareArtifactWallClockDeadline.run( diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index 00f82b8e..1fd0f844 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -90,15 +90,22 @@ final class SniConnectPinnedResolverLease { } } -final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionTaskDelegate { +final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDataDelegate { private let hostname: String private let ip: String private let resolverLease: SniConnectPinnedResolverLease + private weak var forwardingDataDelegate: URLSessionDataDelegate? - init(hostname: String, ip: String, resolverLease: SniConnectPinnedResolverLease) { + init( + hostname: String, + ip: String, + resolverLease: SniConnectPinnedResolverLease, + forwardingDataDelegate: URLSessionDataDelegate? = nil + ) { self.hostname = hostname self.ip = ip self.resolverLease = resolverLease + self.forwardingDataDelegate = forwardingDataDelegate } func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { @@ -118,7 +125,55 @@ final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionTaskDeleg newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { - completionHandler(nil) + if let forwardingDataDelegate { + forwardingDataDelegate.urlSession?( + session, + task: task, + willPerformHTTPRedirection: response, + newRequest: request, + completionHandler: completionHandler + ) + } else { + completionHandler(nil) + } + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + forwardingDataDelegate?.urlSession?( + session, + dataTask: dataTask, + didReceive: response, + completionHandler: completionHandler + ) ?? completionHandler(.cancel) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + forwardingDataDelegate?.urlSession?( + session, + dataTask: dataTask, + didReceive: data + ) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + forwardingDataDelegate?.urlSession?( + session, + task: task, + didCompleteWithError: error + ) } } diff --git a/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift b/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift index 3769d3d0..65f0f55b 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectPinnedTransport.swift @@ -30,7 +30,8 @@ public final class SniConnectPinnedSession { public enum SniConnectPinnedTransport { static func makeResources( hostname: String, - ip: String + ip: String, + dataDelegate: URLSessionDataDelegate? = nil ) throws -> SniConnectPinnedTransportResources { try SniConnectValidation.validateHostname(hostname) try SniConnectValidation.validatePublicIP(ip) @@ -65,7 +66,8 @@ public enum SniConnectPinnedTransport { let delegate = SniConnectSessionInvalidationDelegate( hostname: normalizedHostname, ip: ip, - resolverLease: resolverLease + resolverLease: resolverLease, + forwardingDataDelegate: dataDelegate ) return SniConnectPinnedTransportResources( configuration: configuration, @@ -76,10 +78,15 @@ public enum SniConnectPinnedTransport { public static func makeSession( hostname: String, - ip: String + ip: String, + dataDelegate: URLSessionDataDelegate? = nil ) throws -> SniConnectPinnedSession { SniConnectPinnedSession( - resources: try makeResources(hostname: hostname, ip: ip) + resources: try makeResources( + hostname: hostname, + ip: ip, + dataDelegate: dataDelegate + ) ) } } From 6164f2814ed93f6dd6831aae33f3a42a11edf43d Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 29 Jul 2026 06:53:11 +0800 Subject: [PATCH 14/27] chore: bump app modules prerelease --- 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 | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- 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-network-throttle/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-range-downloader/package.json | 4 ++-- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- 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-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 2 +- 36 files changed, 37 insertions(+), 37 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index fc0e4503..54da096e 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 944de05d..89499721 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 7e045709..91589aeb 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 000fbda4..7ccebab7 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 cb819c06..08960f5b 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.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 50d1f19e..207aa00c 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index f0d450df..b733cb52 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 90c32ffd..57bd9ad8 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 ebc06e5a..f05b67e7 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 ba8ac91b..11dcb209 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 7ff64103..264b2f94 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 2c28ea48..37e5ebde 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 61f6667f..ee287ad5 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 8177062c..65785d0b 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 73697e2e..36e7c39e 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 403c09ff..66834016 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.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index dd6ad9bc..97ed6317 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-network-throttle", "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 8cff4501..6ff9d46b 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 03c6bff6..f89dec83 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 9c41a40c..f29292c1 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 2d580531..142f70e5 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index ed8797b1..9e5d0d48 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,7 +88,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.3", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.4", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index c839ddce..aa8a960b 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "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 8a53552f..dc0e0167 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 23e7f37b..794aaafa 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.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index c2cb35b7..06f4ea21 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 23e81e2e..33dc8c42 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.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-zip-archive Nitro HybridObject 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 8a6fb74b..7293ecc0 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 30ca8f46..586500af 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-chart-webview", "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 66da3531..281a1977 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 386f643d..427a06fe 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index b30033ad..86681a5e 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 2c34caf6..0b8f73af 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.81-alpha.3", + "version": "3.0.81-alpha.4", "description": "react-native-segment-slider", "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 52d6b5ee..92ff0991 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 2388834e..a8a9afd3 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.81-alpha.3", + "version": "3.0.81-alpha.4", "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 306abd85..8861ec79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,7 +3688,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.3 + "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.4 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From f4b2a124ec3c814416d9ef4db586608cab51468a Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 30 Jul 2026 17:04:43 +0800 Subject: [PATCH 15/27] fix: avoid swift optimizer ownership crash --- .../ios/ReactNativeRangeDownloader.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index bca3b900..77496784 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -1634,24 +1634,24 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { if completion.completed { return } + let failure: Error + if let recordedError = completion.error { + failure = recordedError + } else if FirmwareArtifactStore.shared.isTransactionCancelled( + descriptor.transactionId + ) { + failure = FirmwareBackgroundDownloadError.cancelled + } else if let taskError = error { + failure = + isFirmwareArtifactTLSError(taskError) + ? FirmwareBackgroundDownloadError.tlsRejected + : FirmwareBackgroundDownloadError.transferFailed + } else { + failure = FirmwareBackgroundDownloadError.transferFailed + } finishFirmwareTask( key: descriptor.key, - result: .failure( - completion.error ?? - ( - FirmwareArtifactStore.shared.isTransactionCancelled( - descriptor.transactionId - ) - ? FirmwareBackgroundDownloadError.cancelled - : nil - ) ?? - error.map { - isFirmwareArtifactTLSError($0) - ? FirmwareBackgroundDownloadError.tlsRejected - : FirmwareBackgroundDownloadError.transferFailed - } ?? - FirmwareBackgroundDownloadError.transferFailed - ) + result: .failure(failure) ) return } From 2e68238e0c213ee041380e2fa3adc099a7e97099 Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 30 Jul 2026 17:06:01 +0800 Subject: [PATCH 16/27] chore: bump app modules prerelease --- 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 | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- 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-network-throttle/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-range-downloader/package.json | 4 ++-- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- 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-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 2 +- 36 files changed, 37 insertions(+), 37 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 54da096e..4c5775a3 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 89499721..5c795cb2 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 91589aeb..9bc5efe2 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 7ccebab7..ed3456d7 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 08960f5b..325bc84c 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.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 207aa00c..15ab605b 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index b733cb52..0d87112b 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 57bd9ad8..e0e9c12a 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 f05b67e7..da86dc0f 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 11dcb209..603d078d 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 264b2f94..9a3b3633 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 37e5ebde..2ca06b68 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 ee287ad5..be818a06 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 65785d0b..793b123c 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 36e7c39e..1f2ccf5f 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 66834016..5ee51a5c 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.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index 97ed6317..7f5cea8d 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-network-throttle", "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 6ff9d46b..14ca3826 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 f89dec83..56b9353a 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 f29292c1..b6d667f4 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 142f70e5..606f3d97 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 9e5d0d48..5f52790f 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,7 +88,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.4", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.5", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index aa8a960b..5a4c7e63 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "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 dc0e0167..9f00566d 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 794aaafa..8abbdc04 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.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 06f4ea21..893286fb 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 33dc8c42..2db724a1 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.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-zip-archive Nitro HybridObject 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 7293ecc0..5be66eb6 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 586500af..13ef582e 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-chart-webview", "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 281a1977..1f3634f9 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 427a06fe..bea4bf3b 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 86681a5e..17ab3fd2 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 0b8f73af..a8a110cd 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.81-alpha.4", + "version": "3.0.81-alpha.5", "description": "react-native-segment-slider", "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 92ff0991..a9c2f954 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 a8a9afd3..20b37c8e 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.81-alpha.4", + "version": "3.0.81-alpha.5", "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 8861ec79..7441193b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,7 +3688,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.4 + "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.5 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From 53b866925ab972b5edb37cb55c4ce473bb1f3a95 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 31 Jul 2026 00:30:19 +0800 Subject: [PATCH 17/27] fix: settle cached firmware artifact promises --- .../ios/FirmwareArtifactStore.swift | 23 ++++++++++ .../ios/ReactNativeRangeDownloader.swift | 43 ++++++++++++++----- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index bca078cf..07061f42 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -530,6 +530,29 @@ final class FirmwareArtifactStore { } } + func cachedArtifact( + _ params: FirmwareArtifactDownloadParams + ) throws -> StoredFirmwareArtifact? { + try Self.validateDownloadParams(params) + try rejectIfCancelled(transactionId: params.transactionId) + let expectedSha256 = params.expectedSha256.lowercased() + let finalURL = artifactURL(sha256: expectedSha256) + guard let artifact = try? validateStoredArtifact( + fileURL: finalURL, + expectedSize: Int64(params.expectedSize), + expectedSha256: expectedSha256 + ) else { + return nil + } + try retainExpectedArtifact( + leaseRef: params.leaseRef, + transactionId: params.transactionId, + artifactRef: artifact.artifactRef + ) + try rejectIfCancelled(transactionId: params.transactionId) + return artifact + } + func cancelDownloads(transactionId: String) async throws { guard Self.isSafeIdentifier(transactionId) else { throw FirmwareArtifactStoreError.invalidInput( diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index 77496784..a7d23ce1 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -2,6 +2,16 @@ import Foundation import NitroModules import ReactNativeNativeLogger +private func settledFirmwareArtifactPromise( + _ operation: () throws -> T +) -> Promise { + do { + return Promise.resolved(withResult: try operation()) + } catch { + return Promise.rejected(withError: error) + } +} + // MARK: - Nitro HybridObject entry point // // Thin Nitro shim over `RangeDownloader.shared`. The heavy lifting — concurrent @@ -108,7 +118,20 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func downloadFirmwareArtifact( params: FirmwareArtifactDownloadParams ) throws -> Promise { - Promise.async { + do { + if let artifact = try FirmwareArtifactStore.shared.cachedArtifact(params) { + return Promise.resolved( + withResult: FirmwareArtifactReceipt( + artifactRef: artifact.artifactRef, + size: Double(artifact.size), + sha256: artifact.sha256 + ) + ) + } + } catch { + return Promise.rejected(withError: error) + } + return Promise.async { let artifact = try await FirmwareArtifactStore.shared.download(params) return FirmwareArtifactReceipt( artifactRef: artifact.artifactRef, @@ -131,7 +154,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func discardFirmwareArtifact( params: FirmwareArtifactRefParams ) throws -> Promise { - Promise.async { + settledFirmwareArtifactPromise { try FirmwareArtifactStore.shared.discard(artifactRef: params.artifactRef) } } @@ -139,7 +162,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func openFirmwareArtifact( params: FirmwareArtifactRefParams ) throws -> Promise { - Promise.async { + settledFirmwareArtifactPromise { let reader = try FirmwareArtifactStore.shared.open( artifactRef: params.artifactRef ) @@ -153,7 +176,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func readFirmwareArtifact( params: FirmwareArtifactReaderReadParams ) throws -> Promise { - Promise.async { + settledFirmwareArtifactPromise { guard params.offset.isFinite, params.offset >= 0, @@ -180,7 +203,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func closeFirmwareArtifact( params: FirmwareArtifactReaderCloseParams ) throws -> Promise { - Promise.async { + settledFirmwareArtifactPromise { try FirmwareArtifactStore.shared.close(readerId: params.readerId) } } @@ -188,7 +211,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func materializeFirmwareArchive( params: FirmwareArchiveMaterializeParams ) throws -> Promise { - Promise.async { + settledFirmwareArtifactPromise { let entries = try FirmwareArtifactStore.shared.materializeArchive( leaseRef: params.leaseRef, artifactRef: params.archiveArtifactRef, @@ -212,7 +235,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func createFirmwareArtifactLease( params: FirmwareArtifactLeaseCreateParams ) throws -> Promise { - Promise.async { + settledFirmwareArtifactPromise { FirmwareArtifactLease( leaseRef: try FirmwareArtifactStore.shared.createLease( transactionId: params.transactionId @@ -224,7 +247,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func retainFirmwareArtifact( params: FirmwareArtifactLeaseRetainParams ) throws -> Promise { - Promise.async { + settledFirmwareArtifactPromise { try FirmwareArtifactStore.shared.retain( leaseRef: params.leaseRef, artifactRef: params.artifactRef @@ -235,7 +258,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func releaseFirmwareArtifactLease( params: FirmwareArtifactLeaseReleaseParams ) throws -> Promise { - Promise.async { + settledFirmwareArtifactPromise { try FirmwareArtifactStore.shared.releaseLease( leaseRef: params.leaseRef, disposition: params.disposition @@ -244,7 +267,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { } func sweepFirmwareArtifactOrphans() throws -> Promise { - Promise.async { + settledFirmwareArtifactPromise { let result = try FirmwareArtifactStore.shared.sweepOrphans() return FirmwareArtifactSweepResult( deletedFiles: Double(result.deletedFiles), From 230c0caa60705bdd395bf4a9e262916f7c441a59 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 31 Jul 2026 00:31:25 +0800 Subject: [PATCH 18/27] chore: bump app modules prerelease --- 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 | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- 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-network-throttle/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-range-downloader/package.json | 4 ++-- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- 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-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 2 +- 36 files changed, 37 insertions(+), 37 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 4c5775a3..38dc344c 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 5c795cb2..8d01eb54 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 9bc5efe2..0902215e 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 ed3456d7..6834e3eb 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 325bc84c..5e2df9ff 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.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 15ab605b..9ab5936d 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 0d87112b..87c50575 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 e0e9c12a..857390cd 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 da86dc0f..aaa324ef 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 603d078d..69542ce8 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 9a3b3633..2b77c2e9 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 2ca06b68..534ba5bd 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 be818a06..7562bbcc 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 793b123c..03877a65 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 1f2ccf5f..7254a14e 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 5ee51a5c..3e9a6eca 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.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index 7f5cea8d..ed1f969e 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-network-throttle", "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 14ca3826..90a3afb1 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 56b9353a..5c1a02e9 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 b6d667f4..fe26ffa9 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 606f3d97..552e7868 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 5f52790f..0487dcea 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,7 +88,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.5", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.6", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 5a4c7e63..55572bc0 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "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 9f00566d..c6b741bf 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 8abbdc04..85cf52e7 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.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 893286fb..3dce4e7a 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 2db724a1..fe8c24a1 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.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-zip-archive Nitro HybridObject 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 5be66eb6..fa08c9cd 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 13ef582e..df7eb4be 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-chart-webview", "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 1f3634f9..0981bd18 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index bea4bf3b..ba507cb7 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 17ab3fd2..a6a03052 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index a8a110cd..6087fb3f 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.81-alpha.5", + "version": "3.0.81-alpha.6", "description": "react-native-segment-slider", "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 a9c2f954..e325aa55 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 20b37c8e..35dbe8e9 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.81-alpha.5", + "version": "3.0.81-alpha.6", "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 7441193b..523f9abd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,7 +3688,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.5 + "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.6 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From 47b59d8f054356fe8226eca7e7c0484554c1ece8 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 31 Jul 2026 15:58:53 +0800 Subject: [PATCH 19/27] fix: use foreground firmware artifact stream --- .../ios/FirmwareArtifactStore.swift | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index 07061f42..333455cc 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import ReactNativeNativeLogger import SniConnect func isFirmwareArtifactTLSError(_ error: Error) -> Bool { @@ -520,6 +521,10 @@ final class FirmwareArtifactStore { return artifact } catch { markDownloadActive(expectedSha256, delta: -1) + OneKeyLog.error( + "FirmwareArtifact", + "event=download_failed transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) errorType=\(String(describing: type(of: error)))" + ) if error is CancellationError || isTransactionCancelled(params.transactionId) { throw FirmwareArtifactStoreError.downloadFailed( @@ -581,12 +586,8 @@ final class FirmwareArtifactStore { return existing } - if params.routeType == "domain" { - return try await RangeDownloader.shared.downloadFirmwareArtifact( - params: params - ) - } - + // Firmware preflight is foreground-bound, so both routes use the same + // cancellable stream instead of waiting on process-owned background tasks. let partialURL = rootURL.appendingPathComponent( firmwareArtifactPartialFileName( transactionId: params.transactionId, @@ -623,11 +624,19 @@ final class FirmwareArtifactStore { currentSize = 0 } + OneKeyLog.info( + "FirmwareArtifact", + "event=stream_start transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(Int64(params.expectedSize)) resumeBytes=\(currentSize)" + ) try await streamDownload( params, partialURL: partialURL, resumeOffset: min(currentSize, Int64(params.expectedSize)) ) + OneKeyLog.info( + "FirmwareArtifact", + "event=stream_complete transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(Int64(params.expectedSize))" + ) let artifact: StoredFirmwareArtifact do { artifact = try validateStoredArtifact( From ba78c8560d5ec479b2ca613da173f4b0c2d14004 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 31 Jul 2026 16:00:19 +0800 Subject: [PATCH 20/27] chore: bump app modules prerelease --- 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 | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- 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-network-throttle/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-range-downloader/package.json | 4 ++-- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- 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-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 2 +- 36 files changed, 37 insertions(+), 37 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 38dc344c..93ef7971 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 8d01eb54..3e101a4f 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 0902215e..2c57a8da 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 6834e3eb..bb32ff5c 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 5e2df9ff..bfcf471a 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.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 9ab5936d..022afdb5 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 87c50575..27594087 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 857390cd..7f8a1c02 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 aaa324ef..e2753735 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 69542ce8..0df9a170 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 2b77c2e9..5c4fe767 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 534ba5bd..d9cc15d8 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 7562bbcc..5c3c2769 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 03877a65..078e6ef1 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 7254a14e..e2705be8 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 3e9a6eca..26ac4b91 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.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index ed1f969e..54e080a1 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-network-throttle", "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 90a3afb1..b49cb652 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 5c1a02e9..bcb224a2 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 fe26ffa9..df91a727 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 552e7868..3d87a039 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 0487dcea..dab844d7 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,7 +88,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.6", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.7", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 55572bc0..f067a121 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "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 c6b741bf..d0392bf4 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 85cf52e7..79798e35 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.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 3dce4e7a..1f389749 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 fe8c24a1..49cdd68c 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.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-zip-archive Nitro HybridObject 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 fa08c9cd..217aebf8 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index df7eb4be..2afa1dda 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-chart-webview", "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 0981bd18..1043892f 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index ba507cb7..9e85120c 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index a6a03052..f4a9acfa 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 6087fb3f..194680f8 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.81-alpha.6", + "version": "3.0.81-alpha.7", "description": "react-native-segment-slider", "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 e325aa55..dbf4fd2a 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 35dbe8e9..97f1b82a 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.81-alpha.6", + "version": "3.0.81-alpha.7", "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 523f9abd..50f19ff5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,7 +3688,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.6 + "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.7 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From f350f6da5f89d384907dbbb3703e80d18dcaeeb9 Mon Sep 17 00:00:00 2001 From: Leon Date: Sun, 2 Aug 2026 18:01:23 +0800 Subject: [PATCH 21/27] chore: bump app modules prerelease --- 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 | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- 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-network-throttle/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-range-downloader/package.json | 4 ++-- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- 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-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- yarn.lock | 2 +- 36 files changed, 37 insertions(+), 37 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 93ef7971..3d70fd90 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 3e101a4f..bef06f40 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 2c57a8da..28fb186f 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 bb32ff5c..70faa341 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 bfcf471a..52b3af59 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.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 022afdb5..e8fde4b1 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 27594087..7cb99734 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 7f8a1c02..c2eba719 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 e2753735..98a816fc 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 0df9a170..50b74f5d 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 5c4fe767..d30742c5 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 d9cc15d8..e9aaa2fe 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 5c3c2769..0c915521 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 078e6ef1..14b56d31 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 e2705be8..758e4610 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 26ac4b91..84b0bffe 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.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-throttle/package.json b/native-modules/react-native-network-throttle/package.json index 54e080a1..3bc6f1fc 100644 --- a/native-modules/react-native-network-throttle/package.json +++ b/native-modules/react-native-network-throttle/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-throttle", - "version": "3.0.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-network-throttle", "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 b49cb652..bb238f13 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 bcb224a2..5d33067c 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 df91a727..16a5c7b6 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 3d87a039..f53341b3 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index dab844d7..6f028313 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,7 +88,7 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.7", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.8", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index f067a121..248eab33 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "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 d0392bf4..e51ff67a 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 79798e35..78976254 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.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 1f389749..edb84535 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 49cdd68c..1a018fa0 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.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-zip-archive Nitro HybridObject 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 217aebf8..7ac143d9 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 2afa1dda..e262d078 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-chart-webview", "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 1043892f..23eb5124 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 9e85120c..948c94e0 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index f4a9acfa..fe30c539 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 194680f8..d26ee3b0 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.81-alpha.7", + "version": "3.0.81-alpha.8", "description": "react-native-segment-slider", "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 dbf4fd2a..a5a1af6f 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 97f1b82a..219f15f6 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.81-alpha.7", + "version": "3.0.81-alpha.8", "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 50f19ff5..7fa54d96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,7 +3688,7 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.7 + "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.8 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From 4fe147a2577acfa8ba4698c70509e6e98e6e1c6e Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 6 Aug 2026 11:51:53 +0800 Subject: [PATCH 22/27] fix: address firmware artifact review findings --- .../FirmwareArtifactOrphanSweep.kt | 111 +++++++++++ .../FirmwareArtifactStore.kt | 37 +--- .../FirmwareArtifactOrphanSweepTest.kt | 107 +++++++++++ .../ios/FirmwareArtifactStore.swift | 65 +------ .../ios/RangeDownloadLogic.swift | 176 ++++++++++++++++++ .../ios/ReactNativeRangeDownloader.swift | 9 +- .../RangeDownloadLogicTests.swift | 147 +++++++++++++++ 7 files changed, 563 insertions(+), 89 deletions(-) create mode 100644 native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt create mode 100644 native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt new file mode 100644 index 00000000..1a92965a --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt @@ -0,0 +1,111 @@ +package com.margelo.nitro.reactnativerangedownloader + +import java.io.File + +internal const val FIRMWARE_ARTIFACT_FINAL_GRACE_MS = 24L * 60 * 60 * 1000 +internal const val FIRMWARE_ARTIFACT_PARTIAL_GRACE_MS = 7L * 24 * 60 * 60 * 1000 +internal const val FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS = + FIRMWARE_ARTIFACT_PARTIAL_GRACE_MS + +private val firmwareArtifactSha256Pattern = Regex("^[a-f0-9]{64}$") +private val firmwareArchiveScratchPattern = Regex( + "^archive-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + + "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", +) +private val firmwarePromoteScratchPattern = Regex( + "^\\.promote-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + + "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", +) + +private fun File.isSymbolicLinkEntry(): Boolean = try { + val canonicalParent = parentFile?.canonicalFile + val entryFromCanonicalParent = canonicalParent?.let { File(it, name) } ?: this + entryFromCanonicalParent.canonicalFile != entryFromCanonicalParent.absoluteFile +} catch (_: Exception) { + true +} + +private fun firmwareArtifactEntrySize(entry: File): Long { + if (entry.isSymbolicLinkEntry()) return 0 + if (entry.isFile) return entry.length() + if (!entry.isDirectory) return 0 + return entry.listFiles()?.sumOf(::firmwareArtifactEntrySize) ?: 0 +} + +private fun deleteFirmwareArtifactEntry(entry: File): Boolean { + if (entry.isSymbolicLinkEntry()) return entry.delete() + if (entry.isDirectory) { + val children = entry.listFiles() ?: return false + if (children.any { !deleteFirmwareArtifactEntry(it) }) return false + } + return entry.delete() +} + +private fun firmwareArtifactEntryExceededGrace( + entry: File, + nowMs: Long, + graceMs: Long, +): Boolean { + val modifiedAt = entry.lastModified() + return modifiedAt > 0 && modifiedAt <= nowMs && nowMs - modifiedAt >= graceMs +} + +internal fun sweepFirmwareArtifactOrphansAtRoot( + root: File, + retainedSha256: Set, + activeSha256: Set, + openPaths: Set, + nowMs: Long = System.currentTimeMillis(), +): Pair { + var deletedFiles = 0 + var deletedBytes = 0L + root.listFiles()?.forEach { entry -> + val isSymbolicLink = entry.isSymbolicLinkEntry() + val isArchiveScratch = firmwareArchiveScratchPattern.matches(entry.name) + val isPromoteScratch = firmwarePromoteScratchPattern.matches(entry.name) + val isScratchCandidate = !isSymbolicLink && + ((isArchiveScratch && entry.isDirectory) || + (isPromoteScratch && entry.isFile)) + if (isScratchCandidate) { + if ( + firmwareArtifactEntryExceededGrace( + entry, + nowMs, + FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS, + ) + ) { + val size = firmwareArtifactEntrySize(entry) + if (deleteFirmwareArtifactEntry(entry)) { + deletedFiles += 1 + deletedBytes += size + } + } + return@forEach + } + + if (!entry.isFile || isSymbolicLink || entry.name.length < 64) return@forEach + val sha256 = entry.name.take(64) + if ( + !firmwareArtifactSha256Pattern.matches(sha256) || + sha256 in retainedSha256 || + sha256 in activeSha256 || + entry.absolutePath in openPaths + ) { + return@forEach + } + val grace = if (entry.name.endsWith(".bin")) { + FIRMWARE_ARTIFACT_FINAL_GRACE_MS + } else if (entry.name.endsWith(".partial")) { + FIRMWARE_ARTIFACT_PARTIAL_GRACE_MS + } else { + return@forEach + } + if (!firmwareArtifactEntryExceededGrace(entry, nowMs, grace)) return@forEach + val size = entry.length() + if (entry.delete()) { + deletedFiles += 1 + deletedBytes += size + } + } + return deletedFiles to deletedBytes +} diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt index a75574e3..5f206bf8 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt @@ -69,8 +69,6 @@ internal object FirmwareArtifactStore { const val MAX_READ_BYTES = 256 * 1024 private const val MAX_ARTIFACT_BYTES = 512L * 1024 * 1024 - private const val FINAL_ARTIFACT_GRACE_MS = 24L * 60 * 60 * 1000 - private const val PARTIAL_ARTIFACT_GRACE_MS = 7L * 24 * 60 * 60 * 1000 private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$") private val artifactRefPattern = Regex("^fw:[a-f0-9]{64}$") private val leaseRefPattern = Regex("^fwlease:[a-f0-9-]{36}$") @@ -646,35 +644,12 @@ internal object FirmwareArtifactStore { val openFiles = synchronized(readerLock) { readers.values.mapTo(mutableSetOf()) { it.file.absolutePath } } - val now = System.currentTimeMillis() - var deletedFiles = 0 - var deletedBytes = 0L - root.listFiles()?.forEach { file -> - if (!file.isFile) return@forEach - val sha256 = file.name.take(64) - if ( - !sha256Pattern.matches(sha256) || - sha256 in retainedSha256 || - sha256 in activeSha256 || - file.absolutePath in openFiles - ) { - return@forEach - } - val grace = if (file.name.endsWith(".bin")) { - FINAL_ARTIFACT_GRACE_MS - } else if (file.name.endsWith(".partial")) { - PARTIAL_ARTIFACT_GRACE_MS - } else { - return@forEach - } - if (now - file.lastModified() < grace) return@forEach - val size = file.length() - if (file.delete()) { - deletedFiles += 1 - deletedBytes += size - } - } - return deletedFiles to deletedBytes + return sweepFirmwareArtifactOrphansAtRoot( + root = root, + retainedSha256 = retainedSha256, + activeSha256 = activeSha256, + openPaths = openFiles, + ) } private fun requireLease(leaseRef: String) { diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt new file mode 100644 index 00000000..37d570c5 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt @@ -0,0 +1,107 @@ +package com.margelo.nitro.reactnativerangedownloader + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class FirmwareArtifactOrphanSweepTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun removesOnlyStaleRootScratchEntriesWithExactNamesAndTypes() { + val root = temporaryFolder.newFolder("firmware-artifact-sweep") + val nowMs = 2_000_000_000_000L + val staleAt = nowMs - FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS - 1 + val freshAt = nowMs - FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS + 1 + val staleArchive = File( + root, + "archive-00000000-0000-4000-8000-000000000001", + ).apply { + assertTrue(mkdirs()) + File(this, "0.entry").writeText("archive") + } + val stalePromote = File( + root, + ".promote-00000000-0000-4000-8000-000000000002", + ).apply { writeText("promote") } + val freshArchive = File( + root, + "archive-00000000-0000-4000-8000-000000000003", + ).apply { assertTrue(mkdirs()) } + val freshPromote = File( + root, + ".promote-00000000-0000-4000-8000-000000000004", + ).apply { writeText("promote") } + val malformedArchive = File( + root, + "archive-00000000-0000-4000-8000-000000000005.extra", + ).apply { assertTrue(mkdirs()) } + val malformedPromote = File( + root, + ".promote-00000000-0000-4000-8000-000000000006.tmp", + ).apply { writeText("promote") } + val archiveNamedFile = File( + root, + "archive-00000000-0000-4000-8000-000000000007", + ).apply { writeText("promote") } + val promoteNamedDirectory = File( + root, + ".promote-00000000-0000-4000-8000-000000000008", + ).apply { assertTrue(mkdirs()) } + val nestedArchive = File( + File(root, "nested"), + "archive-00000000-0000-4000-8000-000000000009", + ).apply { assertTrue(mkdirs()) } + + for ( + entry in listOf( + staleArchive, + stalePromote, + malformedArchive, + malformedPromote, + archiveNamedFile, + promoteNamedDirectory, + nestedArchive, + ) + ) { + assertTrue(entry.setLastModified(staleAt)) + } + for (entry in listOf(freshArchive, freshPromote)) { + assertTrue(entry.setLastModified(freshAt)) + } + + val result = sweepFirmwareArtifactOrphansAtRoot( + root = root, + retainedSha256 = emptySet(), + activeSha256 = emptySet(), + openPaths = emptySet(), + nowMs = nowMs, + ) + + assertEquals(2, result.first) + assertEquals(14L, result.second) + assertFalse(staleArchive.exists()) + assertFalse(stalePromote.exists()) + for ( + retainedEntry in listOf( + freshArchive, + freshPromote, + malformedArchive, + malformedPromote, + archiveNamedFile, + promoteNamedDirectory, + nestedArchive, + ) + ) { + assertTrue( + "Unexpectedly removed ${retainedEntry.name}", + retainedEntry.exists(), + ) + } + } +} diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index 333455cc..9480537c 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -30,14 +30,6 @@ func isFirmwareArtifactTLSError(_ error: Error) -> Bool { return false } -enum FirmwareArtifactStoreError: Error { - case invalidInput(String) - case downloadFailed(String) - case integrityMismatch(String) - case readerInvalid(String) - case archiveInvalid(String) -} - struct StoredFirmwareArtifact: Sendable { let artifactRef: String let size: Int64 @@ -388,8 +380,6 @@ final class FirmwareArtifactStore { static let maxReadBytes = 256 * 1024 private static let defaultDownloadDeadline: TimeInterval = 180 private static let maxDownloadDeadline: TimeInterval = 24 * 60 * 60 - private static let finalArtifactGrace: TimeInterval = 24 * 60 * 60 - private static let partialArtifactGrace: TimeInterval = 7 * 24 * 60 * 60 private struct OpenReader { let handle: FileHandle @@ -971,49 +961,13 @@ final class FirmwareArtifactStore { let openPaths = Set(readers.values.map(\.fileURL.path)) readerLock.unlock() - let now = Date() - var deletedFiles = 0 - var deletedBytes: Int64 = 0 - let files = try fileManager.contentsOfDirectory( - at: rootURL, - includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], - options: [.skipsHiddenFiles] + return try sweepFirmwareArtifactOrphansAtRoot( + rootURL, + retainedSha256: retained, + activeSha256: active, + openPaths: openPaths, + fileManager: fileManager ) - for fileURL in files { - let name = fileURL.lastPathComponent - guard name.count >= 64 else { continue } - let sha256 = String(name.prefix(64)) - guard - sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil, - !retained.contains(sha256), - !active.contains(sha256), - !openPaths.contains(fileURL.path) - else { - continue - } - let grace: TimeInterval - if name.hasSuffix(".bin") { - grace = Self.finalArtifactGrace - } else if name.hasSuffix(".partial") { - grace = Self.partialArtifactGrace - } else { - continue - } - let values = try fileURL.resourceValues( - forKeys: [.contentModificationDateKey, .fileSizeKey] - ) - guard - let modifiedAt = values.contentModificationDate, - now.timeIntervalSince(modifiedAt) >= grace - else { - continue - } - let size = Int64(values.fileSize ?? 0) - try fileManager.removeItem(at: fileURL) - deletedFiles += 1 - deletedBytes += size - } - return (deletedFiles, deletedBytes) } private func requireLease(_ leaseRef: String) throws { @@ -1073,11 +1027,8 @@ final class FirmwareArtifactStore { ) != nil } - private static func isSafeIdentifier(_ value: String) -> Bool { - value.range( - of: "^[A-Za-z0-9._:-]{1,160}$", - options: .regularExpression - ) != nil + static func isSafeIdentifier(_ value: String) -> Bool { + firmwareArtifactIdentifierIsSafe(value) } private func markDownloadActive(_ sha256: String, delta: Int) { diff --git a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift index 3727e1fa..1d4c47ed 100644 --- a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift +++ b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift @@ -1,6 +1,23 @@ import Foundation import CommonCrypto +enum FirmwareArtifactStoreError: Error, CustomStringConvertible { + case invalidInput(String) + case downloadFailed(String) + case integrityMismatch(String) + case readerInvalid(String) + case archiveInvalid(String) + + var description: String { + switch self { + case let .invalidInput(message), let .downloadFailed(message), + let .integrityMismatch(message), let .readerInvalid(message), + let .archiveInvalid(message): + return message + } + } +} + enum FirmwareArtifactDeadlineError: Error { case exceeded } @@ -85,6 +102,165 @@ func firmwareArtifactResponseFits( expectedContentLength <= maxBytes - baseOffset } +let firmwareArtifactFinalGrace: TimeInterval = 24 * 60 * 60 +let firmwareArtifactPartialGrace: TimeInterval = 7 * 24 * 60 * 60 +let firmwareArtifactScratchGrace: TimeInterval = firmwareArtifactPartialGrace + +func firmwareArtifactIdentifierIsSafe(_ value: String) -> Bool { + value.range( + of: "^[A-Za-z0-9._:-]{1,160}$", + options: .regularExpression + ) != nil +} + +private func firmwareArtifactScratchNameIsValid( + _ name: String, + prefix: String +) -> Bool { + guard name.hasPrefix(prefix) else { + return false + } + let rawUUID = String(name.dropFirst(prefix.count)) + guard + rawUUID.count == 36, + let uuid = UUID(uuidString: rawUUID) + else { + return false + } + return uuid.uuidString.caseInsensitiveCompare(rawUUID) == .orderedSame +} + +private func firmwareArtifactEntrySize( + _ entryURL: URL, + values: URLResourceValues, + fileManager: FileManager +) -> Int64 { + if values.isRegularFile == true { + return Int64(values.fileSize ?? 0) + } + guard + values.isDirectory == true, + let enumerator = fileManager.enumerator( + at: entryURL, + includingPropertiesForKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey, + .fileSizeKey, + ], + options: [], + errorHandler: nil + ) + else { + return 0 + } + var size: Int64 = 0 + for case let childURL as URL in enumerator { + guard + let childValues = try? childURL.resourceValues( + forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey] + ), + childValues.isRegularFile == true, + childValues.isSymbolicLink != true + else { + continue + } + size += Int64(childValues.fileSize ?? 0) + } + return size +} + +func sweepFirmwareArtifactOrphansAtRoot( + _ rootURL: URL, + retainedSha256: Set, + activeSha256: Set, + openPaths: Set, + now: Date = Date(), + fileManager: FileManager = .default +) throws -> (deletedFiles: Int, deletedBytes: Int64) { + var deletedFiles = 0 + var deletedBytes: Int64 = 0 + let resourceKeys: Set = [ + .isRegularFileKey, + .isDirectoryKey, + .isSymbolicLinkKey, + .contentModificationDateKey, + .fileSizeKey, + ] + let entries = try fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: Array(resourceKeys), + options: [] + ) + for entryURL in entries { + let name = entryURL.lastPathComponent + let values = try entryURL.resourceValues(forKeys: resourceKeys) + let isArchiveScratch = firmwareArtifactScratchNameIsValid( + name, + prefix: "archive-" + ) + let isPromoteScratch = firmwareArtifactScratchNameIsValid( + name, + prefix: ".promote-" + ) + let isScratchCandidate = values.isSymbolicLink != true && + ((isArchiveScratch && values.isDirectory == true) || + (isPromoteScratch && values.isRegularFile == true)) + if isScratchCandidate { + guard + let modifiedAt = values.contentModificationDate, + now.timeIntervalSince(modifiedAt) >= firmwareArtifactScratchGrace + else { + continue + } + let size = firmwareArtifactEntrySize( + entryURL, + values: values, + fileManager: fileManager + ) + try fileManager.removeItem(at: entryURL) + deletedFiles += 1 + deletedBytes += size + continue + } + + guard + values.isRegularFile == true, + values.isSymbolicLink != true, + name.count >= 64 + else { + continue + } + let sha256 = String(name.prefix(64)) + guard + sha256.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil, + !retainedSha256.contains(sha256), + !activeSha256.contains(sha256), + !openPaths.contains(entryURL.path) + else { + continue + } + let grace: TimeInterval + if name.hasSuffix(".bin") { + grace = firmwareArtifactFinalGrace + } else if name.hasSuffix(".partial") { + grace = firmwareArtifactPartialGrace + } else { + continue + } + guard + let modifiedAt = values.contentModificationDate, + now.timeIntervalSince(modifiedAt) >= grace + else { + continue + } + let size = Int64(values.fileSize ?? 0) + try fileManager.removeItem(at: entryURL) + deletedFiles += 1 + deletedBytes += size + } + return (deletedFiles, deletedBytes) +} + enum FirmwareBackgroundDownloadError: LocalizedError, CustomStringConvertible { case cancelled case invalidTask diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index a7d23ce1..cfecdc59 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -144,7 +144,14 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func cancelFirmwareArtifactDownloads( params: FirmwareArtifactCancelParams ) throws -> Promise { - Promise.async { + guard FirmwareArtifactStore.isSafeIdentifier(params.transactionId) else { + return Promise.rejected( + withError: FirmwareArtifactStoreError.invalidInput( + "Invalid firmware transactionId" + ) + ) + } + return Promise.async { try await FirmwareArtifactStore.shared.cancelDownloads( transactionId: params.transactionId ) diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift index 8a46d305..64e9efc1 100644 --- a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift @@ -114,6 +114,153 @@ final class RangeDownloadLogicTests: XCTestCase { ) } + func testFirmwareArtifactStoreErrorsHaveStableDescriptions() { + XCTAssertEqual( + String(describing: FirmwareArtifactStoreError.invalidInput("ARTIFACT_INVALID_INPUT")), + "ARTIFACT_INVALID_INPUT" + ) + XCTAssertEqual( + String(describing: FirmwareArtifactStoreError.downloadFailed("ARTIFACT_CANCELLED")), + "ARTIFACT_CANCELLED" + ) + XCTAssertEqual( + String( + describing: FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED" + ) + ), + "ARTIFACT_INTEGRITY_FAILED" + ) + XCTAssertEqual( + String(describing: FirmwareArtifactStoreError.readerInvalid("ARTIFACT_READER_INVALID")), + "ARTIFACT_READER_INVALID" + ) + XCTAssertEqual( + String(describing: FirmwareArtifactStoreError.archiveInvalid("ARTIFACT_ARCHIVE_INVALID")), + "ARTIFACT_ARCHIVE_INVALID" + ) + } + + func testFirmwareArtifactIdentifierValidationForSynchronousCancelRejection() { + XCTAssertTrue(firmwareArtifactIdentifierIsSafe("fwtx:valid-1")) + XCTAssertFalse(firmwareArtifactIdentifierIsSafe("")) + XCTAssertFalse(firmwareArtifactIdentifierIsSafe("invalid/path")) + XCTAssertFalse( + firmwareArtifactIdentifierIsSafe(String(repeating: "a", count: 161)) + ) + } + + func testFirmwareArtifactOrphanSweepRemovesOnlyStaleRootScratchEntries() throws { + let fileManager = FileManager.default + let rootURL = fileManager.temporaryDirectory.appendingPathComponent( + "firmware-artifact-sweep-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: rootURL) } + + let now = Date(timeIntervalSince1970: 2_000_000_000) + let staleDate = now.addingTimeInterval(-firmwareArtifactScratchGrace - 1) + let freshDate = now.addingTimeInterval(-firmwareArtifactScratchGrace + 1) + let staleArchive = rootURL.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000001", + isDirectory: true + ) + let stalePromote = rootURL.appendingPathComponent( + ".promote-00000000-0000-4000-8000-000000000002" + ) + let freshArchive = rootURL.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000003", + isDirectory: true + ) + let freshPromote = rootURL.appendingPathComponent( + ".promote-00000000-0000-4000-8000-000000000004" + ) + let malformedArchive = rootURL.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000005.extra", + isDirectory: true + ) + let malformedPromote = rootURL.appendingPathComponent( + ".promote-00000000-0000-4000-8000-000000000006.tmp" + ) + let archiveNamedFile = rootURL.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000007" + ) + let promoteNamedDirectory = rootURL.appendingPathComponent( + ".promote-00000000-0000-4000-8000-000000000008", + isDirectory: true + ) + let nestedContainer = rootURL.appendingPathComponent("nested", isDirectory: true) + let nestedArchive = nestedContainer.appendingPathComponent( + "archive-00000000-0000-4000-8000-000000000009", + isDirectory: true + ) + + for directory in [ + staleArchive, + freshArchive, + malformedArchive, + promoteNamedDirectory, + nestedArchive, + ] { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + try Data("archive".utf8).write( + to: staleArchive.appendingPathComponent("0.entry") + ) + for file in [stalePromote, freshPromote, malformedPromote, archiveNamedFile] { + try Data("promote".utf8).write(to: file) + } + for entry in [ + staleArchive, + stalePromote, + malformedArchive, + malformedPromote, + archiveNamedFile, + promoteNamedDirectory, + nestedArchive, + ] { + try fileManager.setAttributes( + [.modificationDate: staleDate], + ofItemAtPath: entry.path + ) + } + for entry in [freshArchive, freshPromote] { + try fileManager.setAttributes( + [.modificationDate: freshDate], + ofItemAtPath: entry.path + ) + } + + let result = try sweepFirmwareArtifactOrphansAtRoot( + rootURL, + retainedSha256: [], + activeSha256: [], + openPaths: [], + now: now, + fileManager: fileManager + ) + + XCTAssertEqual(result.deletedFiles, 2) + XCTAssertEqual(result.deletedBytes, 14) + XCTAssertFalse(fileManager.fileExists(atPath: staleArchive.path)) + XCTAssertFalse(fileManager.fileExists(atPath: stalePromote.path)) + for retainedEntry in [ + freshArchive, + freshPromote, + malformedArchive, + malformedPromote, + archiveNamedFile, + promoteNamedDirectory, + nestedArchive, + ] { + XCTAssertTrue( + fileManager.fileExists(atPath: retainedEntry.path), + "Unexpectedly removed \(retainedEntry.lastPathComponent)" + ) + } + } + func testFirmwareArtifactWallClockDeadlineRejectsSlowOperation() async { do { _ = try await FirmwareArtifactWallClockDeadline.run( From 96e0943ce07a515feaf72b17f0fccf4a83ecf84b Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 6 Aug 2026 14:44:43 +0800 Subject: [PATCH 23/27] refactor: remove unused firmware background pipeline --- .../ios/FirmwareArtifactStore.swift | 48 -- ...FirmwareBackgroundSessionEventRouter.swift | 17 - .../ios/RangeDownloadLogic.swift | 36 -- .../ios/ReactNativeRangeDownloader.swift | 421 ------------------ .../RangeDownloadLogicTests.swift | 13 - 5 files changed, 535 deletions(-) delete mode 100644 native-modules/react-native-range-downloader/ios/FirmwareBackgroundSessionEventRouter.swift diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index 9480537c..787de9a3 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -558,9 +558,6 @@ final class FirmwareArtifactStore { cancelledTransactions.insert(transactionId) } await downloadCoordinator.cancel(transactionId: transactionId) - try await RangeDownloader.shared.cancelFirmwareArtifactDownloads( - transactionId: transactionId - ) } private func downloadLocked( @@ -647,51 +644,6 @@ final class FirmwareArtifactStore { ) } - func acceptBackgroundDownload( - temporaryURL: URL, - transactionId: String, - expectedSize: Int64, - expectedSha256: String - ) throws -> StoredFirmwareArtifact { - let normalizedExpectedSha256 = expectedSha256.lowercased() - try rejectIfCancelled(transactionId: transactionId) - let finalURL = artifactURL(sha256: normalizedExpectedSha256) - if let existing = try? validateStoredArtifact( - fileURL: finalURL, - expectedSize: expectedSize, - expectedSha256: normalizedExpectedSha256 - ) { - return existing - } - let artifact = try validateStoredArtifact( - fileURL: temporaryURL, - expectedSize: expectedSize, - expectedSha256: normalizedExpectedSha256 - ) - try promote(source: temporaryURL, destination: finalURL) - return StoredFirmwareArtifact( - artifactRef: artifact.artifactRef, - size: artifact.size, - sha256: artifact.sha256, - fileURL: finalURL - ) - } - - func storedArtifact( - expectedSize: Int64, - expectedSha256: String - ) throws -> StoredFirmwareArtifact? { - let finalURL = artifactURL(sha256: expectedSha256) - guard fileManager.fileExists(atPath: finalURL.path) else { - return nil - } - return try validateStoredArtifact( - fileURL: finalURL, - expectedSize: expectedSize, - expectedSha256: expectedSha256 - ) - } - func discard(artifactRef: String) throws { let fileURL = try resolveArtifactURL(artifactRef) leaseLock.lock() diff --git a/native-modules/react-native-range-downloader/ios/FirmwareBackgroundSessionEventRouter.swift b/native-modules/react-native-range-downloader/ios/FirmwareBackgroundSessionEventRouter.swift deleted file mode 100644 index 477fb9ad..00000000 --- a/native-modules/react-native-range-downloader/ios/FirmwareBackgroundSessionEventRouter.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -@objc(FirmwareBackgroundSessionEventRouter) -public final class FirmwareBackgroundSessionEventRouter: NSObject { - @objc(routeEventsForBackgroundURLSession:completionHandler:) - public static func routeEvents( - forBackgroundURLSession identifier: String, - completionHandler: @escaping () -> Void - ) -> NSNumber { - NSNumber( - value: RangeDownloader.routeFirmwareBackgroundEvents( - identifier: identifier, - completionHandler: completionHandler - ) - ) - } -} diff --git a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift index 1d4c47ed..2fe2539a 100644 --- a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift +++ b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift @@ -261,42 +261,6 @@ func sweepFirmwareArtifactOrphansAtRoot( return (deletedFiles, deletedBytes) } -enum FirmwareBackgroundDownloadError: LocalizedError, CustomStringConvertible { - case cancelled - case invalidTask - case deadlineExceeded - case redirectRejected - case responseRejected - case sizeRejected - case tlsRejected - case transferFailed - - var errorDescription: String? { - switch self { - case .cancelled: - return "ARTIFACT_CANCELLED: firmware background download was cancelled" - case .invalidTask: - return "ARTIFACT_PROTOCOL_INVALID: firmware background task is invalid" - case .deadlineExceeded: - return "ARTIFACT_DEADLINE_EXCEEDED: firmware download exceeded its deadline" - case .redirectRejected: - return "ARTIFACT_REDIRECT_REJECTED: firmware redirect changed canonical identity" - case .responseRejected: - return "ARTIFACT_PROTOCOL_INVALID: firmware response is invalid" - case .sizeRejected: - return "ARTIFACT_PROTOCOL_INVALID: firmware artifact size is invalid" - case .tlsRejected: - return "ARTIFACT_TLS_FAILED: firmware TLS validation failed" - case .transferFailed: - return "ARTIFACT_NETWORK_FAILED: firmware background transfer failed" - } - } - - var description: String { - errorDescription ?? "ARTIFACT_NETWORK_FAILED: firmware background transfer failed" - } -} - // MARK: - Dependency-free RangeDownloader logic (OCDS §4 / §5) // // This file holds the DETERMINISTIC, dependency-light pieces of the range diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index cfecdc59..885b189c 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -343,50 +343,10 @@ extension RangeFallbackClass { } } -private struct FirmwareBackgroundTaskDescriptor: Codable, Equatable { - let schemaVersion: Int - let taskId: String - let transactionId: String - let expectedSize: Int64 - let expectedSha256: String - let hostname: String - let deadlineAt: TimeInterval - - var key: String { - firmwareArtifactDownloadKey( - transactionId: transactionId, - expectedSize: expectedSize, - expectedSha256: expectedSha256 - ) - } - - func hasSameDownloadIdentity( - as other: FirmwareBackgroundTaskDescriptor - ) -> Bool { - transactionId == other.transactionId && - expectedSize == other.expectedSize && - expectedSha256 == other.expectedSha256 - } -} - public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { public static let shared = RangeDownloader() - public static func routeFirmwareBackgroundEvents( - identifier: String, - completionHandler: @escaping () -> Void - ) -> Bool { - guard identifier == sessionIdentifier(for: .firmware) else { - return false - } - shared.attachBackgroundEvents( - identifier: identifier, - completionHandler: completionHandler - ) - return true - } - /// Posted by the AppDelegate from /// application(_:handleEventsForBackgroundURLSession:completionHandler:). /// userInfo: ["identifier": String, "completionHandler": () -> Void]. @@ -423,11 +383,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { /// notification), keyed by session identifier, so we can call each back once /// all its queued background events have been delivered. private var backgroundCompletionHandlers: [String: () -> Void] = [:] - private var firmwareWaiters: [ - String: [CheckedContinuation] - ] = [:] - private var firmwareTaskErrors: [Int: Error] = [:] - private var completedFirmwareTasks: Set = [] // Per-run mutable state. private final class RunState { @@ -685,238 +640,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { return (r, decoded.segIndex) } - private static let firmwareTaskDescriptionPrefix = "firmware-v2:" - - private static func encodeFirmwareTaskDescription( - _ descriptor: FirmwareBackgroundTaskDescriptor - ) throws -> String { - let data = try JSONEncoder().encode(descriptor) - return firmwareTaskDescriptionPrefix + data.base64EncodedString() - } - - private static func decodeFirmwareTaskDescription( - _ description: String? - ) -> FirmwareBackgroundTaskDescriptor? { - guard - let description, - description.hasPrefix(firmwareTaskDescriptionPrefix), - let data = Data( - base64Encoded: String( - description.dropFirst(firmwareTaskDescriptionPrefix.count) - ) - ), - data.count <= 2048, - let descriptor = try? JSONDecoder().decode( - FirmwareBackgroundTaskDescriptor.self, - from: data - ), - descriptor.schemaVersion == 2, - descriptor.taskId.range( - of: "^[A-Za-z0-9._-]{1,100}$", - options: .regularExpression - ) != nil, - descriptor.transactionId.range( - of: "^[A-Za-z0-9._:-]{1,160}$", - options: .regularExpression - ) != nil, - descriptor.expectedSize > 0, - descriptor.expectedSize <= 512 * 1024 * 1024, - descriptor.expectedSha256.range( - of: "^[a-f0-9]{64}$", - options: .regularExpression - ) != nil, - !descriptor.hostname.isEmpty, - descriptor.hostname.count <= 253, - descriptor.deadlineAt.isFinite, - descriptor.deadlineAt > 0 - else { - return nil - } - return descriptor - } - - func downloadFirmwareArtifact( - params: FirmwareArtifactDownloadParams - ) async throws -> StoredFirmwareArtifact { - guard - params.routeType == "domain", - let url = URL(string: params.url), - let hostname = url.host?.lowercased() - else { - throw FirmwareBackgroundDownloadError.invalidTask - } - let deadlineSeconds = params.overallDeadlineSeconds ?? 180 - guard - deadlineSeconds.isFinite, - deadlineSeconds > 0, - deadlineSeconds <= 24 * 60 * 60 - else { - throw FirmwareBackgroundDownloadError.invalidTask - } - let descriptor = FirmwareBackgroundTaskDescriptor( - schemaVersion: 2, - taskId: params.taskId, - transactionId: params.transactionId, - expectedSize: Int64(params.expectedSize), - expectedSha256: params.expectedSha256.lowercased(), - hostname: hostname, - deadlineAt: Date().timeIntervalSince1970 + deadlineSeconds - ) - guard !FirmwareArtifactStore.shared.isTransactionCancelled( - descriptor.transactionId - ) else { - throw FirmwareBackgroundDownloadError.cancelled - } - if let stored = try FirmwareArtifactStore.shared.storedArtifact( - expectedSize: descriptor.expectedSize, - expectedSha256: descriptor.expectedSha256 - ) { - return stored - } - - return try await withCheckedThrowingContinuation { continuation in - lock.withLockValue { - firmwareWaiters[descriptor.key, default: []].append(continuation) - } - Task { [weak self] in - await self?.reconcileOrStartFirmwareTask( - descriptor: descriptor, - url: url - ) - } - } - } - - func cancelFirmwareArtifactDownloads( - transactionId: String - ) async throws { - guard transactionId.range( - of: "^[A-Za-z0-9._:-]{1,160}$", - options: .regularExpression - ) != nil else { - throw FirmwareBackgroundDownloadError.invalidTask - } - let session = session(forChannel: .firmware, segmentCount: 1) - let tasks = await allTasks(in: session) - for task in tasks { - guard - let descriptor = Self.decodeFirmwareTaskDescription( - task.taskDescription - ), - descriptor.transactionId == transactionId - else { - continue - } - recordFirmwareTaskError( - taskIdentifier: task.taskIdentifier, - error: FirmwareBackgroundDownloadError.cancelled - ) - task.cancel() - } - } - - private func reconcileOrStartFirmwareTask( - descriptor: FirmwareBackgroundTaskDescriptor, - url: URL - ) async { - guard !FirmwareArtifactStore.shared.isTransactionCancelled( - descriptor.transactionId - ) else { - finishFirmwareTask( - key: descriptor.key, - result: .failure(FirmwareBackgroundDownloadError.cancelled) - ) - return - } - let session = session(forChannel: .firmware, segmentCount: 1) - let tasks = await allTasks(in: session) - var matchingTaskFound = false - for task in tasks { - guard - let candidate = Self.decodeFirmwareTaskDescription( - task.taskDescription - ) - else { - continue - } - if candidate.hasSameDownloadIdentity(as: descriptor) { - matchingTaskFound = true - } else if - candidate.transactionId == descriptor.transactionId && - candidate.taskId == descriptor.taskId - { - task.cancel() - } - } - if matchingTaskFound { - return - } - do { - if let stored = try FirmwareArtifactStore.shared.storedArtifact( - expectedSize: descriptor.expectedSize, - expectedSha256: descriptor.expectedSha256 - ) { - finishFirmwareTask( - key: descriptor.key, - result: .success(stored) - ) - return - } - guard Date().timeIntervalSince1970 < descriptor.deadlineAt else { - throw FirmwareBackgroundDownloadError.deadlineExceeded - } - guard !FirmwareArtifactStore.shared.isTransactionCancelled( - descriptor.transactionId - ) else { - throw FirmwareBackgroundDownloadError.cancelled - } - var request = URLRequest(url: url) - request.cachePolicy = .reloadIgnoringLocalCacheData - request.timeoutInterval = max( - 1, - descriptor.deadlineAt - Date().timeIntervalSince1970 - ) - request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") - let task = session.downloadTask(with: request) - task.taskDescription = try Self.encodeFirmwareTaskDescription( - descriptor - ) - task.resume() - } catch { - finishFirmwareTask( - key: descriptor.key, - result: .failure(error) - ) - } - } - - private func allTasks(in session: URLSession) async -> [URLSessionTask] { - await withCheckedContinuation { continuation in - session.getAllTasks { tasks in - continuation.resume(returning: tasks) - } - } - } - - private func finishFirmwareTask( - key: String, - result: Result - ) { - let continuations: [ - CheckedContinuation - ] = lock.withLockValue { - firmwareWaiters.removeValue(forKey: key) ?? [] - } - for continuation in continuations { - switch result { - case let .success(artifact): - continuation.resume(returning: artifact) - case let .failure(error): - continuation.resume(throwing: error) - } - } - } - // MARK: - Public entry /// Downloads [urlString] into [filePath] using concurrent background ranges. @@ -1452,28 +1175,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - if let descriptor = Self.decodeFirmwareTaskDescription( - downloadTask.taskDescription - ) { - let exceedsBound = - totalBytesWritten > descriptor.expectedSize || - ( - totalBytesExpectedToWrite != NSURLSessionTransferSizeUnknown && - totalBytesExpectedToWrite != descriptor.expectedSize - ) - let expired = - Date().timeIntervalSince1970 >= descriptor.deadlineAt - if exceedsBound || expired { - recordFirmwareTaskError( - taskIdentifier: downloadTask.taskIdentifier, - error: exceedsBound - ? FirmwareBackgroundDownloadError.sizeRejected - : FirmwareBackgroundDownloadError.deadlineExceeded - ) - downloadTask.cancel() - } - return - } guard let desc = downloadTask.taskDescription, let (state, idx) = run(for: desc) else { return } lock.lock() @@ -1504,52 +1205,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - if let descriptor = Self.decodeFirmwareTaskDescription( - downloadTask.taskDescription - ) { - guard firmwareTaskError( - taskIdentifier: downloadTask.taskIdentifier - ) == nil else { - return - } - do { - guard - !FirmwareArtifactStore.shared.isTransactionCancelled( - descriptor.transactionId - ), - Date().timeIntervalSince1970 < descriptor.deadlineAt, - let response = downloadTask.response as? HTTPURLResponse, - response.statusCode == 200, - response.url?.scheme?.lowercased() == "https", - response.url?.host?.lowercased() == descriptor.hostname, - response.url?.port == nil || response.url?.port == 443 - else { - throw FirmwareBackgroundDownloadError.responseRejected - } - let artifact = try FirmwareArtifactStore.shared.acceptBackgroundDownload( - temporaryURL: location, - transactionId: descriptor.transactionId, - expectedSize: descriptor.expectedSize, - expectedSha256: descriptor.expectedSha256 - ) - lock.withLockValue { - firmwareTaskErrors.removeValue( - forKey: downloadTask.taskIdentifier - ) - completedFirmwareTasks.insert(downloadTask.taskIdentifier) - } - finishFirmwareTask( - key: descriptor.key, - result: .success(artifact) - ) - } catch { - recordFirmwareTaskError( - taskIdentifier: downloadTask.taskIdentifier, - error: error - ) - } - return - } guard let desc = downloadTask.taskDescription, let (state, idx) = run(for: desc) else { return } let ranges = lock.withLockValue { state.ranges } @@ -1648,43 +1303,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { } public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - if let descriptor = Self.decodeFirmwareTaskDescription( - task.taskDescription - ) { - let completion: (completed: Bool, error: Error?) = - lock.withLockValue { - let completed = completedFirmwareTasks.remove( - task.taskIdentifier - ) != nil - let recordedError = firmwareTaskErrors.removeValue( - forKey: task.taskIdentifier - ) - return (completed, recordedError) - } - if completion.completed { - return - } - let failure: Error - if let recordedError = completion.error { - failure = recordedError - } else if FirmwareArtifactStore.shared.isTransactionCancelled( - descriptor.transactionId - ) { - failure = FirmwareBackgroundDownloadError.cancelled - } else if let taskError = error { - failure = - isFirmwareArtifactTLSError(taskError) - ? FirmwareBackgroundDownloadError.tlsRejected - : FirmwareBackgroundDownloadError.transferFailed - } else { - failure = FirmwareBackgroundDownloadError.transferFailed - } - finishFirmwareTask( - key: descriptor.key, - result: .failure(failure) - ) - return - } guard let desc = task.taskDescription, let (state, idx) = run(for: desc) else { return } let ranges = lock.withLockValue { state.ranges } @@ -1860,26 +1478,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { - if let descriptor = Self.decodeFirmwareTaskDescription( - task.taskDescription - ) { - guard - request.url?.scheme?.lowercased() == "https", - request.url?.host?.lowercased() == descriptor.hostname, - request.url?.port == nil || request.url?.port == 443, - request.url?.user == nil, - request.url?.password == nil - else { - recordFirmwareTaskError( - taskIdentifier: task.taskIdentifier, - error: FirmwareBackgroundDownloadError.redirectRejected - ) - completionHandler(nil) - return - } - completionHandler(request) - return - } if request.url?.scheme?.lowercased() != "https" { OneKeyLog.error("RangeDownloader", "blocked redirect to non-HTTPS URL") if let desc = task.taskDescription, let (state, _) = run(for: desc) { @@ -1891,25 +1489,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { } } - private func recordFirmwareTaskError( - taskIdentifier: Int, - error: Error - ) { - lock.withLockValue { - if firmwareTaskErrors[taskIdentifier] == nil { - firmwareTaskErrors[taskIdentifier] = error - } - } - } - - private func firmwareTaskError( - taskIdentifier: Int - ) -> Error? { - lock.withLockValue { - firmwareTaskErrors[taskIdentifier] - } - } - /// Called on the session delegate queue when all background events for this /// session have been delivered after a background relaunch. public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift index 64e9efc1..32669b6d 100644 --- a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift @@ -277,19 +277,6 @@ final class RangeDownloadLogicTests: XCTestCase { } } - func testFirmwareBackgroundErrorsKeepArtifactCodesInDescriptions() { - XCTAssertTrue( - String( - describing: FirmwareBackgroundDownloadError.transferFailed - ).contains("ARTIFACT_NETWORK_FAILED") - ) - XCTAssertTrue( - String( - describing: FirmwareBackgroundDownloadError.deadlineExceeded - ).contains("ARTIFACT_DEADLINE_EXCEEDED") - ) - } - // MARK: §4 — HTTP status classification // // OCDS §4. Mirrors Android's IsPermanentHttpStatusTest matrix. Asserts BOTH the From f7fb6a97fdfcb067671e84b1844ea49615b9dd1c Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 6 Aug 2026 16:15:30 +0800 Subject: [PATCH 24/27] fix: support optional firmware artifact integrity --- .../ReactNativeRangeDownloader.podspec | 2 +- .../FirmwareArchiveRules.kt | 17 +- .../FirmwareArtifactStore.kt | 163 +++++++--- .../ReactNativeRangeDownloader.kt | 2 +- .../FirmwareArchiveRulesTest.kt | 39 +++ .../ios/FirmwareArtifactStore.swift | 301 ++++++++++++++---- .../ios/RangeDownloadLogic.swift | 32 +- .../ios/ReactNativeRangeDownloader.swift | 2 +- .../package.json | 2 +- .../src/ReactNativeRangeDownloader.nitro.ts | 6 +- .../RangeDownloadLogicTests.swift | 53 ++- 11 files changed, 490 insertions(+), 129 deletions(-) diff --git a/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec b/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec index 87760c3d..38cb65a1 100644 --- a/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec +++ b/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec @@ -23,7 +23,7 @@ Pod::Spec.new do |s| s.dependency 'React-jsi' s.dependency 'React-callinvoker' s.dependency 'ReactNativeNativeLogger' - s.dependency 'SniConnect', package["version"] + s.dependency 'SniConnect', package["peerDependencies"]["@onekeyfe/react-native-sni-connect"] s.public_header_files = "ios/FirmwareArchiveMinizipBridge.h" s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"', diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt index b704948b..88650c2a 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt @@ -78,24 +78,31 @@ internal object FirmwareArchiveRules { fun validateCentralDirectory( file: File, - requirements: List, + requirements: List?, ): List { val entries = scanCentralDirectory(file) - require(entries.size == requirements.size) { + require(requirements == null || entries.size == requirements.size) { "Firmware archive has missing or extra entries" } - val requirementsByName = requirements.associateBy { it.entryName } + val requirementsByName = requirements?.associateBy { it.entryName }.orEmpty() val names = mutableSetOf() val canonicalNames = mutableSetOf() + var totalSize = 0L entries.forEach { entry -> val requirement = requirementsByName[entry.name] - ?: error("Firmware archive contains an unexpected entry") - val expectedSize = requirement.expectedSize.toLong() + require(requirements == null || requirement != null) { + "Firmware archive contains an unexpected entry" + } + val expectedSize = requirement?.expectedSize?.toLong() + ?: entry.uncompressedSize + totalSize = Math.addExact(totalSize, entry.uncompressedSize) require( names.add(entry.name) && validatePortableName(entry.name, canonicalNames) && entry.uncompressedSize == expectedSize && entry.uncompressedSize > 0 && + entry.uncompressedSize <= MAX_ARCHIVE_ENTRY_BYTES && + totalSize <= MAX_ARCHIVE_EXPANDED_BYTES && entry.compressedSize in 0..MAX_ARCHIVE_EXPANDED_BYTES && entry.uncompressedSize <= Math.multiplyExact(entry.compressedSize.coerceAtLeast(1), 1000) && diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt index 5f206bf8..75c86649 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt @@ -57,7 +57,9 @@ private data class StagedFirmwareArchiveEntry( ) private data class FirmwareDownloadKey( - val expectedSha256: String, + val transactionId: String, + val taskId: String, + val downloadToken: String, ) private class FirmwareDownloadLock { @@ -113,27 +115,39 @@ internal object FirmwareArtifactStore { check(!cancelledTransactions.contains(params.transactionId)) { "ARTIFACT_CANCELLED: firmware artifact download was cancelled" } - retainExpectedArtifact( - leaseRef = params.leaseRef, - transactionId = params.transactionId, - artifactRef = "fw:${validated.expectedSha256}", + validated.expectedSha256?.let { + retainExpectedArtifact( + leaseRef = params.leaseRef, + transactionId = params.transactionId, + artifactRef = "fw:$it", + ) + } ?: requireLeaseTransaction(params.leaseRef, params.transactionId) + val lockKey = FirmwareDownloadKey( + params.transactionId, + params.taskId, + validated.downloadToken, ) - val lockKey = FirmwareDownloadKey(validated.expectedSha256) val downloadLock = downloadLocks.compute(lockKey) { _, current -> (current ?: FirmwareDownloadLock()).also { it.references += 1 } } ?: error("Firmware artifact lock is unavailable") - markDownloadActive(validated.expectedSha256, 1) + markDownloadActive(validated.downloadToken, 1) try { - return synchronized(downloadLock.monitor) { + val artifact = synchronized(downloadLock.monitor) { check(!cancelledTransactions.contains(params.transactionId)) { "ARTIFACT_CANCELLED: firmware artifact download was cancelled" } downloadLocked(params, validated) } + retainExpectedArtifact( + leaseRef = params.leaseRef, + transactionId = params.transactionId, + artifactRef = artifact.artifactRef, + ) + return artifact } finally { - markDownloadActive(validated.expectedSha256, -1) + markDownloadActive(validated.downloadToken, -1) downloadLocks.compute(lockKey) { _, current -> if (current !== downloadLock) { current @@ -211,16 +225,19 @@ internal object FirmwareArtifactStore { fun materializeArchive( leaseRef: String, artifactRef: String, - expectedEntries: Array, + expectedEntries: Array?, ): List { requireLease(leaseRef) val archiveFile = resolveArtifactFile(artifactRef) - val requirements = FirmwareArchiveRules.validateRequirements(expectedEntries) + val requirements = expectedEntries?.let { + FirmwareArchiveRules.validateRequirements(it) + } val centralEntries = FirmwareArchiveRules.validateCentralDirectory( archiveFile, requirements, ) - val requirementsByName = requirements.associateBy { it.entryName } + val requirementsByName = requirements?.associateBy { it.entryName }.orEmpty() + val centralEntriesByName = centralEntries.associateBy { it.name } val centralNames = centralEntries.mapTo(mutableSetOf()) { it.name } val scratchDir = File(root, "archive-${UUID.randomUUID()}") check(scratchDir.mkdirs()) { "Firmware archive scratch directory cannot be created" } @@ -233,16 +250,18 @@ internal object FirmwareArtifactStore { require(!zipEntry.isDirectory) { "Firmware archive contains an unexpected directory" } - val requirement = requirementsByName[zipEntry.name] + val centralEntry = centralEntriesByName[zipEntry.name] ?: error("Firmware archive contains an unexpected entry") + val requirement = requirementsByName[zipEntry.name] require( centralNames.contains(zipEntry.name) && entryNames.add(zipEntry.name) ) { "Firmware archive contains a duplicate or mismatched entry" } - val expectedSize = requirement.expectedSize.toLong() - val expectedSha256 = requirement.expectedSha256.lowercase() + val expectedSize = requirement?.expectedSize?.toLong() + ?: centralEntry.uncompressedSize + val expectedSha256 = requirement?.expectedSha256?.lowercase() val scratchFile = File(scratchDir, "${staged.size}.entry") val digest = MessageDigest.getInstance("SHA-256") var entrySize = 0L @@ -262,11 +281,14 @@ internal object FirmwareArtifactStore { output.fd.sync() } val sha256 = digest.digest().toHex() - require(entrySize == expectedSize && sha256 == expectedSha256) { + require( + entrySize == expectedSize && + (expectedSha256 == null || sha256 == expectedSha256) + ) { "Firmware archive entry integrity mismatch" } staged += StagedFirmwareArchiveEntry( - entryName = requirement.entryName, + entryName = zipEntry.name, size = entrySize, sha256 = sha256, file = scratchFile, @@ -305,9 +327,10 @@ internal object FirmwareArtifactStore { } private data class ValidatedDownload( - val expectedSize: Long, + val expectedSize: Long?, val maxBytes: Long, - val expectedSha256: String, + val expectedSha256: String?, + val downloadToken: String, val hostname: String, val overallDeadlineSeconds: Double, ) @@ -342,12 +365,16 @@ internal object FirmwareArtifactStore { ) { "Firmware URL must use HTTPS port 443" } - val expectedSize = params.expectedSize.toExactPositiveLong("expectedSize") + val expectedSize = params.expectedSize?.toExactPositiveLong("expectedSize") val maxBytes = params.maxBytes.toExactPositiveLong("maxBytes") - require(maxBytes == expectedSize && maxBytes <= MAX_ARTIFACT_BYTES) { + require( + maxBytes <= MAX_ARTIFACT_BYTES && + (expectedSize == null || expectedSize <= maxBytes) + ) { "Invalid firmware maxBytes" } - require(sha256Pattern.matches(params.expectedSha256)) { + val expectedSha256 = params.expectedSha256?.lowercase() + require(expectedSha256 == null || sha256Pattern.matches(expectedSha256)) { "Invalid firmware artifact SHA-256" } val overallDeadlineSeconds = @@ -367,7 +394,8 @@ internal object FirmwareArtifactStore { return ValidatedDownload( expectedSize = expectedSize, maxBytes = maxBytes, - expectedSha256 = params.expectedSha256.lowercase(), + expectedSha256 = expectedSha256, + downloadToken = expectedSha256 ?: sha256(params.url), hostname = url.host, overallDeadlineSeconds = overallDeadlineSeconds, ) @@ -377,26 +405,37 @@ internal object FirmwareArtifactStore { params: FirmwareArtifactDownloadParams, validated: ValidatedDownload, ): StoredFirmwareArtifact { - val finalFile = artifactFile(validated.expectedSha256) - validateStoredArtifactOrNull( - finalFile, - validated.expectedSize, - validated.expectedSha256, - )?.let { return it } + validated.expectedSha256?.let { expectedSha256 -> + val finalFile = artifactFile(expectedSha256) + validateDownloadedArtifactOrNull( + finalFile, + validated.expectedSize, + expectedSha256, + validated.maxBytes, + )?.let { return it } + } + val transactionToken = sha256(params.transactionId).take(16) val partialFile = File( root, - "${validated.expectedSha256}.${params.taskId}.partial", + "${validated.downloadToken}.${params.taskId}.$transactionToken.partial", ) - if (partialFile.length() > validated.expectedSize) { + if (validated.expectedSha256 == null && partialFile.length() > 0) { + check(partialFile.delete()) { "Unverified firmware partial cannot be removed" } + } else if (partialFile.length() > validated.maxBytes) { check(partialFile.delete()) { "Invalid firmware partial cannot be removed" } } - if (partialFile.length() == validated.expectedSize) { - validateStoredArtifactOrNull( + if ( + validated.expectedSize != null && + partialFile.length() == validated.expectedSize + ) { + validateDownloadedArtifactOrNull( partialFile, validated.expectedSize, validated.expectedSha256, + validated.maxBytes, )?.let { + val finalFile = artifactFile(it.sha256) promoteAtomically(partialFile, finalFile) return StoredFirmwareArtifact( it.artifactRef, @@ -473,15 +512,17 @@ internal object FirmwareArtifactStore { } val artifact = try { - validateStoredArtifact( + validateDownloadedArtifact( partialFile, validated.expectedSize, validated.expectedSha256, + validated.maxBytes, ) } catch (error: Throwable) { partialFile.delete() throw error } + val finalFile = artifactFile(artifact.sha256) promoteAtomically(partialFile, finalFile) return StoredFirmwareArtifact( artifact.artifactRef, @@ -495,7 +536,7 @@ internal object FirmwareArtifactStore { response: Response, partialFile: File, resumeOffset: Long, - expectedSize: Long, + expectedSize: Long?, maxBytes: Long, ) { require(response.code == 200 || response.code == 206) { @@ -508,6 +549,7 @@ internal object FirmwareArtifactStore { response.header("Content-Range"), if (append) resumeOffset else 0, expectedSize, + maxBytes, ) ) { "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid" @@ -540,7 +582,8 @@ internal object FirmwareArtifactStore { private fun validateContentRange( value: String?, expectedStart: Long, - expectedTotal: Long, + expectedTotal: Long?, + maxBytes: Long, ): Boolean { val match = value ?.lowercase() @@ -552,7 +595,39 @@ internal object FirmwareArtifactStore { return start == expectedStart && end >= start && end < total && - total == expectedTotal + (expectedTotal?.let { total == it } ?: (total in 1..maxBytes)) + } + + private fun validateDownloadedArtifactOrNull( + file: File, + expectedSize: Long?, + expectedSha256: String?, + maxBytes: Long, + ): StoredFirmwareArtifact? = try { + validateDownloadedArtifact(file, expectedSize, expectedSha256, maxBytes) + } catch (_: Throwable) { + null + } + + private fun validateDownloadedArtifact( + file: File, + expectedSize: Long?, + expectedSha256: String?, + maxBytes: Long, + ): StoredFirmwareArtifact { + val size = file.length() + require( + file.isFile && + size in 1..maxBytes && + (expectedSize == null || size == expectedSize) + ) { + "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch" + } + val sha256 = hashFile(file) + require(expectedSha256 == null || sha256 == expectedSha256) { + "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch" + } + return StoredFirmwareArtifact("fw:$sha256", size, sha256, file) } private fun validateStoredArtifactOrNull( @@ -660,6 +735,15 @@ internal object FirmwareArtifactStore { } } + private fun requireLeaseTransaction(leaseRef: String, transactionId: String) { + synchronized(leaseLock) { + val lease = leases[validateLeaseRef(leaseRef)] + require(lease?.transactionId == transactionId) { + "Firmware artifact lease transaction mismatch" + } + } + } + private fun retainExpectedArtifact( leaseRef: String, transactionId: String?, @@ -711,6 +795,11 @@ internal object FirmwareArtifactStore { return digest.digest().toHex() } + private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .toHex() + private fun promoteAtomically(source: File, destination: File) { destination.parentFile?.mkdirs() Os.rename(source.absolutePath, destination.absolutePath) diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt index 09cf1314..f9df5920 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt @@ -272,7 +272,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun getFirmwareArtifactCapabilities(): FirmwareArtifactCapabilities { return FirmwareArtifactCapabilities( - firmwareArtifactProtocolVersion = 2.0, + firmwareArtifactProtocolVersion = 3.0, supportedRouteTypes = arrayOf("domain", "pinnedIp"), supportsArchiveMaterialization = true, maxReadBytes = FirmwareArtifactStore.MAX_READ_BYTES.toDouble(), diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt index 04195465..d9adfc9f 100644 --- a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt @@ -1,10 +1,19 @@ package com.margelo.nitro.reactnativerangedownloader +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder class FirmwareArchiveRulesTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + private fun entry( artifactId: String = "resource-entry", entryName: String = "assets/icon.png", @@ -72,4 +81,34 @@ class FirmwareArchiveRulesTest { ) } } + + @Test + fun acceptsPortableEntriesWithoutExpectedIntegrityMetadata() { + val archive = createArchive("assets/icon.png", byteArrayOf(1, 2, 3)) + + val entries = FirmwareArchiveRules.validateCentralDirectory(archive, null) + + assertEquals(1, entries.size) + assertEquals("assets/icon.png", entries.single().name) + assertEquals(3, entries.single().uncompressedSize) + } + + @Test + fun rejectsTraversalWithoutExpectedIntegrityMetadata() { + val archive = createArchive("../icon.png", byteArrayOf(1)) + + assertThrows(IllegalArgumentException::class.java) { + FirmwareArchiveRules.validateCentralDirectory(archive, null) + } + } + + private fun createArchive(entryName: String, content: ByteArray): File { + val archive = temporaryFolder.newFile("firmware.zip") + ZipOutputStream(FileOutputStream(archive)).use { zip -> + zip.putNextEntry(ZipEntry(entryName)) + zip.write(content) + zip.closeEntry() + } + return archive + } } diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index 787de9a3..36ddf1ec 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -49,11 +49,17 @@ private struct StagedFirmwareArchiveEntry { let stagingURL: URL } +private struct FirmwareArchiveRequirement { + let entryName: String + let expectedSize: Int64 + let expectedSha256: String? +} + private final class FirmwareArtifactStreamDelegate: NSObject, URLSessionDataDelegate { private let partialURL: URL private let hostname: String private let resumeOffset: Int64 - private let expectedSize: Int64 + private let expectedSize: Int64? private let maxBytes: Int64 private let isCancelled: () -> Bool private let stateLock = NSLock() @@ -69,7 +75,7 @@ private final class FirmwareArtifactStreamDelegate: NSObject, URLSessionDataDele partialURL: URL, hostname: String, resumeOffset: Int64, - expectedSize: Int64, + expectedSize: Int64?, maxBytes: Int64, isCancelled: @escaping () -> Bool ) { @@ -213,7 +219,8 @@ private final class FirmwareArtifactStreamDelegate: NSObject, URLSessionDataDele firmwareArtifactContentRangeIsValid( contentRange, expectedStart: append ? resumeOffset : 0, - expectedTotal: expectedSize + expectedTotal: expectedSize, + maxBytes: maxBytes ) else { reject( @@ -415,7 +422,16 @@ final class FirmwareArtifactStore { private init() {} - static func validateDownloadParams(_ params: FirmwareArtifactDownloadParams) throws { + private struct ValidatedDownload { + let expectedSize: Int64? + let expectedSha256: String? + let maxBytes: Int64 + let downloadToken: String + } + + private static func validateDownloadParams( + _ params: FirmwareArtifactDownloadParams + ) throws -> ValidatedDownload { guard !params.taskId.isEmpty, params.taskId.count <= 100, @@ -443,22 +459,47 @@ final class FirmwareArtifactStore { else { throw FirmwareArtifactStoreError.invalidInput("Firmware URL must use HTTPS port 443") } + let expectedSize: Int64? + if let value = params.expectedSize { + guard + value.isFinite, + value > 0, + value <= Double(Int64.max), + value.rounded() == value + else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware artifact expected size" + ) + } + expectedSize = Int64(value) + } else { + expectedSize = nil + } guard - params.expectedSize.isFinite, - params.expectedSize > 0, - params.expectedSize <= Double(Int64.max), - params.expectedSize.rounded() == params.expectedSize, params.maxBytes.isFinite, - params.maxBytes == params.expectedSize, + params.maxBytes > 0, + params.maxBytes <= Double(Int64.max), + params.maxBytes.rounded() == params.maxBytes, params.maxBytes <= Double(512 * 1024 * 1024) else { throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact size") } - guard params.expectedSha256.range( - of: "^[a-fA-F0-9]{64}$", - options: .regularExpression - ) != nil else { - throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact SHA-256") + let maxBytes = Int64(params.maxBytes) + guard expectedSize.map({ $0 <= maxBytes }) ?? true else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact expected size exceeds maxBytes" + ) + } + let expectedSha256 = params.expectedSha256?.lowercased() + if let expectedSha256 { + guard expectedSha256.range( + of: "^[a-f0-9]{64}$", + options: .regularExpression + ) != nil else { + throw FirmwareArtifactStoreError.invalidInput( + "Invalid firmware artifact SHA-256" + ) + } } guard params.routeType == "domain" || params.routeType == "pinnedIp" else { throw FirmwareArtifactStoreError.invalidInput("Invalid firmware route type") @@ -478,23 +519,39 @@ final class FirmwareArtifactStore { } else if params.resolvedIp != nil { throw FirmwareArtifactStoreError.invalidInput("Domain route must not include resolvedIp") } + return ValidatedDownload( + expectedSize: expectedSize, + expectedSha256: expectedSha256, + maxBytes: maxBytes, + downloadToken: firmwareArtifactDownloadToken( + expectedSha256: expectedSha256, + url: params.url + ) + ) } func download(_ params: FirmwareArtifactDownloadParams) async throws -> StoredFirmwareArtifact { - try Self.validateDownloadParams(params) + let validated = try Self.validateDownloadParams(params) try rejectIfCancelled(transactionId: params.transactionId) - let expectedSha256 = params.expectedSha256.lowercased() - try retainExpectedArtifact( - leaseRef: params.leaseRef, - transactionId: params.transactionId, - artifactRef: "fw:\(expectedSha256)" - ) + if let expectedSha256 = validated.expectedSha256 { + try retainExpectedArtifact( + leaseRef: params.leaseRef, + transactionId: params.transactionId, + artifactRef: "fw:\(expectedSha256)" + ) + } else { + try requireLeaseTransaction( + leaseRef: params.leaseRef, + transactionId: params.transactionId + ) + } let key = firmwareArtifactDownloadKey( transactionId: params.transactionId, - expectedSize: Int64(params.expectedSize), - expectedSha256: expectedSha256 + taskId: params.taskId, + expectedSize: validated.expectedSize, + expectedSha256: validated.expectedSha256 ) - markDownloadActive(expectedSha256, delta: 1) + markDownloadActive(validated.downloadToken, delta: 1) do { let artifact = try await downloadCoordinator.run( key: key, @@ -503,14 +560,19 @@ final class FirmwareArtifactStore { try rejectIfCancelled(transactionId: params.transactionId) return try await downloadLocked( params, - expectedSha256: expectedSha256 + validated: validated ) } try rejectIfCancelled(transactionId: params.transactionId) - markDownloadActive(expectedSha256, delta: -1) + try retainExpectedArtifact( + leaseRef: params.leaseRef, + transactionId: params.transactionId, + artifactRef: artifact.artifactRef + ) + markDownloadActive(validated.downloadToken, delta: -1) return artifact } catch { - markDownloadActive(expectedSha256, delta: -1) + markDownloadActive(validated.downloadToken, delta: -1) OneKeyLog.error( "FirmwareArtifact", "event=download_failed transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) errorType=\(String(describing: type(of: error)))" @@ -528,14 +590,17 @@ final class FirmwareArtifactStore { func cachedArtifact( _ params: FirmwareArtifactDownloadParams ) throws -> StoredFirmwareArtifact? { - try Self.validateDownloadParams(params) + let validated = try Self.validateDownloadParams(params) try rejectIfCancelled(transactionId: params.transactionId) - let expectedSha256 = params.expectedSha256.lowercased() + guard let expectedSha256 = validated.expectedSha256 else { + return nil + } let finalURL = artifactURL(sha256: expectedSha256) - guard let artifact = try? validateStoredArtifact( + guard let artifact = try? validateDownloadedArtifact( fileURL: finalURL, - expectedSize: Int64(params.expectedSize), - expectedSha256: expectedSha256 + expectedSize: validated.expectedSize, + expectedSha256: expectedSha256, + maxBytes: validated.maxBytes ) else { return nil } @@ -562,15 +627,18 @@ final class FirmwareArtifactStore { private func downloadLocked( _ params: FirmwareArtifactDownloadParams, - expectedSha256: String + validated: ValidatedDownload ) async throws -> StoredFirmwareArtifact { - let finalURL = artifactURL(sha256: expectedSha256) - if let existing = try? validateStoredArtifact( - fileURL: finalURL, - expectedSize: Int64(params.expectedSize), - expectedSha256: expectedSha256 - ) { - return existing + if let expectedSha256 = validated.expectedSha256 { + let finalURL = artifactURL(sha256: expectedSha256) + if let existing = try? validateDownloadedArtifact( + fileURL: finalURL, + expectedSize: validated.expectedSize, + expectedSha256: expectedSha256, + maxBytes: validated.maxBytes + ) { + return existing + } } // Firmware preflight is foreground-bound, so both routes use the same @@ -579,7 +647,7 @@ final class FirmwareArtifactStore { firmwareArtifactPartialFileName( transactionId: params.transactionId, taskId: params.taskId, - expectedSha256: expectedSha256 + downloadToken: validated.downloadToken ), isDirectory: false ) @@ -587,17 +655,23 @@ final class FirmwareArtifactStore { fileManager.createFile(atPath: partialURL.path, contents: nil) } var currentSize = try fileSize(partialURL) - if currentSize > Int64(params.expectedSize) { + if validated.expectedSha256 == nil && currentSize > 0 { + try fileManager.removeItem(at: partialURL) + fileManager.createFile(atPath: partialURL.path, contents: nil) + currentSize = 0 + } else if currentSize > validated.maxBytes { try fileManager.removeItem(at: partialURL) fileManager.createFile(atPath: partialURL.path, contents: nil) currentSize = 0 } - if currentSize == Int64(params.expectedSize) { - if let completed = try? validateStoredArtifact( + if let expectedSize = validated.expectedSize, currentSize == expectedSize { + if let completed = try? validateDownloadedArtifact( fileURL: partialURL, - expectedSize: Int64(params.expectedSize), - expectedSha256: expectedSha256 + expectedSize: expectedSize, + expectedSha256: validated.expectedSha256, + maxBytes: validated.maxBytes ) { + let finalURL = artifactURL(sha256: completed.sha256) try promote(source: partialURL, destination: finalURL) return StoredFirmwareArtifact( artifactRef: completed.artifactRef, @@ -613,28 +687,31 @@ final class FirmwareArtifactStore { OneKeyLog.info( "FirmwareArtifact", - "event=stream_start transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(Int64(params.expectedSize)) resumeBytes=\(currentSize)" + "event=stream_start transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(validated.expectedSize ?? -1) resumeBytes=\(currentSize)" ) try await streamDownload( params, partialURL: partialURL, - resumeOffset: min(currentSize, Int64(params.expectedSize)) + resumeOffset: min(currentSize, validated.maxBytes), + validated: validated ) OneKeyLog.info( "FirmwareArtifact", - "event=stream_complete transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(Int64(params.expectedSize))" + "event=stream_complete transactionId=\(params.transactionId) artifactId=\(params.artifactId) route=\(params.routeType) expectedBytes=\(validated.expectedSize ?? -1)" ) let artifact: StoredFirmwareArtifact do { - artifact = try validateStoredArtifact( + artifact = try validateDownloadedArtifact( fileURL: partialURL, - expectedSize: Int64(params.expectedSize), - expectedSha256: expectedSha256 + expectedSize: validated.expectedSize, + expectedSha256: validated.expectedSha256, + maxBytes: validated.maxBytes ) } catch { try? fileManager.removeItem(at: partialURL) throw error } + let finalURL = artifactURL(sha256: artifact.sha256) try promote(source: partialURL, destination: finalURL) return StoredFirmwareArtifact( artifactRef: artifact.artifactRef, @@ -728,7 +805,7 @@ final class FirmwareArtifactStore { func materializeArchive( leaseRef: String, artifactRef: String, - expectedEntries: [FirmwareArchiveExpectedEntry] + expectedEntries: [FirmwareArchiveExpectedEntry]? ) throws -> [StoredFirmwareArchiveEntry] { try requireLease(leaseRef) let archiveURL = try resolveArtifactURL(artifactRef) @@ -739,10 +816,13 @@ final class FirmwareArtifactStore { try fileManager.createDirectory(at: scratchURL, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: scratchURL) } - let requirements = try validateArchiveRequirements(expectedEntries) let archiveEntries = try FirmwareArchiveMinizipBridge.scanArchive( atPath: archiveURL.path ) + let requirements = try resolveArchiveRequirements( + expectedEntries, + archiveEntries: archiveEntries + ) try validateArchiveEntries( archiveEntries, requirements: requirements @@ -769,7 +849,7 @@ final class FirmwareArtifactStore { } do { actualSize += Int64(chunk.count) - guard actualSize <= Int64(requirement.expectedSize) else { + guard actualSize <= requirement.expectedSize else { throw FirmwareArtifactStoreError.archiveInvalid( "Firmware archive entry exceeds its expected size" ) @@ -795,8 +875,8 @@ final class FirmwareArtifactStore { String(format: "%02x", $0) }.joined() guard - actualSize == Int64(requirement.expectedSize), - sha256 == requirement.expectedSha256.lowercased() + actualSize == requirement.expectedSize, + requirement.expectedSha256.map({ sha256 == $0 }) ?? true else { throw FirmwareArtifactStoreError.archiveInvalid( "Firmware archive entry integrity mismatch" @@ -932,6 +1012,22 @@ final class FirmwareArtifactStore { } } + private func requireLeaseTransaction( + leaseRef: String, + transactionId: String + ) throws { + leaseLock.lock() + defer { leaseLock.unlock() } + guard + let lease = leases[try validateLeaseRef(leaseRef)], + lease.transactionId == transactionId + else { + throw FirmwareArtifactStoreError.invalidInput( + "Firmware artifact lease transaction mismatch" + ) + } + } + private func retainExpectedArtifact( leaseRef: String, transactionId: String?, @@ -996,7 +1092,7 @@ final class FirmwareArtifactStore { private func validateArchiveRequirements( _ expectedEntries: [FirmwareArchiveExpectedEntry] - ) throws -> [FirmwareArchiveExpectedEntry] { + ) throws -> [FirmwareArchiveRequirement] { guard !expectedEntries.isEmpty, expectedEntries.count <= 4096 else { throw FirmwareArtifactStoreError.archiveInvalid( "Firmware archive expected entry count is invalid" @@ -1037,12 +1133,34 @@ final class FirmwareArtifactStore { ) } } - return expectedEntries + return expectedEntries.map { + FirmwareArchiveRequirement( + entryName: $0.entryName, + expectedSize: Int64($0.expectedSize), + expectedSha256: $0.expectedSha256.lowercased() + ) + } + } + + private func resolveArchiveRequirements( + _ expectedEntries: [FirmwareArchiveExpectedEntry]?, + archiveEntries: [FirmwareArchiveEntryInfo] + ) throws -> [FirmwareArchiveRequirement] { + if let expectedEntries { + return try validateArchiveRequirements(expectedEntries) + } + return archiveEntries.map { + FirmwareArchiveRequirement( + entryName: $0.name, + expectedSize: $0.uncompressedSize, + expectedSha256: nil + ) + } } private func validateArchiveEntries( _ entries: [FirmwareArchiveEntryInfo], - requirements: [FirmwareArchiveExpectedEntry] + requirements: [FirmwareArchiveRequirement] ) throws { guard entries.count == requirements.count else { throw FirmwareArtifactStoreError.archiveInvalid( @@ -1054,16 +1172,32 @@ final class FirmwareArtifactStore { ) var names = Set() var canonicalNames = Set() + var totalSize: Int64 = 0 for entry in entries { + guard let requirement = requirementsByName[entry.name] else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive contains an unexpected entry" + ) + } + let (nextTotalSize, overflowed) = totalSize.addingReportingOverflow( + entry.uncompressedSize + ) + guard !overflowed else { + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive expanded size is invalid" + ) + } + totalSize = nextTotalSize guard names.insert(entry.name).inserted, isPortableArchiveEntryName( entry.name, canonicalNames: &canonicalNames ), - let requirement = requirementsByName[entry.name], - entry.uncompressedSize == Int64(requirement.expectedSize), + entry.uncompressedSize == requirement.expectedSize, entry.uncompressedSize > 0, + entry.uncompressedSize <= 128 * 1024 * 1024, + totalSize <= 512 * 1024 * 1024, entry.compressedSize >= 0, entry.compressedSize <= 512 * 1024 * 1024, entry.uncompressedSize <= max(entry.compressedSize, 1) * 1000, @@ -1134,7 +1268,8 @@ final class FirmwareArtifactStore { private func streamDownload( _ params: FirmwareArtifactDownloadParams, partialURL: URL, - resumeOffset: Int64 + resumeOffset: Int64, + validated: ValidatedDownload ) async throws { do { try await FirmwareArtifactWallClockDeadline.run( @@ -1144,7 +1279,8 @@ final class FirmwareArtifactStore { try await streamDownloadWithinDeadline( params, partialURL: partialURL, - resumeOffset: resumeOffset + resumeOffset: resumeOffset, + validated: validated ) } } catch FirmwareArtifactDeadlineError.exceeded { @@ -1157,7 +1293,8 @@ final class FirmwareArtifactStore { private func streamDownloadWithinDeadline( _ params: FirmwareArtifactDownloadParams, partialURL: URL, - resumeOffset: Int64 + resumeOffset: Int64, + validated: ValidatedDownload ) async throws { guard let url = URL(string: params.url), let hostname = url.host else { throw FirmwareArtifactStoreError.invalidInput("Invalid firmware URL") @@ -1175,8 +1312,8 @@ final class FirmwareArtifactStore { partialURL: partialURL, hostname: hostname, resumeOffset: resumeOffset, - expectedSize: Int64(params.expectedSize), - maxBytes: Int64(params.maxBytes), + expectedSize: validated.expectedSize, + maxBytes: validated.maxBytes, isCancelled: { [weak self] in Task.isCancelled || self?.isTransactionCancelled(params.transactionId) == true @@ -1282,6 +1419,36 @@ final class FirmwareArtifactStore { ) } + private func validateDownloadedArtifact( + fileURL: URL, + expectedSize: Int64?, + expectedSha256: String?, + maxBytes: Int64 + ) throws -> StoredFirmwareArtifact { + let size = try fileSize(fileURL) + guard + size > 0, + size <= maxBytes, + expectedSize.map({ size == $0 }) ?? true + else { + throw FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch" + ) + } + let sha256 = try hashFile(fileURL) + guard expectedSha256.map({ sha256 == $0 }) ?? true else { + throw FirmwareArtifactStoreError.integrityMismatch( + "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch" + ) + } + return StoredFirmwareArtifact( + artifactRef: "fw:\(sha256)", + size: size, + sha256: sha256, + fileURL: fileURL + ) + } + private func resolveArtifactURL(_ artifactRef: String) throws -> URL { guard artifactRef.hasPrefix("fw:") else { throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifactRef") diff --git a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift index 2fe2539a..0533ba7a 100644 --- a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift +++ b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift @@ -47,16 +47,32 @@ enum FirmwareArtifactWallClockDeadline { func firmwareArtifactDownloadKey( transactionId: String, - expectedSize: Int64, - expectedSha256: String + taskId: String, + expectedSize: Int64?, + expectedSha256: String? +) -> String { + "\(transactionId)|\(taskId)|\(expectedSize.map(String.init) ?? "unknown")|\(expectedSha256 ?? "unknown")" +} + +func firmwareArtifactDownloadToken( + expectedSha256: String?, + url: String ) -> String { - "\(transactionId)|\(expectedSize)|\(expectedSha256)" + if let expectedSha256 { + return expectedSha256.lowercased() + } + let urlData = Data(url.utf8) + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + urlData.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(urlData.count), &hash) + } + return hash.map { String(format: "%02x", $0) }.joined() } func firmwareArtifactPartialFileName( transactionId: String, taskId: String, - expectedSha256: String + downloadToken: String ) -> String { let transactionData = Data(transactionId.utf8) var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) @@ -67,13 +83,14 @@ func firmwareArtifactPartialFileName( .prefix(8) .map { String(format: "%02x", $0) } .joined() - return "\(expectedSha256).\(taskId).\(transactionToken).partial" + return "\(downloadToken).\(taskId).\(transactionToken).partial" } func firmwareArtifactContentRangeIsValid( _ value: String, expectedStart: Int64, - expectedTotal: Int64 + expectedTotal: Int64?, + maxBytes: Int64 ) -> Bool { guard value.lowercased().hasPrefix("bytes ") else { return false @@ -84,10 +101,11 @@ func firmwareArtifactContentRangeIsValid( else { return false } + let totalMatches = expectedTotal.map { total == $0 } ?? (total > 0 && total <= maxBytes) return bounds.start == expectedStart && bounds.end >= bounds.start && bounds.end < total && - total == expectedTotal + totalMatches } func firmwareArtifactResponseFits( diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index 885b189c..6b7b7839 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -108,7 +108,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func getFirmwareArtifactCapabilities() throws -> FirmwareArtifactCapabilities { FirmwareArtifactCapabilities( - firmwareArtifactProtocolVersion: 2, + firmwareArtifactProtocolVersion: 3, supportedRouteTypes: ["domain", "pinnedIp"], supportsArchiveMaterialization: true, maxReadBytes: Double(FirmwareArtifactStore.maxReadBytes) diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 6f028313..80f67aae 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.8", + "version": "3.0.81-alpha.9", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts index 6472ddb3..c8b4bd17 100644 --- a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts +++ b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts @@ -76,8 +76,8 @@ export interface FirmwareArtifactDownloadParams { url: string; routeType: string; resolvedIp?: string; - expectedSize: number; - expectedSha256: string; + expectedSize?: number; + expectedSha256?: string; maxBytes: number; overallDeadlineSeconds?: number; // defaults to 180; must be > 0 and <= 24 hours } @@ -121,7 +121,7 @@ export interface FirmwareArtifactReaderCloseParams { export interface FirmwareArchiveMaterializeParams { leaseRef: string; archiveArtifactRef: string; - expectedEntries: FirmwareArchiveExpectedEntry[]; + expectedEntries?: FirmwareArchiveExpectedEntry[]; } export interface FirmwareArchiveExpectedEntry { diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift index 32669b6d..2241ac31 100644 --- a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift @@ -29,11 +29,13 @@ final class RangeDownloadLogicTests: XCTestCase { func testFirmwareArtifactDownloadKeyIsolatesTransactions() { let first = firmwareArtifactDownloadKey( transactionId: "fwtx:first", + taskId: "firmware", expectedSize: 42, expectedSha256: String(repeating: "a", count: 64) ) let second = firmwareArtifactDownloadKey( transactionId: "fwtx:second", + taskId: "firmware", expectedSize: 42, expectedSha256: String(repeating: "a", count: 64) ) @@ -46,12 +48,12 @@ final class RangeDownloadLogicTests: XCTestCase { let first = firmwareArtifactPartialFileName( transactionId: "fwtx:first", taskId: "firmware", - expectedSha256: sha256 + downloadToken: sha256 ) let second = firmwareArtifactPartialFileName( transactionId: "fwtx:second", taskId: "firmware", - expectedSha256: sha256 + downloadToken: sha256 ) XCTAssertNotEqual(first, second) @@ -64,32 +66,71 @@ final class RangeDownloadLogicTests: XCTestCase { firmwareArtifactContentRangeIsValid( "bytes 128-255/256", expectedStart: 128, - expectedTotal: 256 + expectedTotal: 256, + maxBytes: 256 ) ) XCTAssertFalse( firmwareArtifactContentRangeIsValid( "bytes 0-255/256", expectedStart: 128, - expectedTotal: 256 + expectedTotal: 256, + maxBytes: 256 ) ) XCTAssertFalse( firmwareArtifactContentRangeIsValid( "bytes 128-256/256", expectedStart: 128, - expectedTotal: 256 + expectedTotal: 256, + maxBytes: 256 ) ) XCTAssertFalse( firmwareArtifactContentRangeIsValid( "items 128-255/256", expectedStart: 128, - expectedTotal: 256 + expectedTotal: 256, + maxBytes: 256 ) ) } + func testFirmwareArtifactContentRangeAcceptsUnknownExpectedTotalWithinBound() { + XCTAssertTrue( + firmwareArtifactContentRangeIsValid( + "bytes 128-255/256", + expectedStart: 128, + expectedTotal: nil, + maxBytes: 512 + ) + ) + XCTAssertFalse( + firmwareArtifactContentRangeIsValid( + "bytes 128-1023/1024", + expectedStart: 128, + expectedTotal: nil, + maxBytes: 512 + ) + ) + } + + func testFirmwareArtifactDownloadTokenFallsBackToStableUrlHash() { + let url = "https://common.onekey-asset.com/firmware.bin" + let first = firmwareArtifactDownloadToken(expectedSha256: nil, url: url) + let second = firmwareArtifactDownloadToken(expectedSha256: nil, url: url) + + XCTAssertEqual(first, second) + XCTAssertEqual(first.count, 64) + XCTAssertEqual( + firmwareArtifactDownloadToken( + expectedSha256: String(repeating: "A", count: 64), + url: url + ), + String(repeating: "a", count: 64) + ) + } + func testFirmwareArtifactResponseSizeIsBoundedBeforeStreaming() { XCTAssertTrue( firmwareArtifactResponseFits( From 59217e08edcbceb2bc19b06d5fbe62c516b7145f Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 7 Aug 2026 11:48:49 +0800 Subject: [PATCH 25/27] fix: validate optional firmware artifacts --- .../react-native-range-downloader/SPEC.md | 29 +++++++++++-- .../ios/FirmwareArtifactStore.swift | 17 +++++++- .../ios/RangeDownloadLogic.swift | 23 +++++++++- .../package.json | 2 +- .../RangeDownloadLogicTests.swift | 42 ++++++++++++++++++- 5 files changed, 104 insertions(+), 9 deletions(-) diff --git a/native-modules/react-native-range-downloader/SPEC.md b/native-modules/react-native-range-downloader/SPEC.md index 0eca36b4..da615e06 100644 --- a/native-modules/react-native-range-downloader/SPEC.md +++ b/native-modules/react-native-range-downloader/SPEC.md @@ -1,11 +1,12 @@ # OneKey Concurrent Download Standard (OCDS) -- **Version:** 1.2 +- **Version:** 1.3 - **Status:** Active -- **Last updated:** 2026-06-20 +- **Last updated:** 2026-08-07 - **Applies to:** every implementation of OneKey's concurrent (multi-range) downloader — iOS (Swift), Android (Kotlin), Desktop (Node/Electron), and any - future platform. + future platform — plus the firmware artifact staging APIs described under the + trust boundary. This document defines the behavior a concurrent downloader is expected to provide. It is platform-agnostic: implementations differ in language and @@ -285,6 +286,25 @@ existing obligations made explicit here, not new mechanism inside this module: - Artifacts are stored in an app-private location. - Anti-rollback / version-monotonicity is the consumer's responsibility. +### Firmware artifact staging APIs + +The firmware artifact APIs permit `expectedSize`, `expectedSha256`, and archive +`expectedEntries` to be omitted for legacy inputs. Omitting these fields changes +which checks the module can perform; it does not turn a locally computed digest +into a trusted expectation: + +- `maxBytes`, HTTPS/TLS validation, bounded streaming, and archive structural + safety checks remain mandatory. +- Without `expectedSha256`, the module computes SHA-256 only as a content-addressed + local artifact reference. It does not prove publisher integrity or authenticity. +- Without `expectedEntries`, archive names, types, uniqueness, non-emptiness, and + size/expansion bounds are still validated, but entry digests are not compared + with publisher-supplied values. +- A caller may omit trusted expectations only when a downstream consumer performs + an independent authenticity check before use, or when an explicit product + policy accepts that legacy artifact. The native module does not make that + product-policy decision. + --- ## 6. Conformance scenarios @@ -325,6 +345,9 @@ own notes until closed. **This document records no implementation's state.** ## Appendix B. Change log +- **1.3** (2026-08-07) — Documented the optional integrity boundary for firmware + artifact staging. Missing expectations retain transport and structural safety + checks but delegate publisher integrity/authenticity acceptance to the caller. - **1.2** (2026-06-22) — Removed the "retry once via single-stream" requirement on a whole-file checksum/signature mismatch (§4 failure table, §6 scenario 6). A mismatch after assembly is now simply Permanent → discard + terminal failure. diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index 36ddf1ec..23dc2c2d 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -549,7 +549,8 @@ final class FirmwareArtifactStore { transactionId: params.transactionId, taskId: params.taskId, expectedSize: validated.expectedSize, - expectedSha256: validated.expectedSha256 + expectedSha256: validated.expectedSha256, + downloadToken: validated.downloadToken ) markDownloadActive(validated.downloadToken, delta: 1) do { @@ -1149,6 +1150,20 @@ final class FirmwareArtifactStore { if let expectedEntries { return try validateArchiveRequirements(expectedEntries) } + if let issue = firmwareArchiveDiscoveredEntriesIssue( + archiveEntries.map(\.name) + ) { + switch issue { + case .empty: + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive must contain at least one entry" + ) + case .duplicateName: + throw FirmwareArtifactStoreError.archiveInvalid( + "Firmware archive contains duplicate entry names" + ) + } + } return archiveEntries.map { FirmwareArchiveRequirement( entryName: $0.name, diff --git a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift index 0533ba7a..e7c6e6e8 100644 --- a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift +++ b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift @@ -49,9 +49,28 @@ func firmwareArtifactDownloadKey( transactionId: String, taskId: String, expectedSize: Int64?, - expectedSha256: String? + expectedSha256: String?, + downloadToken: String ) -> String { - "\(transactionId)|\(taskId)|\(expectedSize.map(String.init) ?? "unknown")|\(expectedSha256 ?? "unknown")" + "\(transactionId)|\(taskId)|\(expectedSize.map(String.init) ?? "unknown")|\(expectedSha256 ?? "unknown")|\(downloadToken)" +} + +enum FirmwareArchiveDiscoveredEntriesIssue: Equatable { + case empty + case duplicateName +} + +func firmwareArchiveDiscoveredEntriesIssue( + _ entryNames: [String] +) -> FirmwareArchiveDiscoveredEntriesIssue? { + guard !entryNames.isEmpty else { + return .empty + } + var names = Set() + guard entryNames.allSatisfy({ names.insert($0).inserted }) else { + return .duplicateName + } + return nil } func firmwareArtifactDownloadToken( diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 80f67aae..495d5b27 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.9", + "version": "3.0.81-alpha.10", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift index 2241ac31..29c767fe 100644 --- a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift @@ -31,18 +31,56 @@ final class RangeDownloadLogicTests: XCTestCase { transactionId: "fwtx:first", taskId: "firmware", expectedSize: 42, - expectedSha256: String(repeating: "a", count: 64) + expectedSha256: String(repeating: "a", count: 64), + downloadToken: String(repeating: "a", count: 64) ) let second = firmwareArtifactDownloadKey( transactionId: "fwtx:second", taskId: "firmware", expectedSize: 42, - expectedSha256: String(repeating: "a", count: 64) + expectedSha256: String(repeating: "a", count: 64), + downloadToken: String(repeating: "a", count: 64) ) XCTAssertNotEqual(first, second) } + func testFirmwareArtifactDownloadKeyIsolatesUnverifiedURLs() { + let first = firmwareArtifactDownloadKey( + transactionId: "fwtx:same", + taskId: "firmware", + expectedSize: nil, + expectedSha256: nil, + downloadToken: firmwareArtifactDownloadToken( + expectedSha256: nil, + url: "https://common.onekey-asset.com/first.bin" + ) + ) + let second = firmwareArtifactDownloadKey( + transactionId: "fwtx:same", + taskId: "firmware", + expectedSize: nil, + expectedSha256: nil, + downloadToken: firmwareArtifactDownloadToken( + expectedSha256: nil, + url: "https://common.onekey-asset.com/second.bin" + ) + ) + + XCTAssertNotEqual(first, second) + } + + func testFirmwareArchiveDiscoveredEntriesRejectEmptyAndDuplicateNames() { + XCTAssertEqual(firmwareArchiveDiscoveredEntriesIssue([]), .empty) + XCTAssertEqual( + firmwareArchiveDiscoveredEntriesIssue(["fw.bin", "fw.bin"]), + .duplicateName + ) + XCTAssertNil( + firmwareArchiveDiscoveredEntriesIssue(["fw.bin", "resource.bin"]) + ) + } + func testFirmwareArtifactPartialFileNameIsolatesTransactions() { let sha256 = String(repeating: "a", count: 64) let first = firmwareArtifactPartialFileName( From ab89c3d5490cf8a4462fe343a13383045dc07e8c Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 7 Aug 2026 13:41:36 +0800 Subject: [PATCH 26/27] fix: address firmware artifact review follow-ups --- .../ReactNativeRangeDownloader.podspec | 3 +- .../react-native-range-downloader/SPEC.md | 7 ++- .../android/build.gradle | 1 - .../FirmwareArtifactStore.kt | 34 +++-------- .../ReactNativeRangeDownloader.kt | 8 ++- .../ios/FirmwareArtifactStore.swift | 56 ++++++------------- .../ios/RangeDownloadLogic.swift | 20 +++++++ .../ios/ReactNativeRangeDownloader.swift | 35 +++++++----- .../package.json | 3 +- .../src/ReactNativeRangeDownloader.nitro.ts | 3 + .../RangeDownloadLogicTests.swift | 40 +++++++++++++ yarn.lock | 1 - 12 files changed, 123 insertions(+), 88 deletions(-) diff --git a/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec b/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec index 38cb65a1..9d60ca17 100644 --- a/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec +++ b/native-modules/react-native-range-downloader/ReactNativeRangeDownloader.podspec @@ -23,13 +23,12 @@ Pod::Spec.new do |s| s.dependency 'React-jsi' s.dependency 'React-callinvoker' s.dependency 'ReactNativeNativeLogger' - s.dependency 'SniConnect', package["peerDependencies"]["@onekeyfe/react-native-sni-connect"] s.public_header_files = "ios/FirmwareArchiveMinizipBridge.h" s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"', } - s.dependency 'SSZipArchive', '>= 2.5.4' + s.dependency 'SSZipArchive', '2.5.5' load 'nitrogen/generated/ios/ReactNativeRangeDownloader+autolinking.rb' add_nitrogen_files(s) diff --git a/native-modules/react-native-range-downloader/SPEC.md b/native-modules/react-native-range-downloader/SPEC.md index da615e06..cbd3d0ca 100644 --- a/native-modules/react-native-range-downloader/SPEC.md +++ b/native-modules/react-native-range-downloader/SPEC.md @@ -1,6 +1,6 @@ # OneKey Concurrent Download Standard (OCDS) -- **Version:** 1.3 +- **Version:** 1.4 - **Status:** Active - **Last updated:** 2026-08-07 - **Applies to:** every implementation of OneKey's concurrent (multi-range) @@ -297,6 +297,9 @@ into a trusted expectation: safety checks remain mandatory. - Without `expectedSha256`, the module computes SHA-256 only as a content-addressed local artifact reference. It does not prove publisher integrity or authenticity. +- Every artifact receipt exposes `expectedSha256Verified`; it is `true` only when + the returned digest was compared with caller-supplied `expectedSha256`. A locally + computed digest returned without that expectation is explicitly marked `false`. - Without `expectedEntries`, archive names, types, uniqueness, non-emptiness, and size/expansion bounds are still validated, but entry digests are not compared with publisher-supplied values. @@ -345,6 +348,8 @@ own notes until closed. **This document records no implementation's state.** ## Appendix B. Change log +- **1.4** (2026-08-07) — Added an explicit receipt signal distinguishing a + caller-supplied SHA-256 match from a locally computed content-address digest. - **1.3** (2026-08-07) — Documented the optional integrity boundary for firmware artifact staging. Missing expectations retain transport and structural safety checks but delegate publisher integrity/authenticity acceptance to the caller. diff --git a/native-modules/react-native-range-downloader/android/build.gradle b/native-modules/react-native-range-downloader/android/build.gradle index c6f428de..7ab18d64 100644 --- a/native-modules/react-native-range-downloader/android/build.gradle +++ b/native-modules/react-native-range-downloader/android/build.gradle @@ -127,7 +127,6 @@ dependencies { implementation project(":react-native-nitro-modules") implementation project(":onekeyfe_react-native-native-logger") - implementation project(":onekeyfe_react-native-sni-connect") implementation "com.squareup.okhttp3:okhttp:4.12.0" diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt index 75c86649..72a9e355 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt @@ -2,7 +2,6 @@ package com.margelo.nitro.reactnativerangedownloader import android.system.Os import com.margelo.nitro.NitroModules -import com.sniconnect.SniPinnedTransport import java.io.BufferedInputStream import java.io.File import java.io.FileInputStream @@ -70,7 +69,7 @@ private class FirmwareDownloadLock { internal object FirmwareArtifactStore { const val MAX_READ_BYTES = 256 * 1024 - private const val MAX_ARTIFACT_BYTES = 512L * 1024 * 1024 + const val MAX_ARTIFACT_BYTES = 512L * 1024 * 1024 private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$") private val artifactRefPattern = Regex("^fw:[a-f0-9]{64}$") private val leaseRefPattern = Regex("^fwlease:[a-f0-9-]{36}$") @@ -331,7 +330,6 @@ internal object FirmwareArtifactStore { val maxBytes: Long, val expectedSha256: String?, val downloadToken: String, - val hostname: String, val overallDeadlineSeconds: Double, ) @@ -379,24 +377,17 @@ internal object FirmwareArtifactStore { } val overallDeadlineSeconds = validateFirmwareDownloadDeadlineSeconds(params.overallDeadlineSeconds) - require(params.routeType == "domain" || params.routeType == "pinnedIp") { + require(params.routeType == "domain") { "Invalid firmware route type" } - if (params.routeType == "pinnedIp") { - require(!params.resolvedIp.isNullOrEmpty()) { - "Pinned route requires resolvedIp" - } - } else { - require(params.resolvedIp == null) { - "Domain route must not include resolvedIp" - } + require(params.resolvedIp == null) { + "Domain route must not include resolvedIp" } return ValidatedDownload( expectedSize = expectedSize, maxBytes = maxBytes, expectedSha256 = expectedSha256, downloadToken = expectedSha256 ?: sha256(params.url), - hostname = url.host, overallDeadlineSeconds = overallDeadlineSeconds, ) } @@ -455,18 +446,11 @@ internal object FirmwareArtifactStore { requestBuilder.header("Range", "bytes=$resumeOffset-") } - val client = if (params.routeType == "pinnedIp") { - SniPinnedTransport.createClient( - ip = checkNotNull(params.resolvedIp), - hostname = validated.hostname, - ) - } else { - OkHttpClient.Builder() - .protocols(listOf(Protocol.HTTP_1_1)) - .followRedirects(false) - .followSslRedirects(false) - .build() - } + val client = OkHttpClient.Builder() + .protocols(listOf(Protocol.HTTP_1_1)) + .followRedirects(false) + .followSslRedirects(false) + .build() val call = client.newCall(requestBuilder.build()) call.timeout().timeout( ceil(validated.overallDeadlineSeconds * 1000).toLong(), diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt index f9df5920..a814d68d 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt @@ -272,8 +272,8 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { override fun getFirmwareArtifactCapabilities(): FirmwareArtifactCapabilities { return FirmwareArtifactCapabilities( - firmwareArtifactProtocolVersion = 3.0, - supportedRouteTypes = arrayOf("domain", "pinnedIp"), + firmwareArtifactProtocolVersion = 4.0, + supportedRouteTypes = arrayOf("domain"), supportsArchiveMaterialization = true, maxReadBytes = FirmwareArtifactStore.MAX_READ_BYTES.toDouble(), ) @@ -288,6 +288,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { artifactRef = artifact.artifactRef, size = artifact.size.toDouble(), sha256 = artifact.sha256, + expectedSha256Verified = params.expectedSha256 != null, ) } } @@ -324,9 +325,11 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { require( params.offset.isFinite() && params.offset >= 0 && + params.offset <= FirmwareArtifactStore.MAX_ARTIFACT_BYTES.toDouble() && params.offset.toLong().toDouble() == params.offset && params.length.isFinite() && params.length > 0 && + params.length <= FirmwareArtifactStore.MAX_READ_BYTES.toDouble() && params.length.toInt().toDouble() == params.length ) { "Invalid firmware artifact read" @@ -366,6 +369,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { artifactRef = entry.artifact.artifactRef, size = entry.artifact.size.toDouble(), sha256 = entry.artifact.sha256, + expectedSha256Verified = params.expectedEntries != null, ), ) }.toTypedArray(), diff --git a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift index 23dc2c2d..b3a1f36e 100644 --- a/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift +++ b/native-modules/react-native-range-downloader/ios/FirmwareArtifactStore.swift @@ -1,7 +1,6 @@ import CryptoKit import Foundation import ReactNativeNativeLogger -import SniConnect func isFirmwareArtifactTLSError(_ error: Error) -> Bool { var current: NSError? = error as NSError @@ -384,7 +383,8 @@ private actor FirmwareArtifactDownloadCoordinator { final class FirmwareArtifactStore { static let shared = FirmwareArtifactStore() - static let maxReadBytes = 256 * 1024 + static let maxReadBytes = Int(firmwareArtifactMaxReadBytes) + static let maxArtifactBytes = firmwareArtifactMaxBytes private static let defaultDownloadDeadline: TimeInterval = 180 private static let maxDownloadDeadline: TimeInterval = 24 * 60 * 60 @@ -461,30 +461,26 @@ final class FirmwareArtifactStore { } let expectedSize: Int64? if let value = params.expectedSize { - guard - value.isFinite, - value > 0, - value <= Double(Int64.max), - value.rounded() == value - else { + guard let converted = firmwareArtifactExactInt64( + value, + minimum: 1, + maximum: Self.maxArtifactBytes + ) else { throw FirmwareArtifactStoreError.invalidInput( "Invalid firmware artifact expected size" ) } - expectedSize = Int64(value) + expectedSize = converted } else { expectedSize = nil } - guard - params.maxBytes.isFinite, - params.maxBytes > 0, - params.maxBytes <= Double(Int64.max), - params.maxBytes.rounded() == params.maxBytes, - params.maxBytes <= Double(512 * 1024 * 1024) - else { + guard let maxBytes = firmwareArtifactExactInt64( + params.maxBytes, + minimum: 1, + maximum: Self.maxArtifactBytes + ) else { throw FirmwareArtifactStoreError.invalidInput("Invalid firmware artifact size") } - let maxBytes = Int64(params.maxBytes) guard expectedSize.map({ $0 <= maxBytes }) ?? true else { throw FirmwareArtifactStoreError.invalidInput( "Firmware artifact expected size exceeds maxBytes" @@ -501,7 +497,7 @@ final class FirmwareArtifactStore { ) } } - guard params.routeType == "domain" || params.routeType == "pinnedIp" else { + guard params.routeType == "domain" else { throw FirmwareArtifactStoreError.invalidInput("Invalid firmware route type") } let deadline = params.overallDeadlineSeconds ?? defaultDownloadDeadline @@ -512,11 +508,7 @@ final class FirmwareArtifactStore { else { throw FirmwareArtifactStoreError.invalidInput("Invalid firmware download deadline") } - if params.routeType == "pinnedIp" { - guard let resolvedIp = params.resolvedIp, !resolvedIp.isEmpty else { - throw FirmwareArtifactStoreError.invalidInput("Pinned route requires resolvedIp") - } - } else if params.resolvedIp != nil { + if params.resolvedIp != nil { throw FirmwareArtifactStoreError.invalidInput("Domain route must not include resolvedIp") } return ValidatedDownload( @@ -1334,27 +1326,13 @@ final class FirmwareArtifactStore { self?.isTransactionCancelled(params.transactionId) == true } ) - let pinnedSession: SniConnectPinnedSession? - if params.routeType == "pinnedIp" { - pinnedSession = try SniConnectPinnedTransport.makeSession( - hostname: hostname, - ip: params.resolvedIp!, - dataDelegate: streamDelegate - ) - } else { - pinnedSession = nil - } - let session = pinnedSession?.session ?? URLSession( + let session = URLSession( configuration: .ephemeral, delegate: streamDelegate, delegateQueue: nil ) defer { - if let pinnedSession { - pinnedSession.close() - } else { - session.finishTasksAndInvalidate() - } + session.finishTasksAndInvalidate() } do { diff --git a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift index e7c6e6e8..c7bea5df 100644 --- a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift +++ b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift @@ -142,6 +142,26 @@ func firmwareArtifactResponseFits( let firmwareArtifactFinalGrace: TimeInterval = 24 * 60 * 60 let firmwareArtifactPartialGrace: TimeInterval = 7 * 24 * 60 * 60 let firmwareArtifactScratchGrace: TimeInterval = firmwareArtifactPartialGrace +let firmwareArtifactMaxBytes: Int64 = 512 * 1024 * 1024 +let firmwareArtifactMaxReadBytes: Int64 = 256 * 1024 + +func firmwareArtifactExactInt64( + _ value: Double, + minimum: Int64, + maximum: Int64 +) -> Int64? { + guard + minimum <= maximum, + value.isFinite, + value.rounded() == value, + let converted = Int64(exactly: value), + converted >= minimum, + converted <= maximum + else { + return nil + } + return converted +} func firmwareArtifactIdentifierIsSafe(_ value: String) -> Bool { value.range( diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index 6b7b7839..8048681e 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -108,8 +108,8 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func getFirmwareArtifactCapabilities() throws -> FirmwareArtifactCapabilities { FirmwareArtifactCapabilities( - firmwareArtifactProtocolVersion: 3, - supportedRouteTypes: ["domain", "pinnedIp"], + firmwareArtifactProtocolVersion: 4, + supportedRouteTypes: ["domain"], supportsArchiveMaterialization: true, maxReadBytes: Double(FirmwareArtifactStore.maxReadBytes) ) @@ -124,7 +124,8 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { withResult: FirmwareArtifactReceipt( artifactRef: artifact.artifactRef, size: Double(artifact.size), - sha256: artifact.sha256 + sha256: artifact.sha256, + expectedSha256Verified: params.expectedSha256 != nil ) ) } @@ -136,7 +137,8 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { return FirmwareArtifactReceipt( artifactRef: artifact.artifactRef, size: Double(artifact.size), - sha256: artifact.sha256 + sha256: artifact.sha256, + expectedSha256Verified: params.expectedSha256 != nil ) } } @@ -185,14 +187,16 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { ) throws -> Promise { settledFirmwareArtifactPromise { guard - params.offset.isFinite, - params.offset >= 0, - params.offset <= Double(Int64.max), - params.offset.rounded() == params.offset, - params.length.isFinite, - params.length > 0, - params.length <= Double(Int.max), - params.length.rounded() == params.length + let offset = firmwareArtifactExactInt64( + params.offset, + minimum: 0, + maximum: FirmwareArtifactStore.maxArtifactBytes + ), + let length = firmwareArtifactExactInt64( + params.length, + minimum: 1, + maximum: firmwareArtifactMaxReadBytes + ) else { throw FirmwareArtifactStoreError.readerInvalid( "Invalid firmware artifact read" @@ -200,8 +204,8 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { } let data = try FirmwareArtifactStore.shared.read( readerId: params.readerId, - offset: Int64(params.offset), - length: Int(params.length) + offset: offset, + length: Int(length) ) return try ArrayBuffer.copy(data: data) } @@ -231,7 +235,8 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { receipt: FirmwareArtifactReceipt( artifactRef: entry.artifact.artifactRef, size: Double(entry.artifact.size), - sha256: entry.artifact.sha256 + sha256: entry.artifact.sha256, + expectedSha256Verified: params.expectedEntries != nil ) ) } diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 495d5b27..72215ed2 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.81-alpha.10", + "version": "3.0.81-alpha.11", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -88,7 +88,6 @@ "typescript": "^5.9.2" }, "peerDependencies": { - "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.8", "react": "*", "react-native": "*", "react-native-nitro-modules": "0.33.2" diff --git a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts index c8b4bd17..ec8d9a4c 100644 --- a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts +++ b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts @@ -86,6 +86,9 @@ export interface FirmwareArtifactReceipt { artifactRef: string; size: number; sha256: string; + // True only when sha256 was compared with expectedSha256 supplied by the caller. + // A locally computed content-address digest without that expectation is not trusted integrity. + expectedSha256Verified: boolean; } export interface FirmwareArtifactCapabilities { diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift index 29c767fe..651c89e8 100644 --- a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift @@ -229,6 +229,46 @@ final class RangeDownloadLogicTests: XCTestCase { ) } + func testFirmwareArtifactExactInt64RejectsRoundedOverflowAndOutOfBoundsValues() { + XCTAssertNil( + firmwareArtifactExactInt64( + pow(2.0, 63.0), + minimum: 1, + maximum: firmwareArtifactMaxBytes + ) + ) + XCTAssertNil( + firmwareArtifactExactInt64( + Double(firmwareArtifactMaxBytes) + 1, + minimum: 1, + maximum: firmwareArtifactMaxBytes + ) + ) + XCTAssertNil( + firmwareArtifactExactInt64( + 1.5, + minimum: 1, + maximum: firmwareArtifactMaxBytes + ) + ) + XCTAssertEqual( + firmwareArtifactExactInt64( + Double(firmwareArtifactMaxBytes), + minimum: 1, + maximum: firmwareArtifactMaxBytes + ), + firmwareArtifactMaxBytes + ) + XCTAssertEqual( + firmwareArtifactExactInt64( + Double(firmwareArtifactMaxReadBytes), + minimum: 1, + maximum: firmwareArtifactMaxReadBytes + ), + firmwareArtifactMaxReadBytes + ) + } + func testFirmwareArtifactOrphanSweepRemovesOnlyStaleRootScratchEntries() throws { let fileManager = FileManager.default let rootURL = fileManager.temporaryDirectory.appendingPathComponent( diff --git a/yarn.lock b/yarn.lock index 7fa54d96..b15ec080 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3688,7 +3688,6 @@ __metadata: turbo: "npm:^2.5.6" typescript: "npm:^5.9.2" peerDependencies: - "@onekeyfe/react-native-sni-connect": 3.0.81-alpha.8 react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 From bdc74dea2a349eebd2f016cec4f01b2576b39106 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 7 Aug 2026 18:32:09 +0800 Subject: [PATCH 27/27] fix: restore iOS SNI responses --- .github/workflows/package-publish.yml | 4 + .../ios/SniConnectClient.swift | 4 +- .../ios/SniConnectCore.swift | 4 + .../SniConnectValidationTests.swift | 9 ++ .../react-native-sni-connect/package.json | 2 +- scripts/validate-npm-dist-tag.mjs | 87 +++++++++++++++++++ scripts/validate-npm-dist-tag.test.mjs | 35 ++++++++ 7 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 scripts/validate-npm-dist-tag.mjs create mode 100644 scripts/validate-npm-dist-tag.test.mjs diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index 900aa195..719c7a91 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -21,6 +21,10 @@ jobs: with: node-version: "24.x" registry-url: "https://registry.npmjs.org" + - name: Validate npm dist-tag + env: + NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} + run: node scripts/validate-npm-dist-tag.mjs "$NPM_DIST_TAG" - name: Install Package run: corepack enable && yarn install - name: Publish packages diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index 1fd0f844..07b52a37 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -149,7 +149,9 @@ final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDataDeleg dataTask: dataTask, didReceive: response, completionHandler: completionHandler - ) ?? completionHandler(.cancel) + ) ?? completionHandler( + SniConnectSessionDelegatePolicy.responseDispositionWithoutForwardingDelegate + ) } func urlSession( diff --git a/native-modules/react-native-sni-connect/ios/SniConnectCore.swift b/native-modules/react-native-sni-connect/ios/SniConnectCore.swift index db21e3af..06656df4 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectCore.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectCore.swift @@ -1,5 +1,9 @@ import Foundation +enum SniConnectSessionDelegatePolicy { + static let responseDispositionWithoutForwardingDelegate: URLSession.ResponseDisposition = .allow +} + enum SniConnectCoreDiagnostics { static var warnSink: ((String) -> Void)? diff --git a/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift b/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift index 6e3403cf..90a74d67 100644 --- a/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +++ b/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift @@ -3,6 +3,15 @@ import XCTest final class SniConnectValidationTests: XCTestCase { + func testSessionWithoutForwardingDataDelegateAllowsResponse() { + switch SniConnectSessionDelegatePolicy.responseDispositionWithoutForwardingDelegate { + case .allow: + break + default: + XCTFail("A session without a forwarding data delegate must allow its response") + } + } + func testAcceptsValidRequestBoundaryValues() throws { XCTAssertNoThrow(try SniConnectValidation.validateRequestId("req-1")) XCTAssertNoThrow(try SniConnectValidation.validateTimeout(120_000)) diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index 248eab33..5b1c37ff 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.81-alpha.8", + "version": "3.0.81-alpha.9", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/scripts/validate-npm-dist-tag.mjs b/scripts/validate-npm-dist-tag.mjs new file mode 100644 index 00000000..5206e785 --- /dev/null +++ b/scripts/validate-npm-dist-tag.mjs @@ -0,0 +1,87 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const allowedDistTags = new Set(["latest", "next"]); + +export function validateNpmDistTag(distTag, releaseWorkspaces) { + if (!allowedDistTags.has(distTag)) { + throw new Error(`Unsupported npm dist-tag: ${distTag}`); + } + + if (distTag !== "latest") { + return; + } + + const prereleaseWorkspaces = releaseWorkspaces.filter(({ version }) => + version.includes("-") + ); + if (prereleaseWorkspaces.length === 0) { + return; + } + + const versions = prereleaseWorkspaces + .map(({ name, version }) => `${name}@${version}`) + .sort() + .join(", "); + throw new Error( + `Refusing to publish prerelease workspaces with the latest dist-tag: ${versions}` + ); +} + +async function loadReleaseWorkspaces(repoRoot) { + const rootPackage = JSON.parse( + await readFile(join(repoRoot, "package.json"), "utf8") + ); + const releaseWorkspaces = []; + + for (const workspacePattern of rootPackage.workspaces ?? []) { + if (!workspacePattern.endsWith("/*")) { + throw new Error(`Unsupported workspace pattern: ${workspacePattern}`); + } + const workspaceRoot = join(repoRoot, workspacePattern.slice(0, -2)); + const entries = await readdir(workspaceRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const packagePath = join(workspaceRoot, entry.name, "package.json"); + let workspacePackage; + try { + workspacePackage = JSON.parse(await readFile(packagePath, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") { + continue; + } + throw error; + } + if ( + workspacePackage.private !== true && + typeof workspacePackage.scripts?.release === "string" + ) { + releaseWorkspaces.push({ + name: workspacePackage.name, + version: workspacePackage.version, + }); + } + } + } + + return releaseWorkspaces; +} + +async function main() { + const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + const releaseWorkspaces = await loadReleaseWorkspaces(repoRoot); + validateNpmDistTag(process.argv[2], releaseWorkspaces); + console.log( + `Validated npm dist-tag ${process.argv[2]} for ${releaseWorkspaces.length} release workspaces` + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/validate-npm-dist-tag.test.mjs b/scripts/validate-npm-dist-tag.test.mjs new file mode 100644 index 00000000..65581db4 --- /dev/null +++ b/scripts/validate-npm-dist-tag.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { validateNpmDistTag } from "./validate-npm-dist-tag.mjs"; + +const prereleaseWorkspaces = [ + { name: "@onekeyfe/module-a", version: "3.0.81-alpha.1" }, + { name: "@onekeyfe/module-b", version: "3.0.81-alpha.2" }, +]; + +test("allows prerelease workspaces on the next dist-tag", () => { + assert.doesNotThrow(() => validateNpmDistTag("next", prereleaseWorkspaces)); +}); + +test("rejects prerelease workspaces on the latest dist-tag", () => { + assert.throws( + () => validateNpmDistTag("latest", prereleaseWorkspaces), + /Refusing to publish prerelease workspaces with the latest dist-tag/ + ); +}); + +test("allows stable workspaces on the latest dist-tag", () => { + assert.doesNotThrow(() => + validateNpmDistTag("latest", [ + { name: "@onekeyfe/module-a", version: "3.0.81" }, + ]) + ); +}); + +test("rejects unsupported dist-tags", () => { + assert.throws( + () => validateNpmDistTag("beta", prereleaseWorkspaces), + /Unsupported npm dist-tag/ + ); +});