ubriot-updates-rn 0.1.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/README.md +173 -0
- package/UbriotUpdatesRn.podspec +19 -0
- package/android/build.gradle +39 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/dev/ubriot/updates/UbriotUpdatesModule.kt +268 -0
- package/android/src/main/java/dev/ubriot/updates/UbriotUpdatesPackage.kt +13 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +158 -0
- package/ios/UbriotUpdates.h +7 -0
- package/ios/UbriotUpdates.m +228 -0
- package/package.json +46 -0
- package/src/index.ts +218 -0
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# @ubriot/updates-rn
|
|
2
|
+
|
|
3
|
+
Over-the-air (OTA) JavaScript update client for **bare React Native** apps (no Expo),
|
|
4
|
+
built on top of the existing Ubriot update backend. It lets a shipped app download a
|
|
5
|
+
newer JS bundle in the background, swap it in on the next launch, and automatically roll
|
|
6
|
+
back to the last known good bundle if a new one fails to boot.
|
|
7
|
+
|
|
8
|
+
It reuses the Ubriot update service that already powers the Expo clients. You publish
|
|
9
|
+
updates the same way, with the Ubriot CLI `update` command. This package is only the
|
|
10
|
+
native + JS runtime that consumes those updates in a bare app.
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
- Talks to the existing `GET /ubriot/updates/manifest` endpoint (Expo-Updates-style
|
|
15
|
+
manifest contract, including the `expo-runtime-version`, `expo-channel-name`, and
|
|
16
|
+
`expo-current-update-id` request headers).
|
|
17
|
+
- Downloads the manifest's `launchAsset` bundle to app storage (iOS
|
|
18
|
+
`Library/ubriot-updates/`, Android `filesDir/ubriot-updates/`).
|
|
19
|
+
- Persists which bundle is `pending`, `active`, `verified`, and `lastKnownGood` in a
|
|
20
|
+
small `state.json` next to the bundles.
|
|
21
|
+
- Boots the verified/active downloaded bundle instead of the embedded one, with
|
|
22
|
+
crash-rollback safety.
|
|
23
|
+
- Never blocks or crashes app start: every network and file operation is wrapped, and a
|
|
24
|
+
failed OTA quietly falls back to the embedded bundle.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
Add it as a dependency (workspace or file path), then rebuild the native app.
|
|
29
|
+
|
|
30
|
+
```jsonc
|
|
31
|
+
// package.json
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ubriot/updates-rn": "*"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The native module autolinks (CocoaPods on iOS, `autolinkLibrariesWithApp()` /
|
|
38
|
+
gradle on Android). Run `pod install` in `ios/` and rebuild.
|
|
39
|
+
|
|
40
|
+
## JS API
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import * as UbriotUpdates from "@ubriot/updates-rn";
|
|
44
|
+
|
|
45
|
+
UbriotUpdates.configure({
|
|
46
|
+
apiBase: "https://api.ubriot.dev/api/v1",
|
|
47
|
+
appSlug: "your-app-slug",
|
|
48
|
+
channel: "production",
|
|
49
|
+
runtimeVersion: "your-app-1.0.0",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Convenience: check -> download -> apply (does not restart by default)
|
|
53
|
+
const result = await UbriotUpdates.sync(); // 'up-to-date' | 'updated'
|
|
54
|
+
|
|
55
|
+
// Or drive each step:
|
|
56
|
+
const { available, manifestId } = await UbriotUpdates.checkForUpdate();
|
|
57
|
+
const { ready } = await UbriotUpdates.downloadUpdate();
|
|
58
|
+
await UbriotUpdates.applyUpdate({ restart: true }); // reload JS now
|
|
59
|
+
|
|
60
|
+
// Call once the app has rendered and run OK. Promotes the active bundle to
|
|
61
|
+
// "verified" so it becomes the rollback target instead of being rolled back.
|
|
62
|
+
UbriotUpdates.markLaunchSuccessful();
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Recommended app-start pattern:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
UbriotUpdates.configure({ /* ... */ });
|
|
70
|
+
UbriotUpdates.sync().catch(() => {});
|
|
71
|
+
const t = setTimeout(() => UbriotUpdates.markLaunchSuccessful(), 4000);
|
|
72
|
+
return () => clearTimeout(t);
|
|
73
|
+
}, []);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`sync()` downloads and stages the update; it applies on the next launch. Use
|
|
77
|
+
`applyUpdate({ restart: true })` if you want to reload immediately.
|
|
78
|
+
|
|
79
|
+
## Rollback model
|
|
80
|
+
|
|
81
|
+
- A freshly applied bundle is `active` but unverified.
|
|
82
|
+
- On the first launch of an unverified active bundle, a launch counter is incremented and
|
|
83
|
+
persisted **before** JS runs.
|
|
84
|
+
- If the app boots that bundle again while still unverified (meaning the previous launch
|
|
85
|
+
never reached `markLaunchSuccessful()`, e.g. it crashed on load), the client reverts to
|
|
86
|
+
the last known good bundle (or the embedded bundle) instead.
|
|
87
|
+
- `markLaunchSuccessful()` marks the active bundle verified and records it as the new last
|
|
88
|
+
known good.
|
|
89
|
+
|
|
90
|
+
## Native integration
|
|
91
|
+
|
|
92
|
+
Autolinking registers the native module. You only need to point the app at the resolved
|
|
93
|
+
bundle so downloaded updates actually load.
|
|
94
|
+
|
|
95
|
+
### iOS (`AppDelegate`)
|
|
96
|
+
|
|
97
|
+
Use the embedded bundle in DEBUG (Metro) and the OTA-resolved bundle in RELEASE.
|
|
98
|
+
|
|
99
|
+
Objective-C:
|
|
100
|
+
|
|
101
|
+
```objc
|
|
102
|
+
#import <UbriotUpdatesRn/UbriotUpdates.h>
|
|
103
|
+
|
|
104
|
+
- (NSURL *)bundleURL {
|
|
105
|
+
#if DEBUG
|
|
106
|
+
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
|
|
107
|
+
#else
|
|
108
|
+
return [UbriotUpdates bundleURL];
|
|
109
|
+
#endif
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Swift (add a bridging header that imports `<UbriotUpdatesRn/UbriotUpdates.h>`):
|
|
114
|
+
|
|
115
|
+
```swift
|
|
116
|
+
override func bundleURL() -> URL? {
|
|
117
|
+
#if DEBUG
|
|
118
|
+
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
|
|
119
|
+
#else
|
|
120
|
+
UbriotUpdates.bundleURL()
|
|
121
|
+
#endif
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`+ [UbriotUpdates bundleURL]` returns the verified/active downloaded bundle when present
|
|
126
|
+
and valid (applying rollback), otherwise the embedded `main.jsbundle`.
|
|
127
|
+
|
|
128
|
+
### Android (`MainApplication`)
|
|
129
|
+
|
|
130
|
+
Pass the resolved bundle path to `getDefaultReactHost` (new architecture) or return it
|
|
131
|
+
from `ReactNativeHost.getJSBundleFile()` (classic). Return `null` to fall back to the
|
|
132
|
+
embedded asset bundle.
|
|
133
|
+
|
|
134
|
+
```kotlin
|
|
135
|
+
import dev.ubriot.updates.UbriotUpdatesModule
|
|
136
|
+
|
|
137
|
+
override val reactHost: ReactHost by lazy {
|
|
138
|
+
getDefaultReactHost(
|
|
139
|
+
context = applicationContext,
|
|
140
|
+
packageList = PackageList(this).packages,
|
|
141
|
+
jsBundleFilePath =
|
|
142
|
+
if (BuildConfig.DEBUG) null
|
|
143
|
+
else UbriotUpdatesModule.resolveBundleFilePath(applicationContext),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`resolveBundleFilePath(context)` applies the same rollback logic as iOS and returns the
|
|
149
|
+
bundle file path or `null`.
|
|
150
|
+
|
|
151
|
+
## Publishing an update
|
|
152
|
+
|
|
153
|
+
Publishing uses the existing Ubriot CLI, unchanged:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
UBRIOT_API_BASE=https://api.ubriot.dev/api/v1 \
|
|
157
|
+
node ../cli/dist/index.js update \
|
|
158
|
+
--app your-app-slug \
|
|
159
|
+
--channel production \
|
|
160
|
+
--runtime-version your-app-1.0.0 \
|
|
161
|
+
--public-url https://api.ubriot.dev/api/v1 \
|
|
162
|
+
--message "what changed"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The `runtimeVersion` you pass here MUST match the `runtimeVersion` passed to
|
|
166
|
+
`configure()`. Only JS-only changes can ship over the air; anything touching native code
|
|
167
|
+
requires a new store/TestFlight build with a bumped runtime version.
|
|
168
|
+
|
|
169
|
+
## Signature verification
|
|
170
|
+
|
|
171
|
+
Manifest signature verification is stubbed (`verifyManifestSignature` currently returns
|
|
172
|
+
`true`) and structured as a single async gate in `checkForUpdate`, ready to enforce a
|
|
173
|
+
signed-manifest policy later.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "UbriotUpdatesRn"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = "https://ubriot.dev"
|
|
10
|
+
s.license = { :type => "MIT" }
|
|
11
|
+
s.authors = { "Ubriot" => "team@ubriot.dev" }
|
|
12
|
+
s.platforms = { :ios => "15.1" }
|
|
13
|
+
s.source = { :git => "https://github.com/ubriot/ubriot.git", :tag => "#{s.version}" }
|
|
14
|
+
|
|
15
|
+
s.source_files = "ios/**/*.{h,m,mm}"
|
|
16
|
+
s.requires_arc = true
|
|
17
|
+
|
|
18
|
+
s.dependency "React-Core"
|
|
19
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
apply plugin: "com.android.library"
|
|
2
|
+
apply plugin: "org.jetbrains.kotlin.android"
|
|
3
|
+
|
|
4
|
+
def safeExtGet(prop, fallback) {
|
|
5
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
android {
|
|
9
|
+
namespace "dev.ubriot.updates"
|
|
10
|
+
compileSdk safeExtGet("compileSdkVersion", 36)
|
|
11
|
+
|
|
12
|
+
defaultConfig {
|
|
13
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
14
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
buildFeatures {
|
|
18
|
+
buildConfig true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
compileOptions {
|
|
22
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
23
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
kotlinOptions {
|
|
27
|
+
jvmTarget = "17"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
repositories {
|
|
32
|
+
google()
|
|
33
|
+
mavenCentral()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
dependencies {
|
|
37
|
+
implementation "com.facebook.react:react-android"
|
|
38
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib"
|
|
39
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
package dev.ubriot.updates
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.facebook.react.ReactApplication
|
|
5
|
+
import com.facebook.react.bridge.Arguments
|
|
6
|
+
import com.facebook.react.bridge.Promise
|
|
7
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
9
|
+
import com.facebook.react.bridge.ReactMethod
|
|
10
|
+
import com.facebook.react.bridge.UiThreadUtil
|
|
11
|
+
import com.facebook.react.bridge.WritableMap
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
import java.io.File
|
|
14
|
+
import java.net.HttpURLConnection
|
|
15
|
+
import java.net.URL
|
|
16
|
+
|
|
17
|
+
data class UbriotUpdateEntry(val id: String, val bundlePath: String)
|
|
18
|
+
|
|
19
|
+
class UbriotUpdateState(
|
|
20
|
+
var runningId: String? = null,
|
|
21
|
+
var active: UbriotUpdateEntry? = null,
|
|
22
|
+
var verified: Boolean = false,
|
|
23
|
+
var pending: UbriotUpdateEntry? = null,
|
|
24
|
+
var lastKnownGood: UbriotUpdateEntry? = null,
|
|
25
|
+
var launchCount: Int = 0,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
class UbriotUpdatesModule(
|
|
29
|
+
private val reactContext: ReactApplicationContext,
|
|
30
|
+
) : ReactContextBaseJavaModule(reactContext) {
|
|
31
|
+
|
|
32
|
+
override fun getName(): String = "UbriotUpdates"
|
|
33
|
+
|
|
34
|
+
private fun filesDir(): File = reactContext.applicationContext.filesDir
|
|
35
|
+
|
|
36
|
+
@ReactMethod
|
|
37
|
+
fun getState(promise: Promise) {
|
|
38
|
+
try {
|
|
39
|
+
promise.resolve(stateToWritableMap(readState(filesDir())))
|
|
40
|
+
} catch (error: Throwable) {
|
|
41
|
+
promise.reject("ubriot_updates_state_failed", error)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@ReactMethod
|
|
46
|
+
fun downloadBundle(url: String, manifestId: String, promise: Promise) {
|
|
47
|
+
Thread {
|
|
48
|
+
var connection: HttpURLConnection? = null
|
|
49
|
+
try {
|
|
50
|
+
if (url.isEmpty() || manifestId.isEmpty()) {
|
|
51
|
+
promise.reject("ubriot_updates_bad_args", "url and manifestId are required")
|
|
52
|
+
return@Thread
|
|
53
|
+
}
|
|
54
|
+
val directory = updatesDir(filesDir())
|
|
55
|
+
if (!directory.exists()) directory.mkdirs()
|
|
56
|
+
val destination = File(directory, "${sanitize(manifestId)}.jsbundle")
|
|
57
|
+
|
|
58
|
+
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
|
59
|
+
instanceFollowRedirects = true
|
|
60
|
+
connectTimeout = 30000
|
|
61
|
+
readTimeout = 60000
|
|
62
|
+
requestMethod = "GET"
|
|
63
|
+
}
|
|
64
|
+
val status = connection.responseCode
|
|
65
|
+
if (status < 200 || status >= 400) {
|
|
66
|
+
promise.reject("ubriot_updates_download_failed", "Update download HTTP $status")
|
|
67
|
+
return@Thread
|
|
68
|
+
}
|
|
69
|
+
connection.inputStream.use { input ->
|
|
70
|
+
destination.outputStream().use { output -> input.copyTo(output) }
|
|
71
|
+
}
|
|
72
|
+
if (!destination.exists() || destination.length() == 0L) {
|
|
73
|
+
promise.reject("ubriot_updates_download_failed", "Empty update payload")
|
|
74
|
+
return@Thread
|
|
75
|
+
}
|
|
76
|
+
val state = readState(filesDir())
|
|
77
|
+
state.pending = UbriotUpdateEntry(manifestId, destination.absolutePath)
|
|
78
|
+
writeState(filesDir(), state)
|
|
79
|
+
|
|
80
|
+
val result = Arguments.createMap()
|
|
81
|
+
result.putBoolean("ready", true)
|
|
82
|
+
result.putString("bundlePath", destination.absolutePath)
|
|
83
|
+
promise.resolve(result)
|
|
84
|
+
} catch (error: Throwable) {
|
|
85
|
+
promise.reject("ubriot_updates_download_failed", error)
|
|
86
|
+
} finally {
|
|
87
|
+
connection?.disconnect()
|
|
88
|
+
}
|
|
89
|
+
}.start()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@ReactMethod
|
|
93
|
+
fun applyPending(promise: Promise) {
|
|
94
|
+
try {
|
|
95
|
+
val state = readState(filesDir())
|
|
96
|
+
val pending = state.pending
|
|
97
|
+
if (pending != null) {
|
|
98
|
+
state.active = pending
|
|
99
|
+
state.verified = false
|
|
100
|
+
state.launchCount = 0
|
|
101
|
+
state.pending = null
|
|
102
|
+
writeState(filesDir(), state)
|
|
103
|
+
}
|
|
104
|
+
promise.resolve(null)
|
|
105
|
+
} catch (error: Throwable) {
|
|
106
|
+
promise.reject("ubriot_updates_apply_failed", error)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@ReactMethod
|
|
111
|
+
fun markLaunchSuccessful(promise: Promise) {
|
|
112
|
+
try {
|
|
113
|
+
val state = readState(filesDir())
|
|
114
|
+
val active = state.active
|
|
115
|
+
if (active != null && !state.verified) {
|
|
116
|
+
state.verified = true
|
|
117
|
+
state.lastKnownGood = active
|
|
118
|
+
state.launchCount = 0
|
|
119
|
+
writeState(filesDir(), state)
|
|
120
|
+
}
|
|
121
|
+
promise.resolve(null)
|
|
122
|
+
} catch (error: Throwable) {
|
|
123
|
+
promise.reject("ubriot_updates_mark_failed", error)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@ReactMethod
|
|
128
|
+
fun reload(promise: Promise) {
|
|
129
|
+
try {
|
|
130
|
+
val application = reactContext.applicationContext
|
|
131
|
+
UiThreadUtil.runOnUiThread {
|
|
132
|
+
try {
|
|
133
|
+
if (application is ReactApplication) {
|
|
134
|
+
application.reactHost.reload("Ubriot OTA update")
|
|
135
|
+
}
|
|
136
|
+
} catch (_: Throwable) {
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
promise.resolve(null)
|
|
140
|
+
} catch (error: Throwable) {
|
|
141
|
+
promise.reject("ubriot_updates_reload_failed", error)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private fun stateToWritableMap(state: UbriotUpdateState): WritableMap {
|
|
146
|
+
val map = Arguments.createMap()
|
|
147
|
+
map.putString("runningId", state.runningId)
|
|
148
|
+
map.putMap("active", entryToWritableMap(state.active))
|
|
149
|
+
map.putBoolean("verified", state.verified)
|
|
150
|
+
map.putMap("pending", entryToWritableMap(state.pending))
|
|
151
|
+
map.putMap("lastKnownGood", entryToWritableMap(state.lastKnownGood))
|
|
152
|
+
map.putInt("launchCount", state.launchCount)
|
|
153
|
+
return map
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private fun entryToWritableMap(entry: UbriotUpdateEntry?): WritableMap? {
|
|
157
|
+
if (entry == null) return null
|
|
158
|
+
val map = Arguments.createMap()
|
|
159
|
+
map.putString("id", entry.id)
|
|
160
|
+
map.putString("bundlePath", entry.bundlePath)
|
|
161
|
+
return map
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
companion object {
|
|
165
|
+
private const val DIR_NAME = "ubriot-updates"
|
|
166
|
+
private const val STATE_FILE = "state.json"
|
|
167
|
+
|
|
168
|
+
private fun updatesDir(filesDir: File): File = File(filesDir, DIR_NAME)
|
|
169
|
+
|
|
170
|
+
private fun stateFile(filesDir: File): File = File(updatesDir(filesDir), STATE_FILE)
|
|
171
|
+
|
|
172
|
+
private fun sanitize(value: String): String {
|
|
173
|
+
val cleaned = value.replace(Regex("[^A-Za-z0-9._-]"), "-")
|
|
174
|
+
return if (cleaned.isEmpty()) "update" else cleaned
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun entryFromJson(obj: JSONObject?): UbriotUpdateEntry? {
|
|
178
|
+
if (obj == null) return null
|
|
179
|
+
val id = obj.optString("id", "")
|
|
180
|
+
val path = obj.optString("bundlePath", "")
|
|
181
|
+
if (id.isEmpty() || path.isEmpty()) return null
|
|
182
|
+
return UbriotUpdateEntry(id, path)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private fun entryToJson(entry: UbriotUpdateEntry?): Any {
|
|
186
|
+
if (entry == null) return JSONObject.NULL
|
|
187
|
+
return JSONObject().put("id", entry.id).put("bundlePath", entry.bundlePath)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@Synchronized
|
|
191
|
+
fun readState(filesDir: File): UbriotUpdateState {
|
|
192
|
+
return try {
|
|
193
|
+
val file = stateFile(filesDir)
|
|
194
|
+
if (!file.exists()) return UbriotUpdateState()
|
|
195
|
+
val json = JSONObject(file.readText())
|
|
196
|
+
UbriotUpdateState(
|
|
197
|
+
runningId = json.optString("runningId", "").ifEmpty { null },
|
|
198
|
+
active = entryFromJson(json.optJSONObject("active")),
|
|
199
|
+
verified = json.optBoolean("verified", false),
|
|
200
|
+
pending = entryFromJson(json.optJSONObject("pending")),
|
|
201
|
+
lastKnownGood = entryFromJson(json.optJSONObject("lastKnownGood")),
|
|
202
|
+
launchCount = json.optInt("launchCount", 0),
|
|
203
|
+
)
|
|
204
|
+
} catch (_: Throwable) {
|
|
205
|
+
UbriotUpdateState()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@Synchronized
|
|
210
|
+
fun writeState(filesDir: File, state: UbriotUpdateState) {
|
|
211
|
+
try {
|
|
212
|
+
val directory = updatesDir(filesDir)
|
|
213
|
+
if (!directory.exists()) directory.mkdirs()
|
|
214
|
+
val json = JSONObject()
|
|
215
|
+
.put("runningId", state.runningId ?: JSONObject.NULL)
|
|
216
|
+
.put("active", entryToJson(state.active))
|
|
217
|
+
.put("verified", state.verified)
|
|
218
|
+
.put("pending", entryToJson(state.pending))
|
|
219
|
+
.put("lastKnownGood", entryToJson(state.lastKnownGood))
|
|
220
|
+
.put("launchCount", state.launchCount)
|
|
221
|
+
stateFile(filesDir).writeText(json.toString())
|
|
222
|
+
} catch (_: Throwable) {
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@JvmStatic
|
|
227
|
+
fun resolveBundleFilePath(context: Context): String? {
|
|
228
|
+
return try {
|
|
229
|
+
val filesDir = context.filesDir
|
|
230
|
+
val state = readState(filesDir)
|
|
231
|
+
val active = state.active
|
|
232
|
+
if (active != null && File(active.bundlePath).exists()) {
|
|
233
|
+
if (state.verified) {
|
|
234
|
+
state.runningId = active.id
|
|
235
|
+
writeState(filesDir, state)
|
|
236
|
+
return active.bundlePath
|
|
237
|
+
}
|
|
238
|
+
if (state.launchCount < 1) {
|
|
239
|
+
state.launchCount = 1
|
|
240
|
+
state.runningId = active.id
|
|
241
|
+
writeState(filesDir, state)
|
|
242
|
+
return active.bundlePath
|
|
243
|
+
}
|
|
244
|
+
state.active = null
|
|
245
|
+
state.verified = false
|
|
246
|
+
state.launchCount = 0
|
|
247
|
+
} else if (active != null) {
|
|
248
|
+
state.active = null
|
|
249
|
+
state.verified = false
|
|
250
|
+
state.launchCount = 0
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
val lastKnownGood = state.lastKnownGood
|
|
254
|
+
if (lastKnownGood != null && File(lastKnownGood.bundlePath).exists()) {
|
|
255
|
+
state.runningId = lastKnownGood.id
|
|
256
|
+
writeState(filesDir, state)
|
|
257
|
+
return lastKnownGood.bundlePath
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
state.runningId = null
|
|
261
|
+
writeState(filesDir, state)
|
|
262
|
+
null
|
|
263
|
+
} catch (_: Throwable) {
|
|
264
|
+
null
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
package dev.ubriot.updates
|
|
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
|
+
|
|
8
|
+
class UbriotUpdatesPackage : ReactPackage {
|
|
9
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
|
10
|
+
listOf(UbriotUpdatesModule(reactContext))
|
|
11
|
+
|
|
12
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
|
|
13
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type UbriotUpdatesConfig = {
|
|
2
|
+
apiBase: string;
|
|
3
|
+
appSlug: string;
|
|
4
|
+
channel: string;
|
|
5
|
+
runtimeVersion: string;
|
|
6
|
+
};
|
|
7
|
+
export type UpdateCheckResult = {
|
|
8
|
+
available: boolean;
|
|
9
|
+
manifestId?: string;
|
|
10
|
+
};
|
|
11
|
+
export type DownloadResult = {
|
|
12
|
+
ready: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type ApplyOptions = {
|
|
15
|
+
restart?: boolean;
|
|
16
|
+
};
|
|
17
|
+
export type SyncResult = "up-to-date" | "updated";
|
|
18
|
+
export declare function configure(next: UbriotUpdatesConfig): void;
|
|
19
|
+
export declare function checkForUpdate(): Promise<UpdateCheckResult>;
|
|
20
|
+
export declare function downloadUpdate(): Promise<DownloadResult>;
|
|
21
|
+
export declare function applyUpdate(options?: ApplyOptions): Promise<void>;
|
|
22
|
+
export declare function sync(): Promise<SyncResult>;
|
|
23
|
+
export declare function markLaunchSuccessful(): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.configure = configure;
|
|
4
|
+
exports.checkForUpdate = checkForUpdate;
|
|
5
|
+
exports.downloadUpdate = downloadUpdate;
|
|
6
|
+
exports.applyUpdate = applyUpdate;
|
|
7
|
+
exports.sync = sync;
|
|
8
|
+
exports.markLaunchSuccessful = markLaunchSuccessful;
|
|
9
|
+
const react_native_1 = require("react-native");
|
|
10
|
+
const MANIFEST_PATH = "/ubriot/updates/manifest";
|
|
11
|
+
let config = null;
|
|
12
|
+
let stagedManifest = null;
|
|
13
|
+
function getNativeModule() {
|
|
14
|
+
const native = react_native_1.NativeModules.UbriotUpdates;
|
|
15
|
+
if (!native || typeof native.getState !== "function") {
|
|
16
|
+
throw new Error("UbriotUpdates native module is not installed. Rebuild the app after adding @ubriot/updates-rn.");
|
|
17
|
+
}
|
|
18
|
+
return native;
|
|
19
|
+
}
|
|
20
|
+
function requireConfig() {
|
|
21
|
+
if (!config) {
|
|
22
|
+
throw new Error("Ubriot updates: call configure() before using the update client.");
|
|
23
|
+
}
|
|
24
|
+
return config;
|
|
25
|
+
}
|
|
26
|
+
function currentPlatform() {
|
|
27
|
+
return react_native_1.Platform.OS === "android" ? "android" : "ios";
|
|
28
|
+
}
|
|
29
|
+
function normalizeBase(apiBase) {
|
|
30
|
+
return apiBase.replace(/\/+$/, "");
|
|
31
|
+
}
|
|
32
|
+
async function verifyManifestSignature(_manifest) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
function configure(next) {
|
|
36
|
+
config = {
|
|
37
|
+
apiBase: normalizeBase(next.apiBase),
|
|
38
|
+
appSlug: next.appSlug,
|
|
39
|
+
channel: next.channel,
|
|
40
|
+
runtimeVersion: next.runtimeVersion,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function checkForUpdate() {
|
|
44
|
+
var _a, _b, _c, _d;
|
|
45
|
+
const cfg = requireConfig();
|
|
46
|
+
const native = getNativeModule();
|
|
47
|
+
let runningId = null;
|
|
48
|
+
let pendingId = null;
|
|
49
|
+
try {
|
|
50
|
+
const state = await native.getState();
|
|
51
|
+
runningId = (_a = state === null || state === void 0 ? void 0 : state.runningId) !== null && _a !== void 0 ? _a : null;
|
|
52
|
+
pendingId = (_c = (_b = state === null || state === void 0 ? void 0 : state.pending) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
runningId = null;
|
|
56
|
+
pendingId = null;
|
|
57
|
+
}
|
|
58
|
+
const platform = currentPlatform();
|
|
59
|
+
const query = new URLSearchParams({
|
|
60
|
+
appId: cfg.appSlug,
|
|
61
|
+
channel: cfg.channel,
|
|
62
|
+
platform,
|
|
63
|
+
runtimeVersion: cfg.runtimeVersion,
|
|
64
|
+
});
|
|
65
|
+
const url = `${cfg.apiBase}${MANIFEST_PATH}?${query.toString()}`;
|
|
66
|
+
const headers = {
|
|
67
|
+
accept: "application/expo+json",
|
|
68
|
+
"expo-platform": platform,
|
|
69
|
+
"expo-runtime-version": cfg.runtimeVersion,
|
|
70
|
+
"expo-channel-name": cfg.channel,
|
|
71
|
+
"expo-protocol-version": "1",
|
|
72
|
+
};
|
|
73
|
+
if (runningId) {
|
|
74
|
+
headers["expo-current-update-id"] = runningId;
|
|
75
|
+
}
|
|
76
|
+
const response = await fetch(url, { method: "GET", headers });
|
|
77
|
+
if (response.status === 204 || response.status === 304) {
|
|
78
|
+
stagedManifest = null;
|
|
79
|
+
return { available: false };
|
|
80
|
+
}
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
stagedManifest = null;
|
|
83
|
+
return { available: false };
|
|
84
|
+
}
|
|
85
|
+
const manifest = (await response.json().catch(() => null));
|
|
86
|
+
const manifestId = manifest === null || manifest === void 0 ? void 0 : manifest.id;
|
|
87
|
+
const launchAssetUrl = (_d = manifest === null || manifest === void 0 ? void 0 : manifest.launchAsset) === null || _d === void 0 ? void 0 : _d.url;
|
|
88
|
+
if (!manifest || !manifestId || !launchAssetUrl) {
|
|
89
|
+
stagedManifest = null;
|
|
90
|
+
return { available: false };
|
|
91
|
+
}
|
|
92
|
+
if (manifestId === runningId || manifestId === pendingId) {
|
|
93
|
+
stagedManifest = null;
|
|
94
|
+
return { available: false };
|
|
95
|
+
}
|
|
96
|
+
const trusted = await verifyManifestSignature(manifest);
|
|
97
|
+
if (!trusted) {
|
|
98
|
+
stagedManifest = null;
|
|
99
|
+
return { available: false };
|
|
100
|
+
}
|
|
101
|
+
stagedManifest = { id: manifestId, launchAssetUrl };
|
|
102
|
+
return { available: true, manifestId };
|
|
103
|
+
}
|
|
104
|
+
async function downloadUpdate() {
|
|
105
|
+
requireConfig();
|
|
106
|
+
const native = getNativeModule();
|
|
107
|
+
if (!stagedManifest) {
|
|
108
|
+
const check = await checkForUpdate();
|
|
109
|
+
if (!check.available) {
|
|
110
|
+
return { ready: false };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!stagedManifest) {
|
|
114
|
+
return { ready: false };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const result = await native.downloadBundle(stagedManifest.launchAssetUrl, stagedManifest.id);
|
|
118
|
+
return { ready: !!(result === null || result === void 0 ? void 0 : result.ready) };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { ready: false };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function applyUpdate(options = {}) {
|
|
125
|
+
const native = getNativeModule();
|
|
126
|
+
await native.applyPending();
|
|
127
|
+
if (options.restart) {
|
|
128
|
+
try {
|
|
129
|
+
await native.reload();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// reload is best-effort; the update still applies on the next launch
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function sync() {
|
|
137
|
+
const check = await checkForUpdate();
|
|
138
|
+
if (!check.available) {
|
|
139
|
+
return "up-to-date";
|
|
140
|
+
}
|
|
141
|
+
const download = await downloadUpdate();
|
|
142
|
+
if (!download.ready) {
|
|
143
|
+
return "up-to-date";
|
|
144
|
+
}
|
|
145
|
+
await applyUpdate({ restart: false });
|
|
146
|
+
return "updated";
|
|
147
|
+
}
|
|
148
|
+
function markLaunchSuccessful() {
|
|
149
|
+
try {
|
|
150
|
+
const native = react_native_1.NativeModules.UbriotUpdates;
|
|
151
|
+
if (native && typeof native.markLaunchSuccessful === "function") {
|
|
152
|
+
native.markLaunchSuccessful().catch(() => { });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// never allow launch bookkeeping to crash the host app
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#import "UbriotUpdates.h"
|
|
2
|
+
#import <React/RCTReloadCommand.h>
|
|
3
|
+
|
|
4
|
+
@implementation UbriotUpdates
|
|
5
|
+
|
|
6
|
+
RCT_EXPORT_MODULE();
|
|
7
|
+
|
|
8
|
+
+ (BOOL)requiresMainQueueSetup {
|
|
9
|
+
return NO;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
#pragma mark - Storage helpers
|
|
13
|
+
|
|
14
|
+
+ (NSString *)updatesDirectory {
|
|
15
|
+
NSString *library = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES).firstObject;
|
|
16
|
+
return [library stringByAppendingPathComponent:@"ubriot-updates"];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
+ (NSString *)stateFilePath {
|
|
20
|
+
return [[self updatesDirectory] stringByAppendingPathComponent:@"state.json"];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
+ (void)ensureDirectory {
|
|
24
|
+
NSFileManager *manager = [NSFileManager defaultManager];
|
|
25
|
+
NSString *directory = [self updatesDirectory];
|
|
26
|
+
if (![manager fileExistsAtPath:directory]) {
|
|
27
|
+
[manager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
+ (NSMutableDictionary *)defaultState {
|
|
32
|
+
return [@{
|
|
33
|
+
@"runningId": [NSNull null],
|
|
34
|
+
@"active": [NSNull null],
|
|
35
|
+
@"verified": @NO,
|
|
36
|
+
@"pending": [NSNull null],
|
|
37
|
+
@"lastKnownGood": [NSNull null],
|
|
38
|
+
@"launchCount": @0,
|
|
39
|
+
} mutableCopy];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
+ (NSMutableDictionary *)readState {
|
|
43
|
+
NSData *data = [NSData dataWithContentsOfFile:[self stateFilePath]];
|
|
44
|
+
if (!data) {
|
|
45
|
+
return [self defaultState];
|
|
46
|
+
}
|
|
47
|
+
id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
|
|
48
|
+
if (![parsed isKindOfClass:[NSDictionary class]]) {
|
|
49
|
+
return [self defaultState];
|
|
50
|
+
}
|
|
51
|
+
NSMutableDictionary *state = [self defaultState];
|
|
52
|
+
[state addEntriesFromDictionary:parsed];
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
+ (void)writeState:(NSDictionary *)state {
|
|
57
|
+
[self ensureDirectory];
|
|
58
|
+
NSData *data = [NSJSONSerialization dataWithJSONObject:state options:0 error:nil];
|
|
59
|
+
if (data) {
|
|
60
|
+
[data writeToFile:[self stateFilePath] atomically:YES];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
+ (NSString *)sanitize:(NSString *)value {
|
|
65
|
+
NSCharacterSet *allowed = [NSCharacterSet characterSetWithCharactersInString:@"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"];
|
|
66
|
+
NSString *cleaned = [[value componentsSeparatedByCharactersInSet:allowed.invertedSet] componentsJoinedByString:@"-"];
|
|
67
|
+
return cleaned.length ? cleaned : @"update";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
+ (NSURL *)embeddedBundleURL {
|
|
71
|
+
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#pragma mark - Bundle resolution with rollback
|
|
75
|
+
|
|
76
|
+
+ (NSURL *)bundleURL {
|
|
77
|
+
@try {
|
|
78
|
+
NSMutableDictionary *state = [self readState];
|
|
79
|
+
NSFileManager *manager = [NSFileManager defaultManager];
|
|
80
|
+
id active = state[@"active"];
|
|
81
|
+
if ([active isKindOfClass:[NSDictionary class]]) {
|
|
82
|
+
NSString *path = active[@"bundlePath"];
|
|
83
|
+
NSString *activeId = active[@"id"];
|
|
84
|
+
if (path.length && [manager fileExistsAtPath:path]) {
|
|
85
|
+
if ([state[@"verified"] boolValue]) {
|
|
86
|
+
state[@"runningId"] = activeId ?: [NSNull null];
|
|
87
|
+
[self writeState:state];
|
|
88
|
+
return [NSURL fileURLWithPath:path];
|
|
89
|
+
}
|
|
90
|
+
NSInteger launchCount = [state[@"launchCount"] integerValue];
|
|
91
|
+
if (launchCount < 1) {
|
|
92
|
+
state[@"launchCount"] = @1;
|
|
93
|
+
state[@"runningId"] = activeId ?: [NSNull null];
|
|
94
|
+
[self writeState:state];
|
|
95
|
+
return [NSURL fileURLWithPath:path];
|
|
96
|
+
}
|
|
97
|
+
state[@"active"] = [NSNull null];
|
|
98
|
+
state[@"verified"] = @NO;
|
|
99
|
+
state[@"launchCount"] = @0;
|
|
100
|
+
} else {
|
|
101
|
+
state[@"active"] = [NSNull null];
|
|
102
|
+
state[@"verified"] = @NO;
|
|
103
|
+
state[@"launchCount"] = @0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
id lastKnownGood = state[@"lastKnownGood"];
|
|
108
|
+
if ([lastKnownGood isKindOfClass:[NSDictionary class]]) {
|
|
109
|
+
NSString *path = lastKnownGood[@"bundlePath"];
|
|
110
|
+
if (path.length && [manager fileExistsAtPath:path]) {
|
|
111
|
+
state[@"runningId"] = lastKnownGood[@"id"] ?: [NSNull null];
|
|
112
|
+
[self writeState:state];
|
|
113
|
+
return [NSURL fileURLWithPath:path];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
state[@"runningId"] = [NSNull null];
|
|
118
|
+
[self writeState:state];
|
|
119
|
+
} @catch (__unused NSException *exception) {
|
|
120
|
+
}
|
|
121
|
+
return [self embeddedBundleURL];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#pragma mark - Exported methods
|
|
125
|
+
|
|
126
|
+
RCT_EXPORT_METHOD(getState:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
|
|
127
|
+
@try {
|
|
128
|
+
resolve([UbriotUpdates readState]);
|
|
129
|
+
} @catch (NSException *exception) {
|
|
130
|
+
reject(@"ubriot_updates_state_failed", exception.reason, nil);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
RCT_EXPORT_METHOD(downloadBundle:(NSString *)url
|
|
135
|
+
manifestId:(NSString *)manifestId
|
|
136
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
137
|
+
rejecter:(RCTPromiseRejectBlock)reject) {
|
|
138
|
+
@try {
|
|
139
|
+
if (url.length == 0 || manifestId.length == 0) {
|
|
140
|
+
reject(@"ubriot_updates_bad_args", @"url and manifestId are required", nil);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
NSURL *remote = [NSURL URLWithString:url];
|
|
144
|
+
if (!remote) {
|
|
145
|
+
reject(@"ubriot_updates_bad_url", @"Invalid update url", nil);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
[UbriotUpdates ensureDirectory];
|
|
149
|
+
NSString *fileName = [NSString stringWithFormat:@"%@.jsbundle", [UbriotUpdates sanitize:manifestId]];
|
|
150
|
+
NSString *destination = [[UbriotUpdates updatesDirectory] stringByAppendingPathComponent:fileName];
|
|
151
|
+
|
|
152
|
+
NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:remote
|
|
153
|
+
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
|
|
154
|
+
if (error || data.length == 0) {
|
|
155
|
+
reject(@"ubriot_updates_download_failed", error ? error.localizedDescription : @"Empty update payload", error);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
|
159
|
+
NSInteger status = ((NSHTTPURLResponse *)response).statusCode;
|
|
160
|
+
if (status < 200 || status >= 300) {
|
|
161
|
+
reject(@"ubriot_updates_download_failed", [NSString stringWithFormat:@"Update download HTTP %ld", (long)status], nil);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
NSError *writeError = nil;
|
|
166
|
+
if (![data writeToFile:destination options:NSDataWritingAtomic error:&writeError]) {
|
|
167
|
+
reject(@"ubriot_updates_write_failed", writeError ? writeError.localizedDescription : @"Could not persist update", writeError);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
@try {
|
|
171
|
+
NSMutableDictionary *state = [UbriotUpdates readState];
|
|
172
|
+
state[@"pending"] = @{ @"id": manifestId, @"bundlePath": destination };
|
|
173
|
+
[UbriotUpdates writeState:state];
|
|
174
|
+
} @catch (__unused NSException *exception) {
|
|
175
|
+
}
|
|
176
|
+
resolve(@{ @"ready": @YES, @"bundlePath": destination });
|
|
177
|
+
}];
|
|
178
|
+
[task resume];
|
|
179
|
+
} @catch (NSException *exception) {
|
|
180
|
+
reject(@"ubriot_updates_download_failed", exception.reason, nil);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
RCT_EXPORT_METHOD(applyPending:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
|
|
185
|
+
@try {
|
|
186
|
+
NSMutableDictionary *state = [UbriotUpdates readState];
|
|
187
|
+
id pending = state[@"pending"];
|
|
188
|
+
if ([pending isKindOfClass:[NSDictionary class]]) {
|
|
189
|
+
state[@"active"] = pending;
|
|
190
|
+
state[@"verified"] = @NO;
|
|
191
|
+
state[@"launchCount"] = @0;
|
|
192
|
+
state[@"pending"] = [NSNull null];
|
|
193
|
+
[UbriotUpdates writeState:state];
|
|
194
|
+
}
|
|
195
|
+
resolve(nil);
|
|
196
|
+
} @catch (NSException *exception) {
|
|
197
|
+
reject(@"ubriot_updates_apply_failed", exception.reason, nil);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
RCT_EXPORT_METHOD(markLaunchSuccessful:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
|
|
202
|
+
@try {
|
|
203
|
+
NSMutableDictionary *state = [UbriotUpdates readState];
|
|
204
|
+
id active = state[@"active"];
|
|
205
|
+
if ([active isKindOfClass:[NSDictionary class]] && ![state[@"verified"] boolValue]) {
|
|
206
|
+
state[@"verified"] = @YES;
|
|
207
|
+
state[@"lastKnownGood"] = active;
|
|
208
|
+
state[@"launchCount"] = @0;
|
|
209
|
+
[UbriotUpdates writeState:state];
|
|
210
|
+
}
|
|
211
|
+
resolve(nil);
|
|
212
|
+
} @catch (NSException *exception) {
|
|
213
|
+
reject(@"ubriot_updates_mark_failed", exception.reason, nil);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
RCT_EXPORT_METHOD(reload:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
|
|
218
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
219
|
+
@try {
|
|
220
|
+
RCTTriggerReloadCommandListeners(@"Ubriot OTA update");
|
|
221
|
+
resolve(nil);
|
|
222
|
+
} @catch (NSException *exception) {
|
|
223
|
+
reject(@"ubriot_updates_reload_failed", exception.reason, nil);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@end
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ubriot-updates-rn",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ubriot over-the-air JS update client for bare React Native (iOS + Android)",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"react-native": "src/index.ts",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"src",
|
|
12
|
+
"android",
|
|
13
|
+
"ios",
|
|
14
|
+
"UbriotUpdatesRn.podspec",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"build": "tsc -p tsconfig.build.json",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": "*",
|
|
24
|
+
"react-native": ">=0.76"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/react": "~19.2.10",
|
|
28
|
+
"react-native": "0.83.6",
|
|
29
|
+
"typescript": "~5.9.2"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"react-native",
|
|
33
|
+
"ota",
|
|
34
|
+
"over-the-air",
|
|
35
|
+
"updates",
|
|
36
|
+
"codepush",
|
|
37
|
+
"ubriot"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"homepage": "https://ubriot.dev",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/dormdeveloper/specvista-monorepo",
|
|
44
|
+
"directory": "products/ubriot/updates-rn"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { NativeModules, Platform } from "react-native";
|
|
2
|
+
|
|
3
|
+
export type UbriotUpdatesConfig = {
|
|
4
|
+
apiBase: string;
|
|
5
|
+
appSlug: string;
|
|
6
|
+
channel: string;
|
|
7
|
+
runtimeVersion: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type UpdateCheckResult = {
|
|
11
|
+
available: boolean;
|
|
12
|
+
manifestId?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type DownloadResult = {
|
|
16
|
+
ready: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ApplyOptions = {
|
|
20
|
+
restart?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SyncResult = "up-to-date" | "updated";
|
|
24
|
+
|
|
25
|
+
type NativeEntry = {
|
|
26
|
+
id: string;
|
|
27
|
+
bundlePath: string;
|
|
28
|
+
} | null;
|
|
29
|
+
|
|
30
|
+
type NativeState = {
|
|
31
|
+
runningId: string | null;
|
|
32
|
+
active: NativeEntry;
|
|
33
|
+
verified: boolean;
|
|
34
|
+
pending: NativeEntry;
|
|
35
|
+
lastKnownGood: NativeEntry;
|
|
36
|
+
launchCount: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type NativeUbriotUpdates = {
|
|
40
|
+
getState(): Promise<NativeState>;
|
|
41
|
+
downloadBundle(url: string, manifestId: string): Promise<{ ready: boolean; bundlePath: string }>;
|
|
42
|
+
applyPending(): Promise<void>;
|
|
43
|
+
markLaunchSuccessful(): Promise<void>;
|
|
44
|
+
reload(): Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type LaunchManifest = {
|
|
48
|
+
id: string;
|
|
49
|
+
launchAssetUrl: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const MANIFEST_PATH = "/ubriot/updates/manifest";
|
|
53
|
+
|
|
54
|
+
let config: UbriotUpdatesConfig | null = null;
|
|
55
|
+
let stagedManifest: LaunchManifest | null = null;
|
|
56
|
+
|
|
57
|
+
function getNativeModule(): NativeUbriotUpdates {
|
|
58
|
+
const native = NativeModules.UbriotUpdates as NativeUbriotUpdates | undefined;
|
|
59
|
+
if (!native || typeof native.getState !== "function") {
|
|
60
|
+
throw new Error("UbriotUpdates native module is not installed. Rebuild the app after adding @ubriot/updates-rn.");
|
|
61
|
+
}
|
|
62
|
+
return native;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function requireConfig(): UbriotUpdatesConfig {
|
|
66
|
+
if (!config) {
|
|
67
|
+
throw new Error("Ubriot updates: call configure() before using the update client.");
|
|
68
|
+
}
|
|
69
|
+
return config;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function currentPlatform(): "ios" | "android" {
|
|
73
|
+
return Platform.OS === "android" ? "android" : "ios";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeBase(apiBase: string): string {
|
|
77
|
+
return apiBase.replace(/\/+$/, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function verifyManifestSignature(_manifest: unknown): Promise<boolean> {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function configure(next: UbriotUpdatesConfig): void {
|
|
85
|
+
config = {
|
|
86
|
+
apiBase: normalizeBase(next.apiBase),
|
|
87
|
+
appSlug: next.appSlug,
|
|
88
|
+
channel: next.channel,
|
|
89
|
+
runtimeVersion: next.runtimeVersion,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
|
94
|
+
const cfg = requireConfig();
|
|
95
|
+
const native = getNativeModule();
|
|
96
|
+
|
|
97
|
+
let runningId: string | null = null;
|
|
98
|
+
let pendingId: string | null = null;
|
|
99
|
+
try {
|
|
100
|
+
const state = await native.getState();
|
|
101
|
+
runningId = state?.runningId ?? null;
|
|
102
|
+
pendingId = state?.pending?.id ?? null;
|
|
103
|
+
} catch {
|
|
104
|
+
runningId = null;
|
|
105
|
+
pendingId = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const platform = currentPlatform();
|
|
109
|
+
const query = new URLSearchParams({
|
|
110
|
+
appId: cfg.appSlug,
|
|
111
|
+
channel: cfg.channel,
|
|
112
|
+
platform,
|
|
113
|
+
runtimeVersion: cfg.runtimeVersion,
|
|
114
|
+
});
|
|
115
|
+
const url = `${cfg.apiBase}${MANIFEST_PATH}?${query.toString()}`;
|
|
116
|
+
|
|
117
|
+
const headers: Record<string, string> = {
|
|
118
|
+
accept: "application/expo+json",
|
|
119
|
+
"expo-platform": platform,
|
|
120
|
+
"expo-runtime-version": cfg.runtimeVersion,
|
|
121
|
+
"expo-channel-name": cfg.channel,
|
|
122
|
+
"expo-protocol-version": "1",
|
|
123
|
+
};
|
|
124
|
+
if (runningId) {
|
|
125
|
+
headers["expo-current-update-id"] = runningId;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const response = await fetch(url, { method: "GET", headers });
|
|
129
|
+
if (response.status === 204 || response.status === 304) {
|
|
130
|
+
stagedManifest = null;
|
|
131
|
+
return { available: false };
|
|
132
|
+
}
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
stagedManifest = null;
|
|
135
|
+
return { available: false };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const manifest = (await response.json().catch(() => null)) as
|
|
139
|
+
| { id?: string; launchAsset?: { url?: string } }
|
|
140
|
+
| null;
|
|
141
|
+
const manifestId = manifest?.id;
|
|
142
|
+
const launchAssetUrl = manifest?.launchAsset?.url;
|
|
143
|
+
if (!manifest || !manifestId || !launchAssetUrl) {
|
|
144
|
+
stagedManifest = null;
|
|
145
|
+
return { available: false };
|
|
146
|
+
}
|
|
147
|
+
if (manifestId === runningId || manifestId === pendingId) {
|
|
148
|
+
stagedManifest = null;
|
|
149
|
+
return { available: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const trusted = await verifyManifestSignature(manifest);
|
|
153
|
+
if (!trusted) {
|
|
154
|
+
stagedManifest = null;
|
|
155
|
+
return { available: false };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
stagedManifest = { id: manifestId, launchAssetUrl };
|
|
159
|
+
return { available: true, manifestId };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function downloadUpdate(): Promise<DownloadResult> {
|
|
163
|
+
requireConfig();
|
|
164
|
+
const native = getNativeModule();
|
|
165
|
+
|
|
166
|
+
if (!stagedManifest) {
|
|
167
|
+
const check = await checkForUpdate();
|
|
168
|
+
if (!check.available) {
|
|
169
|
+
return { ready: false };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!stagedManifest) {
|
|
173
|
+
return { ready: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const result = await native.downloadBundle(stagedManifest.launchAssetUrl, stagedManifest.id);
|
|
178
|
+
return { ready: !!result?.ready };
|
|
179
|
+
} catch {
|
|
180
|
+
return { ready: false };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function applyUpdate(options: ApplyOptions = {}): Promise<void> {
|
|
185
|
+
const native = getNativeModule();
|
|
186
|
+
await native.applyPending();
|
|
187
|
+
if (options.restart) {
|
|
188
|
+
try {
|
|
189
|
+
await native.reload();
|
|
190
|
+
} catch {
|
|
191
|
+
// reload is best-effort; the update still applies on the next launch
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function sync(): Promise<SyncResult> {
|
|
197
|
+
const check = await checkForUpdate();
|
|
198
|
+
if (!check.available) {
|
|
199
|
+
return "up-to-date";
|
|
200
|
+
}
|
|
201
|
+
const download = await downloadUpdate();
|
|
202
|
+
if (!download.ready) {
|
|
203
|
+
return "up-to-date";
|
|
204
|
+
}
|
|
205
|
+
await applyUpdate({ restart: false });
|
|
206
|
+
return "updated";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function markLaunchSuccessful(): void {
|
|
210
|
+
try {
|
|
211
|
+
const native = NativeModules.UbriotUpdates as NativeUbriotUpdates | undefined;
|
|
212
|
+
if (native && typeof native.markLaunchSuccessful === "function") {
|
|
213
|
+
native.markLaunchSuccessful().catch(() => {});
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// never allow launch bookkeeping to crash the host app
|
|
217
|
+
}
|
|
218
|
+
}
|