@exodus/react-native-bundle-loader 0.2.0-exodus.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/SECURITY.md +57 -0
- package/android/build.gradle +119 -0
- package/android/gradle.properties +3 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java +152 -0
- package/android/src/main/java/com/reactnativebundleloader/BundleLoaderPackage.java +31 -0
- package/ios/BundleLoader.h +6 -0
- package/ios/BundleLoader.m +61 -0
- package/ios/BundleLoader.xcodeproj/project.pbxproj +285 -0
- package/lib/commonjs/index.js +165 -0
- package/lib/module/index.js +144 -0
- package/lib/typescript/index.d.ts +12 -0
- package/package.json +149 -0
- package/react-native-bundle-loader.podspec +19 -0
- package/src/index.tsx +171 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Jusbrasil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @exodus/react-native-bundle-loader
|
|
2
|
+
|
|
3
|
+
Loads a remote React Native JS bundle, with optional **hash-pinned integrity verification** before the bridge reloads.
|
|
4
|
+
|
|
5
|
+
> This is the Exodus security-hardened fork of [`react-native-bundle-loader`](https://www.npmjs.com/package/react-native-bundle-loader)
|
|
6
|
+
> (originally by Jusbrasil; upstream GitHub repo deleted; see [provenance](#provenance) for details).
|
|
7
|
+
|
|
8
|
+
## Threat model
|
|
9
|
+
|
|
10
|
+
Loading a remote JS bundle is, by construction, remote code execution inside the host app. **This library is intended for internal/development builds only — do not ship it in store builds without an out-of-band, statically-stripped feature flag.** See `SECURITY.md`.
|
|
11
|
+
|
|
12
|
+
The `loadVerified()` API closes the dominant runtime risk: it fetches the bundle bytes itself, hashes them with `@exodus/crypto`, compares the hash to a caller-supplied digest in constant time, and only then asks the bridge to reload from the verified bytes. Anything that mutates the response between fetch and reload is rejected.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
yarn add @exodus/react-native-bundle-loader
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
iOS:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
cd ios && pod install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Android: `BundleLoaderPackage` is autolinked.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### Verified loading (recommended)
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import BundleLoader from '@exodus/react-native-bundle-loader';
|
|
34
|
+
|
|
35
|
+
await BundleLoader.loadVerified(
|
|
36
|
+
'https://bundles.example.com/main.jsbundle',
|
|
37
|
+
// Lower-case hex sha256, exactly 64 chars
|
|
38
|
+
'4f1b9c…ec'
|
|
39
|
+
);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Behavior:
|
|
43
|
+
|
|
44
|
+
- The URL must use the `https:` scheme.
|
|
45
|
+
- The expected sha256 must be a 64-character hex string.
|
|
46
|
+
- The bytes are fetched, hashed in JS using `@exodus/crypto/hash`, and compared to the expected hash with a constant-time comparison.
|
|
47
|
+
- On match, the bytes are written to the platform's app-private cache (iOS: `NSTemporaryDirectory()` with `NSDataWritingFileProtectionComplete`; Android: `Context.getCacheDir()`) and the bridge is reloaded from the local file path.
|
|
48
|
+
- On mismatch, an error is thrown and the bridge is left untouched.
|
|
49
|
+
|
|
50
|
+
Works on iOS and Android. The hash check happens in JS before any native call, so the integrity contract is identical on both platforms.
|
|
51
|
+
|
|
52
|
+
### Unverified loading
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
BundleLoader.load('https://bundles.example.com/main.jsbundle');
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Functionally identical to the upstream `load()`: passes the URL straight through to the native bridge, which fetches and reloads. **This skips integrity verification — only use it for developer ergonomics, never in production paths.**
|
|
59
|
+
|
|
60
|
+
The URL is required to use `https:`.
|
|
61
|
+
|
|
62
|
+
### `BundlePrompt`
|
|
63
|
+
|
|
64
|
+
A `Modal`-wrapped text input + Reload button intended for developer UX. The default URL field is **empty** (the upstream's hardcoded jsdelivr default has been removed). The button calls the unverified `load()` path.
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { BundlePrompt } from '@exodus/react-native-bundle-loader';
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Do not render `BundlePrompt` in store builds.
|
|
71
|
+
|
|
72
|
+
## Accessing a running Metro packager
|
|
73
|
+
|
|
74
|
+
Same idea as upstream: expose your local Metro packager via a tunnel (e.g. `ngrok http 8081`) and call `BundleLoader.load(<https tunnel URL>)`. Required Metro query params:
|
|
75
|
+
|
|
76
|
+
- `dev`: `true` or `false` matching how the binary was built
|
|
77
|
+
- `excludeSource`: `true`
|
|
78
|
+
- `platform`: `ios` or `android` matching the host
|
|
79
|
+
|
|
80
|
+
Example: `https://example.ngrok.io/index.bundle?dev=false&platform=ios&excludeSource=true`
|
|
81
|
+
|
|
82
|
+
## Platform support
|
|
83
|
+
|
|
84
|
+
| Capability | iOS | Android |
|
|
85
|
+
| --------------------------- | --- | ------- |
|
|
86
|
+
| `load(url)` | ✅ | ✅ |
|
|
87
|
+
| `loadVerified(url, sha256)` | ✅ | ✅ |
|
|
88
|
+
| `runningMode()` | ✅ | ✅ |
|
|
89
|
+
|
|
90
|
+
### How the in-process swap works
|
|
91
|
+
|
|
92
|
+
- **iOS** writes the bundle to `NSTemporaryDirectory()` and sets the bridge's `bundleURL` via KVC (`[bridge setValue:url forKey:@"bundleURL"]`), then calls `[bridge reload]`.
|
|
93
|
+
- **Android** writes the bundle to `Context.getCacheDir()`, builds a `JSBundleLoader.createFileLoader(path)`, swaps it into the private `mBundleLoader` field on `ReactInstanceManager` via reflection, and calls `recreateReactContextInBackground()`.
|
|
94
|
+
|
|
95
|
+
Both mechanisms touch private/internal React Native surface and could break on a major RN upgrade. See `SECURITY.md`. The Android implementation requires the host app to implement `ReactApplication` (the standard React Native template does).
|
|
96
|
+
|
|
97
|
+
## Provenance
|
|
98
|
+
|
|
99
|
+
This is a fork of `react-native-bundle-loader@0.1.0` originally published by Jusbrasil (2020-10-21, npm publisher `helielson`, commit `ec3d4520`). The upstream GitHub repo at `github.com/jusbrasil/react-native-bundle-loader` was subsequently deleted. The complete original git history is preserved through the v0.1.0 release commit; the Android implementation was contributed by `milad.bagherii@digikala.com` in the `mldb/react-native-bundle-loader` mirror in 2021.
|
|
100
|
+
|
|
101
|
+
## Security
|
|
102
|
+
|
|
103
|
+
See `SECURITY.md` for the threat model, accepted residual risks, and disclosure procedure.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Email `security@exodus.com`. We will acknowledge within 2 business days. Do not file a public GitHub issue for security reports.
|
|
6
|
+
|
|
7
|
+
## Threat model
|
|
8
|
+
|
|
9
|
+
This library exists to load and execute a remote JavaScript bundle inside the host React Native app. That is, by construction, **a remote code execution primitive** with the host app's full bridge surface. Two consequences:
|
|
10
|
+
|
|
11
|
+
1. The library has no business in a store-distributed binary unless protected by a build-flavor flag that is statically stripped (not just runtime-gated). A leaked release build with this library reachable is equivalent to a leaked signing key.
|
|
12
|
+
2. Any defense the library offers is layered on top of that fundamental risk. It does not eliminate it.
|
|
13
|
+
|
|
14
|
+
## What this fork hardens vs. upstream
|
|
15
|
+
|
|
16
|
+
| Surface | Upstream `0.1.0` | This fork |
|
|
17
|
+
| ------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
|
+
| Bundle integrity | None — bridge fetches whatever the URL serves | `loadVerified(url, sha256)` fetches bytes in JS, hashes with `@exodus/crypto`, compares constant-time, only then reloads from disk |
|
|
19
|
+
| `BundlePrompt` default URL | Hardcoded `cdn.jsdelivr.net/gh/jusbrasil/...` (deleted) | Empty — operator must type a URL |
|
|
20
|
+
| Scheme enforcement | None — accepts `http://`, `file://`, etc. | `https://` required at the JS boundary; native iOS `load:` re-checks |
|
|
21
|
+
| Verified bundle on-disk protection (iOS) | n/a | Written with `NSDataWritingFileProtectionComplete` |
|
|
22
|
+
| Lockfile | Not shipped | `yarn.lock` committed; `.yarnrc` enforces `--frozen-lockfile` |
|
|
23
|
+
| Dependency version pinning | Carets (`^`) | All direct deps pinned to exact versions; `.npmrc` `save-exact=true` |
|
|
24
|
+
| Maven `+` on `react-native` | Yes | Configurable via `rootProject.ext.reactNativeVersion`; default `+` is the only fallback |
|
|
25
|
+
| Maven `jcenter()` | In repo list | Removed (sunset 2021) |
|
|
26
|
+
| Husky 4 auto-postinstall | Yes | Husky removed entirely |
|
|
27
|
+
| `release-it` publishing | Yes | Removed; releases are manual `npm publish` after `yarn preflight` passes |
|
|
28
|
+
| Eclipse / Xcode user-state in repo | Committed | Removed and gitignored |
|
|
29
|
+
| Source maps shipped to npm | Yes (`lib/**/*.map`) | Excluded via `files` array; tarball denied by `verify-pack` if any reappear |
|
|
30
|
+
| Tarball contents | Anything matching `files` blanket globs | Strict `.npm-tarball-allowlist` exact-match enforced by `yarn verify-pack` |
|
|
31
|
+
| `example/public/ios.min.js` (700kB blob) | Committed; served from jsdelivr to any `BundlePrompt` | Removed along with the rest of `example/` |
|
|
32
|
+
| CircleCI / Node 10 build container | `.circleci/config.yml` shipped | Removed |
|
|
33
|
+
|
|
34
|
+
## Accepted residual risks
|
|
35
|
+
|
|
36
|
+
- **The bridge `bundleURL` setter is a KVC write** on iOS (`[bridge setValue:url forKey:@"bundleURL"]`) to a non-public RN property. Behavior could change on an RN upgrade and silently no-op the loader.
|
|
37
|
+
- **The Android bundle swap reflects on a private field.** `ReactInstanceManager.mBundleLoader` has no public setter, so we use `Field.setAccessible(true)` to install a fresh `JSBundleLoader.createFileLoader(...)` before calling `recreateReactContextInBackground()`. The field name has been stable across RN 0.62–0.74 but is not part of the public API; an RN upgrade could rename or remove it, in which case `loadVerified`/`load` will throw `NoSuchFieldException` rather than silently no-op.
|
|
38
|
+
- **`@exodus/crypto/hash` runs in JS on the JS thread.** Bundles are typically a few MB; hashing time is acceptable. We deliberately keep hashing in JS so the threat-model contract — "Exodus crypto verifies, and the verified bytes are what we hand to native" — is auditable in TypeScript and identical on iOS and Android.
|
|
39
|
+
- **`timingSafeEqual()` is currently inlined** as a small constant-time XOR loop. The threat model anticipates this moving to a future `@exodus/crypto` export. The inlined version is functionally equivalent and lives in `src/index.tsx`.
|
|
40
|
+
|
|
41
|
+
## Release process
|
|
42
|
+
|
|
43
|
+
This package has no CI/CD. Maintainers cut releases manually from a developer machine:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
yarn preflight # lint + typecheck + test + verify-pack
|
|
47
|
+
npm publish --access public
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`yarn verify-pack` runs `npm pack` and diffs the resulting tarball file list against `.npm-tarball-allowlist`. Any deviation (extra files, missing files, renames) fails the script. Adding a new shippable file to the package requires updating `.npm-tarball-allowlist` in the same commit so the change is reviewable.
|
|
51
|
+
|
|
52
|
+
The npm publish credential lives on the publishing maintainer's machine. There is no shared CI token, and no GitHub Actions workflow holds publish rights. The trust boundary is whoever has `npm login` access to `@exodus`.
|
|
53
|
+
|
|
54
|
+
## Out of scope
|
|
55
|
+
|
|
56
|
+
- The host app's CI pipeline that mints the deep links carrying `(url, sha256)` pairs is **not** covered by this library. Threats #1, #5, #6, #7, #8 in the originating threat model are the consuming app's responsibility.
|
|
57
|
+
- Bundle code review and signing are out of scope. This library verifies *what was hashed by your CI* matches *what the device fetched*. It cannot tell you that the bundle does not contain malicious JS.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
repositories {
|
|
3
|
+
google()
|
|
4
|
+
mavenCentral()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
dependencies {
|
|
8
|
+
classpath 'com.android.tools.build:gradle:7.4.2'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
apply plugin: 'com.android.library'
|
|
13
|
+
|
|
14
|
+
def getExtOrDefault(name) {
|
|
15
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['BundleLoader_' + name]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def getExtOrIntegerDefault(name) {
|
|
19
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties['BundleLoader_' + name]).toInteger()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
android {
|
|
23
|
+
compileSdkVersion getExtOrIntegerDefault('compileSdkVersion')
|
|
24
|
+
buildToolsVersion getExtOrDefault('buildToolsVersion')
|
|
25
|
+
defaultConfig {
|
|
26
|
+
minSdkVersion 24
|
|
27
|
+
targetSdkVersion getExtOrIntegerDefault('targetSdkVersion')
|
|
28
|
+
versionCode 1
|
|
29
|
+
versionName "1.0"
|
|
30
|
+
}
|
|
31
|
+
buildTypes {
|
|
32
|
+
release {
|
|
33
|
+
minifyEnabled false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
lintOptions {
|
|
37
|
+
disable 'GradleCompatible'
|
|
38
|
+
}
|
|
39
|
+
compileOptions {
|
|
40
|
+
sourceCompatibility JavaVersion.VERSION_11
|
|
41
|
+
targetCompatibility JavaVersion.VERSION_11
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
repositories {
|
|
46
|
+
google()
|
|
47
|
+
mavenCentral()
|
|
48
|
+
|
|
49
|
+
def found = false
|
|
50
|
+
def defaultDir = null
|
|
51
|
+
def androidSourcesName = 'React Native sources'
|
|
52
|
+
|
|
53
|
+
if (rootProject.ext.has('reactNativeAndroidRoot')) {
|
|
54
|
+
defaultDir = rootProject.ext.get('reactNativeAndroidRoot')
|
|
55
|
+
} else {
|
|
56
|
+
defaultDir = new File(
|
|
57
|
+
projectDir,
|
|
58
|
+
'/../../../node_modules/react-native/android'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (defaultDir.exists()) {
|
|
63
|
+
maven {
|
|
64
|
+
url defaultDir.toString()
|
|
65
|
+
name androidSourcesName
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
logger.info(":${project.name}:reactNativeAndroidRoot ${defaultDir.canonicalPath}")
|
|
69
|
+
found = true
|
|
70
|
+
} else {
|
|
71
|
+
def parentDir = rootProject.projectDir
|
|
72
|
+
|
|
73
|
+
1.upto(5, {
|
|
74
|
+
if (found) return true
|
|
75
|
+
parentDir = parentDir.parentFile
|
|
76
|
+
|
|
77
|
+
def androidSourcesDir = new File(
|
|
78
|
+
parentDir,
|
|
79
|
+
'node_modules/react-native'
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def androidPrebuiltBinaryDir = new File(
|
|
83
|
+
parentDir,
|
|
84
|
+
'node_modules/react-native/android'
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if (androidPrebuiltBinaryDir.exists()) {
|
|
88
|
+
maven {
|
|
89
|
+
url androidPrebuiltBinaryDir.toString()
|
|
90
|
+
name androidSourcesName
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
logger.info(":${project.name}:reactNativeAndroidRoot ${androidPrebuiltBinaryDir.canonicalPath}")
|
|
94
|
+
found = true
|
|
95
|
+
} else if (androidSourcesDir.exists()) {
|
|
96
|
+
maven {
|
|
97
|
+
url androidSourcesDir.toString()
|
|
98
|
+
name androidSourcesName
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
logger.info(":${project.name}:reactNativeAndroidRoot ${androidSourcesDir.canonicalPath}")
|
|
102
|
+
found = true
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!found) {
|
|
108
|
+
throw new GradleException(
|
|
109
|
+
"${project.name}: unable to locate React Native android sources. " +
|
|
110
|
+
"Ensure you have you installed React Native as a dependency in your project and try again."
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def reactNativeArtifactVersion = rootProject.ext.has('reactNativeVersion') ? rootProject.ext.get('reactNativeVersion') : '+'
|
|
116
|
+
|
|
117
|
+
dependencies {
|
|
118
|
+
api "com.facebook.react:react-native:${reactNativeArtifactVersion}"
|
|
119
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
package com.reactnativebundleloader;
|
|
2
|
+
|
|
3
|
+
import android.util.Base64;
|
|
4
|
+
import android.util.Log;
|
|
5
|
+
|
|
6
|
+
import androidx.annotation.NonNull;
|
|
7
|
+
|
|
8
|
+
import com.facebook.react.ReactApplication;
|
|
9
|
+
import com.facebook.react.ReactInstanceManager;
|
|
10
|
+
import com.facebook.react.bridge.JSBundleLoader;
|
|
11
|
+
import com.facebook.react.bridge.Promise;
|
|
12
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
13
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
|
14
|
+
import com.facebook.react.bridge.ReactMethod;
|
|
15
|
+
|
|
16
|
+
import java.io.File;
|
|
17
|
+
import java.io.FileOutputStream;
|
|
18
|
+
import java.io.IOException;
|
|
19
|
+
import java.io.InputStream;
|
|
20
|
+
import java.lang.reflect.Field;
|
|
21
|
+
import java.net.HttpURLConnection;
|
|
22
|
+
import java.net.URL;
|
|
23
|
+
|
|
24
|
+
public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
25
|
+
|
|
26
|
+
private static final String TAG = "BundleLoader";
|
|
27
|
+
private static final String BUNDLE_FILENAME = "verified-bundle.jsbundle";
|
|
28
|
+
private static final int CONNECT_TIMEOUT_MS = 30_000;
|
|
29
|
+
private static final int READ_TIMEOUT_MS = 30_000;
|
|
30
|
+
private static final int MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
private static volatile boolean remoteLoaded = false;
|
|
33
|
+
|
|
34
|
+
BundleLoaderModule(ReactApplicationContext context) {
|
|
35
|
+
super(context);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@NonNull
|
|
39
|
+
@Override
|
|
40
|
+
public String getName() {
|
|
41
|
+
return "BundleLoader";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@ReactMethod
|
|
45
|
+
public void load(final String url) {
|
|
46
|
+
if (url == null || !url.startsWith("https://")) {
|
|
47
|
+
Log.e(TAG, "Bundle URL must use the https scheme");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
new Thread(new Runnable() {
|
|
51
|
+
@Override
|
|
52
|
+
public void run() {
|
|
53
|
+
try {
|
|
54
|
+
File bundleFile = downloadToCache(url);
|
|
55
|
+
swapBundleLoaderAndReload(bundleFile);
|
|
56
|
+
} catch (Exception e) {
|
|
57
|
+
Log.e(TAG, "load(" + url + ") failed", e);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}, "BundleLoader-load").start();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@ReactMethod
|
|
64
|
+
public void loadFromBase64(String base64, Promise promise) {
|
|
65
|
+
try {
|
|
66
|
+
byte[] data = Base64.decode(base64, Base64.DEFAULT);
|
|
67
|
+
if (data == null || data.length == 0) {
|
|
68
|
+
promise.reject("E_INVALID_BASE64", "Invalid base64 input");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
File bundleFile = new File(
|
|
72
|
+
getReactApplicationContext().getCacheDir(),
|
|
73
|
+
BUNDLE_FILENAME
|
|
74
|
+
);
|
|
75
|
+
try (FileOutputStream out = new FileOutputStream(bundleFile)) {
|
|
76
|
+
out.write(data);
|
|
77
|
+
}
|
|
78
|
+
swapBundleLoaderAndReload(bundleFile);
|
|
79
|
+
promise.resolve(null);
|
|
80
|
+
} catch (Exception e) {
|
|
81
|
+
promise.reject("E_LOAD_FAILED", e.getMessage(), e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@ReactMethod
|
|
86
|
+
public void runningMode(Promise promise) {
|
|
87
|
+
promise.resolve(remoteLoaded ? "REMOTE" : "LOCAL");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private File downloadToCache(String urlString) throws IOException {
|
|
91
|
+
URL url = new URL(urlString);
|
|
92
|
+
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
|
93
|
+
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
|
94
|
+
conn.setReadTimeout(READ_TIMEOUT_MS);
|
|
95
|
+
// Disallow follow-redirects so an HTTPS URL cannot transparently downgrade to HTTP.
|
|
96
|
+
conn.setInstanceFollowRedirects(false);
|
|
97
|
+
try {
|
|
98
|
+
int code = conn.getResponseCode();
|
|
99
|
+
if (code != HttpURLConnection.HTTP_OK) {
|
|
100
|
+
throw new IOException("Bundle fetch failed: HTTP " + code);
|
|
101
|
+
}
|
|
102
|
+
File file = new File(
|
|
103
|
+
getReactApplicationContext().getCacheDir(),
|
|
104
|
+
BUNDLE_FILENAME
|
|
105
|
+
);
|
|
106
|
+
long total = 0;
|
|
107
|
+
try (InputStream in = conn.getInputStream();
|
|
108
|
+
FileOutputStream out = new FileOutputStream(file)) {
|
|
109
|
+
byte[] buf = new byte[8192];
|
|
110
|
+
int n;
|
|
111
|
+
while ((n = in.read(buf)) != -1) {
|
|
112
|
+
total += n;
|
|
113
|
+
if (total > MAX_BUNDLE_BYTES) {
|
|
114
|
+
throw new IOException(
|
|
115
|
+
"Bundle exceeds " + MAX_BUNDLE_BYTES + " bytes"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
out.write(buf, 0, n);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return file;
|
|
122
|
+
} finally {
|
|
123
|
+
conn.disconnect();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private void swapBundleLoaderAndReload(File bundleFile) throws Exception {
|
|
128
|
+
ReactApplication app = (ReactApplication)
|
|
129
|
+
getReactApplicationContext().getApplicationContext();
|
|
130
|
+
final ReactInstanceManager instanceManager =
|
|
131
|
+
app.getReactNativeHost().getReactInstanceManager();
|
|
132
|
+
|
|
133
|
+
JSBundleLoader bundleLoader =
|
|
134
|
+
JSBundleLoader.createFileLoader(bundleFile.getAbsolutePath());
|
|
135
|
+
|
|
136
|
+
// mBundleLoader is private on ReactInstanceManager and there is no public
|
|
137
|
+
// setter. The host app's ReactNativeHost wires the initial loader at
|
|
138
|
+
// construction; we swap it in-place so the next reload picks up our file.
|
|
139
|
+
Field field = ReactInstanceManager.class.getDeclaredField("mBundleLoader");
|
|
140
|
+
field.setAccessible(true);
|
|
141
|
+
field.set(instanceManager, bundleLoader);
|
|
142
|
+
|
|
143
|
+
remoteLoaded = true;
|
|
144
|
+
|
|
145
|
+
getReactApplicationContext().runOnUiQueueThread(new Runnable() {
|
|
146
|
+
@Override
|
|
147
|
+
public void run() {
|
|
148
|
+
instanceManager.recreateReactContextInBackground();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package com.reactnativebundleloader;
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import com.facebook.react.ReactApplication;
|
|
5
|
+
import com.facebook.react.ReactPackage;
|
|
6
|
+
import com.facebook.react.bridge.NativeModule;
|
|
7
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
8
|
+
import com.facebook.react.uimanager.ViewManager;
|
|
9
|
+
|
|
10
|
+
import java.util.ArrayList;
|
|
11
|
+
import java.util.Collections;
|
|
12
|
+
import java.util.List;
|
|
13
|
+
|
|
14
|
+
public class BundleLoaderPackage implements ReactPackage {
|
|
15
|
+
|
|
16
|
+
@Override
|
|
17
|
+
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
|
18
|
+
return Collections.emptyList();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Override
|
|
22
|
+
public List<NativeModule> createNativeModules(
|
|
23
|
+
ReactApplicationContext reactContext) {
|
|
24
|
+
List<NativeModule> modules = new ArrayList<>();
|
|
25
|
+
|
|
26
|
+
modules.add(new BundleLoaderModule(reactContext));
|
|
27
|
+
|
|
28
|
+
return modules;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#import "BundleLoader.h"
|
|
2
|
+
|
|
3
|
+
@implementation BundleLoader
|
|
4
|
+
|
|
5
|
+
@synthesize bridge = _bridge;
|
|
6
|
+
|
|
7
|
+
RCT_EXPORT_MODULE()
|
|
8
|
+
|
|
9
|
+
- (void)setBundleURLAndReload:(NSURL *)url
|
|
10
|
+
{
|
|
11
|
+
[_bridge setValue:url forKey:@"bundleURL"];
|
|
12
|
+
[_bridge reload];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
RCT_EXPORT_METHOD(runningMode:(RCTPromiseResolveBlock)resolve
|
|
16
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
17
|
+
{
|
|
18
|
+
NSString *scheme = [[_bridge bundleURL] scheme];
|
|
19
|
+
BOOL isRemote = [scheme isEqualToString:@"https"];
|
|
20
|
+
resolve(isRemote ? @"REMOTE" : @"LOCAL");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
RCT_EXPORT_METHOD(load:(NSURL *)url)
|
|
24
|
+
{
|
|
25
|
+
if (![[url scheme] isEqualToString:@"https"]) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
29
|
+
[self setBundleURLAndReload:url];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
RCT_EXPORT_METHOD(loadFromBase64:(NSString *)base64
|
|
34
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
35
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
36
|
+
{
|
|
37
|
+
NSData *data = [[NSData alloc] initWithBase64EncodedString:base64
|
|
38
|
+
options:0];
|
|
39
|
+
if (data == nil) {
|
|
40
|
+
reject(@"E_INVALID_BASE64", @"Invalid base64 input", nil);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
NSString *path = [NSTemporaryDirectory()
|
|
45
|
+
stringByAppendingPathComponent:@"verified-bundle.jsbundle"];
|
|
46
|
+
NSError *err = nil;
|
|
47
|
+
if (![data writeToFile:path
|
|
48
|
+
options:NSDataWritingAtomic | NSDataWritingFileProtectionComplete
|
|
49
|
+
error:&err]) {
|
|
50
|
+
reject(@"E_WRITE_FAILED", err.localizedDescription, err);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
NSURL *fileURL = [NSURL fileURLWithPath:path];
|
|
55
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
56
|
+
[self setBundleURLAndReload:fileURL];
|
|
57
|
+
resolve(nil);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@end
|