@rovq/react-native 0.2.1 → 0.3.0
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 +44 -21
- package/README.md +39 -10
- package/Rovq.podspec +8 -4
- package/android/build.gradle +37 -20
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +4 -4
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +4 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +2 -2
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +78 -16
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +144 -21
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.java +25 -0
- package/ios/React/RovqHost.swift +24 -3
- package/ios/React/RovqModule.mm +49 -23
- package/ios/React/RovqSetup.h +27 -0
- package/ios/React/RovqSetup.mm +34 -0
- package/package.json +10 -27
- package/src/NativeRovq.ts +52 -8
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +0 -26
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +0 -74
package/LICENSE
CHANGED
|
@@ -1,21 +1,44 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 moatazMahdi
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
ROVQ Software License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 moatazMahdi. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software and its source code are the property of the copyright holder.
|
|
6
|
+
They are licensed, not sold.
|
|
7
|
+
|
|
8
|
+
1. Permission. You may install and use this software, without changes, only
|
|
9
|
+
to add over-the-air updates to your own applications through a ROVQ server
|
|
10
|
+
that the copyright holder operates or has authorized. You may include it,
|
|
11
|
+
as part of your compiled application, in the copies of that application
|
|
12
|
+
you distribute to your users.
|
|
13
|
+
|
|
14
|
+
2. Restrictions. Except as section 1 allows, you may not:
|
|
15
|
+
a. copy, modify, translate, or create derivative works of this software;
|
|
16
|
+
b. distribute, sublicense, sell, rent, or publish this software, or any
|
|
17
|
+
part of it, on its own or as part of another library, tool, or service;
|
|
18
|
+
c. use this software, or any part of it, to build or operate a product or
|
|
19
|
+
service that provides over-the-air updates, or that competes with ROVQ;
|
|
20
|
+
d. use this software with a server that the copyright holder neither
|
|
21
|
+
operates nor has authorized;
|
|
22
|
+
e. remove or change this license or any copyright notice.
|
|
23
|
+
|
|
24
|
+
3. Ownership. The copyright holder keeps all rights not expressly granted
|
|
25
|
+
here. Nothing in this license grants any right to the ROVQ name or logo.
|
|
26
|
+
|
|
27
|
+
4. Termination. This license ends automatically if you break any of its
|
|
28
|
+
terms. You must then stop using this software and delete every copy of it.
|
|
29
|
+
|
|
30
|
+
5. No warranty. THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
|
31
|
+
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
32
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
33
|
+
|
|
34
|
+
6. Limitation of liability. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE
|
|
35
|
+
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
36
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
37
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
38
|
+
|
|
39
|
+
Versions of this software published under the MIT License before this license
|
|
40
|
+
was adopted remain available under the MIT License. This license applies to
|
|
41
|
+
every later version.
|
|
42
|
+
|
|
43
|
+
For any use this license does not allow, contact the copyright holder
|
|
44
|
+
through https://rovq-dashboard.onrender.com.
|
package/README.md
CHANGED
|
@@ -4,23 +4,29 @@ Over-the-air updates for React Native apps, served by your own ROVQ server. Each
|
|
|
4
4
|
|
|
5
5
|
**Dashboard and docs:** [rovq-dashboard.onrender.com](https://rovq-dashboard.onrender.com). Create an app there, and its page shows this setup with your own keys.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
This is line 0.3, for React Native 0.72 to 0.81, with the New Architecture or without it. On React Native 0.82 and newer, install the newest line, `npm install @rovq/react-native@latest`, whose setup differs.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm install @rovq/react-native
|
|
12
|
+
npm install @rovq/react-native@0.3
|
|
13
13
|
cd ios && pod install
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Keep the `0.3`: without it npm installs the newest line, which needs React Native 0.82. On React Native 0.72, also set `platform :ios, '13.4'` in the Podfile.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Then connect the native side, which decides which bundle runs. The docs in the ROVQ dashboard show the code for your React Native version, and your app's setup page fills in its real values.
|
|
19
|
+
|
|
20
|
+
### Android
|
|
21
|
+
|
|
22
|
+
Return the engine's choice from `getJSBundleFile()`, and start the engine first thing in `onCreate`. In `MainApplication.kt`, from React Native 0.73:
|
|
19
23
|
|
|
20
24
|
```kotlin
|
|
21
|
-
override val
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
override val reactNativeHost: ReactNativeHost =
|
|
26
|
+
object : DefaultReactNativeHost(this) {
|
|
27
|
+
// ...what is already here, then:
|
|
28
|
+
override fun getJSBundleFile(): String? = Rovq.getJSBundleFile()
|
|
29
|
+
}
|
|
24
30
|
|
|
25
31
|
override fun onCreate() {
|
|
26
32
|
super.onCreate()
|
|
@@ -33,11 +39,13 @@ override fun onCreate() {
|
|
|
33
39
|
trustedKeys = mapOf("2026-11" to "<public key>"),
|
|
34
40
|
),
|
|
35
41
|
)
|
|
36
|
-
|
|
42
|
+
// ...what is already here.
|
|
37
43
|
}
|
|
38
44
|
```
|
|
39
45
|
|
|
40
|
-
|
|
46
|
+
On React Native 0.72, the same in `MainApplication.java`, with `protected String getJSBundleFile()` and `new RovqConfig(...)`.
|
|
47
|
+
|
|
48
|
+
### iOS, from React Native 0.77, in `AppDelegate.swift`
|
|
41
49
|
|
|
42
50
|
```swift
|
|
43
51
|
RovqHost.initialize(
|
|
@@ -49,6 +57,7 @@ RovqHost.initialize(
|
|
|
49
57
|
)
|
|
50
58
|
)
|
|
51
59
|
|
|
60
|
+
// In the class that overrides bundleURL():
|
|
52
61
|
override func bundleURL() -> URL? {
|
|
53
62
|
#if DEBUG
|
|
54
63
|
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
|
|
@@ -58,6 +67,26 @@ override func bundleURL() -> URL? {
|
|
|
58
67
|
}
|
|
59
68
|
```
|
|
60
69
|
|
|
70
|
+
### iOS, on React Native 0.72 to 0.76, in `AppDelegate.mm`
|
|
71
|
+
|
|
72
|
+
```objc
|
|
73
|
+
#import <Rovq/RovqSetup.h>
|
|
74
|
+
|
|
75
|
+
// First thing in didFinishLaunchingWithOptions:
|
|
76
|
+
[RovqSetup initializeWithApiURL:@"https://your-server/api/v1"
|
|
77
|
+
deploymentKey:@"rovq_dep_..."
|
|
78
|
+
runtimeVersion:@"1"
|
|
79
|
+
trustedKeys:@{@"2026-11" : @"<public key>"}
|
|
80
|
+
allowInsecureURLs:NO];
|
|
81
|
+
|
|
82
|
+
// In bundleURL from 0.74, getBundleURL in 0.73, sourceURLForBridge: in 0.72:
|
|
83
|
+
#if DEBUG
|
|
84
|
+
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
|
|
85
|
+
#else
|
|
86
|
+
return [RovqSetup bundleURL] ?: [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
|
|
87
|
+
#endif
|
|
88
|
+
```
|
|
89
|
+
|
|
61
90
|
## Use
|
|
62
91
|
|
|
63
92
|
```ts
|
|
@@ -120,4 +149,4 @@ A release published by `@rovq/cli` 0.2 lists its files, and the SDK downloads on
|
|
|
120
149
|
|
|
121
150
|
## License
|
|
122
151
|
|
|
123
|
-
|
|
152
|
+
Proprietary. See [LICENSE](LICENSE).
|
package/Rovq.podspec
CHANGED
|
@@ -6,12 +6,16 @@ Pod::Spec.new do |s|
|
|
|
6
6
|
s.name = 'Rovq'
|
|
7
7
|
s.version = package['version']
|
|
8
8
|
s.summary = package['description']
|
|
9
|
-
s.homepage = '
|
|
10
|
-
s.license = '
|
|
9
|
+
s.homepage = package['homepage']
|
|
10
|
+
s.license = { type: 'Proprietary', file: 'LICENSE' }
|
|
11
11
|
s.authors = 'moatazMahdi'
|
|
12
|
-
|
|
12
|
+
# The engine uses CryptoKit and FileHandle APIs from iOS 13.4, React Native 0.73's minimum. An
|
|
13
|
+
# app on 0.72, which allows 12.4, sets platform :ios, '13.4' in its Podfile.
|
|
14
|
+
s.platforms = { ios: '13.4' }
|
|
13
15
|
s.source = { git: 'https://github.com/moatazMahdi/rovq.git', tag: s.version.to_s }
|
|
14
|
-
s.source_files = 'ios/Engine/**/*.swift', 'ios/React/**/*.{swift,mm}'
|
|
16
|
+
s.source_files = 'ios/Engine/**/*.swift', 'ios/React/**/*.{swift,h,mm}'
|
|
17
|
+
# Only this header is for apps. RovqHost reaches Swift apps through the module.
|
|
18
|
+
s.public_header_files = 'ios/React/RovqSetup.h'
|
|
15
19
|
s.swift_version = '5.9'
|
|
16
20
|
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
|
17
21
|
|
package/android/build.gradle
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
|
+
// The engine has no React Native code, so it can be built and tested on its own, in
|
|
2
|
+
// seconds. Inside an app, the React Native integration is added on top of it.
|
|
3
|
+
buildscript {
|
|
4
|
+
// An app on React Native 0.72 has no Kotlin plugin of its own, and apps from 0.73 have one.
|
|
5
|
+
// Two versions of it cannot share a build, so this uses the app's version when there is one.
|
|
6
|
+
if (rootProject.name != 'rovq-engine') {
|
|
7
|
+
def kotlinVersion = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : '1.8.22'
|
|
8
|
+
repositories {
|
|
9
|
+
google()
|
|
10
|
+
mavenCentral()
|
|
11
|
+
}
|
|
12
|
+
dependencies {
|
|
13
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
1
18
|
plugins {
|
|
2
19
|
id 'com.android.library'
|
|
3
20
|
}
|
|
4
21
|
|
|
5
|
-
// The engine has no React Native code, so it can be built and tested on its own, in
|
|
6
|
-
// seconds. Inside an app, the React Native integration is added on top of it.
|
|
7
22
|
def insideApp = rootProject.name != 'rovq-engine'
|
|
8
23
|
|
|
9
|
-
// React Native
|
|
10
|
-
|
|
24
|
+
// React Native 0.72 to 0.81 build with Android Gradle Plugin 8, which compiles Kotlin only through
|
|
25
|
+
// the Kotlin plugin. Without it, the Kotlin sources are left out and the app cannot find them.
|
|
26
|
+
def usesKotlinPlugin = insideApp
|
|
11
27
|
|
|
12
28
|
// Rebuilding only changed files fails with "Cannot access ... it is internal" when this package is
|
|
13
29
|
// linked through a symbolic link, as npm's file: dependencies are. Compiling it fully each time
|
|
@@ -18,30 +34,29 @@ if (insideApp) {
|
|
|
18
34
|
|
|
19
35
|
if (usesKotlinPlugin) {
|
|
20
36
|
apply plugin: 'kotlin-android'
|
|
37
|
+
// Java compiles for 17 below, and Kotlin 1.8 stops the build when the two differ.
|
|
38
|
+
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
|
39
|
+
kotlinOptions.jvmTarget = '17'
|
|
40
|
+
}
|
|
21
41
|
}
|
|
22
42
|
|
|
23
|
-
// Keeps the two kinds of build from mixing outputs.
|
|
24
|
-
// because React Native's autolinking expects the generated code there.
|
|
43
|
+
// Keeps the two kinds of build from mixing outputs.
|
|
25
44
|
if (!insideApp) {
|
|
26
45
|
layout.buildDirectory = layout.projectDirectory.dir('build/standalone')
|
|
27
46
|
}
|
|
28
47
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
react {
|
|
33
|
-
// The app's install holds React Native, not this package's folder.
|
|
34
|
-
reactNativeDir = rootProject.file('../node_modules/react-native')
|
|
35
|
-
codegenDir = rootProject.file('../node_modules/@react-native/codegen')
|
|
36
|
-
}
|
|
48
|
+
def appValue = { String name, Object fallback ->
|
|
49
|
+
rootProject.ext.has(name) ? rootProject.ext.get(name) : fallback
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
android {
|
|
40
53
|
namespace = 'com.rovq.engine'
|
|
41
|
-
compileSdk
|
|
54
|
+
// An app's own compileSdk, because older Android Gradle Plugins cannot read newer platforms.
|
|
55
|
+
compileSdk = insideApp ? appValue('compileSdkVersion', 36) : 37
|
|
42
56
|
|
|
43
57
|
defaultConfig {
|
|
44
|
-
|
|
58
|
+
// React Native 0.72 to 0.75 apps start at API 21.
|
|
59
|
+
minSdk = 21
|
|
45
60
|
testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner'
|
|
46
61
|
}
|
|
47
62
|
|
|
@@ -53,8 +68,6 @@ android {
|
|
|
53
68
|
sourceSets {
|
|
54
69
|
if (usesKotlinPlugin) {
|
|
55
70
|
main.java.srcDirs += 'src/react/java'
|
|
56
|
-
} else if (insideApp) {
|
|
57
|
-
main.kotlin.directories.add('src/react/java')
|
|
58
71
|
} else {
|
|
59
72
|
// Test helpers that run both on the computer and on a device.
|
|
60
73
|
test.kotlin.directories.add('src/sharedTest/java')
|
|
@@ -69,11 +82,15 @@ android {
|
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
dependencies {
|
|
72
|
-
// Android includes Ed25519 only from API 33, and the engine supports API
|
|
85
|
+
// Android includes Ed25519 only from API 33, and the engine supports API 21.
|
|
73
86
|
implementation 'com.google.crypto.tink:tink-android:1.24.0'
|
|
87
|
+
// Tink brings androidx.annotation 1.8, which moved its classes to annotation-jvm, and React
|
|
88
|
+
// Native 0.72 brings 1.3. Asking for 1.8 here lifts the old one, so the classes are not twice
|
|
89
|
+
// in the app.
|
|
90
|
+
implementation 'androidx.annotation:annotation:1.8.2'
|
|
74
91
|
|
|
75
92
|
if (insideApp) {
|
|
76
|
-
// The version is set by the React Native Gradle Plugin.
|
|
93
|
+
// The version is set by the React Native Gradle Plugin of the app.
|
|
77
94
|
implementation 'com.facebook.react:react-android'
|
|
78
95
|
}
|
|
79
96
|
|
|
@@ -49,13 +49,13 @@ data class UpdateInfo(
|
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
sealed interface CheckResult {
|
|
52
|
-
|
|
52
|
+
object UpToDate : CheckResult
|
|
53
53
|
|
|
54
54
|
/** @param alreadyDownloaded whether [RovqEngine.downloadUpdate] has nothing left to fetch. */
|
|
55
55
|
data class UpdateAvailable(val update: UpdateInfo, val alreadyDownloaded: Boolean) : CheckResult
|
|
56
56
|
|
|
57
57
|
/** The server asked to leave the current release. Restart to apply. */
|
|
58
|
-
|
|
58
|
+
object RolledBack : CheckResult
|
|
59
59
|
|
|
60
60
|
data class Failed(val code: RovqErrorCode, val message: String) : CheckResult
|
|
61
61
|
}
|
|
@@ -75,13 +75,13 @@ sealed interface InstallResult {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
sealed interface SyncResult {
|
|
78
|
-
|
|
78
|
+
object UpToDate : SyncResult
|
|
79
79
|
|
|
80
80
|
/** Installed, and loads on the next JavaScript start. */
|
|
81
81
|
data class UpdateReady(val releaseId: String, val mandatory: Boolean) : SyncResult
|
|
82
82
|
|
|
83
83
|
/** The server asked to leave the current release. Restart to apply. */
|
|
84
|
-
|
|
84
|
+
object RolledBack : SyncResult
|
|
85
85
|
|
|
86
86
|
data class Failed(val code: RovqErrorCode, val message: String) : SyncResult
|
|
87
87
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.rovq.engine
|
|
2
2
|
|
|
3
|
+
import android.annotation.SuppressLint
|
|
3
4
|
import java.io.File
|
|
4
5
|
import java.io.IOException
|
|
5
6
|
import java.io.InputStream
|
|
@@ -328,6 +329,9 @@ class UpdateInstaller(
|
|
|
328
329
|
* A hard link costs no space. Files in a release never change, so sharing them is safe.
|
|
329
330
|
* Android before 8.0 has no java.nio.file, so it copies instead.
|
|
330
331
|
*/
|
|
332
|
+
// Lint wants a version check, but SDK_INT is 0 in unit tests on the computer, which would
|
|
333
|
+
// stop them testing the link. Before 8.0 the call throws a LinkageError, caught below.
|
|
334
|
+
@SuppressLint("NewApi")
|
|
331
335
|
private fun link(source: File, target: File) {
|
|
332
336
|
val linked = try {
|
|
333
337
|
java.nio.file.Files.createLink(target.toPath(), source.toPath())
|
|
@@ -22,8 +22,8 @@ data class CheckRequest(
|
|
|
22
22
|
|
|
23
23
|
/** The server's answer. It is advice only, because [OfferedUpdate] is verified before use. */
|
|
24
24
|
sealed interface CheckResponse {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
object None : CheckResponse
|
|
26
|
+
object Rollback : CheckResponse
|
|
27
27
|
data class Update(val offer: OfferedUpdate) : CheckResponse
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -5,12 +5,18 @@ import android.app.Application
|
|
|
5
5
|
import android.content.pm.PackageInfo
|
|
6
6
|
import android.os.Build
|
|
7
7
|
import android.os.Bundle
|
|
8
|
+
import android.util.Log
|
|
8
9
|
import com.rovq.engine.EngineConfig
|
|
9
10
|
import com.rovq.engine.RovqEngine
|
|
10
11
|
import java.io.File
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
private const val LOG_TAG = "Rovq"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What an app supplies. Everything here ships inside the app, so nothing here is secret.
|
|
17
|
+
* JvmOverloads lets an app written in Java leave out allowInsecureUrls.
|
|
18
|
+
*/
|
|
19
|
+
data class RovqConfig @JvmOverloads constructor(
|
|
14
20
|
val apiUrl: String,
|
|
15
21
|
val deploymentKey: String,
|
|
16
22
|
/** Change this whenever native code changes. */
|
|
@@ -33,28 +39,84 @@ object Rovq {
|
|
|
33
39
|
val engineOrNull: RovqEngine?
|
|
34
40
|
get() = instance
|
|
35
41
|
|
|
42
|
+
/**
|
|
43
|
+
* If the settings are unusable, such as a public key in the wrong format, the app still
|
|
44
|
+
* starts, on the bundle inside it, and accepts no update. A typo must not crash every user's
|
|
45
|
+
* app. The reason is logged under the tag Rovq. iOS behaves the same.
|
|
46
|
+
*/
|
|
47
|
+
@JvmStatic
|
|
36
48
|
@Synchronized
|
|
37
49
|
fun initialize(application: Application, config: RovqConfig) {
|
|
38
50
|
if (instance != null) return
|
|
39
|
-
val
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
val engine = try {
|
|
52
|
+
val info = application.packageManager.getPackageInfo(application.packageName, 0)
|
|
53
|
+
RovqEngine(
|
|
54
|
+
File(application.filesDir, "rovq"),
|
|
55
|
+
EngineConfig(
|
|
56
|
+
apiUrl = config.apiUrl,
|
|
57
|
+
deploymentKey = config.deploymentKey,
|
|
58
|
+
runtimeVersion = config.runtimeVersion,
|
|
59
|
+
binaryVersion = info.versionName ?: "0",
|
|
60
|
+
binaryId = binaryIdOf(info),
|
|
61
|
+
trustedKeys = config.trustedKeys,
|
|
62
|
+
allowInsecureUrls = config.allowInsecureUrls,
|
|
63
|
+
),
|
|
64
|
+
builtInFiles = ResourceFiles(application),
|
|
65
|
+
)
|
|
66
|
+
} catch (error: Exception) {
|
|
67
|
+
Log.e(LOG_TAG, "Could not start, so updates are off: ${error.message}", error)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
53
70
|
instance = engine
|
|
54
71
|
application.registerActivityLifecycleCallbacks(BackgroundWatcher(engine))
|
|
55
72
|
watchForCrashes(engine)
|
|
56
73
|
}
|
|
57
74
|
|
|
75
|
+
/** What this JavaScript start runs. Unset until the first answer. */
|
|
76
|
+
private var chosen: Choice? = null
|
|
77
|
+
|
|
78
|
+
private class Choice(val path: String?)
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Return this from ReactNativeHost.getJSBundleFile(). It is the update's bundle, or null for
|
|
82
|
+
* the bundle inside the app, which is what React Native loads when the answer is null.
|
|
83
|
+
*
|
|
84
|
+
* Choosing starts a pending update, so a second choice in the same start would count the
|
|
85
|
+
* first as failed and roll it back. The answer is therefore kept, because apps ask more than
|
|
86
|
+
* once: on React Native 0.76 and later, the template's reactHost getter asks on every read.
|
|
87
|
+
* Only [chooseAgain] chooses again, when RovqModule restarts JavaScript.
|
|
88
|
+
*/
|
|
89
|
+
@JvmStatic
|
|
90
|
+
@Synchronized
|
|
91
|
+
fun getJSBundleFile(): String? {
|
|
92
|
+
chosen?.let { return it.path }
|
|
93
|
+
return chooseAgain()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Chooses the bundle for a new JavaScript start. */
|
|
97
|
+
@Synchronized
|
|
98
|
+
internal fun chooseAgain(): String? {
|
|
99
|
+
val path = choose()
|
|
100
|
+
chosen = Choice(path)
|
|
101
|
+
return path
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private fun choose(): String? {
|
|
105
|
+
val file = try {
|
|
106
|
+
instance?.resolveBundle()
|
|
107
|
+
} catch (error: Throwable) {
|
|
108
|
+
// Whatever goes wrong in the engine, the app must still start.
|
|
109
|
+
Log.e(LOG_TAG, "Falling back to the bundle inside the app", error)
|
|
110
|
+
null
|
|
111
|
+
}
|
|
112
|
+
if (file == null) {
|
|
113
|
+
Log.i(LOG_TAG, "Loading the bundle inside the app")
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
Log.i(LOG_TAG, "Loading ${file.absolutePath}")
|
|
117
|
+
return file.absolutePath
|
|
118
|
+
}
|
|
119
|
+
|
|
58
120
|
private fun watchForCrashes(engine: RovqEngine) {
|
|
59
121
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
|
60
122
|
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
|
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
package com.rovq.engine.react
|
|
2
2
|
|
|
3
|
+
import android.content.Intent
|
|
3
4
|
import android.util.Log
|
|
4
5
|
import com.facebook.react.ReactApplication
|
|
6
|
+
import com.facebook.react.ReactInstanceManager
|
|
5
7
|
import com.facebook.react.bridge.Arguments
|
|
8
|
+
import com.facebook.react.bridge.JSBundleLoader
|
|
6
9
|
import com.facebook.react.bridge.Promise
|
|
7
10
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
11
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
12
|
+
import com.facebook.react.bridge.ReactMethod
|
|
8
13
|
import com.facebook.react.bridge.UiThreadUtil
|
|
9
14
|
import com.facebook.react.bridge.WritableMap
|
|
10
|
-
import com.facebook.react.
|
|
15
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
11
16
|
import com.rovq.engine.CheckResult
|
|
12
17
|
import com.rovq.engine.DownloadResult
|
|
13
18
|
import com.rovq.engine.InstallResult
|
|
19
|
+
import com.rovq.engine.ProgressThrottle
|
|
14
20
|
import com.rovq.engine.RovqEngine
|
|
15
21
|
import com.rovq.engine.RovqErrorCode
|
|
16
|
-
import com.rovq.engine.ProgressThrottle
|
|
17
22
|
import com.rovq.engine.SyncResult
|
|
18
23
|
import com.rovq.engine.UpdateInfo
|
|
24
|
+
import java.lang.reflect.Field
|
|
19
25
|
import java.util.concurrent.Executors
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
// What React Native loads when getJSBundleFile() returns null, unless an app renames it.
|
|
28
|
+
private const val EMBEDDED_BUNDLE = "assets://index.android.bundle"
|
|
29
|
+
|
|
30
|
+
/** A legacy native module, which React Native 0.72 to 0.81 all run. */
|
|
31
|
+
class RovqModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
|
|
23
32
|
// Network and disk work must stay off the JavaScript thread.
|
|
24
33
|
private val worker = Executors.newSingleThreadExecutor()
|
|
25
34
|
|
|
@@ -29,7 +38,8 @@ class RovqModule(context: ReactApplicationContext) : NativeRovqSpec(context) {
|
|
|
29
38
|
|
|
30
39
|
override fun getName(): String = NAME
|
|
31
40
|
|
|
32
|
-
|
|
41
|
+
@ReactMethod
|
|
42
|
+
fun checkForUpdate(promise: Promise) = run(promise) { engine ->
|
|
33
43
|
when (val result = engine.checkForUpdate()) {
|
|
34
44
|
CheckResult.UpToDate -> outcome("UP_TO_DATE")
|
|
35
45
|
CheckResult.RolledBack -> outcome("ROLLED_BACK")
|
|
@@ -41,21 +51,24 @@ class RovqModule(context: ReactApplicationContext) : NativeRovqSpec(context) {
|
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
@ReactMethod
|
|
55
|
+
fun downloadUpdate(promise: Promise) = run(promise) { engine ->
|
|
45
56
|
when (val result = engine.downloadUpdate(progressReporter())) {
|
|
46
57
|
is DownloadResult.Downloaded -> outcome("DOWNLOADED").apply { putMap("update", toMap(result.update)) }
|
|
47
58
|
is DownloadResult.Failed -> failure(result.code, result.message)
|
|
48
59
|
}
|
|
49
60
|
}
|
|
50
61
|
|
|
51
|
-
|
|
62
|
+
@ReactMethod
|
|
63
|
+
fun installUpdate(promise: Promise) = run(promise) { engine ->
|
|
52
64
|
when (val result = engine.installUpdate()) {
|
|
53
65
|
is InstallResult.Installed -> outcome("INSTALLED").apply { putString("releaseId", result.releaseId) }
|
|
54
66
|
is InstallResult.Failed -> failure(result.code, result.message)
|
|
55
67
|
}
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
|
|
70
|
+
@ReactMethod
|
|
71
|
+
fun sync(promise: Promise) = run(promise) { engine ->
|
|
59
72
|
when (val result = engine.sync(progressReporter())) {
|
|
60
73
|
SyncResult.UpToDate -> outcome("UP_TO_DATE")
|
|
61
74
|
SyncResult.RolledBack -> outcome("ROLLED_BACK")
|
|
@@ -67,18 +80,114 @@ class RovqModule(context: ReactApplicationContext) : NativeRovqSpec(context) {
|
|
|
67
80
|
}
|
|
68
81
|
}
|
|
69
82
|
|
|
70
|
-
|
|
83
|
+
@ReactMethod
|
|
84
|
+
fun notifyApplicationReady() {
|
|
71
85
|
Rovq.engineOrNull?.notifyReady()
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
|
|
88
|
+
@ReactMethod
|
|
89
|
+
fun restartApp() {
|
|
75
90
|
UiThreadUtil.runOnUiThread {
|
|
76
|
-
val
|
|
77
|
-
|
|
91
|
+
val bridgeless = reactApplicationContext.isBridgeless
|
|
92
|
+
// Found before the engine is asked, because asking starts trying the pending
|
|
93
|
+
// release. A process that ended then would count as a failed start, and the
|
|
94
|
+
// release would be abandoned, though it never ran.
|
|
95
|
+
val target = if (bridgeless) bridgelessTarget() else bridgeTarget()
|
|
96
|
+
if (target == null) {
|
|
97
|
+
restartProcess()
|
|
98
|
+
return@runOnUiThread
|
|
99
|
+
}
|
|
100
|
+
// Not getJSBundleFile(), which keeps the answer of the start that is ending.
|
|
101
|
+
val file = Rovq.chooseAgain()
|
|
102
|
+
val loader = if (file != null) {
|
|
103
|
+
JSBundleLoader.createFileLoader(file)
|
|
104
|
+
} else {
|
|
105
|
+
// Loaded the way each kind of host loads it by default.
|
|
106
|
+
JSBundleLoader.createAssetLoader(reactApplicationContext, EMBEDDED_BUNDLE, bridgeless)
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
target.field.set(target.owner, loader)
|
|
110
|
+
} catch (error: Exception) {
|
|
111
|
+
// Unlikely once the field is accessible. The release then counts as a failed
|
|
112
|
+
// start, which is the safe way to lose it.
|
|
113
|
+
Log.w(NAME, "Could not replace the bundle loader, so the whole app restarts", error)
|
|
114
|
+
restartProcess()
|
|
115
|
+
return@runOnUiThread
|
|
116
|
+
}
|
|
117
|
+
target.reload()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Where a host keeps its bundle loader, and how it starts JavaScript again. */
|
|
122
|
+
private class LoaderTarget(val owner: Any, val field: Field, val reload: () -> Unit)
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Without the New Architecture's bridgeless mode, ReactInstanceManager reads
|
|
126
|
+
* getJSBundleFile() once, when it is created, and keeps a loader in a private field.
|
|
127
|
+
* Recreating the context alone would load the old bundle again, so restartApp puts a loader
|
|
128
|
+
* for the bundle the engine chooses now in that field first, as CodePush did.
|
|
129
|
+
*/
|
|
130
|
+
private fun bridgeTarget(): LoaderTarget? {
|
|
131
|
+
val manager = (reactApplicationContext.applicationContext as ReactApplication)
|
|
132
|
+
.reactNativeHost.reactInstanceManager
|
|
133
|
+
// The Java source names it mBundleLoader. A conversion to Kotlin would drop the m.
|
|
134
|
+
val field = findField(ReactInstanceManager::class.java, "mBundleLoader", "bundleLoader") ?: return null
|
|
135
|
+
return LoaderTarget(manager, field) { manager.recreateReactContextInBackground() }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bridgeless mode, the default of the New Architecture from React Native 0.76, gives
|
|
140
|
+
* getJSBundleFile() to DefaultReactHostDelegate, which keeps a loader in a field. The host
|
|
141
|
+
* asks its delegate for that loader each time JavaScript starts, so replacing it before a
|
|
142
|
+
* reload is enough. Reflection throughout, because ReactApplication.getReactHost does not
|
|
143
|
+
* exist in React Native 0.72, and this file must compile there too.
|
|
144
|
+
*/
|
|
145
|
+
private fun bridgelessTarget(): LoaderTarget? = try {
|
|
146
|
+
val application = reactApplicationContext.applicationContext
|
|
147
|
+
val host = application.javaClass.getMethod("getReactHost").invoke(application)
|
|
148
|
+
val delegate = host?.let { findField(it.javaClass, "mReactHostDelegate", "reactHostDelegate")?.get(it) }
|
|
149
|
+
val field = delegate?.let { findField(it.javaClass, "jsBundleLoader", "mJsBundleLoader") }
|
|
150
|
+
val reload = host?.javaClass?.getMethod("reload", String::class.java)
|
|
151
|
+
if (host == null || delegate == null || field == null || reload == null) {
|
|
152
|
+
null
|
|
153
|
+
} else {
|
|
154
|
+
LoaderTarget(delegate, field) { reload.invoke(host, "ROVQ restart") }
|
|
78
155
|
}
|
|
156
|
+
} catch (error: Exception) {
|
|
157
|
+
Log.w(NAME, "Could not reach the React host, so the whole app restarts", error)
|
|
158
|
+
null
|
|
79
159
|
}
|
|
80
160
|
|
|
81
|
-
|
|
161
|
+
/** Searches the class and its parents. Null if this React Native has no such field. */
|
|
162
|
+
private fun findField(type: Class<*>, vararg names: String): Field? {
|
|
163
|
+
var current: Class<*>? = type
|
|
164
|
+
while (current != null) {
|
|
165
|
+
for (name in names) {
|
|
166
|
+
try {
|
|
167
|
+
return current.getDeclaredField(name).apply { isAccessible = true }
|
|
168
|
+
} catch (missing: NoSuchFieldException) {
|
|
169
|
+
// Try the next name. Not `_`, which Kotlin 1.8 does not accept here.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
current = current.superclass
|
|
173
|
+
}
|
|
174
|
+
Log.w(NAME, "${type.name} has no field ${names.joinToString(" or ")}, so the whole app restarts")
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Slower than a reload, but React Native creates everything again, so it always works. */
|
|
179
|
+
private fun restartProcess() {
|
|
180
|
+
val context = reactApplicationContext.applicationContext
|
|
181
|
+
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
|
182
|
+
if (intent != null) {
|
|
183
|
+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
|
184
|
+
context.startActivity(intent)
|
|
185
|
+
}
|
|
186
|
+
Runtime.getRuntime().exit(0)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@ReactMethod
|
|
190
|
+
fun rollback(promise: Promise) {
|
|
82
191
|
worker.execute {
|
|
83
192
|
try {
|
|
84
193
|
promise.resolve(Rovq.engineOrNull?.rollback() ?: false)
|
|
@@ -88,7 +197,8 @@ class RovqModule(context: ReactApplicationContext) : NativeRovqSpec(context) {
|
|
|
88
197
|
}
|
|
89
198
|
}
|
|
90
199
|
|
|
91
|
-
|
|
200
|
+
@ReactMethod
|
|
201
|
+
fun getStatus(promise: Promise) {
|
|
92
202
|
val status = Rovq.engineOrNull?.status()
|
|
93
203
|
promise.resolve(
|
|
94
204
|
Arguments.createMap().apply {
|
|
@@ -104,6 +214,13 @@ class RovqModule(context: ReactApplicationContext) : NativeRovqSpec(context) {
|
|
|
104
214
|
)
|
|
105
215
|
}
|
|
106
216
|
|
|
217
|
+
// NativeEventEmitter calls these. Events are sent whether anyone listens or not.
|
|
218
|
+
@ReactMethod
|
|
219
|
+
fun addListener(@Suppress("UNUSED_PARAMETER") eventName: String) = Unit
|
|
220
|
+
|
|
221
|
+
@ReactMethod
|
|
222
|
+
fun removeListeners(@Suppress("UNUSED_PARAMETER") count: Double) = Unit
|
|
223
|
+
|
|
107
224
|
private fun run(promise: Promise, work: (RovqEngine) -> WritableMap) {
|
|
108
225
|
worker.execute {
|
|
109
226
|
try {
|
|
@@ -123,13 +240,16 @@ class RovqModule(context: ReactApplicationContext) : NativeRovqSpec(context) {
|
|
|
123
240
|
|
|
124
241
|
private fun progressReporter(): (Long, Long) -> Unit {
|
|
125
242
|
val throttle = ProgressThrottle { received, total ->
|
|
126
|
-
if (!invalidated) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
243
|
+
if (!invalidated && reactApplicationContext.hasActiveReactInstance()) {
|
|
244
|
+
reactApplicationContext
|
|
245
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
246
|
+
.emit(
|
|
247
|
+
PROGRESS_EVENT,
|
|
248
|
+
Arguments.createMap().apply {
|
|
249
|
+
putDouble("receivedBytes", received.toDouble())
|
|
250
|
+
putDouble("totalBytes", total.toDouble())
|
|
251
|
+
},
|
|
252
|
+
)
|
|
133
253
|
}
|
|
134
254
|
}
|
|
135
255
|
return throttle::report
|
|
@@ -160,5 +280,8 @@ class RovqModule(context: ReactApplicationContext) : NativeRovqSpec(context) {
|
|
|
160
280
|
|
|
161
281
|
companion object {
|
|
162
282
|
const val NAME = "Rovq"
|
|
283
|
+
|
|
284
|
+
/** The same name as DOWNLOAD_PROGRESS_EVENT in src/NativeRovq.ts. */
|
|
285
|
+
const val PROGRESS_EVENT = "RovqDownloadProgress"
|
|
163
286
|
}
|
|
164
287
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package com.rovq.engine.react;
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage;
|
|
4
|
+
import com.facebook.react.bridge.NativeModule;
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager;
|
|
7
|
+
import java.util.Collections;
|
|
8
|
+
import java.util.List;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Found by React Native autolinking. Java, not Kotlin, because ReactPackage moved from Java to
|
|
12
|
+
* Kotlin within the versions this line supports, and only a Java class overrides both.
|
|
13
|
+
*/
|
|
14
|
+
public class RovqPackage implements ReactPackage {
|
|
15
|
+
@Override
|
|
16
|
+
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
|
|
17
|
+
return Collections.singletonList(new RovqModule(reactContext));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@Override
|
|
21
|
+
@SuppressWarnings("rawtypes")
|
|
22
|
+
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
|
23
|
+
return Collections.emptyList();
|
|
24
|
+
}
|
|
25
|
+
}
|
package/ios/React/RovqHost.swift
CHANGED
|
@@ -65,6 +65,27 @@ public final class RovqHost: NSObject {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/// The same, for an AppDelegate written in Objective-C, as React Native's templates were until
|
|
69
|
+
/// 0.77. A Swift struct such as RovqConfig cannot cross into Objective-C.
|
|
70
|
+
@objc(initializeWithApiURL:deploymentKey:runtimeVersion:trustedKeys:allowInsecureURLs:)
|
|
71
|
+
public static func initialize(
|
|
72
|
+
apiURL: String,
|
|
73
|
+
deploymentKey: String,
|
|
74
|
+
runtimeVersion: String,
|
|
75
|
+
trustedKeys: [String: String],
|
|
76
|
+
allowInsecureURLs: Bool
|
|
77
|
+
) {
|
|
78
|
+
initialize(
|
|
79
|
+
config: RovqConfig(
|
|
80
|
+
apiURL: apiURL,
|
|
81
|
+
deploymentKey: deploymentKey,
|
|
82
|
+
runtimeVersion: runtimeVersion,
|
|
83
|
+
trustedKeys: trustedKeys,
|
|
84
|
+
allowInsecureURLs: allowInsecureURLs
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
/// An app with scenes gets the scene notification, and one without gets the app one. With
|
|
69
90
|
/// several windows, only the last one leaving counts as leaving the app.
|
|
70
91
|
private static func watchForBackground() {
|
|
@@ -82,9 +103,9 @@ public final class RovqHost: NSObject {
|
|
|
82
103
|
]
|
|
83
104
|
}
|
|
84
105
|
|
|
85
|
-
///
|
|
86
|
-
/// for the bundle inside the app.
|
|
87
|
-
public static func bundleURL() -> URL? {
|
|
106
|
+
/// Return this from sourceURLForBridge: or bundleURL, which React Native calls each time
|
|
107
|
+
/// JavaScript starts, including on reload. Returns nil for the bundle inside the app.
|
|
108
|
+
@objc public static func bundleURL() -> URL? {
|
|
88
109
|
guard let engine else { return nil }
|
|
89
110
|
let bundle = engine.resolveBundle()
|
|
90
111
|
if engine.status().tryingReleaseId != nil {
|
package/ios/React/RovqModule.mm
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// A legacy native module, which React Native 0.72 to 0.81 all run, with or without the New
|
|
2
|
+
// Architecture. The work happens in Swift.
|
|
2
3
|
#import <Foundation/Foundation.h>
|
|
3
|
-
#import <
|
|
4
|
+
#import <React/RCTBridgeModule.h>
|
|
5
|
+
#import <React/RCTEventEmitter.h>
|
|
4
6
|
#import <React/RCTReloadCommand.h>
|
|
5
|
-
#import <React/RCTUtils.h>
|
|
6
7
|
|
|
7
8
|
#if __has_include(<Rovq/Rovq-Swift.h>)
|
|
8
9
|
#import <Rovq/Rovq-Swift.h>
|
|
@@ -10,47 +11,66 @@
|
|
|
10
11
|
#import "Rovq-Swift.h"
|
|
11
12
|
#endif
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
// The same name as DOWNLOAD_PROGRESS_EVENT in src/NativeRovq.ts.
|
|
15
|
+
static NSString *const RovqDownloadProgressEvent = @"RovqDownloadProgress";
|
|
16
|
+
|
|
17
|
+
@interface RovqModule : RCTEventEmitter <RCTBridgeModule>
|
|
14
18
|
@end
|
|
15
19
|
|
|
16
|
-
@implementation RovqModule
|
|
20
|
+
@implementation RovqModule {
|
|
21
|
+
BOOL _hasListeners;
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
RCT_EXPORT_MODULE(Rovq)
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
+ (BOOL)requiresMainQueueSetup
|
|
27
|
+
{
|
|
28
|
+
return NO;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
- (NSArray<NSString *> *)supportedEvents
|
|
32
|
+
{
|
|
33
|
+
return @[ RovqDownloadProgressEvent ];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
- (void)startObserving
|
|
37
|
+
{
|
|
38
|
+
_hasListeners = YES;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
- (void)stopObserving
|
|
22
42
|
{
|
|
23
|
-
|
|
43
|
+
_hasListeners = NO;
|
|
24
44
|
}
|
|
25
45
|
|
|
26
|
-
|
|
46
|
+
RCT_EXPORT_METHOD(checkForUpdate : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
|
27
47
|
{
|
|
28
48
|
[RovqHost checkForUpdateWithCompletion:^(NSDictionary<NSString *, id> *result) {
|
|
29
49
|
resolve(result);
|
|
30
50
|
}];
|
|
31
51
|
}
|
|
32
52
|
|
|
33
|
-
|
|
53
|
+
RCT_EXPORT_METHOD(downloadUpdate : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
|
34
54
|
{
|
|
35
55
|
[RovqHost downloadUpdateOnProgress:[self progressReporter]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
completion:^(NSDictionary<NSString *, id> *result) {
|
|
57
|
+
resolve(result);
|
|
58
|
+
}];
|
|
39
59
|
}
|
|
40
60
|
|
|
41
|
-
|
|
61
|
+
RCT_EXPORT_METHOD(installUpdate : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
|
42
62
|
{
|
|
43
63
|
[RovqHost installUpdateWithCompletion:^(NSDictionary<NSString *, id> *result) {
|
|
44
64
|
resolve(result);
|
|
45
65
|
}];
|
|
46
66
|
}
|
|
47
67
|
|
|
48
|
-
|
|
68
|
+
RCT_EXPORT_METHOD(sync : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
|
49
69
|
{
|
|
50
70
|
[RovqHost syncOnProgress:[self progressReporter]
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
completion:^(NSDictionary<NSString *, id> *result) {
|
|
72
|
+
resolve(result);
|
|
73
|
+
}];
|
|
54
74
|
}
|
|
55
75
|
|
|
56
76
|
// Weak, because a download keeps running through a reload, when this module has gone.
|
|
@@ -58,16 +78,22 @@ RCT_EXPORT_MODULE(Rovq)
|
|
|
58
78
|
{
|
|
59
79
|
__weak RovqModule *weakSelf = self;
|
|
60
80
|
return ^(int64_t received, int64_t total) {
|
|
61
|
-
|
|
81
|
+
RovqModule *strongSelf = weakSelf;
|
|
82
|
+
// Sending with no listener only logs a warning, so it is skipped.
|
|
83
|
+
if (strongSelf == nil || !strongSelf->_hasListeners) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
[strongSelf sendEventWithName:RovqDownloadProgressEvent
|
|
87
|
+
body:@{@"receivedBytes" : @(received), @"totalBytes" : @(total)}];
|
|
62
88
|
};
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
|
|
91
|
+
RCT_EXPORT_METHOD(notifyApplicationReady)
|
|
66
92
|
{
|
|
67
93
|
[RovqHost notifyReady];
|
|
68
94
|
}
|
|
69
95
|
|
|
70
|
-
|
|
96
|
+
RCT_EXPORT_METHOD(restartApp)
|
|
71
97
|
{
|
|
72
98
|
// Always later, never at once: RCTExecuteOnMainQueue runs immediately when already on the main
|
|
73
99
|
// thread, which reloads JavaScript while it is still inside this call.
|
|
@@ -76,14 +102,14 @@ RCT_EXPORT_MODULE(Rovq)
|
|
|
76
102
|
});
|
|
77
103
|
}
|
|
78
104
|
|
|
79
|
-
|
|
105
|
+
RCT_EXPORT_METHOD(rollback : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
|
80
106
|
{
|
|
81
107
|
[RovqHost rollbackWithCompletion:^(BOOL result) {
|
|
82
108
|
resolve(@(result));
|
|
83
109
|
}];
|
|
84
110
|
}
|
|
85
111
|
|
|
86
|
-
|
|
112
|
+
RCT_EXPORT_METHOD(getStatus : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
|
87
113
|
{
|
|
88
114
|
resolve([RovqHost status]);
|
|
89
115
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
|
|
3
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
4
|
+
|
|
5
|
+
/// For an AppDelegate written in Objective-C, as React Native's templates were until 0.77. Such a
|
|
6
|
+
/// file cannot import the header Swift generates for this pod, so these call RovqHost for it.
|
|
7
|
+
/// An AppDelegate written in Swift uses RovqHost directly.
|
|
8
|
+
@interface RovqSetup : NSObject
|
|
9
|
+
|
|
10
|
+
/// Must run before React Native starts. If the settings are unusable, the app still runs the
|
|
11
|
+
/// bundle inside it, and no update is ever accepted.
|
|
12
|
+
+ (void)initializeWithApiURL:(NSString *)apiURL
|
|
13
|
+
deploymentKey:(NSString *)deploymentKey
|
|
14
|
+
runtimeVersion:(NSString *)runtimeVersion
|
|
15
|
+
trustedKeys:(NSDictionary<NSString *, NSString *> *)trustedKeys
|
|
16
|
+
allowInsecureURLs:(BOOL)allowInsecureURLs;
|
|
17
|
+
|
|
18
|
+
/// Return this from sourceURLForBridge: or bundleURL. Nil means the bundle inside the app.
|
|
19
|
+
+ (nullable NSURL *)bundleURL;
|
|
20
|
+
|
|
21
|
+
/// Goes back one release, as rollback() does in JavaScript. The completion says whether there
|
|
22
|
+
/// was one to leave, on a background queue. It applies when JavaScript next starts.
|
|
23
|
+
+ (void)rollbackWithCompletion:(void (^)(BOOL rolledBack))completion;
|
|
24
|
+
|
|
25
|
+
@end
|
|
26
|
+
|
|
27
|
+
NS_ASSUME_NONNULL_END
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#import "RovqSetup.h"
|
|
2
|
+
|
|
3
|
+
#if __has_include(<Rovq/Rovq-Swift.h>)
|
|
4
|
+
#import <Rovq/Rovq-Swift.h>
|
|
5
|
+
#else
|
|
6
|
+
#import "Rovq-Swift.h"
|
|
7
|
+
#endif
|
|
8
|
+
|
|
9
|
+
@implementation RovqSetup
|
|
10
|
+
|
|
11
|
+
+ (void)initializeWithApiURL:(NSString *)apiURL
|
|
12
|
+
deploymentKey:(NSString *)deploymentKey
|
|
13
|
+
runtimeVersion:(NSString *)runtimeVersion
|
|
14
|
+
trustedKeys:(NSDictionary<NSString *, NSString *> *)trustedKeys
|
|
15
|
+
allowInsecureURLs:(BOOL)allowInsecureURLs
|
|
16
|
+
{
|
|
17
|
+
[RovqHost initializeWithApiURL:apiURL
|
|
18
|
+
deploymentKey:deploymentKey
|
|
19
|
+
runtimeVersion:runtimeVersion
|
|
20
|
+
trustedKeys:trustedKeys
|
|
21
|
+
allowInsecureURLs:allowInsecureURLs];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
+ (nullable NSURL *)bundleURL
|
|
25
|
+
{
|
|
26
|
+
return [RovqHost bundleURL];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
+ (void)rollbackWithCompletion:(void (^)(BOOL rolledBack))completion
|
|
30
|
+
{
|
|
31
|
+
[RovqHost rollbackWithCompletion:completion];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@end
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rovq/react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Over-the-air updates for React Native, with signed releases and automatic rollback",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -11,15 +11,9 @@
|
|
|
11
11
|
"hot-update",
|
|
12
12
|
"rollback"
|
|
13
13
|
],
|
|
14
|
-
"license": "
|
|
14
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
15
15
|
"author": "moatazMahdi",
|
|
16
16
|
"homepage": "https://rovq-dashboard.onrender.com",
|
|
17
|
-
"bugs": "https://github.com/moatazMahdi/rovq/issues",
|
|
18
|
-
"repository": {
|
|
19
|
-
"type": "git",
|
|
20
|
-
"url": "git+https://github.com/moatazMahdi/rovq.git",
|
|
21
|
-
"directory": "packages/react-native"
|
|
22
|
-
},
|
|
23
17
|
"main": "src/index.ts",
|
|
24
18
|
"files": [
|
|
25
19
|
"src",
|
|
@@ -35,7 +29,7 @@
|
|
|
35
29
|
],
|
|
36
30
|
"peerDependencies": {
|
|
37
31
|
"react": "*",
|
|
38
|
-
"react-native": ">=0.82"
|
|
32
|
+
"react-native": ">=0.72 <0.82"
|
|
39
33
|
},
|
|
40
34
|
"peerDependenciesMeta": {
|
|
41
35
|
"react-native": {
|
|
@@ -45,18 +39,10 @@
|
|
|
45
39
|
"optional": true
|
|
46
40
|
}
|
|
47
41
|
},
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"android": {
|
|
53
|
-
"javaPackageName": "com.rovq.engine.react"
|
|
54
|
-
},
|
|
55
|
-
"ios": {
|
|
56
|
-
"modulesProvider": {
|
|
57
|
-
"Rovq": "RovqModule"
|
|
58
|
-
}
|
|
59
|
-
}
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "jest",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"prepublishOnly": "node -e \"if (process.env.npm_config_tag !== 'rn-0.3') { console.error('Line 0.3 must not become latest. Publish with: npm publish --tag rn-0.3'); process.exit(1) }\""
|
|
60
46
|
},
|
|
61
47
|
"devDependencies": {
|
|
62
48
|
"@swc/core": "1.16.22",
|
|
@@ -72,10 +58,7 @@
|
|
|
72
58
|
"react-native": "src/index.ts",
|
|
73
59
|
"types": "src/index.ts",
|
|
74
60
|
"publishConfig": {
|
|
75
|
-
"access": "public"
|
|
76
|
-
|
|
77
|
-
"scripts": {
|
|
78
|
-
"test": "jest",
|
|
79
|
-
"typecheck": "tsc --noEmit"
|
|
61
|
+
"access": "public",
|
|
62
|
+
"tag": "rn-0.3"
|
|
80
63
|
}
|
|
81
|
-
}
|
|
64
|
+
}
|
package/src/NativeRovq.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { NativeEventEmitter, NativeModules } from 'react-native';
|
|
2
|
+
import type { EmitterSubscription } from 'react-native';
|
|
3
3
|
|
|
4
|
-
// React Native
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// React Native 0.72 to 0.81 find this module through NativeModules, which works with or without
|
|
5
|
+
// the New Architecture. The main line, for 0.82 and newer, generates a TurboModule instead.
|
|
6
|
+
// These shapes are the same on both lines, so index.ts does not depend on which one it runs on.
|
|
7
7
|
|
|
8
8
|
export interface NativeUpdate {
|
|
9
9
|
releaseId: string;
|
|
@@ -57,7 +57,10 @@ export interface NativeDownloadProgress {
|
|
|
57
57
|
totalBytes: number;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
/** The name both native modules send download progress under. */
|
|
61
|
+
export const DOWNLOAD_PROGRESS_EVENT = 'RovqDownloadProgress';
|
|
62
|
+
|
|
63
|
+
interface LegacyModule {
|
|
61
64
|
checkForUpdate(): Promise<NativeCheckResult>;
|
|
62
65
|
downloadUpdate(): Promise<NativeDownloadResult>;
|
|
63
66
|
installUpdate(): Promise<NativeInstallResult>;
|
|
@@ -66,7 +69,48 @@ export interface Spec extends TurboModule {
|
|
|
66
69
|
restartApp(): void;
|
|
67
70
|
rollback(): Promise<boolean>;
|
|
68
71
|
getStatus(): Promise<NativeStatus>;
|
|
69
|
-
|
|
72
|
+
// NativeEventEmitter calls these to tell the native side when someone is listening.
|
|
73
|
+
addListener(eventName: string): void;
|
|
74
|
+
removeListeners(count: number): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Spec {
|
|
78
|
+
checkForUpdate(): Promise<NativeCheckResult>;
|
|
79
|
+
downloadUpdate(): Promise<NativeDownloadResult>;
|
|
80
|
+
installUpdate(): Promise<NativeInstallResult>;
|
|
81
|
+
sync(): Promise<NativeSyncResult>;
|
|
82
|
+
notifyApplicationReady(): void;
|
|
83
|
+
restartApp(): void;
|
|
84
|
+
rollback(): Promise<boolean>;
|
|
85
|
+
getStatus(): Promise<NativeStatus>;
|
|
86
|
+
onDownloadProgress(listener: (progress: NativeDownloadProgress) => void): EmitterSubscription;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function load(): Spec {
|
|
90
|
+
const module = (NativeModules as { Rovq?: LegacyModule }).Rovq;
|
|
91
|
+
// The same failure as TurboModuleRegistry.getEnforcing on the main line.
|
|
92
|
+
if (module === undefined) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"ROVQ: the native module 'Rovq' was not found. Rebuild the app after installing @rovq/react-native.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const emitter = new NativeEventEmitter(module);
|
|
98
|
+
return {
|
|
99
|
+
checkForUpdate: () => module.checkForUpdate(),
|
|
100
|
+
downloadUpdate: () => module.downloadUpdate(),
|
|
101
|
+
installUpdate: () => module.installUpdate(),
|
|
102
|
+
sync: () => module.sync(),
|
|
103
|
+
notifyApplicationReady: () => module.notifyApplicationReady(),
|
|
104
|
+
restartApp: () => module.restartApp(),
|
|
105
|
+
rollback: () => module.rollback(),
|
|
106
|
+
getStatus: () => module.getStatus(),
|
|
107
|
+
// A cast, not a type argument: older versions of NativeEventEmitter take none, and apps
|
|
108
|
+
// type-check this file, because the package ships TypeScript.
|
|
109
|
+
onDownloadProgress: (listener) =>
|
|
110
|
+
emitter.addListener(DOWNLOAD_PROGRESS_EVENT, (progress) =>
|
|
111
|
+
listener(progress as NativeDownloadProgress),
|
|
112
|
+
),
|
|
113
|
+
};
|
|
70
114
|
}
|
|
71
115
|
|
|
72
|
-
export default
|
|
116
|
+
export default load();
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
package com.rovq.engine.react
|
|
2
|
-
|
|
3
|
-
import com.facebook.react.BaseReactPackage
|
|
4
|
-
import com.facebook.react.bridge.NativeModule
|
|
5
|
-
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
-
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
-
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
-
|
|
9
|
-
/** Found by React Native autolinking. */
|
|
10
|
-
class RovqPackage : BaseReactPackage() {
|
|
11
|
-
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
|
|
12
|
-
if (name == RovqModule.NAME) RovqModule(reactContext) else null
|
|
13
|
-
|
|
14
|
-
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
|
|
15
|
-
mapOf(
|
|
16
|
-
RovqModule.NAME to ReactModuleInfo(
|
|
17
|
-
RovqModule.NAME,
|
|
18
|
-
RovqModule::class.java.name,
|
|
19
|
-
false,
|
|
20
|
-
false,
|
|
21
|
-
false,
|
|
22
|
-
true,
|
|
23
|
-
),
|
|
24
|
-
)
|
|
25
|
-
}
|
|
26
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
package com.rovq.engine.react
|
|
2
|
-
|
|
3
|
-
import android.app.Application
|
|
4
|
-
import android.util.Log
|
|
5
|
-
import com.facebook.react.ReactHost
|
|
6
|
-
import com.facebook.react.ReactPackage
|
|
7
|
-
import com.facebook.react.bridge.JSBundleLoader
|
|
8
|
-
import com.facebook.react.bridge.JSBundleLoaderDelegate
|
|
9
|
-
import com.facebook.react.common.annotations.UnstableReactNativeAPI
|
|
10
|
-
import com.facebook.react.common.build.ReactBuildConfig
|
|
11
|
-
import com.facebook.react.defaults.DefaultComponentsRegistry
|
|
12
|
-
import com.facebook.react.defaults.DefaultReactHostDelegate
|
|
13
|
-
import com.facebook.react.defaults.DefaultTurboModuleManagerDelegate
|
|
14
|
-
import com.facebook.react.fabric.ComponentFactory
|
|
15
|
-
import com.facebook.react.runtime.ReactHostImpl
|
|
16
|
-
import com.facebook.react.runtime.hermes.HermesInstance
|
|
17
|
-
|
|
18
|
-
private const val TAG = "Rovq"
|
|
19
|
-
private const val EMBEDDED_BUNDLE = "assets://index.android.bundle"
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Chooses the bundle each time JavaScript starts, not once when the host is created.
|
|
23
|
-
* This is what lets reload apply an update without killing the process.
|
|
24
|
-
*/
|
|
25
|
-
private class RovqBundleLoader(private val application: Application) : JSBundleLoader() {
|
|
26
|
-
override fun loadScript(delegate: JSBundleLoaderDelegate): String {
|
|
27
|
-
val file = try {
|
|
28
|
-
Rovq.engine.resolveBundle()
|
|
29
|
-
} catch (error: Throwable) {
|
|
30
|
-
// Whatever goes wrong in the engine, the app must still start.
|
|
31
|
-
Log.e(TAG, "Falling back to the bundle inside the app", error)
|
|
32
|
-
null
|
|
33
|
-
}
|
|
34
|
-
if (file == null) {
|
|
35
|
-
Log.i(TAG, "Loading the bundle inside the app")
|
|
36
|
-
delegate.loadScriptFromAssets(application.assets, EMBEDDED_BUNDLE, true)
|
|
37
|
-
return EMBEDDED_BUNDLE
|
|
38
|
-
}
|
|
39
|
-
Log.i(TAG, "Loading ${file.absolutePath}")
|
|
40
|
-
delegate.loadScriptFromFile(file.absolutePath, file.absolutePath, false)
|
|
41
|
-
return file.absolutePath
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The only file that touches React Native's unstable host API. It does what
|
|
47
|
-
* getDefaultReactHost does, with a different loader, so check it against that function
|
|
48
|
-
* when upgrading React Native. See docs/android-bundle-loading.md.
|
|
49
|
-
*/
|
|
50
|
-
@OptIn(UnstableReactNativeAPI::class)
|
|
51
|
-
object RovqReactHost {
|
|
52
|
-
/** Use in place of getDefaultReactHost in MainApplication. */
|
|
53
|
-
fun create(application: Application, packages: List<ReactPackage>): ReactHost {
|
|
54
|
-
val componentFactory = ComponentFactory()
|
|
55
|
-
DefaultComponentsRegistry.register(componentFactory)
|
|
56
|
-
val delegate = DefaultReactHostDelegate(
|
|
57
|
-
jsMainModulePath = "index",
|
|
58
|
-
jsBundleLoader = RovqBundleLoader(application),
|
|
59
|
-
reactPackages = packages,
|
|
60
|
-
jsRuntimeFactory = HermesInstance(),
|
|
61
|
-
turboModuleManagerDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder(),
|
|
62
|
-
// Letting a fatal JavaScript error kill the process is what makes rollback work:
|
|
63
|
-
// the release stays unconfirmed, so the next start abandons it.
|
|
64
|
-
exceptionHandler = { error -> throw error },
|
|
65
|
-
)
|
|
66
|
-
return ReactHostImpl(
|
|
67
|
-
application.applicationContext,
|
|
68
|
-
delegate,
|
|
69
|
-
componentFactory,
|
|
70
|
-
true,
|
|
71
|
-
ReactBuildConfig.DEBUG,
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
}
|