diff --git a/.gitignore b/.gitignore index 8f5b1635..1e1612d9 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,8 @@ scripts/nitro-view/template/android/.gradle # generated by react-native-builder-bob native-modules/*/lib/ native-views/*/lib/ + +# generated by Android externalNativeBuild (CMake/ndk) for any module +native-modules/*/android/.cxx/ +native-views/*/android/.cxx/ +**/android/.cxx/ diff --git a/example/react-native/android/app/build.gradle b/example/react-native/android/app/build.gradle index 6f4821a9..162c612b 100644 --- a/example/react-native/android/app/build.gradle +++ b/example/react-native/android/app/build.gradle @@ -114,6 +114,23 @@ android { proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } } + + // Several transitive deps (e.g. google-auth-library-*) ship duplicate + // META-INF metadata jars; without this the MergeJavaRes task fails with + // DuplicateRelativeFileException. Example-app packaging only. + packagingOptions { + resources { + pickFirsts += [ + "META-INF/INDEX.LIST", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/*.kotlin_module", + ] + } + } } dependencies { diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 14da6ef3..92b4b7eb 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1,5 +1,61 @@ PODS: - - AutoSizeInput (3.0.40): + - AesCrypto (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - AsyncStorage (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - AutoSizeInput (3.0.61): - boost - DoubleConversion - fast_float @@ -29,7 +85,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - BackgroundThread (3.0.40): + - BackgroundThread (3.0.61): - boost - DoubleConversion - fast_float @@ -59,7 +115,7 @@ PODS: - SocketRocket - Yoga - boost (1.84.0) - - ChartWebview (3.0.40): + - ChartWebview (3.0.61): - boost - DoubleConversion - fast_float @@ -87,9 +143,38 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core + - ReactNativeNativeLogger + - SocketRocket + - Yoga + - CloudFs (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core - SocketRocket - Yoga - - CloudKitModule (3.0.40): + - CloudKitModule (3.0.61): - boost - DoubleConversion - fast_float @@ -123,6 +208,34 @@ PODS: - CocoaLumberjack/Core (3.9.0) - CocoaLumberjack/Swift (3.9.0): - CocoaLumberjack/Core + - DnsLookup (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - DoubleConversion (1.1.6) - fast_float (8.0.0) - FBLazyVector (0.83.0) @@ -131,7 +244,7 @@ PODS: - hermes-engine (0.14.0): - hermes-engine/Pre-built (= 0.14.0) - hermes-engine/Pre-built (0.14.0) - - KeychainModule (3.0.40): + - KeychainModule (3.0.61): - boost - DoubleConversion - fast_float @@ -165,6 +278,34 @@ PODS: - MMKV (2.2.4): - MMKVCore (~> 2.2.4) - MMKVCore (2.2.4) + - NetworkInfo (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - NitroMmkv (4.1.2): - boost - DoubleConversion @@ -225,7 +366,35 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - PerpDepthBar (3.0.40): + - Pbkdf2 (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - PerpDepthBar (3.0.61): - boost - DoubleConversion - fast_float @@ -255,6 +424,34 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - Ping (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -2139,7 +2336,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-pager-view (3.0.40): + - react-native-pager-view (3.0.61): - boost - DoubleConversion - fast_float @@ -2254,7 +2451,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view (3.0.40): + - react-native-tab-view (3.0.61): - boost - DoubleConversion - fast_float @@ -2272,7 +2469,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.40) + - react-native-tab-view/common (= 3.0.61) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2283,7 +2480,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view/common (3.0.40): + - react-native-tab-view/common (3.0.61): - boost - DoubleConversion - fast_float @@ -2868,7 +3065,7 @@ PODS: - React-perflogger (= 0.83.0) - React-utils (= 0.83.0) - SocketRocket - - ReactNativeAppUpdate (3.0.40): + - ReactNativeAppUpdate (3.0.61): - boost - DoubleConversion - fast_float @@ -2899,7 +3096,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleCrypto (3.0.40): + - ReactNativeBundleCrypto (3.0.61): - boost - DoubleConversion - fast_float @@ -2930,7 +3127,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleUpdate (3.0.40): + - ReactNativeBundleUpdate (3.0.61): - boost - DoubleConversion - fast_float @@ -2965,7 +3162,7 @@ PODS: - SocketRocket - SSZipArchive (>= 2.5.4) - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.40): + - ReactNativeCheckBiometricAuthChanged (3.0.61): - boost - DoubleConversion - fast_float @@ -2996,7 +3193,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeDeviceUtils (3.0.40): + - ReactNativeDeviceUtils (3.0.61): - boost - DoubleConversion - fast_float @@ -3027,7 +3224,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeGetRandomValues (3.0.40): + - ReactNativeGetRandomValues (3.0.61): - boost - DoubleConversion - fast_float @@ -3058,7 +3255,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeLiteCard (3.0.40): + - ReactNativeLiteCard (3.0.61): - boost - DoubleConversion - fast_float @@ -3087,7 +3284,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeNativeLogger (3.0.40): + - ReactNativeNativeLogger (3.0.61): - boost - CocoaLumberjack/Swift (~> 3.8) - DoubleConversion @@ -3118,7 +3315,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ReactNativePerfMemory (3.0.40): + - ReactNativePerfMemory (3.0.61): - boost - DoubleConversion - fast_float @@ -3149,7 +3346,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeRangeDownloader (3.0.40): + - ReactNativePerfStats (3.0.61): - boost - DoubleConversion - fast_float @@ -3180,7 +3377,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeSplashScreen (3.0.40): + - ReactNativeRangeDownloader (3.0.61): - boost - DoubleConversion - fast_float @@ -3211,7 +3408,38 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeZipArchive (3.0.40): + - ReactNativeSplashScreen (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - NitroModules + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeNativeLogger + - SocketRocket + - Yoga + - ReactNativeZipArchive (3.0.61): - boost - DoubleConversion - fast_float @@ -3300,7 +3528,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ScrollGuard (3.0.40): + - ScrollGuard (3.0.61): - boost - DoubleConversion - fast_float @@ -3330,7 +3558,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - SegmentSlider (3.0.38): + - SegmentSlider (3.0.61): - boost - DoubleConversion - fast_float @@ -3360,7 +3588,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Skeleton (3.0.40): + - Skeleton (3.0.61): - boost - DoubleConversion - fast_float @@ -3391,15 +3619,76 @@ PODS: - SocketRocket - Yoga - SocketRocket (0.7.1) + - SplitBundleLoader (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeNativeLogger + - SocketRocket + - Yoga - SSZipArchive (2.6.0) + - TcpSocket (3.0.61): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - Yoga (0.0.0) DEPENDENCIES: + - "AesCrypto (from `../../../node_modules/@onekeyfe/react-native-aes-crypto`)" + - "AsyncStorage (from `../../../node_modules/@onekeyfe/react-native-async-storage`)" - "AutoSizeInput (from `../../../node_modules/@onekeyfe/react-native-auto-size-input`)" - "BackgroundThread (from `../../../node_modules/@onekeyfe/react-native-background-thread`)" - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`) - "ChartWebview (from `../../../node_modules/@onekeyfe/react-native-chart-webview`)" + - "CloudFs (from `../../../node_modules/@onekeyfe/react-native-cloud-fs`)" - "CloudKitModule (from `../../../node_modules/@onekeyfe/react-native-cloud-kit-module`)" + - "DnsLookup (from `../../../node_modules/@onekeyfe/react-native-dns-lookup`)" - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) @@ -3407,9 +3696,12 @@ DEPENDENCIES: - glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - "KeychainModule (from `../../../node_modules/@onekeyfe/react-native-keychain-module`)" + - "NetworkInfo (from `../../../node_modules/@onekeyfe/react-native-network-info`)" - NitroMmkv (from `../../../node_modules/react-native-mmkv`) - NitroModules (from `../../../node_modules/react-native-nitro-modules`) + - "Pbkdf2 (from `../../../node_modules/@onekeyfe/react-native-pbkdf2`)" - "PerpDepthBar (from `../../../node_modules/@onekeyfe/react-native-perp-depth-bar`)" + - "Ping (from `../../../node_modules/@onekeyfe/react-native-ping`)" - RCT-Folly (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTRequired (from `../../../node_modules/react-native/Libraries/Required`) @@ -3492,6 +3784,7 @@ DEPENDENCIES: - "ReactNativeLiteCard (from `../../../node_modules/@onekeyfe/react-native-lite-card`)" - "ReactNativeNativeLogger (from `../../../node_modules/@onekeyfe/react-native-native-logger`)" - "ReactNativePerfMemory (from `../../../node_modules/@onekeyfe/react-native-perf-memory`)" + - "ReactNativePerfStats (from `../../../node_modules/@onekeyfe/react-native-perf-stats`)" - "ReactNativeRangeDownloader (from `../../../node_modules/@onekeyfe/react-native-range-downloader`)" - "ReactNativeSplashScreen (from `../../../node_modules/@onekeyfe/react-native-splash-screen`)" - "ReactNativeZipArchive (from `../../../node_modules/@onekeyfe/react-native-zip-archive`)" @@ -3500,6 +3793,8 @@ DEPENDENCIES: - "SegmentSlider (from `../../../node_modules/@onekeyfe/react-native-segment-slider`)" - "Skeleton (from `../../../node_modules/@onekeyfe/react-native-skeleton`)" - SocketRocket (~> 0.7.1) + - "SplitBundleLoader (from `../../../node_modules/@onekeyfe/react-native-split-bundle-loader`)" + - "TcpSocket (from `../../../node_modules/@onekeyfe/react-native-tcp-socket`)" - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -3511,6 +3806,10 @@ SPEC REPOS: - SSZipArchive EXTERNAL SOURCES: + AesCrypto: + :path: "../../../node_modules/@onekeyfe/react-native-aes-crypto" + AsyncStorage: + :path: "../../../node_modules/@onekeyfe/react-native-async-storage" AutoSizeInput: :path: "../../../node_modules/@onekeyfe/react-native-auto-size-input" BackgroundThread: @@ -3519,8 +3818,12 @@ EXTERNAL SOURCES: :podspec: "../../../node_modules/react-native/third-party-podspecs/boost.podspec" ChartWebview: :path: "../../../node_modules/@onekeyfe/react-native-chart-webview" + CloudFs: + :path: "../../../node_modules/@onekeyfe/react-native-cloud-fs" CloudKitModule: :path: "../../../node_modules/@onekeyfe/react-native-cloud-kit-module" + DnsLookup: + :path: "../../../node_modules/@onekeyfe/react-native-dns-lookup" DoubleConversion: :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" fast_float: @@ -3536,12 +3839,18 @@ EXTERNAL SOURCES: :tag: hermes-v0.14.0 KeychainModule: :path: "../../../node_modules/@onekeyfe/react-native-keychain-module" + NetworkInfo: + :path: "../../../node_modules/@onekeyfe/react-native-network-info" NitroMmkv: :path: "../../../node_modules/react-native-mmkv" NitroModules: :path: "../../../node_modules/react-native-nitro-modules" + Pbkdf2: + :path: "../../../node_modules/@onekeyfe/react-native-pbkdf2" PerpDepthBar: :path: "../../../node_modules/@onekeyfe/react-native-perp-depth-bar" + Ping: + :path: "../../../node_modules/@onekeyfe/react-native-ping" RCT-Folly: :podspec: "../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: @@ -3704,6 +4013,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@onekeyfe/react-native-native-logger" ReactNativePerfMemory: :path: "../../../node_modules/@onekeyfe/react-native-perf-memory" + ReactNativePerfStats: + :path: "../../../node_modules/@onekeyfe/react-native-perf-stats" ReactNativeRangeDownloader: :path: "../../../node_modules/@onekeyfe/react-native-range-downloader" ReactNativeSplashScreen: @@ -3718,28 +4029,39 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@onekeyfe/react-native-segment-slider" Skeleton: :path: "../../../node_modules/@onekeyfe/react-native-skeleton" + SplitBundleLoader: + :path: "../../../node_modules/@onekeyfe/react-native-split-bundle-loader" + TcpSocket: + :path: "../../../node_modules/@onekeyfe/react-native-tcp-socket" Yoga: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - AutoSizeInput: 9ac4b7c79249ed3a60831e83a41e0d2932643a4f - BackgroundThread: eeeb678905192edc8ed1cc52a27d158fde2bdb64 + AesCrypto: 5d79d012eda0c688e81fb1e5efb66697a8bf63b2 + AsyncStorage: d36fdf626a5daea9ab7ddf147743f91cb8fc47b2 + AutoSizeInput: 46d8edf6010443fdca7dc3a69bb44553780b1155 + BackgroundThread: 28c77551040ecc7a70dabf132b840f7b6b1cb7ef boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - ChartWebview: 05851c420e0d76c7c170e9d12e83a0a5e558a254 - CloudKitModule: 8a9b788cf9457b77ba13f61779719b8b5f9ad995 + ChartWebview: df5cfa93f2fff737382f08041ba8034932266674 + CloudFs: 7d28ea2cc410fcae83fa573f4d5c313f3203352d + CloudKitModule: 9f6c25469fa12adbcf55b0f9ec9a26bb489978c6 CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a + DnsLookup: 2b92e6ee2a0925934097334e7024214ee39f3fd0 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: a293a88992c4c33f0aee184acab0b64a08ff9458 fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 70fdc9d0bb0d8532e0411dcb21e53ce5a160960a - KeychainModule: b50cdc3bb4ff0747e5291b8e7ad735f05b62b299 + KeychainModule: 91e5f89dfcc8981beb4ee8ad1480eed76ff1d96a MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df + NetworkInfo: aff4df0ccff078e448ad3da1c5620e7285f129fc NitroMmkv: 0be91455465952f2b943f753b9ee7df028d89e5c NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3 - PerpDepthBar: db6f3d8816b48a71cc87befc36a322b70fc1cab7 + Pbkdf2: 723893ae9eaa320168c62cb825a4bcef89703894 + PerpDepthBar: ce30834568bd8b9ea933267437defb431c6c576d + Ping: 8f45505d4f773a5f6348852679e2801e1bf052ac RCT-Folly: b29feb752b08042c62badaef7d453f3bb5e6ae23 RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36 RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81 @@ -3776,9 +4098,9 @@ SPEC CHECKSUMS: React-logger: 9e597cbeda7b8cc8aa8fb93860dade97190f69cc React-Mapbuffer: 20046c0447efaa7aace0b76085aa9bb35b0e8105 React-microtasksnativemodule: 0e837de56519c92d8a2e3097717df9497feb33cb - react-native-pager-view: 1f96cf6db9eb7ab523a24cf5b5cbb452bf3cdd8f + react-native-pager-view: 5bde74d01c86137851dddc589a91566e5fa2a490 react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 - react-native-tab-view: bceb7c3b79e3182a5c4baa4c2009d669c9df9577 + react-native-tab-view: 84fd866023d2753d09cdb1dd62cbe086fb39ccc7 React-NativeModulesApple: 1a378198515f8e825c5931a7613e98da69320cee React-networking: bfd1695ada5a57023006ce05823ac5391c3ce072 React-oscompat: aedc0afbded67280de6bb6bfac8cfde0389e2b33 @@ -3812,26 +4134,29 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 ReactCodegen: 554b421c45b7df35ac791da1b734335470b55fcc ReactCommon: 424cc34cf5055d69a3dcf02f3436481afb8b0f6f - ReactNativeAppUpdate: 9fba072ac56a55cd95d2d1ee6d4ccfbfd19ab56e - ReactNativeBundleCrypto: 88d5e6a3026db2c76f5a17d5656d175691f18c72 - ReactNativeBundleUpdate: 4aba5b1003ee6378225cd39a7156b87ef76548f8 - ReactNativeCheckBiometricAuthChanged: b94623ddae3f9ee72148ad2b89451604dc2b391c - ReactNativeDeviceUtils: 8d69dcd74eb898b3a70f86fac82bcca33c441f01 - ReactNativeGetRandomValues: c689c2d7cb3ece2b2f44ebef42856aaf387562a7 - ReactNativeLiteCard: d056e4c1eca34bac302a752a18df033e78b45269 - ReactNativeNativeLogger: a895e4d31eedb102880b07398bda71328f9d9a19 - ReactNativePerfMemory: 1287435ddd77dd594e651aa66785fefbda0e659c - ReactNativeRangeDownloader: 0f3a1cb2f5018be6363bea19b357468bb3386111 - ReactNativeSplashScreen: f67afa9771cb746089116ca2a1a96fd03ee8e7a0 - ReactNativeZipArchive: 8275163058fadf1d6257d1a121862660ff801be2 + ReactNativeAppUpdate: d4931ea70ba02c29bec1a3db68ef1b2d0b39f2c2 + ReactNativeBundleCrypto: 9b44893be06c117be94f9d5eb7f2666c0b4ee731 + ReactNativeBundleUpdate: 0a352e1fe87dfeefea7b7e477834c6830900500d + ReactNativeCheckBiometricAuthChanged: a422d1c4e87f5de024409e8c6b97469bebe271ec + ReactNativeDeviceUtils: f5b15156b5864fc43ba71b3ec09ad9ec3111e9bb + ReactNativeGetRandomValues: 94a7f6bc3fcb91991d6aaa574d4ad0c140ff5285 + ReactNativeLiteCard: 663a9d5772550d83de6aa79282c9c3fc5410fd15 + ReactNativeNativeLogger: 6337f2f51c7f7c62a8575764799a74d22ffec372 + ReactNativePerfMemory: b0d2ac23e8bcd2e07418efe92a3e19379666bb2e + ReactNativePerfStats: bdff04f45bbd161e0e3c907d1bb36e8ddb7410c0 + ReactNativeRangeDownloader: 73efe1ec206257b2f425d58e7e238f45221ae707 + ReactNativeSplashScreen: 6223f61235b36f12f468e369064416854608aa75 + ReactNativeZipArchive: bdf08f7ee00352d2b2894449c8b1b6810f40f2b2 RNScreens: 7f643ee0fd1407dc5085c7795460bd93da113b8f - ScrollGuard: 07463a68bb1a94a1ce1fdf6cdc7732d13761dd95 - SegmentSlider: 85639c3c8a1053f4a49f3bf4f6ec714dce9fbfaa - Skeleton: 513246ea6afbe9d97fb33be68207726544830f3b + ScrollGuard: 61f6774ecb5ac6ccd5fbeb34830a117ab3e2d4f0 + SegmentSlider: 295a0ade25855058c11d2b185ad13762b1f4a166 + Skeleton: 07da4b794e0e2b58bd75ba820d0c2e6e87c9225e SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 + SplitBundleLoader: 8421ede8cbc7f15a7608b59b126fcfe22aca5ccc SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea + TcpSocket: bd4063e4dda0fb7f6952376592e3efdeac915710 Yoga: 6ca93c8c13f56baeec55eb608577619b17a4d64e -PODFILE CHECKSUM: 0e0b2382ae957b2560c79ae902707fbded29fb51 +PODFILE CHECKSUM: 11e5274bb8ec6380d5fbde829e700fe4f0cf9c8a COCOAPODS: 1.16.2 diff --git a/example/react-native/package.json b/example/react-native/package.json index d959dde8..cf10c522 100644 --- a/example/react-native/package.json +++ b/example/react-native/package.json @@ -11,28 +11,38 @@ "test": "jest" }, "dependencies": { + "@onekeyfe/react-native-aes-crypto": "workspace:*", "@onekeyfe/react-native-app-update": "workspace:*", + "@onekeyfe/react-native-async-storage": "workspace:*", "@onekeyfe/react-native-auto-size-input": "workspace:*", "@onekeyfe/react-native-background-thread": "workspace:*", "@onekeyfe/react-native-bundle-crypto": "workspace:*", "@onekeyfe/react-native-bundle-update": "workspace:*", "@onekeyfe/react-native-chart-webview": "workspace:*", "@onekeyfe/react-native-check-biometric-auth-changed": "workspace:*", + "@onekeyfe/react-native-cloud-fs": "workspace:*", "@onekeyfe/react-native-cloud-kit-module": "workspace:*", "@onekeyfe/react-native-device-utils": "workspace:*", + "@onekeyfe/react-native-dns-lookup": "workspace:*", "@onekeyfe/react-native-get-random-values": "workspace:*", "@onekeyfe/react-native-keychain-module": "workspace:*", "@onekeyfe/react-native-lite-card": "workspace:*", "@onekeyfe/react-native-native-logger": "workspace:*", + "@onekeyfe/react-native-network-info": "workspace:*", "@onekeyfe/react-native-pager-view": "workspace:*", + "@onekeyfe/react-native-pbkdf2": "workspace:*", "@onekeyfe/react-native-perf-memory": "workspace:*", + "@onekeyfe/react-native-perf-stats": "workspace:*", "@onekeyfe/react-native-perp-depth-bar": "workspace:*", + "@onekeyfe/react-native-ping": "workspace:*", "@onekeyfe/react-native-range-downloader": "workspace:*", "@onekeyfe/react-native-scroll-guard": "workspace:*", "@onekeyfe/react-native-segment-slider": "workspace:*", "@onekeyfe/react-native-skeleton": "workspace:*", "@onekeyfe/react-native-splash-screen": "workspace:*", + "@onekeyfe/react-native-split-bundle-loader": "workspace:*", "@onekeyfe/react-native-tab-view": "workspace:*", + "@onekeyfe/react-native-tcp-socket": "workspace:*", "@onekeyfe/react-native-zip-archive": "workspace:*", "@react-native/new-app-screen": "0.83.0", "@react-navigation/native": "^7.1.33", diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 1932a968..829a17b1 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.56", + "version": "3.0.61", "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 bf301cf5..3ecadb73 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.56", + "version": "3.0.61", "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/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt index d87b7938..6bae92ab 100644 --- a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt +++ b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt @@ -1191,6 +1191,53 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ return result } + /** + * Delete every file in cacheDir/apks/ (.apk / .partial / .progress / + * .SHA256SUMS.asc). Tolerates a missing directory and per-file delete + * failures (e.g. the OS reclaimed the cache mid-pass — see + * verifyExistingApk's Indeterminate handling for the same defensive + * stance). Pure file I/O: does NOT touch download/verification state, so + * it is safe to share between clearCache (full reset) and clearApkCache + * (JS-gated, no in-flight download to cancel). The [tag] flows into the + * log lines so the two callers stay distinguishable in logcat. + */ + private fun wipeApkCacheFiles(tag: String, protectedPaths: Set = emptySet()) { + val context = NitroModules.applicationContext + if (context == null) { + OneKeyLog.warn("AppUpdate", "$tag: application context unavailable, skipping file cleanup") + return + } + val apkDir = File(context.cacheDir, "apks") + if (!apkDir.exists()) { + OneKeyLog.info("AppUpdate", "$tag: apks cache directory does not exist, nothing to clean") + return + } + val filesToDelete = apkDir.listFiles() ?: emptyArray() + OneKeyLog.info("AppUpdate", "$tag: found ${filesToDelete.size} cached file(s) to delete in ${apkDir.absolutePath}") + var deletedCount = 0 + var skippedCount = 0 + filesToDelete.forEach { file -> + // Never delete a verified, pending-install APK (its canonical path is + // still in verifiedFiles). Protects the install flow from a racing + // cleanup that slipped past the JS status gate. + if (protectedPaths.isNotEmpty() && file.canonicalPath in protectedPaths) { + OneKeyLog.info("AppUpdate", "$tag: skipping verified pending-install file ${file.name}") + skippedCount++ + return@forEach + } + val size = file.length() + // The file may already be gone (concurrent OS cache reclaim); + // treat a non-existent file as nothing to do, not a failure. + if (!file.exists() || file.delete()) { + OneKeyLog.debug("AppUpdate", "$tag: deleted ${file.name} (${size} bytes)") + deletedCount++ + } else { + OneKeyLog.warn("AppUpdate", "$tag: failed to delete ${file.name}") + } + } + OneKeyLog.info("AppUpdate", "$tag: completed, deleted $deletedCount/${filesToDelete.size} files (skipped $skippedCount protected)") + } + override fun clearCache(): Promise { return Promise.async { OneKeyLog.info("AppUpdate", "clearCache: starting cleanup...") @@ -1200,29 +1247,31 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ OneKeyLog.info("AppUpdate", "clearCache: reset download state, cleared $verifiedCount verified file entries") // Clean up downloaded APK and ASC files from cacheDir/apks/ directory - val context = NitroModules.applicationContext - if (context != null) { - val apkDir = File(context.cacheDir, "apks") - if (apkDir.exists()) { - val filesToDelete = apkDir.listFiles() ?: emptyArray() - OneKeyLog.info("AppUpdate", "clearCache: found ${filesToDelete.size} cached file(s) to delete in ${apkDir.absolutePath}") - var deletedCount = 0 - filesToDelete.forEach { file -> - val size = file.length() - if (file.delete()) { - OneKeyLog.debug("AppUpdate", "clearCache: deleted ${file.name} (${size} bytes)") - deletedCount++ - } else { - OneKeyLog.warn("AppUpdate", "clearCache: failed to delete ${file.name}") - } - } - OneKeyLog.info("AppUpdate", "clearCache: completed, deleted $deletedCount/${filesToDelete.size} files") - } else { - OneKeyLog.info("AppUpdate", "clearCache: apks cache directory does not exist, nothing to clean") - } - } else { - OneKeyLog.warn("AppUpdate", "clearCache: application context unavailable, skipping file cleanup") + wipeApkCacheFiles("clearCache") + } + } + + override fun clearApkCache(): Promise { + return Promise.async { + // Wipe downloaded APK artifacts from cacheDir/apks/ only. The JS + // layer gates on app-update status before calling, but that read is + // not atomic with this delete, so guard natively too: + // - bail entirely if a download is in flight (would yank its + // .partial/.progress out from under the writer); + // - never delete a verified, pending-install APK (still tracked in + // verifiedFiles) so an open installer keeps a readable file. + // Deliberately do NOT touch isDownloading / verifiedFiles state here + // (that's clearCache's job). Missing dir/files are tolerated. + if (isDownloading.get()) { + OneKeyLog.info("AppUpdate", "clearApkCache: download in progress, skipping APK cache cleanup") + return@async } + val protectedPaths = synchronized(verifiedFiles) { verifiedFiles.keys.toSet() } + OneKeyLog.info( + "AppUpdate", + "clearApkCache: starting stale APK cache cleanup (protecting ${protectedPaths.size} verified file(s))...", + ) + wipeApkCacheFiles("clearApkCache", protectedPaths) } } } diff --git a/native-modules/react-native-app-update/ios/ReactNativeAppUpdate.swift b/native-modules/react-native-app-update/ios/ReactNativeAppUpdate.swift index 24aa4c52..0498dbb4 100644 --- a/native-modules/react-native-app-update/ios/ReactNativeAppUpdate.swift +++ b/native-modules/react-native-app-update/ios/ReactNativeAppUpdate.swift @@ -54,6 +54,13 @@ class ReactNativeAppUpdate: HybridReactNativeAppUpdateSpec { return Promise.resolved(withResult: ()) } + func clearApkCache() throws -> Promise { + // APK is Android-only; nothing to wipe on iOS. Safe no-op so the + // cross-platform Nitro spec stays satisfied. + OneKeyLog.debug("AppUpdate", "clearApkCache not available on iOS") + return Promise.resolved(withResult: ()) + } + func addDownloadListener(callback: @escaping (DownloadEvent) -> Void) throws -> Double { let id = nextListenerId nextListenerId += 1 diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 9268f1d6..fb8481cb 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.56", + "version": "3.0.61", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/src/ReactNativeAppUpdate.nitro.ts b/native-modules/react-native-app-update/src/ReactNativeAppUpdate.nitro.ts index 26e1567d..ad0bf5dd 100644 --- a/native-modules/react-native-app-update/src/ReactNativeAppUpdate.nitro.ts +++ b/native-modules/react-native-app-update/src/ReactNativeAppUpdate.nitro.ts @@ -24,6 +24,10 @@ export interface ReactNativeAppUpdate verifyAPK(params: AppUpdateFileParams): Promise; installAPK(params: AppUpdateFileParams): Promise; clearCache(): Promise; + // Wipe downloaded APK artifacts from cacheDir/apks (.apk / .partial / + // .SHA256SUMS.asc). Android-only; the JS layer gates on update status before + // calling so this does NOT cancel an in-flight download. iOS is a no-op stub. + clearApkCache(): Promise; // Verification & testing testVerification(): Promise; diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index 3cafb8be..b7f5c320 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.56", + "version": "3.0.61", "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/android/src/main/cpp/cpp-adapter.cpp b/native-modules/react-native-background-thread/android/src/main/cpp/cpp-adapter.cpp index e747e423..714fb6df 100644 --- a/native-modules/react-native-background-thread/android/src/main/cpp/cpp-adapter.cpp +++ b/native-modules/react-native-background-thread/android/src/main/cpp/cpp-adapter.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -447,6 +448,323 @@ static void installTimersOnRuntime(jsi::Runtime &rt) { LOGI("Timer + rAF + rIC polyfills installed on bg runtime"); } +// ── Background segment eval (fix A: eval-then-resolve on the BG runtime) ── +// +// WHY THIS EXISTS — the "Requiring unknown module" race on the BACKGROUND +// runtime: +// The Kotlin BackgroundThreadManager.registerSegmentInBackground previously +// called `ReactContext.registerSegment(...)`, which (bridgeless) routes through +// ReactHostImpl.registerSegment → ReactInstance.registerSegment → C++ +// ReactInstance::registerSegment, and that only `scheduleWork`s the +// evaluateJavaScript onto the RuntimeScheduler before returning. The Kotlin +// completion callback then fired immediately, so the JS promise resolved BEFORE +// the segment's `__d(...)` module definitions ran. Metro's +// `import().then(() => __r(moduleId))` could then run `__r` before the module +// table was populated → a fatal, uncatchable "Requiring unknown module". Locale +// segments load through THIS bg path, so a language switch could still crash. +// +// THE FIX (mirrors the MAIN-runtime SplitBundleLoaderJSI fix and the iOS +// callFunctionOnBufferedRuntimeExecutor: fix): +// We evaluate the segment OURSELVES on the BACKGROUND JS thread and signal +// completion in the SAME block, strictly AFTER eval. The accessor we use is the +// background runtime's own RuntimeExecutor (`gBgTimerExecutor`), captured in +// nativeInstallSharedBridge for the bg runtime (isMain=false). It dispatches via +// scheduleOnJSThread(isMain=false, ...) → nativeExecuteWork, which runs the work +// on the bg JS queue thread and then drainMicrotasks() — so this targets the +// BACKGROUND runtime, NOT the main one. This is the correct bg analogue of the +// main path's CallInvoker (the bg runtime is created by ReactHostImpl and the bg +// ReactContext does not surface a usable jsCallInvokerHolder the way the main +// one does, but its RuntimeExecutor already exists and routes to the bg JS +// thread). +// +// Off-thread read (fix F): the segment file is read on the CALLING (native +// module) thread before dispatch; only evaluateJavaScript + completion run on +// the bg JS thread. + +// Java callback contract — implemented in Kotlin as +// BackgroundThreadManager.SegmentEvalCallback.onComplete(error). Empty/null +// error string → success. A message prefixed with "NO_RUNTIME:" → bg runtime +// not ready (retryable); "IO_ERROR:" → file read failure (fatal); otherwise → +// eval throw (fatal). Resolved exactly once on the Kotlin side via its watchdog +// guard. +static void invokeBgSegmentCallback(jobject globalCallback, const std::string &error) { + JNIEnv *env = getJNIEnv(); + if (!env || !globalCallback) { + return; + } + jclass cls = env->GetObjectClass(globalCallback); + jmethodID mid = + env->GetMethodID(cls, "onComplete", "(Ljava/lang/String;)V"); + if (mid) { + jstring jerr = error.empty() ? nullptr : env->NewStringUTF(error.c_str()); + env->CallVoidMethod(globalCallback, mid, jerr); + if (env->ExceptionCheck()) { + LOGE("invokeBgSegmentCallback: JNI exception after onComplete"); + env->ExceptionDescribe(); + env->ExceptionClear(); + } + if (jerr) { + env->DeleteLocalRef(jerr); + } + } else { + LOGE("invokeBgSegmentCallback: onComplete method not found!"); + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + } + } + env->DeleteLocalRef(cls); +} + +// ── Pending bg-eval callback registry (fix: bounded global-ref lifetime) ── +// +// The bg-eval work lambda captures a JNI global ref to the SegmentEvalCallback +// and is enqueued onto gPendingWork for the bg JS thread. If that work is +// enqueued but NEVER runs, the captured global ref would leak and the JS promise +// would settle only via the Kotlin 30s watchdog. The drop paths that release it: +// - Java schedule fails (JNI exception / missing scheduleOnJSThread): the C++ +// executor erases gPendingWork[workId] and drains this eval. +// - bg context==null / ptr==0 in scheduleOnJSThread: Kotlin calls +// nativeDropScheduledWork(workId) — which erases the work AND drains — +// BEFORE it would call nativeExecuteWork. (nativeExecuteWork's own +// rt==nullptr guard merely returns and is NOT a drain path; it is never +// reached for a dead ptr because Kotlin intercepts that case first.) +// - nativeDestroy: intentionally leaks the work lambdas (their ~jsi::Function +// can't run on a torn-down runtime) but drains this registry to settle them. +// To make this strictly bounded, every in-flight bg eval registers here. Whoever +// settles it first (the work lambda after eval, OR a drain on a drop path) claims +// it via `settled`, invokes the Java callback exactly once, and deletes the +// global ref. This guarantees no leak and no double-invoke. +struct PendingBgEval { + jobject globalCallback; // owned: deleted by whoever settles + std::shared_ptr> settled; // exactly-once claim +}; +static std::mutex gBgEvalMutex; +static std::unordered_map gPendingBgEvals; +static int64_t gNextBgEvalId = 0; + +// Settle a pending bg eval exactly once: invoke the Java callback with `error` +// (empty => success) and release the global ref. The shared `settled` flag is +// the single source of truth for the one-shot — the registry entry may already +// be gone (claimed/erased by the other party), so this is self-contained. +static void settleBgEval(jobject globalCallback, + const std::shared_ptr> &settled, + const std::string &error) { + if (!settled) { + return; + } + bool expected = false; + if (!settled->compare_exchange_strong(expected, true)) { + // Already settled by the other party (lambda vs drain). Do nothing — + // the winner already invoked + deleted the global ref. + return; + } + invokeBgSegmentCallback(globalCallback, error); + JNIEnv *env = getJNIEnv(); + if (env && globalCallback) { + env->DeleteGlobalRef(globalCallback); + } +} + +// Settle ALL currently-registered bg evals with a retryable NO_RUNTIME-class +// failure and clear the registry. Used when the bg runtime is going away (or is +// unreachable) and any enqueued-but-unrun eval would otherwise leak its global +// ref and hang the JS promise until the Kotlin watchdog. Each settle is +// exactly-once (the work lambda may race us, but the shared flag arbitrates), +// so this never double-invokes. +static void drainPendingBgEvals(const std::string &reason) { + // Move the entries OUT under the lock and clear the registry, then settle + // OUTSIDE the lock. settleBgEval performs a Java upcall (onComplete via + // CallVoidMethod), so holding gBgEvalMutex across it would hold a native + // lock across arbitrary JS — a re-entrancy / deadlock hazard if a callback + // ever synchronously re-enters a gBgEvalMutex-taking path. PendingBgEval is + // copyable (jobject handle + shared_ptr); copies share the same global ref + // and `settled` flag, and the shared flag still arbitrates exactly-once + // against a racing work lambda, so the global ref is released exactly once. + std::vector drained; + { + std::lock_guard lock(gBgEvalMutex); + if (gPendingBgEvals.empty()) { + return; + } + LOGE("[SplitBundle] draining %zu pending bg eval(s): %s", + gPendingBgEvals.size(), reason.c_str()); + drained.reserve(gPendingBgEvals.size()); + for (auto &entry : gPendingBgEvals) { + drained.push_back(entry.second); + } + gPendingBgEvals.clear(); + } + for (auto &entry : drained) { + settleBgEval(entry.globalCallback, entry.settled, "NO_RUNTIME:" + reason); + } +} + +// Reads the whole file at `path` into `out`. Returns false on failure. +static bool readBgFileToString(const std::string &path, std::string &out) { + FILE *f = std::fopen(path.c_str(), "rb"); + if (f == nullptr) { + return false; + } + if (std::fseek(f, 0, SEEK_END) != 0) { + std::fclose(f); + return false; + } + long size = std::ftell(f); + if (size < 0) { + std::fclose(f); + return false; + } + if (std::fseek(f, 0, SEEK_SET) != 0) { + std::fclose(f); + return false; + } + out.resize(static_cast(size)); + size_t readBytes = + (size == 0) ? 0 : std::fread(&out[0], 1, static_cast(size), f); + std::fclose(f); + return readBytes == static_cast(size); +} + +// nativeEvaluateSegmentInBackground: schedule eval of the segment at `path` +// onto the BACKGROUND JS thread via the bg RuntimeExecutor and invoke `callback` +// from INSIDE that same block, strictly AFTER eval. Returns immediately; the +// callback fires later on the bg JS thread (or synchronously here on a fail-fast +// path such as bg runtime not ready / file read failure). +extern "C" JNIEXPORT void JNICALL +Java_com_backgroundthread_BackgroundThreadManager_nativeEvaluateSegmentInBackground( + JNIEnv *env, jobject /* thiz */, jstring segmentPath, jstring sourceURL, + jobject callback) { + + // Global-ref the callback: it is invoked later on a different thread. + jobject globalCallback = env->NewGlobalRef(callback); + // Shared one-shot claim flag for this eval — used by BOTH the work lambda + // (after eval) and any drop-path drain, so exactly one of them invokes the + // callback + deletes the global ref. + auto settled = std::make_shared>(false); + + const char *pathChars = segmentPath ? env->GetStringUTFChars(segmentPath, nullptr) : nullptr; + std::string path = pathChars ? std::string(pathChars) : std::string(); + if (pathChars) env->ReleaseStringUTFChars(segmentPath, pathChars); + + const char *urlChars = sourceURL ? env->GetStringUTFChars(sourceURL, nullptr) : nullptr; + std::string url = urlChars ? std::string(urlChars) : std::string("segment"); + if (urlChars) env->ReleaseStringUTFChars(sourceURL, urlChars); + + // Fail-fast on THIS thread: settle exactly once, no registry entry created. + auto finishOnThisThread = [&](const std::string &err) { + settleBgEval(globalCallback, settled, err); + }; + + if (path.empty()) { + finishOnThisThread("IO_ERROR:Empty segment path"); + return; + } + + // Snapshot the bg RuntimeExecutor under the timer mutex (the same lock that + // guards its assignment/teardown). If it's null the bg runtime hasn't + // installed its SharedBridge yet → retryable NO_RUNTIME. + RPCRuntimeExecutor executor; + { + std::lock_guard lock(gTimerMutex); + executor = gBgTimerExecutor; + } + if (!executor) { + finishOnThisThread("NO_RUNTIME:Background runtime executor not available"); + return; + } + + // F: read the segment file HERE, on the calling (native module) thread, + // BEFORE dispatch — so only evaluateJavaScript + completion run on the bg JS + // thread and the read does not block it or race the watchdog. + std::string source; + if (!readBgFileToString(path, source)) { + finishOnThisThread("IO_ERROR:Failed to read bg segment file: " + path); + return; + } + if (source.empty()) { + finishOnThisThread("IO_ERROR:Empty bg segment file: " + path); + return; + } + + // Register this in-flight eval BEFORE dispatch so that if the enqueued work + // never runs (schedule fails, context==null, ptr==0, or nativeDestroy drops + // pending work) the drain can settle it as a retryable NO_RUNTIME failure + // and release the global ref — instead of leaking it and leaning on the + // Kotlin 30s watchdog. The work lambda removes its own entry when it runs. + int64_t bgEvalId; + { + std::lock_guard lock(gBgEvalMutex); + bgEvalId = gNextBgEvalId++; + gPendingBgEvals[bgEvalId] = PendingBgEval{globalCallback, settled}; + } + + // Move the already-read buffer + the global callback ref into the work + // lambda. The lambda runs on the bg JS thread (via nativeExecuteWork), which + // also drainMicrotasks() after — preserving eval+resolve as one atomic turn. + executor([globalCallback, settled, bgEvalId, source = std::move(source), + url = std::move(url)](jsi::Runtime &rt) { + // We are running now → claim ownership and remove our registry entry so + // a concurrent nativeDestroy drain can't also touch this eval. + { + std::lock_guard lock(gBgEvalMutex); + gPendingBgEvals.erase(bgEvalId); + } + std::string error; + try { + LOGI("[SplitBundle] bg evaluating segment %s (%zu bytes)", url.c_str(), + source.size()); + auto buffer = + std::make_shared(std::move(source)); + // Runs the segment's top-level __d(...) synchronously on the bg JS + // thread before returning. + rt.evaluateJavaScript(std::move(buffer), url); + LOGI("[SplitBundle] bg segment %s evaluated", url.c_str()); + } catch (const jsi::JSError &e) { + error = std::string("Bg segment eval JSError for ") + url + ": " + + e.getMessage(); + LOGE("[SplitBundle] %s", error.c_str()); + } catch (const std::exception &e) { + error = std::string("Bg segment eval failed for ") + url + ": " + + e.what(); + LOGE("[SplitBundle] %s", error.c_str()); + } catch (...) { + error = std::string("Bg segment eval failed for ") + url + + " (unknown C++ exception)"; + LOGE("[SplitBundle] %s", error.c_str()); + } + // Resolve/reject from INSIDE this same bg-JS-thread block, strictly + // AFTER eval above — the ordering guarantee that fixes the race. The + // shared `settled` flag makes this a no-op if a drain already claimed + // it (it would not have, since we erased our entry above, but the flag + // keeps the invariant airtight against any reorder). + settleBgEval(globalCallback, settled, error); + }); +} + +// nativeDropScheduledWork: clean up after a scheduleOnJSThread drop path where +// CallVoidMethod itself SUCCEEDED but Kotlin then found the bg runtime +// unreachable (context==null / ptr==0) and returned WITHOUT calling +// nativeExecuteWork for this `workId`. Two things must be released: +// 1. gPendingWork[workId] — the stored work lambda (holding the segment +// SOURCE BUFFER). nativeExecuteWork is the only other eraser and it will +// never run for this id, so without this it leaks until nativeDestroy. +// 2. The in-flight bg eval(s) — settle as retryable NO_RUNTIME so the JNI +// global ref is released and the JS promise resolves now instead of +// hanging on the 30s watchdog. drain-all is sound: an unreachable bg JS +// thread dooms every enqueued bg eval equally. +// Exactly-once via the shared `settled` flag, so a recovered runtime that later +// DOES run stale work (it can't — we erased it) would be a harmless no-op. +extern "C" JNIEXPORT void JNICALL +Java_com_backgroundthread_BackgroundThreadManager_nativeDropScheduledWork( + JNIEnv * /* env */, jobject /* thiz */, jlong workId) { + { + std::lock_guard lock(gWorkMutex); + gPendingWork.erase(static_cast(workId)); + } + drainPendingBgEvals("Background runtime unreachable when scheduling segment eval"); +} + // ── nativeInstallSharedBridge ─────────────────────────────────────────── // Install SharedStore and SharedRPC into a runtime. extern "C" JNIEXPORT void JNICALL @@ -471,8 +789,24 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge( RPCRuntimeExecutor executor = [ref, capturedIsMain](std::function work) { JNIEnv *env = getJNIEnv(); + + // Settle any enqueued-but-now-unrunnable bg eval when this work will NOT + // reach nativeExecuteWork. Bg eval lambdas are only ever dispatched via + // the bg executor, so this is gated on !capturedIsMain — a main-thread + // schedule hiccup must never falsely reject healthy bg evals. This + // mirrors the existing context==null / ptr==0 (nativeDropScheduledWork) + // and nativeDestroy drop paths: drain-all is sound because a failed bg + // schedule means the bg JS thread is unreachable, so every enqueued bg + // eval is equally doomed. NO_RUNTIME is retryable, so JS re-attempts. + auto drainBgEvalsIfBg = [capturedIsMain](const char *reason) { + if (!capturedIsMain) { + drainPendingBgEvals(reason); + } + }; + if (!env || !ref) { LOGE("executor: env=%p, ref=%p — aborting", env, ref.get()); + drainBgEvalsIfBg("Background executor env/ref unavailable when scheduling segment eval"); return; } @@ -485,6 +819,7 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge( jclass cls = env->GetObjectClass(ref.get()); jmethodID mid = env->GetMethodID(cls, "scheduleOnJSThread", "(ZJ)V"); + bool scheduled = false; if (mid) { LOGI("executor: calling scheduleOnJSThread(isMain=%d, workId=%ld)", capturedIsMain, (long)workId); env->CallVoidMethod(ref.get(), mid, static_cast(capturedIsMain), static_cast(workId)); @@ -492,6 +827,8 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge( LOGE("executor: JNI exception after scheduleOnJSThread"); env->ExceptionDescribe(); env->ExceptionClear(); + } else { + scheduled = true; } } else { LOGE("executor: scheduleOnJSThread method not found!"); @@ -501,6 +838,21 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge( } } env->DeleteLocalRef(cls); + + // Schedule failed (JNI exception or missing method): nativeExecuteWork + // will never run this workId, so erase it now to free the stored work + // (and its captured segment source buffer) instead of leaking it until + // nativeDestroy. Then settle the corresponding bg eval(s) so the JNI + // global ref is released and the JS promise resolves immediately rather + // than hanging on the Kotlin 30s watchdog. Erasing also makes a late + // Kotlin enqueue (if scheduleOnJSThread threw AFTER posting) a no-op. + if (!scheduled) { + { + std::lock_guard lock(gWorkMutex); + gPendingWork.erase(workId); + } + drainBgEvalsIfBg("Background JS thread unreachable when scheduling segment eval"); + } }; std::string runtimeId = isMain ? "main" : "background"; @@ -646,6 +998,11 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeDestroy( // Drain pending cross-runtime work. Each std::function may capture a // shared_ptr tied to the destroyed runtime; leak them // for the same reason as above. + // + // NOTE: the bg-eval work lambdas captured here also hold a JNI global ref + // to a SegmentEvalCallback. Leaking the std::function leaks that ref AND + // leaves the JS promise pending — so we settle those explicitly below via + // gPendingBgEvals (the entry survives independently of the leaked lambda). { std::lock_guard lock(gWorkMutex); for (auto &entry : gPendingWork) { @@ -654,5 +1011,11 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeDestroy( gPendingWork.clear(); } + // Drain pending bg-eval callbacks: the bg runtime is gone, so any eval that + // was enqueued but never ran must be settled NOW (retryable NO_RUNTIME) so + // the JS promise resolves immediately and the global ref is released — + // rather than leaking and relying on the Kotlin 30s watchdog. + drainPendingBgEvals("Background runtime destroyed before segment eval ran"); + LOGI("Native resources cleaned up"); } diff --git a/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadManager.kt b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadManager.kt index f5b7a2ca..fe169ad1 100644 --- a/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadManager.kt +++ b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadManager.kt @@ -24,6 +24,7 @@ import com.facebook.react.shell.MainReactPackage import java.io.File import java.lang.ref.WeakReference import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean /** * Singleton manager for the background React Native runtime. @@ -63,6 +64,12 @@ class BackgroundThreadManager private constructor() { companion object { private const val MODULE_NAME = "background" + // Bounded watchdog: if the bg JS thread never drains to our scheduled + // eval (e.g. the bg entry bundle never finished evaluating), reject as a + // RETRYABLE timeout rather than leaving the JS promise pending forever. + // Matches the main-runtime SplitBundleLoader watchdog. + private const val BG_SEGMENT_EVAL_TIMEOUT_MS = 30_000L + init { System.loadLibrary("background_thread") } @@ -78,6 +85,20 @@ class BackgroundThreadManager private constructor() { } } + /** + * Completion contract invoked by the native (JNI) side AFTER the bg segment + * has been evaluated into the BACKGROUND runtime. Called from the bg JS + * thread (or synchronously on the caller thread for fail-fast paths). + * + * @param error null on success; a non-empty message on failure. A message + * prefixed with "NO_RUNTIME:" means the bg runtime is not ready yet + * (retryable); "IO_ERROR:" means the segment file read failed (fatal); + * any other message is a segment JS/Hermes eval throw (fatal). + */ + fun interface SegmentEvalCallback { + fun onComplete(error: String?) + } + // ── JNI declarations ──────────────────────────────────────────────────── private external fun nativeInstallSharedBridge(runtimePtr: Long, isMain: Boolean) @@ -85,6 +106,34 @@ class BackgroundThreadManager private constructor() { private external fun nativeDestroy() private external fun nativeExecuteWork(runtimePtr: Long, workId: Long) + /** + * Evaluate the segment at [segmentPath] into the BACKGROUND runtime on its + * JS thread and invoke [callback] from inside that same JS-thread block, + * strictly AFTER the segment's `__d(...)` module definitions have run. This + * is the ordering guarantee that fixes the bg "Requiring unknown module" + * race (the bg analogue of the main-runtime SplitBundleLoaderJSI fix). + * + * Returns immediately; [callback] fires later on the bg JS thread (or + * synchronously on the calling thread for fail-fast paths such as the bg + * runtime not being ready or the segment file failing to read). + */ + private external fun nativeEvaluateSegmentInBackground( + segmentPath: String, + sourceURL: String, + callback: SegmentEvalCallback + ) + + /** + * Settle every in-flight bg segment eval as a retryable NO_RUNTIME failure + * without tearing the runtime down. Called from [scheduleOnJSThread] when + * the bg runtime is momentarily unreachable (context == null / ptr == 0) so + * any eval enqueued onto the native pending-work map — which will never be + * drained on the JS thread in that state — releases its JNI global ref and + * settles the JS promise immediately, instead of leaking until the next + * teardown or relying on the bg watchdog. Exactly-once on the native side. + */ + private external fun nativeDropScheduledWork(workId: Long) + /** * Synchronously mark the SharedRPC listener for `runtimeId` as dead * before the underlying JS runtime is torn down. See @@ -412,8 +461,17 @@ class BackgroundThreadManager private constructor() { BTLogger.info("scheduleOnJSThread: isMain=$isMain, workId=$workId, context=${context != null}") if (context == null) { BTLogger.error("scheduleOnJSThread: context is null! isMain=$isMain, mainCtx=${mainReactContext != null}, bgHost=${bgReactHost != null}, bgCtx=${bgReactHost?.currentReactContext != null}") + // The just-enqueued native work will never reach the bg JS thread. + // Drop it now: erase gPendingWork[workId] (frees the captured segment + // source buffer) and, if it was a bg segment eval, settle it + // (retryable NO_RUNTIME) so its JNI global ref is released and the JS + // promise resolves instead of leaking until teardown / the bg watchdog. + if (!isMain) { + nativeDropScheduledWork(workId) + } + return } - context?.runOnJSQueueThread { + context.runOnJSQueueThread { // Re-read ptr inside the block — if a reload happened between // scheduling and execution, the old ptr may be stale. val ptr = if (isMain) mainRuntimePtr else bgRuntimePtr @@ -426,6 +484,13 @@ class BackgroundThreadManager private constructor() { } } else { BTLogger.error("scheduleOnJSThread: ptr is 0! isMain=$isMain") + // Same as the null-context case: the work won't run on this + // (stale/torn-down) bg runtime. Drop gPendingWork[workId] (frees + // the source buffer) and settle any pending bg eval so it doesn't + // leak its global ref / hang the JS promise. + if (!isMain) { + nativeDropScheduledWork(workId) + } } } } @@ -433,51 +498,98 @@ class BackgroundThreadManager private constructor() { // ── Segment Registration (Phase 2.5 spike) ───────────────────────────── /** - * Register a HBC segment in the background runtime. - * Uses CatalystInstance.registerSegment() on the background ReactContext. + * Evaluate a HBC segment into the background runtime with completion + * callback (fix A: eval-then-resolve). * - * @param segmentId The segment ID to register - * @param path Absolute file path to the .seg.hbc file - * @throws IllegalStateException if background runtime is not started - * @throws IllegalArgumentException if segment file does not exist - */ - /** - * Register a HBC segment in the background runtime with completion callback. - * Dispatches to the background JS queue thread and invokes the callback - * only after registerSegment has actually executed. + * Previously this called `ReactContext.registerSegment(...)`, whose + * completion fires BEFORE the segment bytecode is evaluated into the runtime + * (registerSegment only ENQUEUES the eval). That races Metro's + * `import().then(() => __r(moduleId))` and produces a fatal, uncatchable + * "Requiring unknown module" — locale segments load through this bg path, so + * a language switch could still crash. We now evaluate the segment OURSELVES + * on the bg JS thread via [nativeEvaluateSegmentInBackground] and resolve + * ONLY after eval completes, so eval + resolve are one atomic JS-thread turn. * - * @param segmentId The segment ID to register + * @param segmentId The segment ID (used only for the synthetic source URL) * @param path Absolute file path to the .seg.hbc file - * @param onComplete Called with null on success, or an Exception on failure + * @param onComplete Called with (code=null) on success, or + * (code=, message) on failure. The code is one of + * the SHARED split-bundle contract codes so the JS loader's retryable set + * { SPLIT_BUNDLE_NO_RUNTIME, SPLIT_BUNDLE_TIMEOUT } classifies correctly. */ - fun registerSegmentInBackground(segmentId: Int, path: String, onComplete: (Exception?) -> Unit) { + fun registerSegmentInBackground( + segmentId: Int, + path: String, + onComplete: (code: String?, message: String?) -> Unit + ) { if (!isStarted) { - onComplete(IllegalStateException("Background runtime not started")) + // Bg runtime not started yet → retryable (the loader will re-attempt + // once the bg host is up). + onComplete("SPLIT_BUNDLE_NO_RUNTIME", "Background runtime not started") return } val file = File(path) if (!file.exists()) { - onComplete(IllegalArgumentException("Segment file not found: $path")) + onComplete("SPLIT_BUNDLE_NOT_FOUND", "Segment file not found: $path") return } val context = bgReactHost?.currentReactContext if (context == null) { - onComplete(IllegalStateException("Background ReactContext not available")) + onComplete("SPLIT_BUNDLE_NO_RUNTIME", "Background ReactContext not available") return } - // Use ReactContext.registerSegment which works in both bridge - // and bridgeless modes. + // One-shot guard: native success/error AND the watchdog can each try to + // settle; only the first wins. + val settled = AtomicBoolean(false) + val sourceURL = "seg-$segmentId.js" + val segStart = System.nanoTime() + + // Bounded watchdog: if the bg JS thread never drains to our eval, reject + // with the RETRYABLE SPLIT_BUNDLE_TIMEOUT instead of hanging forever. + val watchdog = Handler(Looper.getMainLooper()) + val timeoutRunnable = Runnable { + if (settled.compareAndSet(false, true)) { + BTLogger.error("[SplitBundle] bg segment id=$segmentId eval timed out after ${BG_SEGMENT_EVAL_TIMEOUT_MS}ms (bg entry bundle likely never finished evaluating); rejecting as retryable timeout") + onComplete("SPLIT_BUNDLE_TIMEOUT", "Bg segment eval timed out: id=$segmentId") + } + } + watchdog.postDelayed(timeoutRunnable, BG_SEGMENT_EVAL_TIMEOUT_MS) + try { - context.registerSegment(segmentId, path) { - BTLogger.info("Segment registered in background runtime: id=$segmentId, path=$path") - onComplete(null) + nativeEvaluateSegmentInBackground(path, sourceURL) { error -> + if (settled.compareAndSet(false, true)) { + watchdog.removeCallbacks(timeoutRunnable) + if (error == null) { + val segMs = (System.nanoTime() - segStart) / 1_000_000.0 + BTLogger.info("[SplitBundle] bg segment id=$segmentId evaluated in ${String.format("%.1f", segMs)}ms (eval-complete)") + onComplete(null, null) + } else { + // Native prefixes its failures so we can map to the + // shared contract codes: NO_RUNTIME (retryable), + // IO_ERROR (fatal), else eval throw (fatal). + when { + error.startsWith("NO_RUNTIME:") -> + onComplete("SPLIT_BUNDLE_NO_RUNTIME", error.removePrefix("NO_RUNTIME:")) + error.startsWith("IO_ERROR:") -> + onComplete("SPLIT_BUNDLE_IO_ERROR", error.removePrefix("IO_ERROR:")) + else -> + onComplete("SPLIT_BUNDLE_EVAL_ERROR", error) + } + } + } + } + } catch (e: Throwable) { + // nativeEvaluateSegmentInBackground itself failed to dispatch (e.g. + // UnsatisfiedLinkError). Fail closed; do NOT fall back to the + // race-prone registerSegment path. + if (settled.compareAndSet(false, true)) { + watchdog.removeCallbacks(timeoutRunnable) + BTLogger.error("[SplitBundle] FATAL: nativeEvaluateSegmentInBackground threw for id=$segmentId: ${e.message}") + onComplete("SPLIT_BUNDLE_NATIVE_UNAVAILABLE", "Bg native segment eval unavailable: ${e.message}") } - } catch (e: Exception) { - BTLogger.error("Failed to register segment in background runtime: ${e.message}") - onComplete(e) } } diff --git a/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadModule.kt b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadModule.kt index 300f9dba..1563e2f6 100644 --- a/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadModule.kt +++ b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadModule.kt @@ -30,9 +30,17 @@ class BackgroundThreadModule(reactContext: ReactApplicationContext) : override fun loadSegmentInBackground(segmentId: Double, path: String, promise: Promise) { BackgroundThreadManager.getInstance() - .registerSegmentInBackground(segmentId.toInt(), path) { error -> - if (error != null) { - promise.reject("BG_SEGMENT_LOAD_ERROR", error.message, error) + .registerSegmentInBackground(segmentId.toInt(), path) { code, message -> + if (code != null) { + // Reject with the SHARED split-bundle contract code (e.g. + // SPLIT_BUNDLE_NO_RUNTIME / SPLIT_BUNDLE_TIMEOUT are + // retryable; SPLIT_BUNDLE_IO_ERROR / SPLIT_BUNDLE_EVAL_ERROR + // / SPLIT_BUNDLE_NATIVE_UNAVAILABLE / SPLIT_BUNDLE_NOT_FOUND + // are not) so the JS loader classifies retryability the same + // way it does for the main path. registerSegmentInBackground + // always supplies one of these contract codes here, so there + // is no legacy/opaque default string to fall back to. + promise.reject(code, message) } else { promise.resolve(null) } diff --git a/native-modules/react-native-background-thread/ios/BackgroundThread.mm b/native-modules/react-native-background-thread/ios/BackgroundThread.mm index 960b3d3a..a376bdaa 100644 --- a/native-modules/react-native-background-thread/ios/BackgroundThread.mm +++ b/native-modules/react-native-background-thread/ios/BackgroundThread.mm @@ -38,7 +38,54 @@ - (void)loadSegmentInBackground:(double)segmentId path:path completion:^(NSError * _Nullable error) { if (error) { - reject(@"BG_SEGMENT_LOAD_ERROR", error.localizedDescription, error); + // Fix 1/E: map the manager's DISTINCT NSError.code to its own JS + // reject code (matching SplitBundleLoader's main-runtime mapping and + // the shared error-code contract) so JS can classify + // retryable-vs-fatal. EVERY EBgMgrSegmentEvalError case is mapped + // EXPLICITLY here — there is no silent `default → NO_RUNTIME` that + // could MISCLASSIFY a fatal failure (e.g. a missing segment file) + // as a transient runtime-not-ready that gets retried/masked. The old + // code both collapsed bg failures into one opaque + // `BG_SEGMENT_LOAD_ERROR` and (before that) let raw codes 1/2 fall + // through `default` to retryable NO_RUNTIME. + NSString *rejectCode; + switch ((EBgMgrSegmentEvalError)error.code) { + case EBgMgrSegmentEvalErrorNotStarted: + case EBgMgrSegmentEvalErrorNilInstance: + // Bg runtime not started / RCTInstance nil — TRANSIENT: the + // bg host simply wasn't ready yet; a later attempt may win. + rejectCode = @"SPLIT_BUNDLE_NO_RUNTIME"; // retryable + break; + case EBgMgrSegmentEvalErrorFileNotFound: + // Segment file missing — FATAL packaging/OTA corruption. + rejectCode = @"SPLIT_BUNDLE_NOT_FOUND"; // fatal + break; + case EBgMgrSegmentEvalErrorIvarMissing: + // `_rctInstance` ivar reflection failed — STRUCTURAL/PERMANENT + // (an RN bump renamed the private field). Retrying is futile. + rejectCode = @"SPLIT_BUNDLE_NATIVE_UNAVAILABLE"; // fatal + break; + case EBgMgrSegmentEvalErrorIORead: + rejectCode = @"SPLIT_BUNDLE_IO_ERROR"; // fatal + break; + case EBgMgrSegmentEvalErrorEvalThrow: + rejectCode = @"SPLIT_BUNDLE_EVAL_ERROR"; // fatal (segment bug) + break; + case EBgMgrSegmentEvalErrorTimeout: + rejectCode = @"SPLIT_BUNDLE_TIMEOUT"; // retryable + break; + default: + // Defensive LAST RESORT only: every real EBgMgrSegmentEvalError + // case is handled above, so reaching here means the manager + // emitted an unmapped code (a bug). Choose retryable + // NO_RUNTIME so a stale/unknown native build degrades safely + // rather than permanently poisoning a segment — but log it + // loudly so the unmapped code is caught and named. + [BTLogger warn:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: UNMAPPED EBgMgrSegmentEvalError code=%ld — defaulting to retryable NO_RUNTIME. This is a native bug: add an explicit case.", (long)error.code]]; + rejectCode = @"SPLIT_BUNDLE_NO_RUNTIME"; // retryable (defensive) + break; + } + reject(rejectCode, error.localizedDescription, error); } else { resolve(nil); } diff --git a/native-modules/react-native-background-thread/ios/BackgroundThreadManager.h b/native-modules/react-native-background-thread/ios/BackgroundThreadManager.h index 4c4e97a4..09185f60 100644 --- a/native-modules/react-native-background-thread/ios/BackgroundThreadManager.h +++ b/native-modules/react-native-background-thread/ios/BackgroundThreadManager.h @@ -6,6 +6,49 @@ NS_ASSUME_NONNULL_BEGIN @class RCTReactNativeFactory; @class RCTHost; +/// NSError `code` values produced by `registerSegmentInBackground:` / +/// `evaluateSegmentInBackground:` under the `BackgroundThread` error domain. +/// +/// These are DISTINCT (kept numerically aligned with SplitBundleLoader's +/// `ESegmentEvalError`) so the TurboModule boundary +/// (`BackgroundThread.loadSegmentInBackground`) can map each to its OWN JS +/// reject code and JS can classify retryable-vs-fatal — rather than collapsing +/// every bg failure into one opaque code (fix E). EVERY failure the manager +/// produces MUST have a NAMED case here so the boundary's switch maps it +/// explicitly: a raw integer literal would fall through to the boundary's +/// defensive `default` (→ retryable NO_RUNTIME) and silently MISCLASSIFY a +/// fatal failure (e.g. a missing segment file) as a transient one that gets +/// retried/masked instead of surfaced. The mapping the boundary applies is: +/// - NotStarted (bg runtime not started yet) → `SPLIT_BUNDLE_NO_RUNTIME` (retryable) +/// - FileNotFound (segment file missing) → `SPLIT_BUNDLE_NOT_FOUND` (fatal) +/// - NilInstance (bg RCTInstance is nil) → `SPLIT_BUNDLE_NO_RUNTIME` (retryable) +/// - IORead (file read/mmap failed) → `SPLIT_BUNDLE_IO_ERROR` (fatal) +/// - EvalThrow (segment JS/Hermes bug) → `SPLIT_BUNDLE_EVAL_ERROR` (fatal) +/// - Timeout (buffered executor never ran)→ `SPLIT_BUNDLE_TIMEOUT` (retryable) +/// - IvarMissing (`_rctInstance` reflection +/// failed — STRUCTURAL/permanent)→ `SPLIT_BUNDLE_NATIVE_UNAVAILABLE` (fatal) +/// +/// WHY NilInstance and IvarMissing are split (and not both NO_RUNTIME): a nil +/// RCTInstance is TRANSIENT — the bg host just isn't up yet, a later attempt +/// can succeed. A missing `_rctInstance` ivar is STRUCTURAL/PERMANENT — an RN +/// version bump renamed/removed the private field our reflection depends on, so +/// bg segment loading is disabled until the native code is updated. Retrying +/// the latter is futile, so it maps to fatal NATIVE_UNAVAILABLE. +/// +/// Declared here (not file-local in the .mm) so the boundary references these +/// by NAME — a future renumbering stays exact instead of drifting against a +/// hardcoded magic-number switch. Values 3-6 are kept STABLE; 1/2/7 fill in the +/// previously-raw `registerSegmentInBackground:` codes and the structural split. +typedef NS_ENUM(NSInteger, EBgMgrSegmentEvalError) { + EBgMgrSegmentEvalErrorNotStarted = 1, // bg runtime not started yet (retryable) + EBgMgrSegmentEvalErrorFileNotFound = 2, // segment file missing (fatal) + EBgMgrSegmentEvalErrorNilInstance = 3, // bg RCTInstance is nil (retryable) + EBgMgrSegmentEvalErrorIORead = 4, // file read/mmap failed (fatal) + EBgMgrSegmentEvalErrorEvalThrow = 5, // segment JS/Hermes bug (fatal) + EBgMgrSegmentEvalErrorTimeout = 6, // buffered executor never ran (retryable) + EBgMgrSegmentEvalErrorIvarMissing = 7, // `_rctInstance` ivar reflection failed (fatal, structural) +}; + @interface BackgroundThreadManager : NSObject /// Shared instance for singleton pattern diff --git a/native-modules/react-native-background-thread/ios/BackgroundThreadManager.mm b/native-modules/react-native-background-thread/ios/BackgroundThreadManager.mm index fb18bfa9..1c9fd826 100644 --- a/native-modules/react-native-background-thread/ios/BackgroundThreadManager.mm +++ b/native-modules/react-native-background-thread/ios/BackgroundThreadManager.mm @@ -20,6 +20,82 @@ #import #import #import +#import +#include +#include + +namespace { + +// Zero-copy jsi::Buffer that retains its NSData for the async (buffered) +// executor block's lifetime — same rationale as SplitBundleLoader's +// NSDataJSIBuffer (M4/M5): no second full copy of the segment bytes, and the +// mmap'd/heap bytes stay alive because the buffer owns the NSData. +class BgMgrNSDataJSIBuffer : public facebook::jsi::Buffer { + public: + explicit BgMgrNSDataJSIBuffer(NSData *data) : data_(data) {} + size_t size() const override { return data_.length; } + const uint8_t *data() const override { + return static_cast(data_.bytes); + } + + private: + NSData *data_; // strong retain (ARC). +}; + +} // namespace + +// Exactly-once settle guard for the bg watchdog — same design as +// SplitBundleLoader's SBLSettleGuard. An ARC object captured by both the +// executor block and the watchdog dispatch_after; ARC keeps its lock alive +// until both release, so neither block needs (or may do) a manual free — +// avoiding the use-after-free that would occur if either freed the lock while +// the other still runs. +@interface BgMgrSettleGuard : NSObject +- (BOOL)tryClaim; +@end + +@implementation BgMgrSettleGuard { + os_unfair_lock _lock; + BOOL _settled; +} +- (instancetype)init { + if (self = [super init]) { + _lock = OS_UNFAIR_LOCK_INIT; + _settled = NO; + } + return self; +} +- (BOOL)tryClaim { + os_unfair_lock_lock(&_lock); + BOOL won = !_settled; + if (won) { + _settled = YES; + } + os_unfair_lock_unlock(&_lock); + return won; +} +@end + +// Watchdog window for the bg buffered runtime executor (H2 = bg-runtime port of +// SplitBundleLoader's C1). The bg runtime's buffered executor stays buffered +// until the bg entry bundle finishes evaluating in +// BackgroundReactNativeDelegate.hostDidStart:; if that never completes (host +// teardown, OTA-resolve abort), the block never runs — without this watchdog +// the JS promise would hang inflightSegments forever. +// +// Fix G: 30s (matching Android and the main-runtime watchdog). The buffered +// executor stays buffered until the bg ENTRY bundle finishes evaluating; on a +// slow/throttled cold start that entry eval can itself exceed 10s, which would +// falsely trip the watchdog on a load that was about to succeed. 30s keeps the +// genuine-wedge safety net while leaving generous headroom for slow cold starts. +static const NSTimeInterval kBgSegmentEvalWatchdogSeconds = 30.0; + +// EBgMgrSegmentEvalError NSError `code` values are declared in +// BackgroundThreadManager.h so the TurboModule boundary +// (BackgroundThread.loadSegmentInBackground) can map each distinct code to its +// own JS reject code by NAME. They stay numerically aligned with +// SplitBundleLoader's ESegmentEvalError; see installProdBundleLoader.ts for the +// retryable-vs-fatal classification (H3 / fix E). @interface BackgroundThreadManager () @property (nonatomic, strong) BackgroundReactNativeDelegate *reactNativeFactoryDelegate; @@ -201,33 +277,166 @@ - (void)registerSegmentInBackground:(NSNumber *)segmentId completion:(void (^)(NSError * _Nullable error))completion { if (!self.isStarted || !self.reactNativeFactoryDelegate) { + // Transient: the bg runtime just isn't up yet. NotStarted → NO_RUNTIME + // (retryable). Previously a raw `code:1` which fell through the + // boundary's `default` — same destination, but now NAMED so the mapping + // is explicit and can never drift (fix 1 / E). NSError *error = [NSError errorWithDomain:@"BackgroundThread" - code:1 + code:EBgMgrSegmentEvalErrorNotStarted userInfo:@{NSLocalizedDescriptionKey: @"Background runtime not started"}]; if (completion) completion(error); return; } - // Verify the file exists + // Verify the file exists. FATAL: a missing segment file is real packaging / + // OTA corruption — retrying just re-misses. Previously a raw `code:2` that + // the boundary's `default` MISCLASSIFIED as retryable NO_RUNTIME, masking + // the corruption; now FileNotFound → NOT_FOUND (fatal) (fix 1 / E — the + // NO-SHIP blocker). if (![[NSFileManager defaultManager] fileExistsAtPath:path]) { NSError *error = [NSError errorWithDomain:@"BackgroundThread" - code:2 + code:EBgMgrSegmentEvalErrorFileNotFound userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment file not found: %@", path]}]; if (completion) completion(error); return; } - BOOL success = [self.reactNativeFactoryDelegate registerSegmentWithId:segmentId path:path]; - if (success) { - if (completion) completion(nil); - } else { + [self evaluateSegmentInBackground:segmentId path:path completion:completion]; +} + +// Evaluate-then-resolve segment load for the BACKGROUND runtime (H2). +// +// WHY THIS REPLACES registerSegmentWithId: + immediate completion(nil): +// The old path called `[delegate registerSegmentWithId:path:]` (which routes to +// RCTInstance/ReactInstance::registerSegment — that only ENQUEUES +// `runtime.evaluateJavaScript(segment)` on the runtime scheduler and returns) +// then resolved the JS promise immediately. That is the exact +// "Requiring unknown module" race the MAIN runtime already fixed in +// SplitBundleLoader: Metro's `import().then(() => __r(moduleId))` microtask can +// run `__r` BEFORE the scheduled eval populated the module table → a FATAL, +// uncatchable crash. Locale segments load through THIS path in the bg runtime +// (ServiceSetting.refreshLocaleMessages → import('./json/*.json') runs in +// kit-bg), so a language switch could still crash. +// +// Fix: evaluate the segment OURSELVES inside one +// `callFunctionOnBufferedRuntimeExecutor:` block on the bg RCTInstance and +// signal completion in that SAME block, strictly AFTER eval — making eval + +// resolve one atomic unit so any subsequent `__r(moduleId)` finds the module. +// The bg RCTInstance is the delegate's private `_rctInstance` ivar; we read it +// reflectively (the same pattern installSharedBridgeInMainRuntime: uses on the +// main host) to keep this fix self-contained in this file rather than widening +// the delegate's surface. Includes the same exactly-once + watchdog guard as +// the main-runtime fix (C1) because the bg buffered executor is likewise +// buffered until the bg entry bundle finishes evaluating in hostDidStart:. +- (void)evaluateSegmentInBackground:(NSNumber *)segmentId + path:(NSString *)path + completion:(void (^)(NSError * _Nullable error))completion +{ + // Reach the bg RCTInstance via the delegate's `_rctInstance` ivar. `id` + // (not a typed RCTInstance*) mirrors installSharedBridgeInMainRuntime:'s + // untyped handling and avoids needing the RCTInstance header here. + BackgroundReactNativeDelegate *delegate = self.reactNativeFactoryDelegate; + Ivar ivar = class_getInstanceVariable([delegate class], "_rctInstance"); + if (!ivar) { + // Loud failure (L7 parity): a future RN/delegate refactor that renames + // this ivar silently disables ALL bg segment loading — surface it. + // STRUCTURAL/PERMANENT (not transient): retrying can never recreate a + // renamed ivar, so this maps to fatal NATIVE_UNAVAILABLE — distinct from + // the nil-instance case below, which IS transient. Misclassifying this + // as NO_RUNTIME would make JS retry a permanently-broken reflection. + [BTLogger error:[NSString stringWithFormat:@"[SplitBundle] FATAL: _rctInstance ivar not found on %@ — bg segment loading is DISABLED.", [delegate class]]]; NSError *error = [NSError errorWithDomain:@"BackgroundThread" - code:3 - userInfo:@{NSLocalizedDescriptionKey: - @"Failed to register segment in background runtime"}]; + code:EBgMgrSegmentEvalErrorIvarMissing + userInfo:@{NSLocalizedDescriptionKey: @"_rctInstance ivar not found on bg delegate"}]; + if (completion) completion(error); + return; + } + id instance = object_getIvar(delegate, ivar); + if (!instance) { + [BTLogger error:@"[SplitBundle] bg loadSegment: background RCTInstance not available"]; + NSError *error = [NSError errorWithDomain:@"BackgroundThread" + code:EBgMgrSegmentEvalErrorNilInstance + userInfo:@{NSLocalizedDescriptionKey: @"Background RCTInstance not available"}]; if (completion) completion(error); + return; } + + // M4/M5: mmap + zero-copy buffer (retains the NSData for the async block). + NSError *readError = nil; + NSData *data = [NSData dataWithContentsOfFile:path + options:NSDataReadingMappedIfSafe + error:&readError]; + if (!data || data.length == 0) { + NSError *error = [NSError errorWithDomain:@"BackgroundThread" + code:EBgMgrSegmentEvalErrorIORead + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to read bg segment at %@%@", path, readError ? [NSString stringWithFormat:@": %@", readError.localizedDescription] : @""]}]; + if (completion) completion(error); + return; + } + + // M6: synthetic `seg-.js` source URL for in-segment crash symbolication. + int segIdInt = segmentId.intValue; + NSString *sourceURL = [NSString stringWithFormat:@"seg-%d.js", segIdInt]; + + // C1: exactly-once guard shared (and retained) by the executor block and the + // watchdog. ARC-owned so the lock outlives both with no manual free. + BgMgrSettleGuard *settleGuard = [[BgMgrSettleGuard alloc] init]; + + CFAbsoluteTime dispatchStart = CFAbsoluteTimeGetCurrent(); + [BTLogger info:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: evaluating %@ (id=%d, %lu bytes)", sourceURL, segIdInt, (unsigned long)data.length]]; + + // No __weak dance needed here (unlike the SharedRPC executor): this block + // does not re-dispatch onto the instance — it runs synchronously inside the + // instance's own runtime executor with a live `runtime &`, so by the time it + // executes the instance is necessarily still alive. + [instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) { + @autoreleasepool { + BOOL won = [settleGuard tryClaim]; + NSError *evalError = nil; + CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent(); + try { + auto buffer = std::make_shared(data); + runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]); + double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0; + [BTLogger info:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ evaluated in %.1fms", sourceURL, evalMs]]; + } catch (const std::exception &e) { + evalError = [NSError errorWithDomain:@"BackgroundThread" + code:EBgMgrSegmentEvalErrorEvalThrow + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Bg segment evaluation failed for %@: %s", sourceURL, e.what()]}]; + [BTLogger error:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ evaluation threw: %s", sourceURL, e.what()]]; + } catch (...) { + evalError = [NSError errorWithDomain:@"BackgroundThread" + code:EBgMgrSegmentEvalErrorEvalThrow + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Bg segment evaluation failed for %@ (unknown C++ exception)", sourceURL]}]; + [BTLogger error:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ evaluation threw an unknown exception", sourceURL]]; + } + if (won) { + // Resolve AFTER eval — the ordering guarantee that fixes the race. + if (completion) completion(evalError); + } else { + [BTLogger warn:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ evaluated AFTER watchdog already settled (bg entry was wedged >%.0fs)", sourceURL, kBgSegmentEvalWatchdogSeconds]]; + } + } + }]; + + // C1 watchdog — fires only on a genuine wedge (bg entry bundle never + // finished evaluating). Rejects with a retryable timeout so the JS loader + // re-attempts. settleGuard is retained by this block, so the lock stays + // valid even if the executor block later runs. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kBgSegmentEvalWatchdogSeconds * NSEC_PER_SEC)), + dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + if ([settleGuard tryClaim]) { + [BTLogger error:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ WATCHDOG fired after %.0fs — bg runtime executor never ran (bg entry bundle likely never finished evaluating). Rejecting as retryable timeout.", sourceURL, kBgSegmentEvalWatchdogSeconds]]; + NSError *timeoutError = [NSError errorWithDomain:@"BackgroundThread" + code:EBgMgrSegmentEvalErrorTimeout + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Bg segment %@ eval timed out after %.0fs (buffered runtime executor never ran)", sourceURL, kBgSegmentEvalWatchdogSeconds]}]; + if (completion) completion(timeoutError); + } + }); + + double dispatchMs = (CFAbsoluteTimeGetCurrent() - dispatchStart) * 1000.0; + [BTLogger info:[NSString stringWithFormat:@"[SplitBundle] bg loadSegment: %@ dispatched in %.1fms (resolve fires after eval; watchdog %.0fs)", sourceURL, dispatchMs, kBgSegmentEvalWatchdogSeconds]]; } #pragma mark - Restart diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index 3982128e..f102ba0b 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.56", + "version": "3.0.61", "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 9b63de3d..7b20b693 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.56", + "version": "3.0.61", "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/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt index d57984e6..7906888c 100644 --- a/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt +++ b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt @@ -1013,16 +1013,26 @@ object BundleUpdateStoreAndroid { } } - private fun deleteDirectory(directory: File) { - if (directory.exists()) { - directory.listFiles()?.forEach { file -> - if (file.isDirectory) deleteDirectory(file) else file.delete() - } - directory.delete() + /** + * Recursively deletes [directory], returning true only when nothing is left + * behind. An already-missing entry counts as success (tolerates concurrent + * removal); a child that fails to delete makes the whole call return false + * so callers never report a half-deleted tree as a clean delete. + */ + private fun deleteDirectory(directory: File): Boolean { + if (!directory.exists()) return true + var allDeleted = true + directory.listFiles()?.forEach { file -> + val ok = if (file.isDirectory) deleteDirectory(file) else (!file.exists() || file.delete()) + if (!ok) allDeleted = false } + // delete() on a non-empty dir returns false, so a child failure above + // naturally propagates here too. + val dirDeleted = !directory.exists() || directory.delete() + return allDeleted && dirDeleted } - fun deleteDir(dir: File) = deleteDirectory(dir) + fun deleteDir(dir: File): Boolean = deleteDirectory(dir) private const val MAX_UNZIPPED_SIZE = 512L * 1024 * 1024 // 512 MB limit @@ -1757,6 +1767,148 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } } + /** + * Prunes every artifact whose appVersion != the running native binary + * version: stale onekey-bundle/ dirs, onekey-bundle-download/ stages + * (.zip / .partial / .progress / .resume), orphan asc signatures, and + * lingering fallback entries. Hard-refuses to delete the current + * appVersion's artifacts and the active currentBundleVersion. Tolerates + * already-missing files. Returns the count of deleted version directories. + * + * appVersion is parsed from the "{appVersion}-{bundleVersion}" stem using + * the SAME last-dash split as listLocalBundles / the installBundle fallback + * logic, so behavior matches the rest of the module. + */ + override fun pruneStaleAppVersionBundles(): Promise { + BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache() + return Promise.async { + val context = getContext() + val currentAppV = BundleUpdateStoreAndroid.getAppVersion(context) ?: "" + // Safety net: never delete the bundle backing the active pointer. + val currentBundleVersion = BundleUpdateStoreAndroid.getCurrentBundleVersion(context) + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: currentAppV=$currentAppV, currentBundleVersion=$currentBundleVersion") + + // Without a known native version we cannot decide what is stale; + // bail out rather than risk deleting the wrong artifacts. + if (currentAppV.isEmpty()) { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: empty currentAppV, skipping") + return@async 0.0 + } + + // Parses an "{appVersion}-{bundleVersion}" stem into its appVersion + // component using the same last-dash split as listLocalBundles. + // Returns null for stems without a dash or with an empty appVersion. + fun appVersionFromStem(stem: String): String? { + val lastDash = stem.lastIndexOf('-') + if (lastDash <= 0) return null + val appV = stem.substring(0, lastDash) + return if (appV.isEmpty()) null else appV + } + + // True when this entry stem must be kept: its appVersion matches the + // running binary, it IS the active currentBundleVersion, or it is + // unparseable (leave foreign names alone). + fun shouldKeep(stem: String): Boolean { + if (currentBundleVersion != null && stem == currentBundleVersion) return true + val appV = appVersionFromStem(stem) ?: return true + return appV == currentAppV + } + + var deletedDirCount = 0 + + // 1. onekey-bundle/* extracted dirs + val bundleDir = File(BundleUpdateStoreAndroid.getBundleDir(context)) + if (bundleDir.exists() && bundleDir.isDirectory) { + bundleDir.listFiles()?.forEach { child -> + if (!child.isDirectory) return@forEach + val name = child.name + // Skip non-version entries (asc dir, fallback json, etc.) + if (name == "asc" || name == "fallbackUpdateBundleData.json") return@forEach + if (shouldKeep(name)) return@forEach + try { + if (BundleUpdateStoreAndroid.deleteDir(child)) { + deletedDirCount++ + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: deleted stale bundle dir $name") + } else { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: incomplete delete of bundle dir $name (left behind)") + } + } catch (e: Exception) { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: failed to delete bundle dir $name: ${e.message}") + } + } + } + + // 2. onekey-bundle-download/* stages (zip / partial / progress / resume) + val downloadDir = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context)) + if (downloadDir.exists() && downloadDir.isDirectory) { + downloadDir.listFiles()?.forEach { file -> + val name = file.name + // Strip the trailing extension chain to recover the + // "{appV}-{bV}" stem (e.g. "6.3.0-123.zip.partial"). + var stem = name + for (suffix in listOf(".resume", ".progress", ".partial", ".zip")) { + if (stem.endsWith(suffix)) { + stem = stem.substring(0, stem.length - suffix.length) + } + } + if (shouldKeep(stem)) return@forEach + try { + val deleted = if (file.isDirectory) { + BundleUpdateStoreAndroid.deleteDir(file) + } else { + !file.exists() || file.delete() + } + if (deleted) { + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: deleted stale download $name") + } else { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: failed to delete download $name") + } + } catch (e: Exception) { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: failed to delete download $name: ${e.message}") + } + } + } + + // 3. onekey-bundle/asc/*-signature.asc orphan signatures + val ascDir = File(BundleUpdateStoreAndroid.getAscDir(context)) + if (ascDir.exists() && ascDir.isDirectory) { + val suffix = "-signature.asc" + ascDir.listFiles()?.forEach { file -> + val name = file.name + if (!name.endsWith(suffix)) return@forEach + val stem = name.substring(0, name.length - suffix.length) + if (shouldKeep(stem)) return@forEach + try { + file.delete() + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: deleted orphan asc $name") + } catch (e: Exception) { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: failed to delete asc $name: ${e.message}") + } + } + } + + // 4. Persisted fallback list: drop entries whose appVersion != currentAppV. + // Fixes the latent leak where stale fallback entries linger after a + // native upgrade. Reuses the existing read/write helpers. + try { + val fallbackData = BundleUpdateStoreAndroid.readFallbackUpdateBundleDataFile(context) + val prunedFallback = fallbackData.filter { entry -> + val appV = entry["appVersion"] + appV.isNullOrEmpty() || appV == currentAppV + } + if (prunedFallback.size != fallbackData.size) { + BundleUpdateStoreAndroid.writeFallbackUpdateBundleDataFile(prunedFallback, context) + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: pruned fallback entries ${fallbackData.size} -> ${prunedFallback.size}") + } + } catch (e: Exception) { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: fallback prune error: ${e.message}") + } + + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: completed, deletedDirCount=$deletedDirCount") + deletedDirCount.toDouble() + } + } + override fun resetToBuiltInBundle(): Promise { BundleUpdateStoreAndroid.invalidateValidatedBundleInfoCache() return Promise.async { diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index 3d362053..a05eb521 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -1725,6 +1725,130 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { } } + /// Parses an "{appVersion}-{bundleVersion}" folder/file stem into its + /// appVersion component using the SAME last-dash split as listLocalBundles + /// and the installBundle fallback logic. Returns nil when the stem has no + /// dash or an empty appVersion, so callers leave unrecognized entries + /// untouched. + private static func appVersionFromStem(_ stem: String) -> String? { + guard let lastDash = stem.range(of: "-", options: .backwards), + lastDash.lowerBound > stem.startIndex else { return nil } + let appVersion = String(stem[stem.startIndex.. Promise { + BundleUpdateStore.invalidateValidatedBundleInfoCache() + return Promise.async { + let fm = FileManager.default + let currentAppV = BundleUpdateStore.getCurrentNativeVersion() + // Safety net: never delete the bundle backing the active pointer. + let currentBundleVersion = BundleUpdateStore.currentBundleVersion() + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: currentAppV=\(currentAppV), currentBundleVersion=\(currentBundleVersion ?? "nil")") + + // Without a known native version we cannot decide what is stale; + // bail out rather than risk deleting the wrong artifacts. + guard !currentAppV.isEmpty else { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: empty currentAppV, skipping") + return 0 + } + + /// True when this entry stem must be kept: its appVersion matches + /// the running binary, it IS the active currentBundleVersion, or it + /// is unparseable (leave foreign names alone). + func shouldKeep(stem: String) -> Bool { + if let active = currentBundleVersion, stem == active { return true } + guard let appV = Self.appVersionFromStem(stem) else { return true } + return appV == currentAppV + } + + var deletedDirCount = 0 + + // 1. onekey-bundle/* extracted dirs + let bundleDir = BundleUpdateStore.bundleDir() + if let contents = try? fm.contentsOfDirectory(atPath: bundleDir) { + for name in contents { + // Skip non-version entries (asc dir, fallback json, etc.) + if name == "asc" || name == "fallbackUpdateBundleData.json" { continue } + let fullPath = (bundleDir as NSString).appendingPathComponent(name) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: fullPath, isDirectory: &isDir), isDir.boolValue else { continue } + if shouldKeep(stem: name) { continue } + do { + try fm.removeItem(atPath: fullPath) + deletedDirCount += 1 + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: deleted stale bundle dir \(name)") + } catch { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: failed to delete bundle dir \(name): \(error)") + } + } + } + + // 2. onekey-bundle-download/* stages (zip / partial / progress / resume) + let downloadDir = BundleUpdateStore.downloadBundleDir() + if let contents = try? fm.contentsOfDirectory(atPath: downloadDir) { + for name in contents { + // Strip the trailing extension chain to recover the + // "{appV}-{bV}" stem (e.g. "6.3.0-123.zip.partial"). + var stem = name + for suffix in [".resume", ".progress", ".partial", ".zip"] { + if stem.hasSuffix(suffix) { + stem = String(stem.dropLast(suffix.count)) + } + } + if shouldKeep(stem: stem) { continue } + let fullPath = (downloadDir as NSString).appendingPathComponent(name) + do { + try fm.removeItem(atPath: fullPath) + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: deleted stale download \(name)") + } catch { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: failed to delete download \(name): \(error)") + } + } + } + + // 3. onekey-bundle/asc/*-signature.asc orphan signatures + let ascDir = BundleUpdateStore.ascDir() + if let contents = try? fm.contentsOfDirectory(atPath: ascDir) { + let suffix = "-signature.asc" + for name in contents { + guard name.hasSuffix(suffix) else { continue } + let stem = String(name.dropLast(suffix.count)) + if shouldKeep(stem: stem) { continue } + let fullPath = (ascDir as NSString).appendingPathComponent(name) + do { + try fm.removeItem(atPath: fullPath) + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: deleted orphan asc \(name)") + } catch { + OneKeyLog.warn("BundleUpdate", "pruneStaleAppVersionBundles: failed to delete asc \(name): \(error)") + } + } + } + + // 4. Persisted fallback list: drop entries whose appVersion != currentAppV. + // Fixes the latent leak where stale fallback entries linger after a + // native upgrade. Reuses the existing read/write helpers. + let fallbackData = BundleUpdateStore.readFallbackUpdateBundleDataFile() + let prunedFallback = fallbackData.filter { entry in + guard let appV = entry["appVersion"], !appV.isEmpty else { return true } + return appV == currentAppV + } + if prunedFallback.count != fallbackData.count { + BundleUpdateStore.writeFallbackUpdateBundleDataFile(prunedFallback) + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: pruned fallback entries \(fallbackData.count) -> \(prunedFallback.count)") + } + + OneKeyLog.info("BundleUpdate", "pruneStaleAppVersionBundles: completed, deletedDirCount=\(deletedDirCount)") + return Double(deletedDirCount) + } + } + func resetToBuiltInBundle() throws -> Promise { BundleUpdateStore.invalidateValidatedBundleInfoCache() return Promise.async { diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 3aed790b..e3c180a6 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.56", + "version": "3.0.61", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/src/ReactNativeBundleUpdate.nitro.ts b/native-modules/react-native-bundle-update/src/ReactNativeBundleUpdate.nitro.ts index 5a993236..29fc3f23 100644 --- a/native-modules/react-native-bundle-update/src/ReactNativeBundleUpdate.nitro.ts +++ b/native-modules/react-native-bundle-update/src/ReactNativeBundleUpdate.nitro.ts @@ -99,6 +99,10 @@ export interface ReactNativeBundleUpdate clearBundle(): Promise; clearAllJSBundleData(): Promise; resetToBuiltInBundle(): Promise; + // Prune every artifact whose appVersion differs from the running native + // binary version (stale OTA dirs, download stages, orphan asc, lingering + // fallback entries). Returns the count of deleted version directories. + pruneStaleAppVersionBundles(): Promise; // Bundle data getFallbackUpdateBundleData(): Promise; 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 60ced134..37368903 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.56", + "version": "3.0.61", "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 5181669d..fe366058 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.56", + "version": "3.0.61", "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 6422a3cc..5eef4bae 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.56", + "version": "3.0.61", "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/ios/ReactNativeDeviceUtils.swift b/native-modules/react-native-device-utils/ios/ReactNativeDeviceUtils.swift index 620793f8..0ee36139 100644 --- a/native-modules/react-native-device-utils/ios/ReactNativeDeviceUtils.swift +++ b/native-modules/react-native-device-utils/ios/ReactNativeDeviceUtils.swift @@ -1,6 +1,7 @@ import NitroModules import UIKit import ReactNativeNativeLogger +import os @objcMembers public class LaunchOptionsStore: NSObject { @@ -34,8 +35,20 @@ public class LaunchOptionsStore: NSObject { private static let deviceTokenKey = "1k_device_token" - // Read-once: hand the payload to JS exactly once per launch. + // Serializes access to `coldStartLocalNotification`. The getter resolves on a + // nitro background thread (Promise.async) while AppDelegate writes the slot on + // the main thread during launch, so the read-and-clear below must be one + // critical section to avoid a double-drain race (delivering the deep-link + // twice) and memory-visibility issues. NOTE: AppDelegate writes via KVC + // (`setValue(_:forKey:)`), which bypasses this lock, so we cannot fully + // synchronize the writer from here — we protect the take side as best we can. + private var coldStartLock = os_unfair_lock() + + // Read-once: hand the payload to JS exactly once per launch. The read+clear is + // performed atomically under `coldStartLock`. public func takeColdStartLocalNotification() -> String { + os_unfair_lock_lock(&coldStartLock) + defer { os_unfair_lock_unlock(&coldStartLock) } let value = coldStartLocalNotification ?? "" coldStartLocalNotification = nil return value @@ -209,7 +222,10 @@ class ReactNativeDeviceUtils: HybridReactNativeDeviceUtilsSpec { func getAndClearColdStartLocalNotification() throws -> Promise { return Promise.async { - return LaunchOptionsStore.shared.takeColdStartLocalNotification() + let value = LaunchOptionsStore.shared.takeColdStartLocalNotification() + // Presence only — never log the payload (it's a deep-link target / PII). + OneKeyLog.info("DeviceUtils", "getAndClearColdStartLocalNotification: hasPayload=\(!value.isEmpty)") + return value } } diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index 72bf9922..f56f6b35 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.56", + "version": "3.0.61", "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 42d19d9c..46041c3c 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.56", + "version": "3.0.61", "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 3a895a5d..f5554c1f 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.56", + "version": "3.0.61", "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 bcd391d7..d66c7089 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.56", + "version": "3.0.61", "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 0108f7e3..95c8cc32 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.56", + "version": "3.0.61", "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 711205ef..223f5ac8 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.56", + "version": "3.0.61", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index 3b62f5f4..ea76506f 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.56", + "version": "3.0.61", "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 da32f249..f853e720 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.56", + "version": "3.0.61", "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 290cb9d7..e9d75418 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.56", + "version": "3.0.61", "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 94a7d90c..fd7d1480 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.56", + "version": "3.0.61", "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 e655fee1..a255201d 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.56", + "version": "3.0.61", "description": "react-native-range-downloader", "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 6a5c8d48..8618e6ea 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.56", + "version": "3.0.61", "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/android/CMakeLists.txt b/native-modules/react-native-split-bundle-loader/android/CMakeLists.txt new file mode 100644 index 00000000..3c7ea54d --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/android/CMakeLists.txt @@ -0,0 +1,51 @@ +cmake_minimum_required(VERSION 3.13) + +# Native (JNI) side of the SplitBundleLoader module. +# +# WHY THIS EXISTS: +# The Kotlin `loadSegment` previously called `ReactContext.registerSegment`, +# whose completion callback fires BEFORE the segment bytecode is evaluated +# into the runtime (the C++ ReactInstance::registerSegment only ENQUEUES the +# eval onto the RuntimeScheduler and returns). That races Metro's +# `import().then(() => __r(moduleId))` and can produce a fatal, uncatchable +# "Requiring unknown module". +# +# This library evaluates the segment OURSELVES on the JS thread via the +# bridgeless CallInvoker (which receives `jsi::Runtime&`) and signals +# completion in that SAME callback, so eval + resolve are one atomic unit — +# mirroring the iOS `callFunctionOnBufferedRuntimeExecutor:` fix. +project(splitbundleloader) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_VERBOSE_MAKEFILE ON) + +add_library( + splitbundleloader + SHARED + src/main/cpp/SplitBundleLoaderJSI.cpp +) + +target_include_directories( + splitbundleloader + PRIVATE + src/main/cpp +) + +# jsi, reactnative (which carries CallInvoker / CallInvokerHolder headers and +# the merged libreactnative.so), and fbjni are all prefab targets exposed by +# the react-android AAR. See ReactAndroid build.gradle.kts prefab entries: +# - jsi (../ReactCommon/jsi/) +# - reactnative (turbomodule/ReactCommon/CallInvokerHolder.h, +# callinvoker/ReactCommon/CallInvoker.h, ...) +find_package(ReactAndroid REQUIRED CONFIG) +find_package(fbjni REQUIRED CONFIG) + +target_link_libraries( + splitbundleloader + android + log + fbjni::fbjni + ReactAndroid::jsi + ReactAndroid::reactnative +) diff --git a/native-modules/react-native-split-bundle-loader/android/build.gradle b/native-modules/react-native-split-bundle-loader/android/build.gradle index 7c23f785..edc97715 100644 --- a/native-modules/react-native-split-bundle-loader/android/build.gradle +++ b/native-modules/react-native-split-bundle-loader/android/build.gradle @@ -33,10 +33,36 @@ android { defaultConfig { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + + externalNativeBuild { + cmake { + // C++20 to match React Native's prefab targets. + cppFlags "-O2", "-frtti", "-fexceptions", "-std=c++20" + // The react-android / hermestooling prefab targets are built against + // the shared STL; without this the default static STL is selected and + // CMake configure fails ("static STL but library requires a shared + // STL [//ReactAndroid/hermestooling]"). Matches react-native-background-thread. + arguments "-DANDROID_STL=c++_shared" + // Only build the ABIs the app ships. + abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + } + } } buildFeatures { buildConfig true + // Consume react-android / fbjni prefab packages (jsi, reactnative, fbjni) + // from the AAR for the native (JNI) side. + prefab true + } + + // Native (JNI) side: evaluates split-bundle segments on the JS thread and + // resolves the promise strictly AFTER eval (fixes the "Requiring unknown + // module" race). See src/main/cpp/SplitBundleLoaderJSI.cpp. + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } } buildTypes { diff --git a/native-modules/react-native-split-bundle-loader/android/src/main/cpp/SplitBundleLoaderJSI.cpp b/native-modules/react-native-split-bundle-loader/android/src/main/cpp/SplitBundleLoaderJSI.cpp new file mode 100644 index 00000000..f9e08751 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/android/src/main/cpp/SplitBundleLoaderJSI.cpp @@ -0,0 +1,239 @@ +/* + * SplitBundleLoaderJSI.cpp + * + * JNI bridge that evaluates a Metro split-bundle segment into the CURRENT + * Hermes runtime and signals completion ONLY AFTER the segment's `__d(...)` + * module definitions have actually run. + * + * WHY THIS EXISTS — the "Requiring unknown module" race: + * The previous Kotlin implementation called `ReactContext.registerSegment`, + * which routes (bridgeless) through `ReactHostImpl.registerSegment` → + * `ReactInstance.registerSegment` → C++ `ReactInstance::registerSegment`, and + * that only does `runtimeScheduler_->scheduleWork([]{ evaluateJavaScript() })` + * — i.e. it ENQUEUES the eval and returns. `ReactHostImpl.registerSegment` + * then invokes the completion callback immediately on `Task.IMMEDIATE_EXECUTOR`, + * so the loadSegment promise resolves BEFORE the segment is evaluated. Metro's + * `import().then(() => __r(moduleId))` microtask can therefore run `__r` before + * the module table is populated → a fatal, uncatchable "Requiring unknown + * module" inside metroRequire. + * + * THE FIX (mirrors iOS callFunctionOnBufferedRuntimeExecutor:): + * We evaluate the segment OURSELVES on the JS thread via the bridgeless + * CallInvoker. `CallInvoker::invokeAsync(CallFunc&&)` schedules the callback + * onto the SAME RuntimeScheduler that registerSegment would have used, and the + * callback receives `jsi::Runtime&`. We read the segment file, call + * `runtime.evaluateJavaScript(...)`, and invoke the completion callback from + * INSIDE that same block, strictly AFTER eval. Eval + completion are one atomic + * unit of work on the JS thread, so any subsequent `__r(moduleId)` is + * guaranteed to find the module. + * + * SEGMENT FORMAT: OneKey segments are standalone-evaluatable Metro bundles + * (top-level `__d(moduleId, factory, deps)` calls; the `.seg.hbc` is just the + * Hermes-compiled form of the same `.seg.js`). C++ ReactInstance::registerSegment + * itself just calls `runtime.evaluateJavaScript(buffer, ...)` with no RAM/indexed + * segment manifest wiring, confirming these are evaluatable as plain scripts. + */ + +#include +#include + +#include +#include + +#include + +#include +#include +#include +#include + +#define SBL_LOG_TAG "SplitBundleLoader" +#define SBL_LOGI(...) \ + __android_log_print(ANDROID_LOG_INFO, SBL_LOG_TAG, __VA_ARGS__) +#define SBL_LOGW(...) \ + __android_log_print(ANDROID_LOG_WARN, SBL_LOG_TAG, __VA_ARGS__) + +namespace facebook::react::splitbundleloader { + +namespace { + +// Reads the whole file at `path` into a std::string. Returns false on failure. +bool readFileToString(const std::string& path, std::string& out) { + FILE* f = std::fopen(path.c_str(), "rb"); + if (f == nullptr) { + return false; + } + if (std::fseek(f, 0, SEEK_END) != 0) { + std::fclose(f); + return false; + } + long size = std::ftell(f); + if (size < 0) { + std::fclose(f); + return false; + } + if (std::fseek(f, 0, SEEK_SET) != 0) { + std::fclose(f); + return false; + } + out.resize(static_cast(size)); + size_t read = (size == 0) + ? 0 + : std::fread(&out[0], 1, static_cast(size), f); + std::fclose(f); + return read == static_cast(size); +} + +} // namespace + +// Java callback contract — implemented in Kotlin as +// SplitBundleLoaderModule.SegmentEvalCallback. `onComplete(null)` on success, +// `onComplete(errorMessage)` on failure. Invoked exactly once. +// +// Error-message prefix convention (read by the Kotlin side to pick the contract +// reject code): a message prefixed with "IO_ERROR:" is a segment file read/mmap +// failure → SPLIT_BUNDLE_IO_ERROR (non-retryable). Any other message is a +// segment JS/Hermes eval throw → SPLIT_BUNDLE_EVAL_ERROR (non-retryable). +struct JSegmentEvalCallback + : jni::JavaClass { + static constexpr auto kJavaDescriptor = + "Lcom/splitbundleloader/SplitBundleLoaderModule$SegmentEvalCallback;"; + + // `error` empty → success (passes Java null); non-empty → failure (passes the + // message). We always pass null on success and a non-empty message on failure, + // so this mapping is unambiguous. `const` so it can be invoked through a + // (const) global_ref / alias_ref. + void onComplete(const std::string& error) const { + static const auto method = + javaClassStatic()->getMethod)>( + "onComplete"); + jni::local_ref arg = + error.empty() ? jni::local_ref(nullptr) + : jni::make_jstring(error); + method(self(), arg); + } +}; + +class SplitBundleLoaderJSI + : public jni::JavaClass { + public: + static constexpr auto kJavaDescriptor = + "Lcom/splitbundleloader/SplitBundleLoaderModule;"; + + // Schedules evaluation of the segment at `segmentPath` onto the JS thread via + // the bridgeless CallInvoker, then invokes `callback` from INSIDE the same JS + // thread block, strictly AFTER the segment has been evaluated. This is the + // ordering guarantee that fixes the "Requiring unknown module" race. + // + // Does NOT block the calling (native modules) thread — returns immediately + // after scheduling. Resolution happens later on the JS thread. + static void nativeEvaluateSegment( + jni::alias_ref /* unused */, + jni::alias_ref callInvokerHolder, + jni::alias_ref segmentPath, + jni::alias_ref sourceURL, + jni::alias_ref callback) { + // Capture everything we need as values / global refs because the callback + // runs later on a different thread. + auto globalCallback = jni::make_global(callback); + + if (!callInvokerHolder) { + globalCallback->onComplete("CallInvokerHolder is null"); + return; + } + + std::shared_ptr callInvoker = + callInvokerHolder->cthis()->getCallInvoker(); + if (!callInvoker) { + globalCallback->onComplete("CallInvoker is null"); + return; + } + + std::string path = segmentPath ? segmentPath->toStdString() : std::string(); + std::string url = + sourceURL ? sourceURL->toStdString() : std::string("segment"); + + if (path.empty()) { + globalCallback->onComplete("Empty segment path"); + return; + } + + // F: Read the segment file HERE, on the calling (native module) thread, + // BEFORE invokeAsync. Doing the disk read inside the JS-thread callback + // would block the JS thread on I/O and race the Kotlin watchdog. We move + // the already-read buffer into the lambda so only evaluateJavaScript + + // completion run on the JS thread (mirrors iOS, which mmaps off-thread and + // only evaluates on the runtime thread). The read error is surfaced as a + // dedicated IO error so JS can classify it as NON-retryable. + std::string source; + bool ioOk = readFileToString(path, source); + if (!ioOk) { + globalCallback->onComplete("IO_ERROR:Failed to read segment file: " + path); + return; + } + if (source.empty()) { + globalCallback->onComplete("IO_ERROR:Empty segment file: " + path); + return; + } + + // CallFunc = std::function; this runs on the JS thread + // on the SAME RuntimeScheduler the segment registration would have used. + callInvoker->invokeAsync([globalCallback, + source = std::move(source), + url = std::move(url)](jsi::Runtime& runtime) { + std::string error; + try { + { + SBL_LOGI( + "[SplitBundle] evaluating segment %s (%zu bytes)", + url.c_str(), + source.size()); + auto buffer = std::make_shared(std::move(source)); + // Evaluate the segment into the CURRENT runtime. evaluateJavaScript + // runs the segment's top-level __d(...) module definitions + // synchronously on this JS thread before returning. + runtime.evaluateJavaScript(std::move(buffer), url); + SBL_LOGI("[SplitBundle] segment %s evaluated", url.c_str()); + } + } catch (const jsi::JSError& e) { + error = std::string("Segment evaluation JSError for ") + url + ": " + + e.getMessage(); + SBL_LOGW("[SplitBundle] %s", error.c_str()); + } catch (const std::exception& e) { + error = std::string("Segment evaluation failed for ") + url + ": " + + e.what(); + SBL_LOGW("[SplitBundle] %s", error.c_str()); + } catch (...) { + error = std::string("Segment evaluation failed for ") + url + + " (unknown C++ exception)"; + SBL_LOGW("[SplitBundle] %s", error.c_str()); + } + + // Attach to the JVM for this (JS) thread before calling back into Java. + // The JS thread is a native (pthread) thread not implicitly attached to + // the JVM; ThreadScope ensures a valid JNIEnv for the callback. + jni::ThreadScope ts; + // Resolve/reject from INSIDE this same JS-thread block, strictly AFTER + // eval above — the ordering guarantee that fixes the race. + globalCallback->onComplete(error); + }); + } + + static void registerNatives() { + // Static JNI methods on a plain JavaClass (NOT a HybridClass): bind via + // the class's registerNatives, not registerHybrid. + javaClassStatic()->registerNatives({ + makeNativeMethod( + "nativeEvaluateSegment", + SplitBundleLoaderJSI::nativeEvaluateSegment), + }); + } +}; + +} // namespace facebook::react::splitbundleloader + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* /* reserved */) { + return facebook::jni::initialize(vm, [] { + facebook::react::splitbundleloader::SplitBundleLoaderJSI::registerNatives(); + }); +} diff --git a/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderModule.kt b/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderModule.kt index c138d5a0..bdaac147 100644 --- a/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderModule.kt +++ b/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderModule.kt @@ -5,13 +5,18 @@ import android.content.res.AssetManager import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.common.annotations.FrameworkAPI import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.turbomodule.core.CallInvokerHolderImpl import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.security.MessageDigest import java.util.concurrent.Semaphore +import java.util.concurrent.atomic.AtomicBoolean +import android.os.Handler +import android.os.Looper /** * TurboModule entry point for SplitBundleLoader. @@ -26,9 +31,65 @@ import java.util.concurrent.Semaphore class SplitBundleLoaderModule(reactContext: ReactApplicationContext) : NativeSplitBundleLoaderSpec(reactContext) { + /** + * Completion contract invoked by the native (JNI) side AFTER the segment + * has been evaluated into the runtime. Called from the JS thread. + * + * @param error null on success; a non-empty message on failure. A failure + * message prefixed with "IO_ERROR:" denotes a segment file read/mmap + * failure (mapped to SPLIT_BUNDLE_IO_ERROR); any other message denotes a + * segment JS/Hermes eval throw (mapped to SPLIT_BUNDLE_EVAL_ERROR). + */ + fun interface SegmentEvalCallback { + fun onComplete(error: String?) + } + companion object { const val NAME = "SplitBundleLoader" private const val BUILTIN_EXTRACT_DIR = "onekey-builtin-segments" + + // Bounded watchdog: if the JS thread is wedged and the segment eval + // never runs, reject rather than leaving the JS promise pending forever. + // Generous because a cold JS thread under load can legitimately take a + // while to drain to our scheduled eval. + private const val SEGMENT_EVAL_TIMEOUT_MS = 30_000L + + // Loads the JNI library that provides nativeEvaluateSegment. Wrapped so + // a missing/failed load is detectable: loadSegment then fail-closes with + // SPLIT_BUNDLE_NATIVE_UNAVAILABLE instead of crashing (we deliberately do + // NOT fall back to the legacy registerSegment path). + @JvmStatic + @Volatile + var nativeLibLoaded: Boolean = false + private set + + init { + nativeLibLoaded = try { + System.loadLibrary("splitbundleloader") + true + } catch (e: Throwable) { + SBLLogger.warn("[SplitBundle] failed to load native lib 'splitbundleloader': ${e.message}") + false + } + } + + /** + * JNI entry point. Schedules evaluation of the segment at [segmentPath] + * onto the JS thread via the bridgeless CallInvoker and invokes + * [callback] from inside that same JS-thread block, strictly AFTER the + * segment's `__d(...)` module definitions have run. This is the ordering + * guarantee that fixes the "Requiring unknown module" race. + * + * Returns immediately; [callback] fires later on the JS thread. + */ + @JvmStatic + @OptIn(FrameworkAPI::class) + external fun nativeEvaluateSegment( + callInvokerHolder: CallInvokerHolderImpl, + segmentPath: String, + sourceURL: String, + callback: SegmentEvalCallback + ) // #18: Limit concurrent asset extractions to avoid I/O contention private const val MAX_CONCURRENT_EXTRACTS = 2 private val extractSemaphore = Semaphore(MAX_CONCURRENT_EXTRACTS) @@ -197,6 +258,7 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) : // loadSegment // ----------------------------------------------------------------------- + @OptIn(FrameworkAPI::class) override fun loadSegment( segmentId: Double, segmentKey: String, @@ -229,15 +291,119 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) : return } - // Use ReactContext.registerSegment which works in both bridge - // and bridgeless modes. In bridge mode it delegates to - // CatalystInstance; in bridgeless mode it delegates to ReactHost. val reactContext = reactApplicationContext val segStart = System.nanoTime() - reactContext.registerSegment(segId, absolutePath) { - val segMs = (System.nanoTime() - segStart) / 1_000_000.0 - SBLLogger.info("[SplitBundle] segment $segmentKey (id=$segId) registered in ${String.format("%.1f", segMs)}ms") - promise.resolve(null) + + // PRIMARY PATH (fixes the "Requiring unknown module" race): + // Evaluate the segment OURSELVES on the JS thread via the bridgeless + // CallInvoker and resolve ONLY after eval completes. We intentionally + // do NOT use ReactContext.registerSegment here: that resolves its + // callback immediately (on Task.IMMEDIATE_EXECUTOR) while the actual + // segment eval is merely ENQUEUED onto the RuntimeScheduler, so the + // promise resolves BEFORE the segment's __d(...) module definitions + // run — Metro's import().then(() => __r(moduleId)) can then hit a + // fatal "Requiring unknown module". nativeEvaluateSegment collapses + // eval + resolve into one JS-thread block (mirrors the iOS + // callFunctionOnBufferedRuntimeExecutor: fix). + val callInvokerHolder = + reactContext.jsCallInvokerHolder as? CallInvokerHolderImpl + + if (nativeLibLoaded && callInvokerHolder != null) { + val sourceURL = File(absolutePath).name + // One-shot guard: native success/error AND the watchdog can each + // try to settle the promise; only the first wins. + val settled = AtomicBoolean(false) + + // Bounded watchdog: if the JS thread never drains to our eval, + // reject instead of hanging the JS promise forever. + val watchdog = Handler(Looper.getMainLooper()) + val timeoutRunnable = Runnable { + if (settled.compareAndSet(false, true)) { + SBLLogger.warn("[SplitBundle] segment $segmentKey (id=$segId) eval timed out after ${SEGMENT_EVAL_TIMEOUT_MS}ms") + // D: Reject with the RETRYABLE timeout code from the shared + // contract (NOT SPLIT_BUNDLE_EVAL_TIMEOUT). A wedged JS + // thread is a transient condition; using the retryable + // SPLIT_BUNDLE_TIMEOUT lets the JS loader re-attempt rather + // than caching this segment as a permanent failure. + promise.reject( + "SPLIT_BUNDLE_TIMEOUT", + "Segment eval timed out: $segmentKey (id=$segId)" + ) + } + } + watchdog.postDelayed(timeoutRunnable, SEGMENT_EVAL_TIMEOUT_MS) + + // Catch Throwable, NOT just Exception, around the native call. + // nativeEvaluateSegment is `external`: if the symbol is + // registered-but-broken (or the lib half-loaded) the JVM raises + // UnsatisfiedLinkError, which is a java.lang.Error — it would + // sail past the outer `catch (e: Exception)` and leave the JS + // promise unsettled forever (the watchdog would eventually fire, + // but only after a 30s hang). Settle exactly once here via the + // same AtomicBoolean one-shot guard and cancel the watchdog. + try { + nativeEvaluateSegment( + callInvokerHolder, + absolutePath, + sourceURL + ) { error -> + if (settled.compareAndSet(false, true)) { + watchdog.removeCallbacks(timeoutRunnable) + if (error == null) { + val segMs = (System.nanoTime() - segStart) / 1_000_000.0 + SBLLogger.info("[SplitBundle] segment $segmentKey (id=$segId) evaluated in ${String.format("%.1f", segMs)}ms (eval-complete)") + promise.resolve(null) + } else { + // The native side prefixes I/O failures (file + // read/mmap) with "IO_ERROR:" so we can map them to + // SPLIT_BUNDLE_IO_ERROR (non-retryable). Everything + // else is a segment JS/Hermes eval throw → + // SPLIT_BUNDLE_EVAL_ERROR (also non-retryable, a real + // bug in the segment's own code). + if (error.startsWith("IO_ERROR:")) { + val msg = error.removePrefix("IO_ERROR:") + SBLLogger.warn("[SplitBundle] segment $segmentKey (id=$segId) IO failed: $msg") + promise.reject("SPLIT_BUNDLE_IO_ERROR", msg) + } else { + SBLLogger.warn("[SplitBundle] segment $segmentKey (id=$segId) eval failed: $error") + promise.reject("SPLIT_BUNDLE_EVAL_ERROR", error) + } + } + } + } + } catch (t: Throwable) { + // UnsatisfiedLinkError / any native dispatch failure. The + // callback never fired (the native side never got far enough + // to invoke it), so settle the promise ourselves — exactly + // once — as a fatal, non-retryable native fault. + if (settled.compareAndSet(false, true)) { + watchdog.removeCallbacks(timeoutRunnable) + SBLLogger.error("[SplitBundle] FATAL: nativeEvaluateSegment threw for $segmentKey (id=$segId): ${t.message}") + promise.reject( + "SPLIT_BUNDLE_NATIVE_UNAVAILABLE", + "Native segment eval threw: $segmentKey (id=$segId): ${t.message}" + ) + } + } + } else { + // B: FAIL CLOSED. On this app (RN 0.81, NewArch / bridgeless), + // jsCallInvokerHolder is always a CallInvokerHolderImpl and the + // JNI lib ships in the AAR, so reaching here means a genuine + // native fault (loadLibrary failed or the holder cast failed) — + // NOT a benign legacy-bridge mode (this app never runs the old + // bridge). We deliberately do NOT fall back to the legacy + // ReactContext.registerSegment path: that resolves before eval + // and reintroduces the intermittent, uncatchable "Requiring + // unknown module" native crash. Failing closed (segment load + // unavailable → JS error boundary) is strictly better than an + // intermittent crash, so we reject with the dedicated + // NATIVE_UNAVAILABLE code (non-retryable; the JS loader will not + // hammer-retry a structurally broken native primitive). + SBLLogger.error("[SplitBundle] FATAL: native eval primitive unavailable (libLoaded=$nativeLibLoaded, holderCast=${callInvokerHolder != null}) for $segmentKey (id=$segId); failing closed instead of using the race-prone legacy registerSegment fallback") + promise.reject( + "SPLIT_BUNDLE_NATIVE_UNAVAILABLE", + "Native segment eval unavailable (libLoaded=$nativeLibLoaded, holderCast=${callInvokerHolder != null}): $segmentKey (id=$segId)" + ) } } catch (e: Exception) { promise.reject("SPLIT_BUNDLE_LOAD_ERROR", e.message, e) diff --git a/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.mm b/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.mm index a35a91fe..f5e0e42b 100644 --- a/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.mm +++ b/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.mm @@ -5,7 +5,105 @@ #import #import #import +#import #include +#include + +namespace { + +// Zero-copy jsi::Buffer over an NSData (M4/M5). +// +// WHY: the previous code did `std::string(data.bytes, data.length)` inside a +// jsi::StringBuffer — a FULL second copy of the (potentially multi-MB) segment +// bytes on top of whatever the NSData read already cost. For the hot segment +// path that doubles peak memory and adds a memcpy on the JS thread. +// +// This buffer instead RETAINS the NSData and hands jsi the NSData's own bytes +// directly. Combined with NSDataReadingMappedIfSafe at the read site, the +// segment is mmap'd and Hermes parses straight from the mapped pages — no heap +// copy at all on the happy path. The retained NSData also makes the buffer's +// lifetime explicit and self-owned: the executor block runs ASYNCHRONOUSLY +// (it's buffered until the entry bundle finishes), so the NSData MUST outlive +// the originating scope. Holding it inside the Buffer (which jsi keeps alive +// via the shared_ptr for the duration of evaluateJavaScript) guarantees that. +class NSDataJSIBuffer : public facebook::jsi::Buffer { + public: + explicit NSDataJSIBuffer(NSData *data) : data_(data) {} + size_t size() const override { return data_.length; } + const uint8_t *data() const override { + return static_cast(data_.bytes); + } + + private: + NSData *data_; // strong retain (ARC) — keeps mmap/heap bytes alive for jsi. +}; + +} // namespace + +// Exactly-once settle guard for the C1 watchdog. Wraps a BOOL behind an +// os_unfair_lock. WHY AN OBJECT (not a __block BOOL + manual free): the executor +// block and the watchdog dispatch_after BOTH capture it and BOTH may run +// (dispatch_after is not cancellable, and on a wedge the executor can run AFTER +// the watchdog). Tying the lock's lifetime to ARC — both blocks retain this +// object, it deallocs only after both release — eliminates the use-after-free a +// manual free() in either block would cause. `tryClaim` returns YES to exactly +// one caller; the loser does nothing. +@interface SBLSettleGuard : NSObject +- (BOOL)tryClaim; +@end + +@implementation SBLSettleGuard { + os_unfair_lock _lock; + BOOL _settled; +} +- (instancetype)init { + if (self = [super init]) { + _lock = OS_UNFAIR_LOCK_INIT; + _settled = NO; + } + return self; +} +- (BOOL)tryClaim { + os_unfair_lock_lock(&_lock); + BOOL won = !_settled; + if (won) { + _settled = YES; + } + os_unfair_lock_unlock(&_lock); + return won; +} +@end + +// Watchdog window for the buffered runtime executor (C1). Post-entry eval is +// sub-millisecond, so this only ever elapses on a genuine wedge (entry bundle +// never finished evaluating) — never on a healthy slow device. +// +// Fix G: 30s (matching Android and the bg-runtime watchdog). The executor stays +// buffered until the main ENTRY bundle finishes evaluating; on a slow/throttled +// cold start that entry eval can itself exceed 10s, which would falsely trip the +// watchdog on a segment load that was about to succeed. 30s keeps the +// genuine-wedge safety net while leaving headroom for slow cold starts. +static const NSTimeInterval kSegmentEvalWatchdogSeconds = 30.0; + +// NSError `code` values produced by +evaluateSegmentAtPath:... . These are +// DISTINCT (L8) so loadSegment: can map each to its own JS reject code and JS +// can classify retryable-vs-fatal: +// - HostMissing / NilInstance → SPLIT_BUNDLE_NO_RUNTIME (retryable): +// the runtime/host simply wasn't ready yet; a later attempt may succeed. +// - IvarMissing → SPLIT_BUNDLE_NATIVE_UNAVAILABLE (NOT retryable): a renamed +// ivar is a structural/build defect that no retry can fix. +// - Timeout → SPLIT_BUNDLE_TIMEOUT (retryable): buffered executor never ran. +// - IORead → SPLIT_BUNDLE_IO_ERROR: file read/mmap failed. +// - EvalThrow → SPLIT_BUNDLE_EVAL_ERROR (NOT retryable): a real bug in the +// segment's own JS/Hermes code; retrying just re-throws. +typedef NS_ENUM(NSInteger, ESegmentEvalError) { + ESegmentEvalErrorHostMissing = 1, + ESegmentEvalErrorIvarMissing = 2, + ESegmentEvalErrorNilInstance = 3, + ESegmentEvalErrorIORead = 4, + ESegmentEvalErrorEvalThrow = 5, + ESegmentEvalErrorTimeout = 6, +}; @implementation SplitBundleLoader @@ -127,30 +225,200 @@ + (nullable NSString *)otaBundlePath return result; } -// MARK: - Segment registration helper +// MARK: - RCTHost resolution helper -/// Registers a segment with the current runtime via bridgeless (RCTHost) architecture (#13). -/// -/// Thread safety (#57): This method is called from the TurboModule (JS thread). -/// No queue dispatch is needed. -+ (BOOL)registerSegment:(int)segmentId path:(NSString *)path error:(NSError **)outError +/// Resolves the bridgeless RCTHost via the AppDelegate's `reactHost` accessor +/// (New Architecture). Returns nil when the host is unavailable so callers can +/// reject gracefully. Extracted so both segment registration and the +/// evaluate-then-resolve path (#race) share one lookup. ++ (nullable RCTHost *)currentReactHost { - // Bridgeless (New Architecture): get RCTHost via AppDelegate id appDelegate = [UIApplication sharedApplication].delegate; if ([appDelegate respondsToSelector:NSSelectorFromString(@"reactHost")]) { RCTHost *host = [appDelegate performSelector:NSSelectorFromString(@"reactHost")]; - if (host && [host respondsToSelector:@selector(registerSegmentWithId:path:)]) { - [host registerSegmentWithId:@(segmentId) path:path]; - return YES; + if (host) { + return host; } } + return nil; +} + +// MARK: - Segment evaluation helper (resolve-after-eval, fixes lazy-segment race) - if (outError) { - *outError = [NSError errorWithDomain:@"SplitBundleLoader" - code:1 - userInfo:@{NSLocalizedDescriptionKey: @"RCTHost not available for segment registration"}]; +/// Evaluates a segment bundle into the CURRENT runtime and invokes `onEvaluated` +/// from INSIDE the same runtime-executor block, immediately after the segment's +/// `__d(...)` module definitions have run. +/// +/// WHY THIS EXISTS — the "Requiring unknown module" race (#race): +/// The previous implementation called `RCTHost registerSegmentWithId:path:`, +/// which routes to `ReactInstance::registerSegment` → +/// `runtimeScheduler_->scheduleWork([]{ runtime.evaluateJavaScript(segment) })`. +/// That only ENQUEUES the eval onto the runtime scheduler and returns; the +/// loadSegment promise was resolved IMMEDIATELY afterwards. Metro's +/// `import().then(() => __r(moduleId))` microtask could therefore run `__r` +/// BEFORE the scheduled eval populated the module table → a FATAL, uncatchable +/// "Requiring unknown module" inside metroRequire. +/// +/// We cannot fix this by merely scheduling `resolve` after `registerSegment` on +/// the same executor: `RuntimeScheduler_Modern::scheduleWork` pushes +/// ImmediatePriority tasks into a `std::priority_queue` keyed only on +/// `expirationTime` (RuntimeScheduler_Modern.cpp / Task.h `TaskPriorityComparer`). +/// `std::priority_queue` is NOT stable, so two same-tick tasks have UNDEFINED +/// relative order — FIFO is not guaranteed under the Modern scheduler. +/// +/// Instead we evaluate the segment OURSELVES inside a single +/// `callFunctionOnBufferedRuntimeExecutor:` block (exactly like +/// `loadEntryBundle:`) and signal completion in that SAME block. Eval and the +/// resolve are now one atomic unit of work — there is no cross-task ordering to +/// lose, so any subsequent `__r(moduleId)` is guaranteed to find the module. +/// +/// This is safe because OneKey segments are STANDALONE-EVALUATABLE Metro +/// bundles (the serializer emits `baseJSBundle`/`bundleToString` output — plain +/// top-level `__d(moduleId, factory, deps)` calls, NOT Hermes RAM/indexed +/// segments that would require the registerSegment manifest wiring). The paired +/// `.seg.hbc` is just the Hermes-compiled form of that same source, which +/// `evaluateJavaScript` runs identically to the entry bundle's `.hbc`. +/// +/// `onEvaluated` is invoked EXACTLY ONCE with nil on success or a populated +/// NSError on failure. The NSError `code` is meaningful and mapped by the caller +/// to a distinct JS reject code (see ESegmentEvalError below + loadSegment:): +/// callers use it to classify retryable (no-runtime / timeout) vs fatal +/// (eval-throw / IO) failures. ++ (void)evaluateSegmentAtPath:(NSString *)bundlePath + segmentId:(int)segmentId + segmentKey:(NSString *)segmentKey + onEvaluated:(void (^)(NSError *_Nullable error))onEvaluated +{ + RCTHost *host = [SplitBundleLoader currentReactHost]; + if (!host) { + onEvaluated([NSError errorWithDomain:@"SplitBundleLoader" + code:ESegmentEvalErrorHostMissing + userInfo:@{NSLocalizedDescriptionKey: @"RCTHost not available for segment evaluation"}]); + return; } - return NO; + + // Reach the RCTInstance the same way loadEntryBundle: does, so we can use + // the buffered runtime executor primitive (callFunctionOnBufferedRuntimeExecutor:). + Ivar ivar = class_getInstanceVariable([host class], "_instance"); + if (!ivar) { + // L7: a missing `_instance` ivar means a future RN bump renamed/removed + // the field our reflection depends on. That silently disables ALL + // segment loading, so log loudly (error, with the class name) instead of + // failing quietly — the next RN upgrade then surfaces visibly in logs. + [SBLLogger error:[NSString stringWithFormat:@"[SplitBundle] FATAL: _instance ivar not found on %@ — segment loading is DISABLED. An RN upgrade likely renamed this private field; SplitBundleLoader reflection must be updated.", [host class]]]; + onEvaluated([NSError errorWithDomain:@"SplitBundleLoader" + code:ESegmentEvalErrorIvarMissing + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"_instance ivar not found on %@", [host class]]}]); + return; + } + + RCTInstance *instance = object_getIvar(host, ivar); + if (!instance) { + onEvaluated([NSError errorWithDomain:@"SplitBundleLoader" + code:ESegmentEvalErrorNilInstance + userInfo:@{NSLocalizedDescriptionKey: @"RCTInstance is nil"}]); + return; + } + + // M4/M5: mmap the segment (NSDataReadingMappedIfSafe) instead of a full + // heap read, then wrap it zero-copy in NSDataJSIBuffer (which retains the + // NSData). Net effect: no second copy, and the bytes stay alive for the + // async executor block because the buffer owns the NSData. + NSError *readError = nil; + NSData *data = [NSData dataWithContentsOfFile:bundlePath + options:NSDataReadingMappedIfSafe + error:&readError]; + if (!data || data.length == 0) { + onEvaluated([NSError errorWithDomain:@"SplitBundleLoader" + code:ESegmentEvalErrorIORead + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to read segment at %@%@", bundlePath, readError ? [NSString stringWithFormat:@": %@", readError.localizedDescription] : @""]}]); + return; + } + + // M6: preserve a meaningful eval source URL so in-segment crash frames are + // symbolicated. RN's registerSegment used + // `JSExecutor::getSyntheticBundlePath(segmentId, segmentPath)`, which for a + // non-main segment yields `seg-.js` (see cxxreact/JSExecutor.cpp). We + // replicate that exact form so Hermes/Metro attribute frames to the segment + // the same way the native path did — using `lastPathComponent` here would + // degrade symbolication. + NSString *sourceURL = [NSString stringWithFormat:@"seg-%d.js", segmentId]; + CFAbsoluteTime dispatchStart = CFAbsoluteTimeGetCurrent(); + [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: evaluating %@ (key=%@, %lu bytes)", sourceURL, segmentKey, (unsigned long)data.length]]; + + // C1: the executor block below is BUFFERED — callFunctionOnBufferedRuntimeExecutor + // does not run it until the main entry bundle finishes evaluating + // (RCTInstance/ReactInstance.cpp). If a segment load is requested before the + // entry completes (early startup, reload teardown, host swap) and the entry + // never completes, the block NEVER runs → onEvaluated would never fire → + // the JS promise hangs forever and inflightSegments wedges with no timeout. + // + // Guard: invoke `onEvaluated` EXACTLY ONCE. SBLSettleGuard wraps a BOOL + // behind an os_unfair_lock; whichever racer wins — the executor block (happy + // path) or the watchdog timer (genuine wedge) — gets YES from tryClaim and + // settles the promise; the loser does nothing. The guard is an ARC object + // captured (retained) by BOTH blocks, so its lock outlives both with no + // manual free() and therefore no use-after-free (both blocks can run, in + // either order, on a wedge). + SBLSettleGuard *settleGuard = [[SBLSettleGuard alloc] init]; + + [instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) { + @autoreleasepool { + // If the watchdog already fired (entry took >30s then unwedged), + // the JS promise is already rejected — still evaluate the segment + // (the module table benefits) but don't double-settle. + BOOL won = [settleGuard tryClaim]; + NSError *evalError = nil; + CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent(); + try { + auto buffer = std::make_shared(data); + runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]); + double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0; + [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluated in %.1fms", sourceURL, evalMs]]; + } catch (const std::exception &e) { + // L8: a JS/Hermes eval throw is a REAL BUG in the segment's own + // code, not a transient runtime-readiness problem. Mapped to a + // NON-retryable code by the caller so JS caches it as failed. + evalError = [NSError errorWithDomain:@"SplitBundleLoader" + code:ESegmentEvalErrorEvalThrow + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment evaluation failed for %@: %s", sourceURL, e.what()]}]; + [SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluation threw: %s", sourceURL, e.what()]]; + } catch (...) { + evalError = [NSError errorWithDomain:@"SplitBundleLoader" + code:ESegmentEvalErrorEvalThrow + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment evaluation failed for %@ (unknown C++ exception)", sourceURL]}]; + [SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluation threw an unknown exception", sourceURL]]; + } + if (won) { + // Resolve/reject the JS promise from INSIDE this same block, + // strictly AFTER the segment eval above — the ordering guarantee + // that fixes the "Requiring unknown module" race (see method doc). + onEvaluated(evalError); + } else { + [SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluated AFTER watchdog already settled (entry was wedged >%.0fs)", sourceURL, kSegmentEvalWatchdogSeconds]]; + } + } + }]; + + // C1 watchdog. Fires only on a genuine wedge: in steady state the entry + // bundle is long done and the buffered block runs sub-millisecond, so the + // guard is already claimed (tryClaim returns NO) long before this elapses. + // On a real wedge it settles the promise with a distinct, RETRYABLE timeout + // error so the JS loader can re-attempt instead of hanging inflightSegments + // forever. The block retains settleGuard, so the lock stays valid even if + // the executor block later runs (entry finally evaluates). + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kSegmentEvalWatchdogSeconds * NSEC_PER_SEC)), + dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + if ([settleGuard tryClaim]) { + [SBLLogger error:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ (key=%@) WATCHDOG fired after %.0fs — runtime executor never ran (entry bundle likely never finished evaluating). Rejecting as retryable timeout.", sourceURL, segmentKey, kSegmentEvalWatchdogSeconds]]; + onEvaluated([NSError errorWithDomain:@"SplitBundleLoader" + code:ESegmentEvalErrorTimeout + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment %@ eval timed out after %.0fs (buffered runtime executor never ran)", segmentKey, kSegmentEvalWatchdogSeconds]}]); + } + }); + + double dispatchMs = (CFAbsoluteTimeGetCurrent() - dispatchStart) * 1000.0; + [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ dispatched in %.1fms (resolve fires after eval; watchdog %.0fs)", sourceURL, dispatchMs, kSegmentEvalWatchdogSeconds]]; } // MARK: - getRuntimeBundleContext @@ -280,7 +548,12 @@ + (void)loadEntryBundle:(NSString *)bundlePath inHost:(id)host return; } - NSData *data = [NSData dataWithContentsOfFile:bundlePath]; + // M5: mmap + zero-copy (NSDataJSIBuffer retains the NSData for the async block), + // mirroring the segment path. Entry bundle is the largest single read, so this + // saves the biggest single copy. + NSData *data = [NSData dataWithContentsOfFile:bundlePath + options:NSDataReadingMappedIfSafe + error:nil]; if (!data || data.length == 0) { [SBLLogger warn:[NSString stringWithFormat:@"loadEntryBundle: failed to read bundle at %@", bundlePath]]; return; @@ -293,9 +566,8 @@ + (void)loadEntryBundle:(NSString *)bundlePath inHost:(id)host [instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) { @autoreleasepool { CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent(); - auto buffer = std::make_shared( - std::string(static_cast(data.bytes), data.length)); - runtime.evaluateJavaScript(std::move(buffer), [sourceURL UTF8String]); + auto buffer = std::make_shared(data); + runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]); double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0; [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadEntryBundle: %@ evaluated in %.1fms", sourceURL, evalMs]]; } @@ -342,17 +614,62 @@ - (void)loadSegment:(double)segmentId return; } - // Register segment (#13: supports both bridge and bridgeless) - NSError *regError = nil; - if ([SplitBundleLoader registerSegment:segId path:absolutePath error:®Error]) { + // Evaluate the segment into the current runtime and resolve ONLY after + // its module definitions have actually run (#race). We intentionally do + // NOT use registerSegmentWithId: + immediate resolve here: that resolves + // before the scheduler-enqueued eval completes, so Metro's + // `import().then(() => __r(moduleId))` can hit "Requiring unknown module" + // (a fatal, uncatchable crash). See +evaluateSegmentAtPath:... doc for + // the full ordering rationale (Modern scheduler priority_queue is not + // FIFO, so we collapse eval+resolve into one runtime-executor block). + // segId is threaded through for the synthetic eval source URL (M6) and + // for log parity with the previous register flow. + [SplitBundleLoader evaluateSegmentAtPath:absolutePath + segmentId:segId + segmentKey:segmentKey + onEvaluated:^(NSError *_Nullable evalError) { + if (evalError) { + // L8: map the helper's distinct NSError code to a distinct JS + // reject code so JS can classify retryable vs fatal (see + // ESegmentEvalError + installProdBundleLoader.ts H3). + NSString *rejectCode; + switch ((ESegmentEvalError)evalError.code) { + case ESegmentEvalErrorTimeout: + rejectCode = @"SPLIT_BUNDLE_TIMEOUT"; // retryable + break; + case ESegmentEvalErrorIORead: + rejectCode = @"SPLIT_BUNDLE_IO_ERROR"; // fatal + break; + case ESegmentEvalErrorEvalThrow: + rejectCode = @"SPLIT_BUNDLE_EVAL_ERROR"; // fatal (segment bug) + break; + case ESegmentEvalErrorIvarMissing: + // Fix 2: `_instance` ivar reflection failed — STRUCTURAL/ + // PERMANENT. An RN version bump renamed/removed the + // private field our reflection depends on, so segment + // loading is disabled until the native code is updated. + // Retrying can NEVER recreate a renamed ivar, so this is + // fatal NATIVE_UNAVAILABLE — NOT retryable NO_RUNTIME. By + // contrast HostMissing / NilInstance below are genuinely + // transient (host/instance not up yet → a later attempt + // may succeed). + rejectCode = @"SPLIT_BUNDLE_NATIVE_UNAVAILABLE"; // fatal + break; + case ESegmentEvalErrorHostMissing: + case ESegmentEvalErrorNilInstance: + default: + rejectCode = @"SPLIT_BUNDLE_NO_RUNTIME"; // retryable + break; + } + reject(rejectCode, + evalError.localizedDescription ?: @"Segment evaluation failed", + evalError); + return; + } double segMs = (CFAbsoluteTimeGetCurrent() - segStart) * 1000.0; - [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] Loaded segment %@ (id=%d) in %.1fms", segmentKey, segId, segMs]]; + [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] Loaded segment %@ (id=%d) in %.1fms (eval-complete)", segmentKey, segId, segMs]]; resolve(nil); - } else { - reject(@"SPLIT_BUNDLE_NO_RUNTIME", - regError.localizedDescription ?: @"Runtime not available", - regError); - } + }]; } @catch (NSException *exception) { reject(@"SPLIT_BUNDLE_LOAD_ERROR", [NSString stringWithFormat:@"Failed to load segment %@: %@", segmentKey, exception.reason], diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index 34040312..86ed11b7 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.56", + "version": "3.0.61", "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 978b8f79..dae95ac7 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.56", + "version": "3.0.61", "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 a6366fc3..74477da6 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.56", + "version": "3.0.61", "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 686d0ab3..aa043e6f 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.56", + "version": "3.0.61", "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/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt b/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt index f30c5dce..ea128d81 100644 --- a/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt +++ b/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt @@ -123,6 +123,15 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp get() = _paramsJson set(value) { _paramsJson = value; applySource() } + // ANDROID: the host the offline `localBundle` is served under. When set, the + // offline bundle is served from https:/// so the WebView reuses the + // real chart origin's same-origin storage (zero migration). Falls back to the + // built-in appassets host in the pool when null. (iOS ignores this prop.) + private var _assetHost: String? = null + override var assetHost: String? + get() = _assetHost + set(value) { _assetHost = value; applySource() } + // Document-start bridge JS (single source of truth in the TS layer). Stored and // handed to the pooled WebView when we claim it, before its first load. private var _bridgeScript: String? = null @@ -259,7 +268,7 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp if (!bs.isNullOrEmpty()) { entry.setBridgeScript(bs) entry.warmDriver = this - entry.setSource(_uri, _localBundle, _entry, _paramsJson) + entry.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost) } if (wantsOwnership()) { entry.owner = this @@ -299,7 +308,7 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp pooled.owner = this pooled.setBridgeScript(_bridgeScript ?: "") pooled.attachTo(container) - pooled.setSource(_uri, _localBundle, _entry, _paramsJson) + pooled.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost) } // Apply the source synchronously when a source/bridge prop changes. For a @@ -315,10 +324,10 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp if (isPooled() && !bs.isNullOrEmpty()) { pooled.setBridgeScript(bs) pooled.warmDriver = this - pooled.setSource(_uri, _localBundle, _entry, _paramsJson) + pooled.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost) } else if (pooled.owner == this) { pooled.setBridgeScript(_bridgeScript ?: "") - pooled.setSource(_uri, _localBundle, _entry, _paramsJson) + pooled.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost) } } diff --git a/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt b/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt index 333d3008..25939529 100644 --- a/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt +++ b/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt @@ -25,6 +25,7 @@ import androidx.webkit.WebViewClientCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import org.json.JSONObject +import java.lang.ref.WeakReference import java.net.URLEncoder import java.util.concurrent.atomic.AtomicInteger @@ -126,12 +127,12 @@ class PooledChartWebView private constructor( } // The trusted origin set for the current source: the offline asset origin - // (https://appassets.androidplatform.net) always, plus the online/fallback - // `uri` origin when running in online mode. computeTargetUrl serves offline - // content from ASSET_HOST and online content from `uri`, so these are exactly - // the origins the privileged bridge legitimately runs in. + // (https://, default appassets.androidplatform.net) always, plus the + // online/fallback `uri` origin when running in online mode. computeTargetUrl + // serves offline content from `assetHost` and online content from `uri`, so + // these are exactly the origins the privileged bridge legitimately runs in. private fun trustedOriginsFor(uri: String?): Set { - val origins = linkedSetOf("https://$ASSET_HOST") + val origins = linkedSetOf("https://$assetHost") originOf(uri)?.let { origins.add(it) } return origins } @@ -151,21 +152,25 @@ class PooledChartWebView private constructor( } /** The host currently displaying this WebView; page events route here. */ - private var _owner: HybridChartWebview? = null + // Weak (mirror of iOS): the pool entry is immortal, so a strong ref here would + // pin a disposed host — and its ReactContext/Activity — forever. A GC'd host + // reads back as null, which makes `entry.owner == this` correctly false. + private var _ownerRef: WeakReference? = null var owner: HybridChartWebview? - get() = _owner + get() = _ownerRef?.get() set(value) { - _owner = value + _ownerRef = value?.let { WeakReference(it) } } // The host that warm-booted the page and can drive its symbol / receive its // callbacks while there is no VISIBLE owner yet. Separate from `owner` (the // YIELD path clears `owner`); callbacks fall back to this so bars-state / // load-end aren't dropped during warm. Mirror of the iOS warmDriver. - private var _warmDriver: HybridChartWebview? = null + // Weak for the same immortal-pool reason as `owner` above. + private var _warmDriverRef: WeakReference? = null var warmDriver: HybridChartWebview? - get() = _warmDriver + get() = _warmDriverRef?.get() set(value) { - _warmDriver = value + _warmDriverRef = value?.let { WeakReference(it) } } // PERF (Android only): Android's in-process WebView/Chromium does NOT throttle @@ -195,14 +200,14 @@ class PooledChartWebView private constructor( return } paused = true - android.util.Log.i(TAG, "pool[$key] PAUSE (renderer idle)") + android.util.Log.d(TAG, "pool[$key] PAUSE (renderer idle)") runOnUiThread { webView.onPause() } } fun resume() { if (!paused) return paused = false - android.util.Log.i(TAG, "pool[$key] RESUME") + android.util.Log.d(TAG, "pool[$key] RESUME") runOnUiThread { webView.onResume() webView.invalidate() @@ -212,6 +217,13 @@ class PooledChartWebView private constructor( private var assetLoader: WebViewAssetLoader? = null private var lastLoadedUrl: String? = null private var lastLocalBundle: String? = null + // The host the offline bundle is served under. Defaults to the built-in + // appassets host (old behavior); when the app passes `assetHost` we serve the + // bundle under the real chart origin so the WebView reuses that origin's + // same-origin storage (zero migration). Tracked so an assetHost change rebuilds + // the asset loader + recomputes the target URL, same as a localBundle change. + private var assetHost: String = ASSET_HOST + private var lastAssetHost: String = ASSET_HOST // Last rendered frame, used to mask the brief blank frame while the WebView's // surface is torn down and recreated during a reparent (move). Kept until @@ -226,6 +238,9 @@ class PooledChartWebView private constructor( // How long the snapshot overlay stays up after a reparent, giving the WebView // time to draw its first frame in the new container (~5 frames). private val overlayHideDelayMs = 80L + // Delay between bounded attach retries (~1 vsync at 60Hz): long enough for the + // old parent's pending layout / removeView to flush, short enough to stay snappy. + private val attachRetryDelayMs = 16L private val attachGeneration = AtomicInteger(0) val webView: WebView = WebView(context).apply { @@ -311,10 +326,18 @@ class PooledChartWebView private constructor( // hold; removeViewInLayout() is the in-layout fallback if the child is still held. private fun forceDetach(parent: ViewGroup) { if (webView.parent !== parent) return - try { parent.endViewTransition(webView) } catch (e: Throwable) {} + try { + parent.endViewTransition(webView) + } catch (e: Throwable) { + android.util.Log.w(TAG, "forceDetach: endViewTransition failed", e) + } parent.removeView(webView) if (webView.parent === parent) { - try { parent.removeViewInLayout(webView) } catch (e: Throwable) {} + try { + parent.removeViewInLayout(webView) + } catch (e: Throwable) { + android.util.Log.w(TAG, "forceDetach: removeViewInLayout failed", e) + } parent.requestLayout() } } @@ -340,14 +363,25 @@ class PooledChartWebView private constructor( // which is attached to the window so its queue keeps draining, instead of the // detached webView/old parent whose post() runnables may never run. if (retriesLeft > 0) { - container.post { + // postDelayed (not a tight container.post spin): a small delay lets the + // old parent's pending layout / removeView flush between attempts, instead + // of re-checking on the very next vsync before anything could change. + container.postDelayed({ attachToContainer(container, generation, retriesLeft = retriesLeft - 1) - } + }, attachRetryDelayMs) } else { - android.util.Log.w( - TAG, - "Skip attach key=$key after retries because WebView parent was not cleared: $currentParent", - ) + // Last resort before giving up: the old parent never released the WebView + // through the normal path. Force-detach it and try the attach once more so + // we don't leave the WebView unparented (the blank-chart symptom). + (currentParent as? ViewGroup)?.let { forceDetach(it) } + if (webView.parent == null || webView.parent === container) { + attachToContainer(container, generation, retriesLeft = 0) + } else { + android.util.Log.w( + TAG, + "Skip attach key=$key after retries because WebView parent was not cleared: $currentParent", + ) + } } return } @@ -515,13 +549,26 @@ class PooledChartWebView private constructor( * so reparenting / redundant prop re-applies never reload (which would lose * chart state, the whole point of pooling). */ - fun setSource(uri: String?, localBundle: String?, entry: String?, paramsJson: String?) { + fun setSource( + uri: String?, + localBundle: String?, + entry: String?, + paramsJson: String?, + assetHost: String?, + ) { // Never load before the document-start bridge is registered — otherwise the // page boots without the bridge and its first $private requests are lost. The // host re-calls setSource once the bridgeScript prop arrives. if (!bridgeRegistered) return - if (localBundle != lastLocalBundle) { + // Per-instance asset host: fall back to the built-in appassets host (old + // behavior) when the app doesn't pass one. Empty string is treated as absent. + // Sanitize untrusted values before they reach the privileged-bridge origin + // ("https://$assetHost") and WebViewAssetLoader.setDomain — see sanitizeAssetHost. + this.assetHost = assetHost?.takeIf { it.isNotEmpty() } + ?.let { sanitizeAssetHost(it) } ?: ASSET_HOST + if (localBundle != lastLocalBundle || this.assetHost != lastAssetHost) { lastLocalBundle = localBundle + lastAssetHost = this.assetHost rebuildAssetLoader(localBundle) } // Register the document-start bridge scoped to exactly the origin(s) this load @@ -567,14 +614,62 @@ class PooledChartWebView private constructor( } } + // Validate an incoming assetHost prop before it becomes the trusted bridge + // origin ("https://$assetHost") and WebViewAssetLoader.setDomain(assetHost). A + // malformed value (scheme, path, '/', '@'/userinfo, whitespace, port, query) + // could corrupt the privileged-origin allowlist or throw on the UI thread, so a + // candidate that is not a bare hostname falls back to the built-in ASSET_HOST. + private fun sanitizeAssetHost(candidate: String): String { + val invalid = { + android.util.Log.w( + TAG, + "Ignoring invalid assetHost '$candidate'; falling back to default $ASSET_HOST", + ) + ASSET_HOST + } + // Cheap rejects first: a bare hostname has no whitespace and no '/'. + if (candidate.any { it.isWhitespace() } || candidate.contains('/')) return invalid() + return try { + val uri = java.net.URI("https://$candidate") + val bareHost = + uri.host == candidate && + uri.path.isNullOrEmpty() && + uri.userInfo == null && + uri.query == null && + uri.port == -1 + if (bareHost) candidate else invalid() + } catch (e: Exception) { + invalid() + } + } + private fun rebuildAssetLoader(localBundle: String?) { if (localBundle.isNullOrEmpty()) { assetLoader = null return } + // Narrow the path handler to ONLY the offline bundle subtree + // (//, e.g. /tradingview-assets/) instead of intercepting all of + // "/". When the bundle is served under the real chart origin (`assetHost`), + // intercepting "/" would shadow every web path on that domain; with the narrow + // handler, unmatched paths fall through to the network — so an `assetHost` + // that is a real public domain keeps behaving normally off-bundle. + // computeTargetUrl produces https:////, which + // stays inside this handler's path. (Path built as // so a + // localBundle that already has a trailing slash doesn't double it.) + val bundleDir = localBundle.trim('/') + val bundlePath = "/$bundleDir/" + // WebViewAssetLoader strips the registered prefix before calling the handler, + // so AssetsPathHandler alone would resolve the remainder against the assets + // ROOT (assets/) and lose the bundle namespace. Re-prepend / + // so we still serve from assets//. Returning null on a + // miss lets WebViewAssetLoader fall through to the network. + val assets = WebViewAssetLoader.AssetsPathHandler(webView.context) + val namespacedHandler = + WebViewAssetLoader.PathHandler { suffix -> assets.handle("$bundleDir/$suffix") } assetLoader = WebViewAssetLoader.Builder() - .setDomain(ASSET_HOST) - .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context)) + .setDomain(assetHost) + .addPathHandler(bundlePath, namespacedHandler) .build() } @@ -588,15 +683,21 @@ class PooledChartWebView private constructor( if (localBundle.isNullOrEmpty()) return null val entryPath = entry?.takeIf { it.isNotEmpty() } ?: DEFAULT_ENTRY val query = buildQueryFromParamsJson(paramsJson) + // Normalize the bundle dir the SAME way rebuildAssetLoader does (trim '/'), so + // the URL has exactly single slashes between host, bundle dir and entry. A raw + // localBundle like "/tradingview-assets/" would otherwise yield + // https://host//tradingview-assets//index.html, which misses the registered + // handler prefix → falls through to the network → blank chart. + val bundleDir = localBundle.trim('/') return buildString { append("https://") - append(ASSET_HOST) + append(assetHost) append('/') // Serve the offline bundle from assets// (namespaced) rather // than the assets root, so it doesn't collide with other bundled assets // (e.g. web-embed). The dist uses relative asset paths (PUBLIC_URL='./'), // so loading the entry from a subpath resolves the rest correctly. - append(localBundle) + append(bundleDir) append('/') append(entryPath) if (query.isNotEmpty()) { diff --git a/native-views/react-native-chart-webview/ios/ChartWebview.swift b/native-views/react-native-chart-webview/ios/ChartWebview.swift index bcd0423d..06804c2a 100644 --- a/native-views/react-native-chart-webview/ios/ChartWebview.swift +++ b/native-views/react-native-chart-webview/ios/ChartWebview.swift @@ -129,6 +129,11 @@ class HybridChartWebview: HybridChartWebviewSpec { var localBundle: String? { didSet { applySource() } } var entry: String? { didSet { applySource() } } var paramsJson: String? { didSet { applySource() } } + // ANDROID ONLY. iOS serves offline content from the onekey-chart:// custom + // scheme (https can't be intercepted here), so this host stores the prop to + // satisfy the generated spec but never reads it — offline origin/behavior is + // unchanged on iOS. + var assetHost: String? // Document-start bridge JS (single source of truth in the TS layer). Handed to // the pooled WebView when we claim it, before its first load. diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 2ad67c43..611537f3 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.56", + "version": "3.0.61", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/src/ChartWebview.nitro.ts b/native-views/react-native-chart-webview/src/ChartWebview.nitro.ts index 508537f2..7732eed4 100644 --- a/native-views/react-native-chart-webview/src/ChartWebview.nitro.ts +++ b/native-views/react-native-chart-webview/src/ChartWebview.nitro.ts @@ -20,6 +20,14 @@ export interface ChartWebviewProps extends HybridViewProps { // '{"symbol":"BTC","type":"market","theme":"dark"}'). Kept as a string so the // native side stays free of a structured-prop dependency. paramsJson?: string; + // ANDROID ONLY: the host the offline `localBundle` is served under, so Android + // can reuse the real TradingView origin (e.g. "tradingview.onekey.so") and read + // the existing same-origin WebView storage with zero migration. When omitted, + // Android falls back to the built-in "appassets.androidplatform.net" host + // (identical to the old behavior). IGNORED on iOS, which always serves offline + // content from the onekey-chart:// custom scheme (https can't be intercepted + // there). + assetHost?: string; // JS injected into the page at document-start (before any page JS), wiring the // page's outbound channels to the native transport. Single source of truth in diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 7831366d..d5af0151 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.56", + "version": "3.0.61", "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 c988d25c..709ff6fe 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.56", + "version": "3.0.61", "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 cc2b585a..31461c65 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.56", + "version": "3.0.61", "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 7132e33a..19c43ebe 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.56", + "version": "3.0.61", "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 80a44605..6070bbaf 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.56", + "version": "3.0.61", "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 d3878b3e..54afdf4d 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.56", + "version": "3.0.61", "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 727cd033..266ebb7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2744,28 +2744,38 @@ __metadata: "@babel/core": "npm:^7.25.2" "@babel/preset-env": "npm:^7.25.3" "@babel/runtime": "npm:^7.25.0" + "@onekeyfe/react-native-aes-crypto": "workspace:*" "@onekeyfe/react-native-app-update": "workspace:*" + "@onekeyfe/react-native-async-storage": "workspace:*" "@onekeyfe/react-native-auto-size-input": "workspace:*" "@onekeyfe/react-native-background-thread": "workspace:*" "@onekeyfe/react-native-bundle-crypto": "workspace:*" "@onekeyfe/react-native-bundle-update": "workspace:*" "@onekeyfe/react-native-chart-webview": "workspace:*" "@onekeyfe/react-native-check-biometric-auth-changed": "workspace:*" + "@onekeyfe/react-native-cloud-fs": "workspace:*" "@onekeyfe/react-native-cloud-kit-module": "workspace:*" "@onekeyfe/react-native-device-utils": "workspace:*" + "@onekeyfe/react-native-dns-lookup": "workspace:*" "@onekeyfe/react-native-get-random-values": "workspace:*" "@onekeyfe/react-native-keychain-module": "workspace:*" "@onekeyfe/react-native-lite-card": "workspace:*" "@onekeyfe/react-native-native-logger": "workspace:*" + "@onekeyfe/react-native-network-info": "workspace:*" "@onekeyfe/react-native-pager-view": "workspace:*" + "@onekeyfe/react-native-pbkdf2": "workspace:*" "@onekeyfe/react-native-perf-memory": "workspace:*" + "@onekeyfe/react-native-perf-stats": "workspace:*" "@onekeyfe/react-native-perp-depth-bar": "workspace:*" + "@onekeyfe/react-native-ping": "workspace:*" "@onekeyfe/react-native-range-downloader": "workspace:*" "@onekeyfe/react-native-scroll-guard": "workspace:*" "@onekeyfe/react-native-segment-slider": "workspace:*" "@onekeyfe/react-native-skeleton": "workspace:*" "@onekeyfe/react-native-splash-screen": "workspace:*" + "@onekeyfe/react-native-split-bundle-loader": "workspace:*" "@onekeyfe/react-native-tab-view": "workspace:*" + "@onekeyfe/react-native-tcp-socket": "workspace:*" "@onekeyfe/react-native-zip-archive": "workspace:*" "@react-native-community/cli": "npm:20.0.0" "@react-native-community/cli-platform-android": "npm:20.0.0" @@ -2832,7 +2842,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-aes-crypto@workspace:native-modules/react-native-aes-crypto": +"@onekeyfe/react-native-aes-crypto@workspace:*, @onekeyfe/react-native-aes-crypto@workspace:native-modules/react-native-aes-crypto": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-aes-crypto@workspace:native-modules/react-native-aes-crypto" dependencies: @@ -2901,7 +2911,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-async-storage@workspace:native-modules/react-native-async-storage": +"@onekeyfe/react-native-async-storage@workspace:*, @onekeyfe/react-native-async-storage@workspace:native-modules/react-native-async-storage": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-async-storage@workspace:native-modules/react-native-async-storage" dependencies: @@ -3149,7 +3159,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-cloud-fs@workspace:native-modules/react-native-cloud-fs": +"@onekeyfe/react-native-cloud-fs@workspace:*, @onekeyfe/react-native-cloud-fs@workspace:native-modules/react-native-cloud-fs": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-cloud-fs@workspace:native-modules/react-native-cloud-fs" dependencies: @@ -3236,7 +3246,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-dns-lookup@workspace:native-modules/react-native-dns-lookup": +"@onekeyfe/react-native-dns-lookup@workspace:*, @onekeyfe/react-native-dns-lookup@workspace:native-modules/react-native-dns-lookup": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-dns-lookup@workspace:native-modules/react-native-dns-lookup" dependencies: @@ -3410,7 +3420,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-network-info@workspace:native-modules/react-native-network-info": +"@onekeyfe/react-native-network-info@workspace:*, @onekeyfe/react-native-network-info@workspace:native-modules/react-native-network-info": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-network-info@workspace:native-modules/react-native-network-info" dependencies: @@ -3458,7 +3468,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-pbkdf2@workspace:native-modules/react-native-pbkdf2": +"@onekeyfe/react-native-pbkdf2@workspace:*, @onekeyfe/react-native-pbkdf2@workspace:native-modules/react-native-pbkdf2": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-pbkdf2@workspace:native-modules/react-native-pbkdf2" dependencies: @@ -3527,7 +3537,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-perf-stats@workspace:native-modules/react-native-perf-stats": +"@onekeyfe/react-native-perf-stats@workspace:*, @onekeyfe/react-native-perf-stats@workspace:native-modules/react-native-perf-stats": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-perf-stats@workspace:native-modules/react-native-perf-stats" dependencies: @@ -3599,7 +3609,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-ping@workspace:native-modules/react-native-ping": +"@onekeyfe/react-native-ping@workspace:*, @onekeyfe/react-native-ping@workspace:native-modules/react-native-ping": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-ping@workspace:native-modules/react-native-ping" dependencies: @@ -3777,7 +3787,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-split-bundle-loader@workspace:native-modules/react-native-split-bundle-loader": +"@onekeyfe/react-native-split-bundle-loader@workspace:*, @onekeyfe/react-native-split-bundle-loader@workspace:native-modules/react-native-split-bundle-loader": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-split-bundle-loader@workspace:native-modules/react-native-split-bundle-loader" dependencies: @@ -3829,7 +3839,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-tcp-socket@workspace:native-modules/react-native-tcp-socket": +"@onekeyfe/react-native-tcp-socket@workspace:*, @onekeyfe/react-native-tcp-socket@workspace:native-modules/react-native-tcp-socket": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-tcp-socket@workspace:native-modules/react-native-tcp-socket" dependencies: