diff --git a/example/react-native/ios/Podfile b/example/react-native/ios/Podfile
index 6b191e01..3b73561b 100644
--- a/example/react-native/ios/Podfile
+++ b/example/react-native/ios/Podfile
@@ -1,3 +1,8 @@
+# Spec sources. The default CDN plus Aliyun's repo, which hosts EMASCurl
+# (a transitive native dependency of @onekeyfe/react-native-sni-connect).
+source 'https://github.com/CocoaPods/Specs.git'
+source 'https://github.com/aliyun/aliyun-specs.git'
+
# Resolve react_native_pods.rb with node to allow for hoisting
ENV['RCT_NEW_ARCH_ENABLED'] = '1'
@@ -27,6 +32,10 @@ target 'example' do
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
+ # Enable modular headers for EMASCurl so SniConnect's Swift `import EMASCurl`
+ # works when building as static libraries (matches app-monorepo).
+ pod 'EMASCurl', :modular_headers => true
+
# Copy optional offline TradingView chart material into the app bundle as
# `tradingview-assets/`, preserving the
# directory tree so react-native-chart-webview's WKURLSchemeHandler can serve it.
diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock
index 92b4b7eb..e5d3a9cc 100644
--- a/example/react-native/ios/Podfile.lock
+++ b/example/react-native/ios/Podfile.lock
@@ -1,5 +1,5 @@
PODS:
- - AesCrypto (3.0.61):
+ - AesCrypto (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -27,7 +27,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - AsyncStorage (3.0.61):
+ - AsyncStorage (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -55,7 +55,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - AutoSizeInput (3.0.61):
+ - AutoSizeInput (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -85,7 +85,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - BackgroundThread (3.0.61):
+ - BackgroundThread (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -115,7 +115,7 @@ PODS:
- SocketRocket
- Yoga
- boost (1.84.0)
- - ChartWebview (3.0.61):
+ - ChartWebview (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -146,7 +146,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - CloudFs (3.0.61):
+ - CloudFs (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -174,7 +174,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - CloudKitModule (3.0.61):
+ - CloudKitModule (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -208,7 +208,7 @@ PODS:
- CocoaLumberjack/Core (3.9.0)
- CocoaLumberjack/Swift (3.9.0):
- CocoaLumberjack/Core
- - DnsLookup (3.0.61):
+ - DnsLookup (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -237,6 +237,9 @@ PODS:
- SocketRocket
- Yoga
- DoubleConversion (1.1.6)
+ - EMASCurl (1.5.5):
+ - EMASCurl/HTTP2 (= 1.5.5)
+ - EMASCurl/HTTP2 (1.5.5)
- fast_float (8.0.0)
- FBLazyVector (0.83.0)
- fmt (12.1.0)
@@ -244,7 +247,7 @@ PODS:
- hermes-engine (0.14.0):
- hermes-engine/Pre-built (= 0.14.0)
- hermes-engine/Pre-built (0.14.0)
- - KeychainModule (3.0.61):
+ - KeychainModule (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -278,7 +281,7 @@ PODS:
- MMKV (2.2.4):
- MMKVCore (~> 2.2.4)
- MMKVCore (2.2.4)
- - NetworkInfo (3.0.61):
+ - NetworkInfo (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -366,7 +369,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - Pbkdf2 (3.0.61):
+ - Pbkdf2 (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -394,7 +397,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - PerpDepthBar (3.0.61):
+ - PerpDepthBar (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -424,7 +427,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - Ping (3.0.61):
+ - Ping (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -494,6 +497,7 @@ PODS:
- React-RCTText (= 0.83.0)
- React-RCTVibration (= 0.83.0)
- React-callinvoker (0.83.0)
+ - React-Codegen (0.1.0)
- React-Core (0.83.0):
- boost
- DoubleConversion
@@ -2336,7 +2340,7 @@ PODS:
- React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core
- SocketRocket
- - react-native-pager-view (3.0.61):
+ - react-native-pager-view (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -2451,7 +2455,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - react-native-tab-view (3.0.61):
+ - react-native-tab-view (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -2469,7 +2473,7 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- - react-native-tab-view/common (= 3.0.61)
+ - react-native-tab-view/common (= 3.0.66)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
@@ -2480,7 +2484,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - react-native-tab-view/common (3.0.61):
+ - react-native-tab-view/common (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3065,7 +3069,7 @@ PODS:
- React-perflogger (= 0.83.0)
- React-utils (= 0.83.0)
- SocketRocket
- - ReactNativeAppUpdate (3.0.61):
+ - ReactNativeAppUpdate (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3096,7 +3100,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativeBundleCrypto (3.0.61):
+ - ReactNativeBundleCrypto (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3127,7 +3131,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativeBundleUpdate (3.0.61):
+ - ReactNativeBundleUpdate (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3162,7 +3166,7 @@ PODS:
- SocketRocket
- SSZipArchive (>= 2.5.4)
- Yoga
- - ReactNativeCheckBiometricAuthChanged (3.0.61):
+ - ReactNativeCheckBiometricAuthChanged (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3193,7 +3197,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativeDeviceUtils (3.0.61):
+ - ReactNativeDeviceUtils (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3224,7 +3228,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativeGetRandomValues (3.0.61):
+ - ReactNativeGetRandomValues (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3255,7 +3259,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativeLiteCard (3.0.61):
+ - ReactNativeLiteCard (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3284,7 +3288,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativeNativeLogger (3.0.61):
+ - ReactNativeNativeLogger (3.0.66):
- boost
- CocoaLumberjack/Swift (~> 3.8)
- DoubleConversion
@@ -3315,7 +3319,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - ReactNativePerfMemory (3.0.61):
+ - ReactNativePerfMemory (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3346,7 +3350,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativePerfStats (3.0.61):
+ - ReactNativePerfStats (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3377,7 +3381,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativeRangeDownloader (3.0.61):
+ - ReactNativeRangeDownloader (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3408,7 +3412,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativeSplashScreen (3.0.61):
+ - ReactNativeSplashScreen (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3439,7 +3443,7 @@ PODS:
- ReactNativeNativeLogger
- SocketRocket
- Yoga
- - ReactNativeZipArchive (3.0.61):
+ - ReactNativeZipArchive (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3528,7 +3532,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - ScrollGuard (3.0.61):
+ - ScrollGuard (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3558,7 +3562,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - SegmentSlider (3.0.61):
+ - SegmentSlider (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3588,7 +3592,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - Skeleton (3.0.61):
+ - Skeleton (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3618,8 +3622,39 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
+ - SniConnect (3.0.66):
+ - boost
+ - DoubleConversion
+ - EMASCurl (= 1.5.5)
+ - fast_float
+ - fmt
+ - glog
+ - hermes-engine
+ - RCT-Folly
+ - RCT-Folly/Fabric
+ - RCTRequired
+ - RCTTypeSafety
+ - React-callinvoker
+ - React-Codegen
+ - 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
- SocketRocket (0.7.1)
- - SplitBundleLoader (3.0.61):
+ - SplitBundleLoader (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3649,7 +3684,7 @@ PODS:
- SocketRocket
- Yoga
- SSZipArchive (2.6.0)
- - TcpSocket (3.0.61):
+ - TcpSocket (3.0.66):
- boost
- DoubleConversion
- fast_float
@@ -3690,6 +3725,7 @@ DEPENDENCIES:
- "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`)
+ - EMASCurl
- fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
- FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
- fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`)
@@ -3792,12 +3828,17 @@ DEPENDENCIES:
- "ScrollGuard (from `../../../node_modules/@onekeyfe/react-native-scroll-guard`)"
- "SegmentSlider (from `../../../node_modules/@onekeyfe/react-native-segment-slider`)"
- "Skeleton (from `../../../node_modules/@onekeyfe/react-native-skeleton`)"
+ - "SniConnect (from `../../../node_modules/@onekeyfe/react-native-sni-connect`)"
- 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:
+ https://github.com/aliyun/aliyun-specs.git:
+ - EMASCurl
+ https://github.com/CocoaPods/Specs.git:
+ - React-Codegen
trunk:
- CocoaLumberjack
- MMKV
@@ -4029,6 +4070,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/@onekeyfe/react-native-segment-slider"
Skeleton:
:path: "../../../node_modules/@onekeyfe/react-native-skeleton"
+ SniConnect:
+ :path: "../../../node_modules/@onekeyfe/react-native-sni-connect"
SplitBundleLoader:
:path: "../../../node_modules/@onekeyfe/react-native-split-bundle-loader"
TcpSocket:
@@ -4037,31 +4080,32 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
- AesCrypto: 5d79d012eda0c688e81fb1e5efb66697a8bf63b2
- AsyncStorage: d36fdf626a5daea9ab7ddf147743f91cb8fc47b2
- AutoSizeInput: 46d8edf6010443fdca7dc3a69bb44553780b1155
- BackgroundThread: 28c77551040ecc7a70dabf132b840f7b6b1cb7ef
+ AesCrypto: c1bf211cf41135c255df481b6474620d90944a61
+ AsyncStorage: 2521eaa9e267dbb236912e6203031780054f2fac
+ AutoSizeInput: 935e5589d4643a686314474588a2f884cd79fa03
+ BackgroundThread: f57ed61f48fc67af75249053cf74a391c0a4933d
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
- ChartWebview: df5cfa93f2fff737382f08041ba8034932266674
- CloudFs: 7d28ea2cc410fcae83fa573f4d5c313f3203352d
- CloudKitModule: 9f6c25469fa12adbcf55b0f9ec9a26bb489978c6
+ ChartWebview: cbd9d05147be896535d76c76a2ace1bc1054963b
+ CloudFs: 2690dc61a62394b67992678efd6ca1d3a502d61f
+ CloudKitModule: 4861dcf5360d70f327c11cdd8373e2c368b648f1
CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a
- DnsLookup: 2b92e6ee2a0925934097334e7024214ee39f3fd0
+ DnsLookup: 341981fbc4687d23e0c02f9749f00b638f0e4786
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
+ EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
FBLazyVector: a293a88992c4c33f0aee184acab0b64a08ff9458
fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: 70fdc9d0bb0d8532e0411dcb21e53ce5a160960a
- KeychainModule: 91e5f89dfcc8981beb4ee8ad1480eed76ff1d96a
+ KeychainModule: 8f41a100d9d34335eaf2d4792ba2956ce88866e5
MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
- NetworkInfo: aff4df0ccff078e448ad3da1c5620e7285f129fc
+ NetworkInfo: aa0fa012d7fd9dc5aa4e4cc35b89322207f59ad8
NitroMmkv: 0be91455465952f2b943f753b9ee7df028d89e5c
NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3
- Pbkdf2: 723893ae9eaa320168c62cb825a4bcef89703894
- PerpDepthBar: ce30834568bd8b9ea933267437defb431c6c576d
- Ping: 8f45505d4f773a5f6348852679e2801e1bf052ac
+ Pbkdf2: 39cdd7f31a7936f70addc722a75b99fde194f801
+ PerpDepthBar: 121bdfc2bf3e388610a71c831a78214f1455078b
+ Ping: adffa990ffb4060dbe4754405124d6df45b2458d
RCT-Folly: b29feb752b08042c62badaef7d453f3bb5e6ae23
RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36
RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81
@@ -4070,6 +4114,7 @@ SPEC CHECKSUMS:
RCTTypeSafety: 6359ff3fcbe18c52059f4d4ce301e47f9da5f0d5
React: f6f8fc5c01e77349cdfaf49102bcb928ac31d8ed
React-callinvoker: 032b6d1d03654b9fb7de9e2b3b978d3cb1a893ad
+ React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a
React-Core: 418c9278f8a071b44a88a87be9a4943234cc2e77
React-CoreModules: 925b8cb677649f967f6000f9b1ef74dc4ff60c30
React-cxxreact: 21f6f0cb2a7d26fbed4d09e04482e5c75662beaf
@@ -4098,9 +4143,9 @@ SPEC CHECKSUMS:
React-logger: 9e597cbeda7b8cc8aa8fb93860dade97190f69cc
React-Mapbuffer: 20046c0447efaa7aace0b76085aa9bb35b0e8105
React-microtasksnativemodule: 0e837de56519c92d8a2e3097717df9497feb33cb
- react-native-pager-view: 5bde74d01c86137851dddc589a91566e5fa2a490
+ react-native-pager-view: 7f97a6caeec2a8c8abd37e6bd2d216dae14c7a68
react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
- react-native-tab-view: 84fd866023d2753d09cdb1dd62cbe086fb39ccc7
+ react-native-tab-view: 843a641740637e3c076e6343e1261394db417e8b
React-NativeModulesApple: 1a378198515f8e825c5931a7613e98da69320cee
React-networking: bfd1695ada5a57023006ce05823ac5391c3ce072
React-oscompat: aedc0afbded67280de6bb6bfac8cfde0389e2b33
@@ -4134,29 +4179,30 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568
ReactCodegen: 554b421c45b7df35ac791da1b734335470b55fcc
ReactCommon: 424cc34cf5055d69a3dcf02f3436481afb8b0f6f
- 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
+ ReactNativeAppUpdate: 1eac55fd1fa44816bb68d42a7f2e333880ac3ddb
+ ReactNativeBundleCrypto: c9f74b20cf635cbd534edee13a65fb55f6815359
+ ReactNativeBundleUpdate: 573f062683f448a72c38672d69aa9781e7d48b27
+ ReactNativeCheckBiometricAuthChanged: 3d280d3594c1327ee5fe4a5d6f6ef59fecf46375
+ ReactNativeDeviceUtils: 3da5317e60cc777c3aa47fc746be8c931631be60
+ ReactNativeGetRandomValues: 950ff0d769f9260000caffa18681e9aad092b9e2
+ ReactNativeLiteCard: 23ea28539156c3270a7fd277ce02f4a26e0dabde
+ ReactNativeNativeLogger: 6ccaed6def6d2e1b52fef0901f34e79dcb6436f5
+ ReactNativePerfMemory: d38d0ba81f149400311636dd9ea98891e1be2235
+ ReactNativePerfStats: ad2c7ab56937a34123dd8168bd57e2ee093a97bd
+ ReactNativeRangeDownloader: 5b55d8eb35eeaa1af87f1e9eb2c55fb69b03c22a
+ ReactNativeSplashScreen: 0b1cb4fbd97604af746248145f2e820911d75369
+ ReactNativeZipArchive: fb1928a0ae11a523e42857cbf55ce8ccb89c6318
RNScreens: 7f643ee0fd1407dc5085c7795460bd93da113b8f
- ScrollGuard: 61f6774ecb5ac6ccd5fbeb34830a117ab3e2d4f0
- SegmentSlider: 295a0ade25855058c11d2b185ad13762b1f4a166
- Skeleton: 07da4b794e0e2b58bd75ba820d0c2e6e87c9225e
+ ScrollGuard: b6fa0a7cc51ade6238cc0ad94e59b59b261ffb06
+ SegmentSlider: 4382a67176bed36e68fcbf263683ac12f3f904d8
+ Skeleton: 1ca94119dfd1d0fe3efd2fecda43c13a78a76be8
+ SniConnect: 7c2ace3603235ac8d4eabfa3cb9dde5ee2a5e7ec
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
- SplitBundleLoader: 8421ede8cbc7f15a7608b59b126fcfe22aca5ccc
+ SplitBundleLoader: dff61bc974752e162221f99fe25cf3ec0ad35566
SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea
- TcpSocket: bd4063e4dda0fb7f6952376592e3efdeac915710
+ TcpSocket: 4bbe12c84aa8c6513106bf4c9f462c060c6b5aa3
Yoga: 6ca93c8c13f56baeec55eb608577619b17a4d64e
-PODFILE CHECKSUM: 11e5274bb8ec6380d5fbde829e700fe4f0cf9c8a
+PODFILE CHECKSUM: 7dbc28b8923a22b4d9fcd35e5fe10389c4fccc09
COCOAPODS: 1.16.2
diff --git a/example/react-native/package.json b/example/react-native/package.json
index cf10c522..248215d6 100644
--- a/example/react-native/package.json
+++ b/example/react-native/package.json
@@ -39,6 +39,7 @@
"@onekeyfe/react-native-scroll-guard": "workspace:*",
"@onekeyfe/react-native-segment-slider": "workspace:*",
"@onekeyfe/react-native-skeleton": "workspace:*",
+ "@onekeyfe/react-native-sni-connect": "workspace:*",
"@onekeyfe/react-native-splash-screen": "workspace:*",
"@onekeyfe/react-native-split-bundle-loader": "workspace:*",
"@onekeyfe/react-native-tab-view": "workspace:*",
diff --git a/native-modules/react-native-sni-connect/README.md b/native-modules/react-native-sni-connect/README.md
new file mode 100644
index 00000000..099779c5
--- /dev/null
+++ b/native-modules/react-native-sni-connect/README.md
@@ -0,0 +1,62 @@
+# @onekeyfe/react-native-sni-connect
+
+OneKey SNI HTTP client for React Native. Performs HTTPS requests to a caller-supplied
+IP address while preserving the original TLS SNI / `Host` of a hostname, so certificate
+chain and hostname verification are still enforced against the real hostname (not the IP).
+
+Backed by EMASCurl (libcurl) on iOS and OkHttp on Android.
+
+## Installation
+
+This package is published as part of the OneKey `app-modules` workspace:
+
+```sh
+yarn add @onekeyfe/react-native-sni-connect
+```
+
+iOS: run `pod install`. Android autolinks.
+
+## Usage
+
+```ts
+import {
+ request,
+ cancelRequest,
+ cancelAllRequests,
+ clearDNSCache,
+} from '@onekeyfe/react-native-sni-connect';
+
+const res = await request({
+ // requestId is optional; required only if you want to cancel the request.
+ requestId: 'health-check-1',
+ ip: '93.184.216.34', // must be a public IP literal (private/loopback/metadata are rejected)
+ hostname: 'example.com', // used for SNI, Host header and certificate validation
+ method: 'GET',
+ path: '/api/v1/ping', // relative path only — absolute URLs are rejected
+ headers: { 'Content-Type': 'application/json' },
+ timeout: 30_000,
+});
+
+console.log(res.status, res.headers, res.data);
+
+// Cancellation (requires requestId on the request)
+await cancelRequest('health-check-1');
+await cancelAllRequests();
+
+// Drop pinned-IP connections / cached clients
+await clearDNSCache();
+```
+
+### Security notes
+
+- The request scheme is always `https` on port `443`; `path` cannot override scheme, host or port.
+- `ip` must be an IPv4/IPv6 literal that routes to a public destination; loopback, private,
+ link-local (incl. cloud metadata), CGNAT, multicast and reserved ranges are rejected.
+- Header names/values containing CR/LF/control characters are rejected; the `Host` header is
+ managed by the module.
+- Native logs go through OneKey's native logger (with sensitive-data redaction); there is no
+ JS log event channel.
+
+## License
+
+MIT
diff --git a/native-modules/react-native-sni-connect/SniConnect.podspec b/native-modules/react-native-sni-connect/SniConnect.podspec
new file mode 100644
index 00000000..8ff6dbf0
--- /dev/null
+++ b/native-modules/react-native-sni-connect/SniConnect.podspec
@@ -0,0 +1,36 @@
+require "json"
+
+package = JSON.parse(File.read(File.join(__dir__, "package.json")))
+
+Pod::Spec.new do |s|
+ s.name = "SniConnect"
+ s.version = package["version"]
+ s.summary = package["description"]
+ s.homepage = package["homepage"]
+ s.license = package["license"]
+ s.authors = package["author"]
+
+ s.platforms = { :ios => min_ios_version_supported }
+ s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect.git", :tag => "#{s.version}" }
+
+ s.static_framework = true
+
+ s.source_files = [
+ "ios/**/*.{swift}",
+ "ios/**/*.{m,mm}"
+ ]
+
+ s.dependency 'React-Core'
+ s.dependency 'React-jsi'
+ s.dependency 'React-callinvoker'
+ s.dependency 'React-Codegen'
+ s.dependency 'EMASCurl', '1.5.5'
+
+ s.pod_target_xcconfig = {
+ "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/Headers/Private/React-Core\"",
+ "DEFINES_MODULE" => "YES"
+ }
+
+
+ install_modules_dependencies(s)
+end
diff --git a/native-modules/react-native-sni-connect/android/build.gradle b/native-modules/react-native-sni-connect/android/build.gradle
new file mode 100644
index 00000000..efd08421
--- /dev/null
+++ b/native-modules/react-native-sni-connect/android/build.gradle
@@ -0,0 +1,68 @@
+buildscript {
+ ext.getExtOrDefault = {name ->
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['SniConnect_' + name]
+ }
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath "com.android.tools.build:gradle:8.7.2"
+ // noinspection DifferentKotlinGradleVersion
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
+ }
+}
+
+
+apply plugin: "com.android.library"
+apply plugin: "kotlin-android"
+apply plugin: "com.facebook.react"
+
+def getExtOrIntegerDefault(name) {
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["SniConnect_" + name]).toInteger()
+}
+
+android {
+ namespace "com.sniconnect"
+
+ compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
+
+ defaultConfig {
+ minSdkVersion getExtOrIntegerDefault("minSdkVersion")
+ targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
+ }
+
+ buildFeatures {
+ buildConfig true
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+
+ lintOptions {
+ disable "GradleCompatible"
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+repositories {
+ mavenCentral()
+ google()
+}
+
+def kotlin_version = getExtOrDefault("kotlinVersion")
+
+dependencies {
+ implementation "com.facebook.react:react-android"
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ implementation "com.squareup.okhttp3:okhttp:4.12.0"
+}
diff --git a/native-modules/react-native-sni-connect/android/gradle.properties b/native-modules/react-native-sni-connect/android/gradle.properties
new file mode 100644
index 00000000..6480e036
--- /dev/null
+++ b/native-modules/react-native-sni-connect/android/gradle.properties
@@ -0,0 +1,5 @@
+SniConnect_kotlinVersion=2.0.21
+SniConnect_minSdkVersion=24
+SniConnect_targetSdkVersion=34
+SniConnect_compileSdkVersion=35
+SniConnect_ndkVersion=27.1.12297006
diff --git a/native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml b/native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a2f47b60
--- /dev/null
+++ b/native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt
new file mode 100644
index 00000000..d4489394
--- /dev/null
+++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt
@@ -0,0 +1,55 @@
+package com.sniconnect
+
+/**
+ * Lightweight logging wrapper that dynamically dispatches to OneKeyLog.
+ * Uses reflection to avoid a hard dependency on the (nitro-based) native-logger
+ * module. Falls back to android.util.Log when OneKeyLog is not available.
+ *
+ * Mirrors iOS SniConnectLog and the existing BTLogger / SBLLogger.
+ */
+internal object SniConnectLogger {
+ private const val TAG = "SniConnect"
+
+ private val logClass: Class<*>? by lazy {
+ try {
+ Class.forName("com.margelo.nitro.nativelogger.OneKeyLog")
+ } catch (_: ClassNotFoundException) {
+ null
+ }
+ }
+
+ private val methods by lazy {
+ val cls = logClass ?: return@lazy null
+ mapOf(
+ "debug" to cls.getMethod("debug", String::class.java, String::class.java),
+ "info" to cls.getMethod("info", String::class.java, String::class.java),
+ "warn" to cls.getMethod("warn", String::class.java, String::class.java),
+ "error" to cls.getMethod("error", String::class.java, String::class.java),
+ )
+ }
+
+ @JvmStatic
+ fun debug(message: String) = log("debug", message, android.util.Log.DEBUG)
+
+ @JvmStatic
+ fun info(message: String) = log("info", message, android.util.Log.INFO)
+
+ @JvmStatic
+ fun warn(message: String) = log("warn", message, android.util.Log.WARN)
+
+ @JvmStatic
+ fun error(message: String) = log("error", message, android.util.Log.ERROR)
+
+ private fun log(level: String, message: String, androidLogLevel: Int) {
+ val method = methods?.get(level)
+ if (method != null) {
+ try {
+ method.invoke(null, TAG, message)
+ return
+ } catch (_: Exception) {
+ // Fall through to android.util.Log
+ }
+ }
+ android.util.Log.println(androidLogLevel, TAG, message)
+ }
+}
diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt
new file mode 100644
index 00000000..a86da5da
--- /dev/null
+++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt
@@ -0,0 +1,341 @@
+package com.sniconnect
+
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.WritableMap
+import com.facebook.react.module.annotations.ReactModule
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.ConnectionPool
+import okhttp3.Dispatcher
+import okhttp3.Dns
+import okhttp3.Headers
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import okhttp3.ResponseBody
+import java.io.IOException
+import java.net.InetAddress
+import java.util.Locale
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.net.ssl.HttpsURLConnection
+
+private const val TAG = "SniConnect"
+
+@ReactModule(name = SniConnectModule.NAME)
+class SniConnectModule(reactContext: ReactApplicationContext) :
+ NativeSniConnectSpec(reactContext) {
+
+ companion object {
+ const val NAME = "SniConnect"
+
+ // Upper bound on cached OkHttpClient instances to prevent unbounded growth
+ // from JS-controlled host/IP pairs (e.g. speed-testing many endpoints).
+ private const val MAX_CLIENTS = 32
+
+ // A single dispatcher + connection pool shared across all cached clients so we
+ // don't spawn a thread pool / connection pool per (hostname, ip) pair.
+ private val sharedDispatcher = Dispatcher()
+ private val sharedConnectionPool = ConnectionPool()
+ }
+
+ /**
+ * Cache key for OkHttpClient instances.
+ * Uses hostname:IP so different IPs for the same hostname stay isolated (accurate
+ * speed testing) while the same hostname+IP reuses connections. Timeout is NOT part
+ * of the key — it is applied per-call via `call.timeout()`.
+ */
+ private data class ClientKey(
+ val hostname: String,
+ val ip: String,
+ )
+
+ // Bounded LRU: access-ordered, evicts the eldest client when capacity is exceeded.
+ // Idle connections are reclaimed by the shared ConnectionPool's own keep-alive, so
+ // we do NOT evictAll here (that pool is shared by every client). Synchronized because
+ // LinkedHashMap is not thread-safe.
+ private val clientCache = object : LinkedHashMap(16, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean {
+ return size > MAX_CLIENTS
+ }
+ }
+
+ private val activeCalls = ConcurrentHashMap()
+
+ override fun getName(): String = NAME
+
+ override fun request(config: ReadableMap, promise: Promise) {
+ try {
+ val requestConfig = config.toRequestConfig()
+ performRequest(requestConfig, promise)
+ } catch (error: Exception) {
+ SniConnectLogger.error("Config parsing failed: ${error.message}")
+ promise.reject("SNI_INVALID_CONFIG", error.message, error)
+ }
+ }
+
+ @ReactMethod
+ override fun cancelRequest(requestId: String, promise: Promise) {
+ val call = activeCalls.remove(requestId)
+ if (call != null) {
+ call.cancel()
+ SniConnectLogger.info("Cancelled request: $requestId")
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
+ } else {
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", false) })
+ }
+ }
+
+ @ReactMethod
+ override fun cancelAllRequests(promise: Promise) {
+ val count = activeCalls.size
+ activeCalls.forEach { (_, call) -> call.cancel() }
+ activeCalls.clear()
+ SniConnectLogger.info("Cancelled $count active requests")
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
+ }
+
+ @ReactMethod
+ override fun clearDNSCache(promise: Promise) {
+ synchronized(clientCache) {
+ clientCache.clear()
+ }
+ // Drop pinned-IP connections from the shared pool.
+ sharedConnectionPool.evictAll()
+ SniConnectLogger.info("DNS cache cleared")
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
+ }
+
+ private fun performRequest(config: RequestConfig, promise: Promise) {
+ try {
+ val client = getOrCreateClient(config)
+ val request = buildRequest(config)
+ val call = client.newCall(request)
+
+ // Apply per-request timeout
+ call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
+
+ // Register the call if requestId is provided
+ config.requestId?.let { requestId ->
+ activeCalls[requestId] = call
+ }
+
+ // Guard against double-settling the promise (RN hard-crashes otherwise).
+ val settled = AtomicBoolean(false)
+
+ call.enqueue(object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ config.requestId?.let { activeCalls.remove(it) }
+ if (!settled.compareAndSet(false, true)) return
+
+ if (call.isCanceled()) {
+ promise.reject("SNI_CANCELLED", "Request cancelled", null)
+ } else {
+ SniConnectLogger.error("Request failed: ${e.message}")
+ promise.reject("SNI_REQUEST_FAILED", e.message, e)
+ }
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ config.requestId?.let { activeCalls.remove(it) }
+
+ val result: WritableMap = try {
+ response.use {
+ val bodyString = response.body.safeString()
+ val headerMap = headersToMap(response.headers)
+ Arguments.createMap().apply {
+ putString("data", bodyString)
+ putInt("status", response.code)
+ putString("statusText", response.message)
+ putMap("headers", headerMap.toWritableMap())
+ }
+ }
+ } catch (error: Exception) {
+ if (!settled.compareAndSet(false, true)) return
+ SniConnectLogger.error("Response processing failed: ${error.message}")
+ promise.reject("SNI_RESPONSE_FAILED", error.message, error)
+ return
+ }
+
+ if (response.code >= 400) {
+ SniConnectLogger.warn("HTTP ${response.code} for ${config.hostname}")
+ }
+ if (settled.compareAndSet(false, true)) {
+ promise.resolve(result)
+ }
+ }
+ })
+ } catch (error: Exception) {
+ SniConnectLogger.error("Request setup failed: ${error.message}")
+ promise.reject("SNI_REQUEST_FAILED", error.message, error)
+ }
+ }
+
+ private fun getOrCreateClient(config: RequestConfig): OkHttpClient {
+ val normalizedHost = config.hostname.lowercase(Locale.US)
+ val key = ClientKey(normalizedHost, config.ip)
+
+ synchronized(clientCache) {
+ clientCache[key]?.let { return it }
+
+ // 60s defaults at the client level; the real deadline is the per-call timeout.
+ val defaultTimeout = 60_000L
+
+ val client = OkHttpClient.Builder()
+ .dispatcher(sharedDispatcher)
+ .connectionPool(sharedConnectionPool)
+ .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
+ .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
+ .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
+ .callTimeout(0, TimeUnit.MILLISECONDS)
+ // TLS is validated normally: cert chain via the default trust manager and
+ // hostname verification against the REAL hostname (not the pinned IP).
+ .hostnameVerifier { _, session ->
+ HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session)
+ }
+ .dns(createPinnedDns(config.ip, config.hostname))
+ .build()
+
+ clientCache[key] = client
+ return client
+ }
+ }
+
+ private fun createPinnedDns(ip: String, hostname: String): Dns =
+ object : Dns {
+ private val expectedHost = hostname.lowercase(Locale.US)
+ // Resolve the literal IP once up front (validated; never triggers DNS).
+ private val pinnedAddress: InetAddress = SniConnectValidation.literalToInetAddress(ip)
+
+ override fun lookup(requestedHost: String): List {
+ return if (requestedHost.lowercase(Locale.US) == expectedHost) {
+ listOf(pinnedAddress)
+ } else {
+ Dns.SYSTEM.lookup(requestedHost)
+ }
+ }
+ }
+
+ /**
+ * Build the request. Always `https://` on the implicit port 443 —
+ * `path` has been validated as relative, so scheme/host/port cannot be overridden.
+ */
+ private fun buildRequest(config: RequestConfig): Request {
+ val url = "https://${config.hostname}${config.path}"
+ val builder = Request.Builder().url(url)
+
+ config.headers.forEach { (key, value) ->
+ if (!key.equals("host", ignoreCase = true)) {
+ builder.addHeader(key, value)
+ }
+ }
+ builder.header("Host", config.hostname)
+
+ val method = config.method
+ val bodyContent = config.body ?: ""
+ val mediaType = config.headers.entries
+ .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
+ ?.value
+ ?.toMediaTypeOrNull()
+ ?: "application/json; charset=utf-8".toMediaTypeOrNull()
+
+ when (method) {
+ "GET" -> builder.get()
+ "HEAD" -> builder.head()
+ else -> {
+ val requestBody = bodyContent.toRequestBody(mediaType)
+ when (method) {
+ "POST" -> builder.post(requestBody)
+ "PUT" -> builder.put(requestBody)
+ "PATCH" -> builder.patch(requestBody)
+ "DELETE" -> builder.delete(requestBody)
+ else -> builder.method(method, requestBody)
+ }
+ }
+ }
+
+ return builder.build()
+ }
+
+ private fun ResponseBody?.safeString(): String {
+ if (this == null) return ""
+ return try {
+ this.string()
+ } catch (error: IOException) {
+ throw IOException("Failed to read response body", error)
+ }
+ }
+
+ private fun headersToMap(headers: Headers): Map {
+ val map = mutableMapOf()
+ for (name in headers.names()) {
+ map[name] = headers[name] ?: ""
+ }
+ return map
+ }
+
+ private fun Map.toWritableMap(): WritableMap {
+ return Arguments.createMap().apply {
+ forEach { (key, value) -> putString(key, value) }
+ }
+ }
+
+ private fun ReadableMap.toRequestConfig(): RequestConfig {
+ val headersMap = if (hasKey("headers") && !isNull("headers")) {
+ getMap("headers")?.toHashMap()
+ ?.mapValues { (_, value) -> value?.toString() ?: "" }
+ ?: emptyMap()
+ } else {
+ emptyMap()
+ }
+
+ val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) {
+ getDouble("timeout").toLong().coerceAtLeast(1L)
+ } else {
+ 30_000L
+ }
+
+ val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null
+
+ val ip = getString("ip") ?: throw IllegalArgumentException("ip is required")
+ val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
+ val method = getString("method") ?: "GET"
+ val path = getString("path") ?: "/"
+
+ // Validate every caller-controlled field at the boundary.
+ SniConnectValidation.validatePublicIp(ip)
+ SniConnectValidation.validateHostname(hostname)
+ SniConnectValidation.validateHeaders(headersMap)
+ val normalizedMethod = SniConnectValidation.normalizeMethod(method)
+ val normalizedPath = SniConnectValidation.normalizePath(path)
+
+ return RequestConfig(
+ requestId = requestId,
+ ip = ip,
+ hostname = hostname,
+ method = normalizedMethod,
+ path = normalizedPath,
+ headers = headersMap,
+ body = if (hasKey("body") && !isNull("body")) getString("body") else null,
+ timeoutMillis = timeoutMillis,
+ )
+ }
+
+ private data class RequestConfig(
+ val requestId: String?,
+ val ip: String,
+ val hostname: String,
+ val method: String,
+ val path: String,
+ val headers: Map,
+ val body: String?,
+ val timeoutMillis: Long,
+ )
+}
diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt
new file mode 100644
index 00000000..7ab45e26
--- /dev/null
+++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt
@@ -0,0 +1,31 @@
+package com.sniconnect
+
+import com.facebook.react.TurboReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.module.model.ReactModuleInfo
+import com.facebook.react.module.model.ReactModuleInfoProvider
+import com.facebook.react.uimanager.ViewManager
+
+class SniConnectPackage : TurboReactPackage() {
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
+ if (name == SniConnectModule.NAME) SniConnectModule(reactContext) else null
+
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider =
+ ReactModuleInfoProvider {
+ mapOf(
+ SniConnectModule.NAME to ReactModuleInfo(
+ SniConnectModule.NAME,
+ SniConnectModule.NAME,
+ false,
+ false,
+ true,
+ false,
+ true
+ )
+ )
+ }
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> =
+ emptyList()
+}
diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt
new file mode 100644
index 00000000..5d1c9a12
--- /dev/null
+++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt
@@ -0,0 +1,144 @@
+package com.sniconnect
+
+import java.net.Inet6Address
+import java.net.InetAddress
+import java.util.Locale
+
+/**
+ * Boundary validation/normalization for SNI request inputs.
+ *
+ * The module connects to a caller-supplied IP while preserving the TLS SNI/Host of
+ * `hostname`. Because the connect target is caller-controlled, every field that
+ * reaches the network layer is validated here to prevent SSRF, scheme/host/port
+ * override, cleartext downgrade and CR/LF header injection.
+ */
+internal object SniConnectValidation {
+
+ class ValidationException(message: String) : IllegalArgumentException(message)
+
+ private val ALLOWED_METHODS =
+ setOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
+
+ private val HOSTNAME_REGEX = Regex(
+ "^(?=.{1,253}\$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\$"
+ )
+ private val IPV4_REGEX = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$")
+ private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:")
+
+ fun normalizeMethod(method: String): String {
+ val upper = method.trim().uppercase(Locale.US)
+ if (upper !in ALLOWED_METHODS) {
+ throw ValidationException("Invalid method: $method")
+ }
+ return upper
+ }
+
+ fun validateHostname(hostname: String) {
+ if (hostname.isEmpty() || hostname.length > 253 || !HOSTNAME_REGEX.matches(hostname)) {
+ throw ValidationException("Invalid hostname: $hostname")
+ }
+ }
+
+ /** Must be a relative path/query only — reject absolute/protocol-relative URLs and control chars. */
+ fun normalizePath(path: String): String {
+ val trimmed = path.trim()
+ if (containsControlChars(trimmed)) {
+ throw ValidationException("Invalid path")
+ }
+ if (trimmed.contains("://") || trimmed.startsWith("//") || SCHEME_REGEX.containsMatchIn(trimmed.take(64).substringBefore('/'))) {
+ throw ValidationException("Invalid path: absolute URLs are not allowed")
+ }
+ if (trimmed.isEmpty()) return "/"
+ return if (trimmed.startsWith("/")) trimmed else "/$trimmed"
+ }
+
+ fun validateHeaders(headers: Map) {
+ for ((key, value) in headers) {
+ if (key.isEmpty() || containsControlChars(key) || containsControlChars(value)) {
+ throw ValidationException("Invalid header: $key")
+ }
+ }
+ }
+
+ private fun containsControlChars(s: String): Boolean =
+ s.any { it.code < 0x20 || it.code == 0x7F }
+
+ /**
+ * Validate `ip` is a literal IPv4/IPv6 address (never a hostname) routing to a
+ * public/global-unicast destination. Rejects loopback, private, link-local
+ * (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved ranges.
+ */
+ fun validatePublicIp(ip: String) {
+ val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt() }
+ if (octets != null) {
+ if (octets.any { it > 255 }) throw ValidationException("Invalid IP: $ip")
+ if (isForbiddenIpv4(octets)) throw ValidationException("Forbidden IP: $ip")
+ return
+ }
+ // IPv6: only treat as literal if it contains ':' (avoids any DNS lookup).
+ if (ip.contains(':')) {
+ val addr: InetAddress = try {
+ InetAddress.getByName(ip)
+ } catch (e: Exception) {
+ throw ValidationException("Invalid IP: $ip")
+ }
+ if (addr !is Inet6Address) throw ValidationException("Invalid IP: $ip")
+ if (isForbiddenIpv6(addr)) throw ValidationException("Forbidden IP: $ip")
+ return
+ }
+ throw ValidationException("Invalid IP: $ip")
+ }
+
+ private fun isForbiddenIpv4(o: List): Boolean {
+ val a = o[0]; val b = o[1]; val c = o[2]; val d = o[3]
+ return when {
+ a == 0 -> true // 0.0.0.0/8
+ a == 10 -> true // 10/8 private
+ a == 127 -> true // 127/8 loopback
+ a == 100 && (b and 0xC0) == 0x40 -> true // 100.64/10 CGNAT
+ a == 169 && b == 254 -> true // 169.254/16 link-local + metadata
+ a == 172 && b in 16..31 -> true // 172.16/12 private
+ a == 192 && b == 168 -> true // 192.168/16 private
+ a == 192 && b == 0 && c == 0 -> true // 192.0.0/24
+ a == 192 && b == 0 && c == 2 -> true // 192.0.2/24 TEST-NET-1
+ a == 198 && (b == 18 || b == 19) -> true // 198.18/15 benchmarking
+ a == 198 && b == 51 && c == 100 -> true // 198.51.100/24 TEST-NET-2
+ a == 203 && b == 0 && c == 113 -> true // 203.0.113/24 TEST-NET-3
+ a >= 224 -> true // 224/4 multicast + 240/4 reserved + broadcast
+ else -> false
+ }
+ }
+
+ private fun isForbiddenIpv6(addr: Inet6Address): Boolean {
+ if (addr.isAnyLocalAddress || addr.isLoopbackAddress || addr.isLinkLocalAddress ||
+ addr.isSiteLocalAddress || addr.isMulticastAddress
+ ) {
+ return true
+ }
+ val bytes = addr.address
+ // Unique local fc00::/7
+ if ((bytes[0].toInt() and 0xFE) == 0xFC) return true
+ // IPv4-mapped ::ffff:a.b.c.d — validate the embedded IPv4
+ val mappedPrefixZero = (0..9).all { bytes[it].toInt() == 0 }
+ if (mappedPrefixZero && (bytes[10].toInt() and 0xFF) == 0xFF && (bytes[11].toInt() and 0xFF) == 0xFF) {
+ return isForbiddenIpv4(
+ listOf(
+ bytes[12].toInt() and 0xFF,
+ bytes[13].toInt() and 0xFF,
+ bytes[14].toInt() and 0xFF,
+ bytes[15].toInt() and 0xFF,
+ )
+ )
+ }
+ return false
+ }
+
+ /** Parse a validated IPv4/IPv6 literal into an InetAddress without DNS resolution. */
+ fun literalToInetAddress(ip: String): InetAddress {
+ val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt().toByte() }
+ if (octets != null) {
+ return InetAddress.getByAddress(byteArrayOf(octets[0], octets[1], octets[2], octets[3]))
+ }
+ return InetAddress.getByName(ip) // safe: already validated as an IPv6 literal
+ }
+}
diff --git a/native-modules/react-native-sni-connect/babel.config.js b/native-modules/react-native-sni-connect/babel.config.js
new file mode 100644
index 00000000..0c05fd69
--- /dev/null
+++ b/native-modules/react-native-sni-connect/babel.config.js
@@ -0,0 +1,12 @@
+module.exports = {
+ overrides: [
+ {
+ exclude: /\/node_modules\//,
+ presets: ['module:react-native-builder-bob/babel-preset'],
+ },
+ {
+ include: /\/node_modules\//,
+ presets: ['module:@react-native/babel-preset'],
+ },
+ ],
+};
diff --git a/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h
new file mode 100644
index 00000000..bf77aa61
--- /dev/null
+++ b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h
@@ -0,0 +1,12 @@
+//
+// SniConnect-Bridging-Header.h
+// SniConnect
+//
+// Used to import Objective-C headers that should be visible to Swift.
+//
+
+#import
+
+#ifdef RCT_NEW_ARCH_ENABLED
+#import
+#endif
diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.mm b/native-modules/react-native-sni-connect/ios/SniConnect.mm
new file mode 100644
index 00000000..68e8d4b9
--- /dev/null
+++ b/native-modules/react-native-sni-connect/ios/SniConnect.mm
@@ -0,0 +1,119 @@
+#import
+#import
+
+#ifdef RCT_NEW_ARCH_ENABLED
+#import
+#endif
+
+// Forward declaration of the Swift implementation
+@interface SniConnectImpl : NSObject
+- (instancetype)init;
+- (void)request:(NSDictionary *)config
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject;
+- (void)cancelRequest:(NSString *)requestId
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject;
+- (void)cancelAllRequests:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject;
+- (void)clearDNSCache:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject;
+@end
+
+@interface SniConnect : NSObject
+#ifdef RCT_NEW_ARCH_ENABLED
+
+#else
+
+#endif
+@end
+
+@implementation SniConnect {
+ SniConnectImpl *_implementation;
+}
+
+RCT_EXPORT_MODULE(SniConnect)
+
++ (BOOL)requiresMainQueueSetup {
+ return NO;
+}
+
+- (instancetype)init {
+ if (self = [super init]) {
+ _implementation = [[SniConnectImpl alloc] init];
+ }
+ return self;
+}
+
+#ifdef RCT_NEW_ARCH_ENABLED
+// TurboModule interface implementation
+- (void)request:(JS::NativeSniConnect::SniConnectRequest &)config
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ // Convert Codegen struct to NSDictionary for the Swift implementation.
+ // Null-guard every field so a nil value never crashes the dictionary literal,
+ // and forward requestId so cancellation works under the new architecture.
+ NSMutableDictionary *configDict = [NSMutableDictionary dictionary];
+ configDict[@"ip"] = config.ip() ?: @"";
+ configDict[@"hostname"] = config.hostname() ?: @"";
+ configDict[@"method"] = config.method() ?: @"GET";
+ configDict[@"path"] = config.path() ?: @"/";
+ configDict[@"headers"] = config.headers() ?: @{};
+ configDict[@"timeout"] = @(config.timeout());
+ if (config.requestId()) {
+ configDict[@"requestId"] = config.requestId();
+ }
+ if (config.body()) {
+ configDict[@"body"] = config.body();
+ }
+
+ [_implementation request:configDict resolve:resolve reject:reject];
+}
+
+- (void)cancelRequest:(NSString *)requestId
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [_implementation cancelRequest:requestId resolve:resolve reject:reject];
+}
+
+- (void)cancelAllRequests:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [_implementation cancelAllRequests:resolve reject:reject];
+}
+
+- (void)clearDNSCache:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [_implementation clearDNSCache:resolve reject:reject];
+}
+
+- (std::shared_ptr)getTurboModule:
+ (const facebook::react::ObjCTurboModule::InitParams &)params
+{
+ return std::make_shared(params);
+}
+#else
+// Bridge module interface implementation
+RCT_EXPORT_METHOD(request:(NSDictionary *)config
+ resolver:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject) {
+ [_implementation request:config resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_METHOD(cancelRequest:(NSString *)requestId
+ resolver:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject) {
+ [_implementation cancelRequest:requestId resolve:resolve reject:reject];
+}
+
+RCT_EXPORT_METHOD(cancelAllRequests:(RCTPromiseResolveBlock)resolver
+ rejecter:(RCTPromiseRejectBlock)rejecter) {
+ [_implementation cancelAllRequests:resolver reject:rejecter];
+}
+
+RCT_EXPORT_METHOD(clearDNSCache:(RCTPromiseResolveBlock)resolver
+ rejecter:(RCTPromiseRejectBlock)rejecter) {
+ [_implementation clearDNSCache:resolver reject:rejecter];
+}
+#endif
+
+@end
diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.swift b/native-modules/react-native-sni-connect/ios/SniConnect.swift
new file mode 100644
index 00000000..2ad98a4b
--- /dev/null
+++ b/native-modules/react-native-sni-connect/ios/SniConnect.swift
@@ -0,0 +1,146 @@
+import Foundation
+import React
+
+private enum SniConnectError: Error {
+ case invalidConfig(String)
+}
+
+@objc(SniConnectImpl)
+final class SniConnectImpl: NSObject {
+ private let client: SniConnectClient
+
+ @objc
+ override init() {
+ self.client = SniConnectClient()
+ super.init()
+ }
+
+ @objc
+ public func request(
+ _ config: NSDictionary,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock
+ ) {
+ do {
+ let parsed = try Self.parseDictionary(config)
+ handleRequest(config: parsed, resolve: resolve, reject: reject)
+ } catch {
+ SniConnectLog.error("Config parsing failed: \(error)")
+ reject("SNI_INVALID_CONFIG", "\(error)", error)
+ }
+ }
+
+ private func handleRequest(
+ config: SniConnectClient.RequestConfig,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock
+ ) {
+ // Create task and register it synchronously before it starts executing
+ let task = Task { () -> SniConnectClient.Response in
+ return try await client.performRequest(config: config)
+ }
+
+ // Register task if requestId is provided
+ if let requestId = config.requestId {
+ client.registerTask(task, for: requestId)
+ }
+
+ // Handle the task result asynchronously
+ Task {
+ do {
+ let result = try await task.value
+ let responseBody = Self.serializeResponseData(result.data)
+
+ resolve([
+ "data": responseBody,
+ "status": result.status,
+ "statusText": result.statusText,
+ "headers": result.headers,
+ "multiValueHeaders": result.multiValueHeaders,
+ ])
+ } catch let error as SniConnectClient.SniConnectError {
+ reject(error.code, error.message, error)
+ } catch is CancellationError {
+ reject("SNI_CANCELLED", "Request cancelled", nil)
+ } catch {
+ SniConnectLog.error("Request failed: \(error.localizedDescription)")
+ reject("SNI_UNKNOWN_ERROR", error.localizedDescription, error)
+ }
+ }
+ }
+
+ @objc
+ public func cancelRequest(
+ _ requestId: String,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock
+ ) {
+ client.cancelRequest(requestId: requestId)
+ resolve(["success": true])
+ }
+
+ @objc
+ public func cancelAllRequests(
+ _ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock
+ ) {
+ client.cancelAllRequests()
+ resolve(["success": true])
+ }
+
+ @objc
+ public func clearDNSCache(
+ _ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock
+ ) {
+ client.clearDNSCache()
+ resolve(["success": true])
+ }
+
+ private static func parseDictionary(_ dictionary: NSDictionary) throws -> SniConnectClient.RequestConfig {
+ guard let ip = dictionary["ip"] as? String, !ip.isEmpty else {
+ throw SniConnectError.invalidConfig("Missing ip")
+ }
+ guard let hostname = dictionary["hostname"] as? String, !hostname.isEmpty else {
+ throw SniConnectError.invalidConfig("Missing hostname")
+ }
+
+ let requestId = dictionary["requestId"] as? String
+ let method = (dictionary["method"] as? String)?.uppercased() ?? "GET"
+ let path = dictionary["path"] as? String ?? "/"
+ let headers = dictionary["headers"] as? [String: String] ?? [:]
+ let body = dictionary["body"] as? String
+ let timeout = dictionary["timeout"] as? NSNumber ?? 30_000
+
+ // Parse advanced timeout configurations
+ let connectTimeout = (dictionary["connectTimeout"] as? NSNumber)?.doubleValue
+ let totalTimeout = (dictionary["totalTimeout"] as? NSNumber)?.doubleValue
+
+ return SniConnectClient.RequestConfig(
+ requestId: requestId,
+ ip: ip,
+ hostname: hostname,
+ method: method,
+ path: path,
+ headers: headers,
+ body: body,
+ timeout: timeout.doubleValue,
+ connectTimeout: connectTimeout,
+ totalTimeout: totalTimeout
+ )
+ }
+
+ private static func serializeResponseData(_ data: Any) -> String {
+ if let stringValue = data as? String {
+ return stringValue
+ }
+
+ if JSONSerialization.isValidJSONObject(data),
+ let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []),
+ let jsonString = String(data: jsonData, encoding: .utf8) {
+ return jsonString
+ }
+
+ return String(describing: data)
+ }
+}
diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift
new file mode 100644
index 00000000..511d0736
--- /dev/null
+++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift
@@ -0,0 +1,473 @@
+import Foundation
+import UIKit
+import EMASCurl
+
+/// Core HTTPS client that enforces IP direct connection with SNI.
+final class SniConnectClient {
+
+ // Active requests tracking for cancellation support
+ private var activeTasks: [String: Task] = [:]
+ private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent)
+
+ // Token for the memory-warning observer (block-based observers are not removed
+ // by `removeObserver(self)`, so the token must be retained and removed explicitly).
+ private var memoryWarningObserver: NSObjectProtocol?
+
+ init() {
+ // Register for memory warnings to clean cache
+ memoryWarningObserver = NotificationCenter.default.addObserver(
+ forName: UIApplication.didReceiveMemoryWarningNotification,
+ object: nil,
+ queue: .main
+ ) { _ in
+ SniConnectLog.info("Memory warning received, cleaning DNS cache")
+ DNSResolver.cleanExpiredEntries()
+ }
+ }
+
+ deinit {
+ if let observer = memoryWarningObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ }
+
+ struct RequestConfig {
+ let requestId: String? // Optional request ID for cancellation
+ let ip: String
+ let hostname: String
+ let method: String
+ let path: String
+ let headers: [String: String]
+ let body: String?
+ let timeout: TimeInterval
+
+ // Advanced timeout configurations
+ let connectTimeout: TimeInterval? // Connection establishment timeout
+ let totalTimeout: TimeInterval? // Total request timeout (overrides `timeout`)
+
+ var effectiveConnectTimeout: TimeInterval {
+ connectTimeout ?? min(timeout / 3, 10.0)
+ }
+
+ var effectiveTotalTimeout: TimeInterval {
+ totalTimeout ?? timeout
+ }
+ }
+
+ struct Response {
+ let data: Any
+ let status: Int
+ let statusText: String
+ let headers: [String: String] // Single-value headers (backward compatible)
+ let multiValueHeaders: [String: [String]] // Multi-value headers (e.g., Set-Cookie)
+ }
+
+ enum SniConnectError: Error {
+ case invalidURL
+ case invalidConfig(String)
+ case dnsResolutionFailed(String)
+ case tlsHandshakeFailed(String)
+ case certificateValidationFailed(String)
+ case connectionTimeout
+ case connectionRefused
+ case networkUnreachable
+ case requestTimeout
+ case httpError(code: Int, message: String)
+ case cancelled
+ case unknown(Error)
+
+ var code: String {
+ switch self {
+ case .invalidURL: return "SNI_INVALID_URL"
+ case .invalidConfig: return "SNI_INVALID_CONFIG"
+ case .dnsResolutionFailed: return "SNI_DNS_FAILED"
+ case .tlsHandshakeFailed: return "SNI_TLS_FAILED"
+ case .certificateValidationFailed: return "SNI_CERT_FAILED"
+ case .connectionTimeout: return "SNI_TIMEOUT"
+ case .connectionRefused: return "SNI_CONNECTION_REFUSED"
+ case .networkUnreachable: return "SNI_NETWORK_UNREACHABLE"
+ case .requestTimeout: return "SNI_REQUEST_TIMEOUT"
+ case .httpError: return "SNI_HTTP_ERROR"
+ case .cancelled: return "SNI_CANCELLED"
+ case .unknown: return "SNI_UNKNOWN_ERROR"
+ }
+ }
+
+ var message: String {
+ switch self {
+ case .invalidURL:
+ return "Invalid URL format"
+ case .invalidConfig(let details):
+ return "Invalid configuration: \(details)"
+ case .dnsResolutionFailed(let domain):
+ return "DNS resolution failed for domain: \(domain)"
+ case .tlsHandshakeFailed(let details):
+ return "TLS handshake failed: \(details)"
+ case .certificateValidationFailed(let details):
+ return "Certificate validation failed: \(details)"
+ case .connectionTimeout:
+ return "Connection timeout"
+ case .connectionRefused:
+ return "Connection refused by server"
+ case .networkUnreachable:
+ return "Network unreachable"
+ case .requestTimeout:
+ return "Request timeout"
+ case .httpError(let code, let message):
+ return "HTTP error \(code): \(message)"
+ case .cancelled:
+ return "Request cancelled"
+ case .unknown(let error):
+ return "Unknown error: \(error.localizedDescription)"
+ }
+ }
+
+ /// Convert NSError to SniConnectError with detailed classification
+ static func from(_ error: Error) -> SniConnectError {
+ let nsError = error as NSError
+
+ // Check for URL-related errors
+ if nsError.domain == NSURLErrorDomain {
+ switch nsError.code {
+ case NSURLErrorTimedOut:
+ return .requestTimeout
+ case NSURLErrorCannotConnectToHost:
+ return .connectionRefused
+ case NSURLErrorNotConnectedToInternet, NSURLErrorNetworkConnectionLost:
+ return .networkUnreachable
+ case NSURLErrorSecureConnectionFailed:
+ return .tlsHandshakeFailed(nsError.localizedDescription)
+ case NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasBadDate,
+ NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateNotYetValid:
+ return .certificateValidationFailed(nsError.localizedDescription)
+ case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
+ return .dnsResolutionFailed(nsError.localizedDescription)
+ case NSURLErrorCancelled:
+ return .cancelled
+ default:
+ return .unknown(error)
+ }
+ }
+
+ return .unknown(error)
+ }
+ }
+
+ private static let urlSession: URLSession = {
+ let configuration = URLSessionConfiguration.default
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
+ configuration.urlCache = nil
+ configuration.httpCookieStorage = nil
+ configuration.httpShouldSetCookies = false
+ configuration.shouldUseExtendedBackgroundIdleMode = false
+
+ let curlConfig = EMASCurlConfiguration.default()
+ curlConfig.httpVersion = .HTTP2
+ curlConfig.connectTimeoutInterval = 2.5
+ curlConfig.enableBuiltInGzip = true
+ curlConfig.enableBuiltInRedirection = true
+ curlConfig.cacheEnabled = false
+
+ // Enable full certificate validation for security.
+ // The certificate is validated against the SNI hostname, not the IP, because
+ // the custom DNS resolver only overrides address resolution — libcurl keeps the
+ // original hostname for SNI and certificate CN/SAN matching.
+ curlConfig.certificateValidationEnabled = true
+ curlConfig.domainNameVerificationEnabled = true
+ curlConfig.dnsResolver = DNSResolver.self
+
+ EMASCurlProtocol.install(into: configuration, with: curlConfig)
+ return URLSession(configuration: configuration)
+ }()
+
+ @objc private final class DNSResolver: NSObject, EMASCurlProtocolDNSResolver {
+ private static let queue = DispatchQueue(label: "com.onekey.sni.connect.dns", attributes: .concurrent)
+ private static let cache = DNSCache()
+
+ /// Thread-safe hostname -> IP pin with TTL.
+ ///
+ /// LIMITATION: EMASCurl only exposes a process-global DNS resolver
+ /// (`setDNSResolver:`), which receives only the hostname. There is no
+ /// per-request DNS API, so the pin is keyed by hostname and the most recent
+ /// IP for a hostname wins. Concurrent requests to the SAME hostname targeting
+ /// DIFFERENT IPs are therefore not guaranteed to each hit their own IP. Normal
+ /// usage (one IP per hostname at a time) is unaffected.
+ private final class DNSCache {
+ private struct Entry {
+ let ip: String
+ let timestamp: TimeInterval
+ }
+
+ private var hostnameToEntry: [String: Entry] = [:]
+ private let maxSize = 100
+ private let ttl: TimeInterval = 300 // 5 minutes
+
+ func get(_ domain: String) -> String? {
+ let key = domain.lowercased()
+ guard let entry = hostnameToEntry[key] else { return nil }
+ if Date().timeIntervalSince1970 - entry.timestamp > ttl {
+ hostnameToEntry.removeValue(forKey: key)
+ return nil
+ }
+ return entry.ip
+ }
+
+ func set(_ ip: String, for domain: String) {
+ let key = domain.lowercased()
+ // Evict the oldest entry when at capacity (and this is a new host).
+ if hostnameToEntry.count >= maxSize && hostnameToEntry[key] == nil {
+ if let oldest = hostnameToEntry.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
+ hostnameToEntry.removeValue(forKey: oldest)
+ }
+ }
+ hostnameToEntry[key] = Entry(ip: ip, timestamp: Date().timeIntervalSince1970)
+ }
+
+ func clear() {
+ hostnameToEntry.removeAll()
+ }
+
+ func cleanExpired() {
+ let now = Date().timeIntervalSince1970
+ let expired = hostnameToEntry.filter { now - $0.value.timestamp > ttl }.map { $0.key }
+ for key in expired {
+ hostnameToEntry.removeValue(forKey: key)
+ }
+ }
+ }
+
+ @objc static func resolveDomain(_ domain: String) -> String? {
+ var result: String?
+ queue.sync {
+ result = cache.get(domain)
+ }
+ return result
+ }
+
+ static func setIP(_ ip: String, for host: String) {
+ queue.sync(flags: .barrier) {
+ cache.set(ip, for: host)
+ }
+ }
+
+ /// Clear all DNS cache entries
+ static func clearCache() {
+ queue.sync(flags: .barrier) {
+ cache.clear()
+ }
+ }
+
+ /// Clean expired DNS cache entries
+ static func cleanExpiredEntries() {
+ queue.sync(flags: .barrier) {
+ cache.cleanExpired()
+ }
+ }
+ }
+
+ /// Clear all DNS cache entries
+ func clearDNSCache() {
+ DNSResolver.clearCache()
+ SniConnectLog.info("DNS cache cleared")
+ }
+
+ /// Cancel a request by ID
+ func cancelRequest(requestId: String) {
+ tasksQueue.async(flags: .barrier) { [weak self] in
+ guard let task = self?.activeTasks[requestId] else {
+ SniConnectLog.warn("No active request found with ID: \(requestId)")
+ return
+ }
+ task.cancel()
+ self?.activeTasks.removeValue(forKey: requestId)
+ SniConnectLog.info("Request cancelled: \(requestId)")
+ }
+ }
+
+ /// Cancel all active requests
+ func cancelAllRequests() {
+ tasksQueue.async(flags: .barrier) { [weak self] in
+ guard let self = self else { return }
+ let count = self.activeTasks.count
+ for (_, task) in self.activeTasks {
+ task.cancel()
+ }
+ self.activeTasks.removeAll()
+ SniConnectLog.info("Cancelled \(count) active requests")
+ }
+ }
+
+ /// Register an active task (async barrier; the task is already running).
+ func registerTask(_ task: Task, for requestId: String) {
+ tasksQueue.async(flags: .barrier) { [weak self] in
+ self?.activeTasks[requestId] = task
+ }
+ }
+
+ /// Unregister a completed or failed task
+ private func unregisterTask(requestId: String?) {
+ guard let requestId = requestId else { return }
+ tasksQueue.async(flags: .barrier) { [weak self] in
+ self?.activeTasks.removeValue(forKey: requestId)
+ }
+ }
+
+ func performRequest(config: RequestConfig) async throws -> Response {
+ // Check if task is cancelled
+ try Task.checkCancellation()
+
+ defer {
+ unregisterTask(requestId: config.requestId)
+ }
+
+ // Validate every caller-controlled field before it reaches the network layer.
+ let method: String
+ let normalizedPath: String
+ do {
+ try SniConnectValidation.validatePublicIP(config.ip)
+ try SniConnectValidation.validateHostname(config.hostname)
+ try SniConnectValidation.validateHeaders(config.headers)
+ method = try SniConnectValidation.normalizeMethod(config.method)
+ normalizedPath = try SniConnectValidation.normalizePath(config.path)
+ } catch {
+ throw SniConnectError.invalidConfig("\(error)")
+ }
+
+ DNSResolver.setIP(config.ip, for: config.hostname)
+
+ let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath)
+
+ let mutableRequest = NSMutableURLRequest(url: url)
+ mutableRequest.httpMethod = method
+
+ // Convert milliseconds to seconds for timeout values
+ let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0
+ let connectTimeoutSeconds = config.effectiveConnectTimeout / 1000.0
+
+ // Set total request timeout
+ mutableRequest.timeoutInterval = totalTimeoutSeconds
+ mutableRequest.cachePolicy = .reloadIgnoringLocalCacheData
+
+ // Explicitly set Host header for SNI
+ mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host")
+
+ for (key, value) in config.headers {
+ if key.caseInsensitiveCompare("host") == .orderedSame {
+ // Host header is already set above, skip duplicate
+ continue
+ }
+ mutableRequest.setValue(value, forHTTPHeaderField: key)
+ }
+
+ if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) {
+ mutableRequest.httpBody = bodyData
+ }
+
+ // Per-request connect timeout (avoids the process-global setter race).
+ EMASCurlProtocol.setConnectTimeoutIntervalFor(mutableRequest, connectTimeoutInterval: connectTimeoutSeconds)
+ let request = mutableRequest as URLRequest
+
+ do {
+ let (data, response) = try await Self.urlSession.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ let errorMsg = "Invalid HTTP response type"
+ SniConnectLog.error(errorMsg)
+ throw SniConnectError.invalidConfig(errorMsg)
+ }
+
+ let status = httpResponse.statusCode
+ let parsedData = Self.parseResponseData(data)
+ let (headers, multiValueHeaders) = Self.extractHeaders(from: httpResponse)
+ let statusText = HTTPURLResponse.localizedString(forStatusCode: status)
+
+ // 4xx/5xx are returned to JS as a normal response (the caller inspects
+ // `status`); we only record it for diagnostics.
+ if status >= 400 {
+ SniConnectLog.warn("HTTP \(status) for \(config.hostname)")
+ }
+
+ return Response(
+ data: parsedData,
+ status: status,
+ statusText: statusText,
+ headers: headers,
+ multiValueHeaders: multiValueHeaders
+ )
+ } catch let error as SniConnectError {
+ SniConnectLog.error("[\(error.code)] \(error.message)")
+ throw error
+ } catch {
+ // Convert generic errors to specific SniConnectError types
+ let sniError = SniConnectError.from(error)
+ SniConnectLog.error("[\(sniError.code)] \(sniError.message)")
+ throw sniError
+ }
+ }
+
+ /// Build the request URL. Always `https://` on port 443 — the
+ /// path has already been validated as relative (no scheme/authority), so the
+ /// caller cannot override scheme, host or port.
+ private static func buildURL(hostname: String, normalizedPath: String) throws -> URL {
+ var components = URLComponents()
+ components.scheme = "https"
+ components.host = hostname
+ // `normalizedPath` is "/...optional?query". Split off the query so URLComponents
+ // percent-encodes each part correctly.
+ if let queryIndex = normalizedPath.firstIndex(of: "?") {
+ components.percentEncodedPath = String(normalizedPath[.. Any {
+ guard !data.isEmpty else {
+ return ""
+ }
+
+ if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
+ return jsonObject
+ }
+
+ if let text = String(data: data, encoding: .utf8) {
+ return text
+ }
+
+ return data.base64EncodedString()
+ }
+
+ /// Extract headers from HTTP response
+ /// Returns both single-value headers (for backward compatibility) and multi-value headers
+ private static func extractHeaders(from response: HTTPURLResponse) -> ([String: String], [String: [String]]) {
+ var singleValueHeaders: [String: String] = [:]
+ var multiValueHeaders: [String: [String]] = [:]
+
+ // Group headers by normalized key (lowercase)
+ var headerGroups: [String: [String]] = [:]
+
+ for (key, value) in response.allHeaderFields {
+ let headerKey = String(describing: key).lowercased()
+ let headerValue = String(describing: value)
+
+ if headerGroups[headerKey] == nil {
+ headerGroups[headerKey] = []
+ }
+ headerGroups[headerKey]?.append(headerValue)
+ }
+
+ // Process grouped headers
+ for (key, values) in headerGroups {
+ // For backward compatibility, single-value headers use the last value
+ singleValueHeaders[key] = values.last
+ multiValueHeaders[key] = values
+ }
+
+ return (singleValueHeaders, multiValueHeaders)
+ }
+}
diff --git a/native-modules/react-native-sni-connect/ios/SniConnectLog.swift b/native-modules/react-native-sni-connect/ios/SniConnectLog.swift
new file mode 100644
index 00000000..b7e34ae4
--- /dev/null
+++ b/native-modules/react-native-sni-connect/ios/SniConnectLog.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+/// Lightweight logging wrapper that dynamically dispatches to OneKeyLog through
+/// the Objective-C runtime.
+///
+/// Using reflection (instead of `import ReactNativeNativeLogger`) keeps this
+/// TurboModule from hard-linking the nitro-based native-logger module. When
+/// OneKeyLog is unavailable the logs are silently dropped. Mirrors the Android
+/// `SniConnectLogger` and the existing `BTLogger` / `SBLLogger`.
+enum SniConnectLog {
+ private static let tag = "SniConnect"
+
+ // Swift classes are exposed to the ObjC runtime as `Module.ClassName`.
+ private static let logClass: AnyObject? =
+ (NSClassFromString("ReactNativeNativeLogger.OneKeyLog")
+ ?? NSClassFromString("OneKeyLog")) as AnyObject?
+
+ static func debug(_ message: String) { dispatch("debug::", message) }
+ static func info(_ message: String) { dispatch("info::", message) }
+ static func warn(_ message: String) { dispatch("warn::", message) }
+ static func error(_ message: String) { dispatch("error::", message) }
+
+ private static func dispatch(_ selectorName: String, _ message: String) {
+ guard let cls = logClass else { return }
+ let sel = NSSelectorFromString(selectorName)
+ guard cls.responds(to: sel) else { return }
+ _ = cls.perform(sel, with: tag, with: message)
+ }
+}
diff --git a/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift b/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift
new file mode 100644
index 00000000..82645647
--- /dev/null
+++ b/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift
@@ -0,0 +1,151 @@
+import Foundation
+
+/// Boundary validation/normalization for SNI request inputs.
+///
+/// The module connects to a caller-supplied IP while preserving the TLS SNI/Host
+/// of `hostname`. Because the connect target is caller-controlled, every field that
+/// reaches the network layer is validated here to prevent SSRF, scheme/host/port
+/// override, cleartext downgrade and CR/LF header injection.
+enum SniConnectValidation {
+
+ enum ValidationError: Error {
+ case invalidIP(String)
+ case forbiddenIP(String)
+ case invalidHostname(String)
+ case invalidMethod(String)
+ case invalidPath(String)
+ case invalidHeader(String)
+ }
+
+ /// HTTP methods the module is allowed to issue.
+ private static let allowedMethods: Set = [
+ "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
+ ]
+
+ /// Validate and uppercase the HTTP method.
+ static func normalizeMethod(_ method: String) throws -> String {
+ let upper = method.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+ guard allowedMethods.contains(upper) else {
+ throw ValidationError.invalidMethod(method)
+ }
+ return upper
+ }
+
+ /// Validate `hostname` as a DNS host (used for SNI, Host header and cert matching).
+ static func validateHostname(_ hostname: String) throws {
+ guard hostname.count <= 253, !hostname.isEmpty else {
+ throw ValidationError.invalidHostname(hostname)
+ }
+ // Labels: 1-63 chars, alphanumeric + hyphen, not starting/ending with hyphen.
+ let pattern = "^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$"
+ guard hostname.range(of: pattern, options: .regularExpression) != nil else {
+ throw ValidationError.invalidHostname(hostname)
+ }
+ }
+
+ /// Validate `path`: must be a relative path/query only. Reject absolute URLs
+ /// (scheme/authority), protocol-relative URLs and control characters so the
+ /// caller cannot override scheme/host/port or downgrade to cleartext.
+ static func normalizePath(_ path: String) throws -> String {
+ let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
+ if containsControlCharacters(trimmed) {
+ throw ValidationError.invalidPath(path)
+ }
+ // Reject anything that looks like it carries a scheme or authority.
+ if trimmed.contains("://") || trimmed.hasPrefix("//") {
+ throw ValidationError.invalidPath(path)
+ }
+ // A bare scheme like "javascript:..." has no "//"; reject any leading scheme.
+ if let schemeRange = trimmed.range(of: "^[A-Za-z][A-Za-z0-9+.-]*:", options: .regularExpression),
+ schemeRange.lowerBound == trimmed.startIndex {
+ throw ValidationError.invalidPath(path)
+ }
+ if trimmed.isEmpty { return "/" }
+ return trimmed.hasPrefix("/") ? trimmed : "/" + trimmed
+ }
+
+ /// Validate header names/values: reject CR/LF/control characters (header
+ /// injection) and the Host header (the module sets Host itself).
+ static func validateHeaders(_ headers: [String: String]) throws {
+ for (key, value) in headers {
+ if key.isEmpty || containsControlCharacters(key) || containsControlCharacters(value) {
+ throw ValidationError.invalidHeader(key)
+ }
+ }
+ }
+
+ static func containsControlCharacters(_ s: String) -> Bool {
+ return s.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F }
+ }
+
+ // MARK: - IP validation
+
+ /// Validate `ip` is a literal IPv4/IPv6 address (never a hostname) and routes
+ /// to a public/global-unicast destination. Rejects loopback, private,
+ /// link-local (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved.
+ static func validatePublicIP(_ ip: String) throws {
+ if let v4 = parseIPv4(ip) {
+ if isForbiddenIPv4(v4) { throw ValidationError.forbiddenIP(ip) }
+ return
+ }
+ if let v6 = parseIPv6(ip) {
+ if isForbiddenIPv6(v6) { throw ValidationError.forbiddenIP(ip) }
+ return
+ }
+ throw ValidationError.invalidIP(ip)
+ }
+
+ private static func parseIPv4(_ ip: String) -> [UInt8]? {
+ var addr = in_addr()
+ guard ip.withCString({ inet_pton(AF_INET, $0, &addr) }) == 1 else { return nil }
+ let raw = addr.s_addr.bigEndian
+ return [
+ UInt8((raw >> 24) & 0xFF),
+ UInt8((raw >> 16) & 0xFF),
+ UInt8((raw >> 8) & 0xFF),
+ UInt8(raw & 0xFF),
+ ]
+ }
+
+ private static func parseIPv6(_ ip: String) -> [UInt8]? {
+ var addr = in6_addr()
+ guard ip.withCString({ inet_pton(AF_INET6, $0, &addr) }) == 1 else { return nil }
+ return withUnsafeBytes(of: &addr) { Array($0.bindMemory(to: UInt8.self)) }
+ }
+
+ private static func isForbiddenIPv4(_ b: [UInt8]) -> Bool {
+ let a = b[0], c = b[1], d = b[2]
+ if a == 0 { return true } // 0.0.0.0/8 "this network"
+ if a == 10 { return true } // 10/8 private
+ if a == 127 { return true } // 127/8 loopback
+ if a == 100 && (c & 0xC0) == 0x40 { return true } // 100.64/10 CGNAT
+ if a == 169 && c == 254 { return true } // 169.254/16 link-local + metadata
+ if a == 172 && c >= 16 && c <= 31 { return true } // 172.16/12 private
+ if a == 192 && c == 168 { return true } // 192.168/16 private
+ if a == 192 && c == 0 && d == 0 { return true } // 192.0.0/24
+ if a == 192 && c == 0 && d == 2 { return true } // 192.0.2/24 TEST-NET-1
+ if a == 198 && (c == 18 || c == 19) { return true } // 198.18/15 benchmarking
+ if a == 198 && c == 51 && d == 100 { return true } // 198.51.100/24 TEST-NET-2
+ if a == 203 && c == 0 && d == 113 { return true } // 203.0.113/24 TEST-NET-3
+ if a >= 224 { return true } // 224/4 multicast + 240/4 reserved + broadcast
+ return false
+ }
+
+ private static func isForbiddenIPv6(_ b: [UInt8]) -> Bool {
+ // Unspecified ::
+ if b.allSatisfy({ $0 == 0 }) { return true }
+ // Loopback ::1
+ if b[0...14].allSatisfy({ $0 == 0 }) && b[15] == 1 { return true }
+ // Multicast ff00::/8
+ if b[0] == 0xFF { return true }
+ // Link-local fe80::/10
+ if b[0] == 0xFE && (b[1] & 0xC0) == 0x80 { return true }
+ // Unique local fc00::/7
+ if (b[0] & 0xFE) == 0xFC { return true }
+ // IPv4-mapped ::ffff:0:0/96 — validate the embedded IPv4
+ if b[0...9].allSatisfy({ $0 == 0 }) && b[10] == 0xFF && b[11] == 0xFF {
+ return isForbiddenIPv4([b[12], b[13], b[14], b[15]])
+ }
+ return false
+ }
+}
diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json
new file mode 100644
index 00000000..f934dfa3
--- /dev/null
+++ b/native-modules/react-native-sni-connect/package.json
@@ -0,0 +1,157 @@
+{
+ "name": "@onekeyfe/react-native-sni-connect",
+ "version": "3.0.66",
+ "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management",
+ "main": "./lib/module/index.js",
+ "types": "./lib/typescript/src/index.d.ts",
+ "exports": {
+ ".": {
+ "source": "./src/index.tsx",
+ "types": "./lib/typescript/src/index.d.ts",
+ "default": "./lib/module/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "src",
+ "lib",
+ "android",
+ "ios",
+ "*.podspec",
+ "!ios/build",
+ "!android/build",
+ "!android/gradle",
+ "!android/gradlew",
+ "!android/gradlew.bat",
+ "!android/local.properties",
+ "!**/__tests__",
+ "!**/__fixtures__",
+ "!**/__mocks__",
+ "!**/.*",
+ "!**/*.map"
+ ],
+ "scripts": {
+ "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib",
+ "prepare": "bob build",
+ "typecheck": "tsc",
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
+ "test": "jest",
+ "release": "yarn prepare && npm whoami && npm publish --access public"
+ },
+ "keywords": [
+ "react-native",
+ "ios",
+ "android"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-sni-connect.git"
+ },
+ "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect/issues"
+ },
+ "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect#readme",
+ "publishConfig": {
+ "registry": "https://registry.npmjs.org/"
+ },
+ "devDependencies": {
+ "@commitlint/config-conventional": "^19.8.1",
+ "@eslint/compat": "^1.3.2",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "^9.35.0",
+ "@react-native/babel-preset": "0.83.0",
+ "@react-native/eslint-config": "0.83.0",
+ "@release-it/conventional-changelog": "^10.0.1",
+ "@types/jest": "^29.5.14",
+ "@types/react": "^19.2.0",
+ "commitlint": "^19.8.1",
+ "del-cli": "^6.0.0",
+ "eslint": "^9.35.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.4",
+ "jest": "^29.7.0",
+ "lefthook": "^2.0.3",
+ "prettier": "^2.8.8",
+ "react": "19.2.0",
+ "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch",
+ "react-native-builder-bob": "^0.40.17",
+ "release-it": "^19.0.4",
+ "turbo": "^2.5.6",
+ "typescript": "^5.9.2"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ },
+ "react-native-builder-bob": {
+ "source": "src",
+ "output": "lib",
+ "targets": [
+ [
+ "module",
+ {
+ "esm": true
+ }
+ ],
+ [
+ "typescript",
+ {
+ "project": "tsconfig.build.json"
+ }
+ ]
+ ]
+ },
+ "codegenConfig": {
+ "name": "SniConnectSpec",
+ "type": "modules",
+ "jsSrcsDir": "src",
+ "android": {
+ "javaPackageName": "com.sniconnect"
+ }
+ },
+ "prettier": {
+ "quoteProps": "consistent",
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "es5",
+ "useTabs": false
+ },
+ "jest": {
+ "preset": "react-native",
+ "modulePathIgnorePatterns": [
+ "/example/node_modules",
+ "/lib/"
+ ]
+ },
+ "commitlint": {
+ "extends": [
+ "@commitlint/config-conventional"
+ ]
+ },
+ "release-it": {
+ "git": {
+ "commitMessage": "chore: release ${version}",
+ "tagName": "v${version}"
+ },
+ "npm": {
+ "publish": true
+ },
+ "github": {
+ "release": true
+ },
+ "plugins": {
+ "@release-it/conventional-changelog": {
+ "preset": {
+ "name": "angular"
+ }
+ }
+ }
+ },
+ "create-react-native-library": {
+ "languages": "kotlin-objc",
+ "type": "turbo-module",
+ "version": "0.54.8"
+ }
+}
diff --git a/native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts b/native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts
new file mode 100644
index 00000000..6bc99af4
--- /dev/null
+++ b/native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts
@@ -0,0 +1,8 @@
+declare module 'react-native/Libraries/Types/CodegenTypes' {
+ export type Int32 = number;
+ export type Double = number;
+ export type Float = number;
+ export type UnsafeObject = Record;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ export type WithDefault = Type;
+}
diff --git a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts
new file mode 100644
index 00000000..9d93ebd2
--- /dev/null
+++ b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts
@@ -0,0 +1,61 @@
+import {
+ NativeModules,
+ Platform,
+ TurboModuleRegistry,
+ type TurboModule,
+} from 'react-native';
+import type { Double, Int32 } from 'react-native/Libraries/Types/CodegenTypes';
+
+type HeaderMap = {
+ [key: string]: string;
+};
+
+export type SniConnectRequest = {
+ requestId?: string;
+ ip: string;
+ hostname: string;
+ method: string;
+ path: string;
+ headers: HeaderMap;
+ body?: string | null;
+ timeout: Double;
+};
+
+export type SniConnectResponse = {
+ data: string;
+ status: Int32;
+ statusText: string;
+ headers: HeaderMap;
+};
+
+export interface Spec extends TurboModule {
+ request(config: SniConnectRequest): Promise;
+ cancelRequest(requestId: string): Promise<{ success: boolean }>;
+ cancelAllRequests(): Promise<{ success: boolean }>;
+ clearDNSCache(): Promise<{ success: boolean }>;
+}
+
+const LINKING_ERROR =
+ `The package '@onekeyfe/react-native-sni-connect' doesn't seem to be linked. Make sure:\n\n` +
+ Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) +
+ '- You rebuilt the app after installing the package\n' +
+ '- You are not using Expo Go; create a custom dev client instead\n';
+
+export type SniConnectModule = Spec;
+
+const turboModuleResult = TurboModuleRegistry.get('SniConnect');
+
+const turboModule: Spec | null = turboModuleResult ?? null;
+
+const bridgeModule: Spec | null =
+ (NativeModules.SniConnect as Spec | undefined) ?? null;
+
+const nativeModule = (turboModule ?? bridgeModule) as SniConnectModule | null;
+
+if (nativeModule == null) {
+ throw new Error(LINKING_ERROR);
+}
+
+const NativeSniConnect: SniConnectModule = nativeModule;
+
+export default NativeSniConnect;
diff --git a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts
new file mode 100644
index 00000000..cc56b008
--- /dev/null
+++ b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts
@@ -0,0 +1,66 @@
+jest.mock('../NativeSniConnect', () => ({
+ __esModule: true,
+ default: {
+ request: jest.fn(),
+ cancelRequest: jest.fn(),
+ cancelAllRequests: jest.fn(),
+ clearDNSCache: jest.fn(),
+ },
+}));
+
+import {
+ cancelAllRequests,
+ cancelRequest,
+ clearDNSCache,
+ request,
+} from '../index';
+import NativeSniConnect, {
+ type SniConnectRequest,
+} from '../NativeSniConnect';
+
+const mockNativeModule = NativeSniConnect as unknown as {
+ request: jest.Mock;
+ cancelRequest: jest.Mock;
+ cancelAllRequests: jest.Mock;
+ clearDNSCache: jest.Mock;
+};
+
+describe('react-native-sni-connect public API', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('forwards request() to the native module and returns its result', async () => {
+ const config: SniConnectRequest = {
+ ip: '93.184.216.34',
+ hostname: 'example.com',
+ method: 'GET',
+ path: '/',
+ headers: {},
+ timeout: 30_000,
+ };
+ const response = { data: '{}', status: 200, statusText: 'OK', headers: {} };
+ mockNativeModule.request.mockResolvedValue(response);
+
+ await expect(request(config)).resolves.toBe(response);
+ expect(mockNativeModule.request).toHaveBeenCalledWith(config);
+ });
+
+ it('forwards cancelRequest() with the request id', async () => {
+ mockNativeModule.cancelRequest.mockResolvedValue({ success: true });
+ await expect(cancelRequest('req-1')).resolves.toEqual({ success: true });
+ expect(mockNativeModule.cancelRequest).toHaveBeenCalledWith('req-1');
+ });
+
+ it('forwards cancelAllRequests()', async () => {
+ mockNativeModule.cancelAllRequests.mockResolvedValue({ success: true });
+ await expect(cancelAllRequests()).resolves.toEqual({ success: true });
+ expect(mockNativeModule.cancelAllRequests).toHaveBeenCalledTimes(1);
+ });
+
+ it('forwards clearDNSCache()', async () => {
+ mockNativeModule.clearDNSCache.mockResolvedValue({ success: true });
+ await expect(clearDNSCache()).resolves.toEqual({ success: true });
+ expect(mockNativeModule.clearDNSCache).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/native-modules/react-native-sni-connect/src/index.tsx b/native-modules/react-native-sni-connect/src/index.tsx
new file mode 100644
index 00000000..e75deb12
--- /dev/null
+++ b/native-modules/react-native-sni-connect/src/index.tsx
@@ -0,0 +1,26 @@
+import NativeSniConnect, {
+ type SniConnectRequest,
+ type SniConnectResponse,
+} from './NativeSniConnect';
+
+export function request(
+ config: SniConnectRequest
+): Promise {
+ return NativeSniConnect.request(config);
+}
+
+export function cancelRequest(
+ requestId: string
+): Promise<{ success: boolean }> {
+ return NativeSniConnect.cancelRequest(requestId);
+}
+
+export function cancelAllRequests(): Promise<{ success: boolean }> {
+ return NativeSniConnect.cancelAllRequests();
+}
+
+export function clearDNSCache(): Promise<{ success: boolean }> {
+ return NativeSniConnect.clearDNSCache();
+}
+
+export type { SniConnectRequest, SniConnectResponse } from './NativeSniConnect';
diff --git a/native-modules/react-native-sni-connect/tsconfig.build.json b/native-modules/react-native-sni-connect/tsconfig.build.json
new file mode 100644
index 00000000..3c0636ad
--- /dev/null
+++ b/native-modules/react-native-sni-connect/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig",
+ "exclude": ["example", "lib"]
+}
diff --git a/native-modules/react-native-sni-connect/tsconfig.json b/native-modules/react-native-sni-connect/tsconfig.json
new file mode 100644
index 00000000..d36b8298
--- /dev/null
+++ b/native-modules/react-native-sni-connect/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "rootDir": ".",
+ "paths": {
+ "@onekeyfe/react-native-sni-connect": ["./src/index"]
+ },
+ "allowUnreachableCode": false,
+ "allowUnusedLabels": false,
+ "customConditions": ["react-native-strict-api"],
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "jsx": "react-jsx",
+ "lib": ["ESNext"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitReturns": true,
+ "noImplicitUseStrict": false,
+ "noStrictGenericChecks": false,
+ "noUncheckedIndexedAccess": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ESNext",
+ "verbatimModuleSyntax": true
+ }
+}
diff --git a/native-modules/react-native-sni-connect/turbo.json b/native-modules/react-native-sni-connect/turbo.json
new file mode 100644
index 00000000..c4d78c49
--- /dev/null
+++ b/native-modules/react-native-sni-connect/turbo.json
@@ -0,0 +1,42 @@
+{
+ "$schema": "https://turbo.build/schema.json",
+ "globalDependencies": [".nvmrc", ".yarnrc.yml"],
+ "globalEnv": ["NODE_ENV"],
+ "tasks": {
+ "build:android": {
+ "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"],
+ "inputs": [
+ "package.json",
+ "android",
+ "!android/build",
+ "src/*.ts",
+ "src/*.tsx",
+ "example/package.json",
+ "example/android",
+ "!example/android/.gradle",
+ "!example/android/build",
+ "!example/android/app/build"
+ ],
+ "outputs": []
+ },
+ "build:ios": {
+ "env": [
+ "RCT_NEW_ARCH_ENABLED",
+ "RCT_USE_RN_DEP",
+ "RCT_USE_PREBUILT_RNCORE"
+ ],
+ "inputs": [
+ "package.json",
+ "*.podspec",
+ "ios",
+ "src/*.ts",
+ "src/*.tsx",
+ "example/package.json",
+ "example/ios",
+ "!example/ios/build",
+ "!example/ios/Pods"
+ ],
+ "outputs": []
+ }
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 266ebb7f..ed78ef59 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2772,6 +2772,7 @@ __metadata:
"@onekeyfe/react-native-scroll-guard": "workspace:*"
"@onekeyfe/react-native-segment-slider": "workspace:*"
"@onekeyfe/react-native-skeleton": "workspace:*"
+ "@onekeyfe/react-native-sni-connect": "workspace:*"
"@onekeyfe/react-native-splash-screen": "workspace:*"
"@onekeyfe/react-native-split-bundle-loader": "workspace:*"
"@onekeyfe/react-native-tab-view": "workspace:*"
@@ -3751,6 +3752,39 @@ __metadata:
languageName: unknown
linkType: soft
+"@onekeyfe/react-native-sni-connect@workspace:*, @onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect":
+ version: 0.0.0-use.local
+ resolution: "@onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect"
+ dependencies:
+ "@commitlint/config-conventional": "npm:^19.8.1"
+ "@eslint/compat": "npm:^1.3.2"
+ "@eslint/eslintrc": "npm:^3.3.1"
+ "@eslint/js": "npm:^9.35.0"
+ "@react-native/babel-preset": "npm:0.83.0"
+ "@react-native/eslint-config": "npm:0.83.0"
+ "@release-it/conventional-changelog": "npm:^10.0.1"
+ "@types/jest": "npm:^29.5.14"
+ "@types/react": "npm:^19.2.0"
+ commitlint: "npm:^19.8.1"
+ del-cli: "npm:^6.0.0"
+ eslint: "npm:^9.35.0"
+ eslint-config-prettier: "npm:^10.1.8"
+ eslint-plugin-prettier: "npm:^5.5.4"
+ jest: "npm:^29.7.0"
+ lefthook: "npm:^2.0.3"
+ prettier: "npm:^2.8.8"
+ react: "npm:19.2.0"
+ react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch"
+ react-native-builder-bob: "npm:^0.40.17"
+ release-it: "npm:^19.0.4"
+ turbo: "npm:^2.5.6"
+ typescript: "npm:^5.9.2"
+ peerDependencies:
+ react: "*"
+ react-native: "*"
+ languageName: unknown
+ linkType: soft
+
"@onekeyfe/react-native-splash-screen@workspace:*, @onekeyfe/react-native-splash-screen@workspace:native-modules/react-native-splash-screen":
version: 0.0.0-use.local
resolution: "@onekeyfe/react-native-splash-screen@workspace:native-modules/react-native-splash-screen"