ubriot-updates-rn 0.1.4 → 0.2.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 +68 -13
- package/android/src/main/java/dev/ubriot/updates/UbriotUpdatesModule.kt +255 -3
- package/app.plugin.js +1 -0
- package/dist/asset-source.d.ts +28 -0
- package/dist/asset-source.js +58 -0
- package/dist/client.d.ts +76 -0
- package/dist/client.js +266 -0
- package/dist/index.d.ts +11 -24
- package/dist/index.js +53 -204
- package/dist/manifest-headers.d.ts +9 -0
- package/dist/manifest-headers.js +22 -0
- package/dist/manifest.d.ts +15 -0
- package/dist/manifest.js +51 -0
- package/ios/UbriotUpdates.m +289 -2
- package/package.json +8 -2
- package/plugin/__tests__/transforms.test.js +79 -0
- package/plugin/transforms.js +38 -0
- package/plugin/withUbriotUpdates.js +46 -0
- package/src/asset-source.ts +94 -0
- package/src/client.ts +335 -0
- package/src/index.ts +69 -258
- package/src/manifest-headers.ts +32 -0
- package/src/manifest.ts +64 -0
package/README.md
CHANGED
|
@@ -14,8 +14,15 @@ native + JS runtime that consumes those updates in a bare app.
|
|
|
14
14
|
- Talks to the existing `GET /ubriot/updates/manifest` endpoint (Expo-Updates-style
|
|
15
15
|
manifest contract, including the `expo-runtime-version`, `expo-channel-name`, and
|
|
16
16
|
`expo-current-update-id` request headers).
|
|
17
|
-
- Downloads the manifest's `launchAsset` bundle
|
|
18
|
-
|
|
17
|
+
- Downloads the manifest's `launchAsset` bundle AND every asset in `manifest.assets`
|
|
18
|
+
(fonts, images, anything the bundle references) into one folder per update (iOS
|
|
19
|
+
`Library/ubriot-updates/<id>/`, Android `filesDir/ubriot-updates/<id>/`). Redirects are
|
|
20
|
+
followed and each file's SHA-256 is checked when the manifest carries a `hash`. The
|
|
21
|
+
update is only staged once every file is present and verified; any failure discards
|
|
22
|
+
it and the app keeps running what it has.
|
|
23
|
+
- Resolves the bundle's `require('./font.ttf')` / `require('./image.png')` references to
|
|
24
|
+
the downloaded files (matched by content hash), falling back to the copy embedded in
|
|
25
|
+
the native build for anything the update did not ship.
|
|
19
26
|
- Persists which bundle is `pending`, `active`, `verified`, and `lastKnownGood` in a
|
|
20
27
|
small `state.json` next to the bundles.
|
|
21
28
|
- Boots the verified/active downloaded bundle instead of the embedded one, with
|
|
@@ -47,6 +54,8 @@ UbriotUpdates.configure({
|
|
|
47
54
|
appSlug: "your-app-slug",
|
|
48
55
|
channel: "production",
|
|
49
56
|
runtimeVersion: "your-app-1.0.0",
|
|
57
|
+
nativeBuildId: "358",
|
|
58
|
+
nativeBuildCreatedAt: "2026-09-26T00:00:00.000Z",
|
|
50
59
|
});
|
|
51
60
|
|
|
52
61
|
// Convenience: check -> download -> apply (does not restart by default)
|
|
@@ -57,25 +66,60 @@ const { available, manifestId } = await UbriotUpdates.checkForUpdate();
|
|
|
57
66
|
const { ready } = await UbriotUpdates.downloadUpdate();
|
|
58
67
|
await UbriotUpdates.applyUpdate({ restart: true }); // reload JS now
|
|
59
68
|
|
|
60
|
-
// Call once the
|
|
61
|
-
// "verified" so it becomes the rollback target instead of being
|
|
69
|
+
// Call once the first real screen has rendered (not the splash). Promotes the
|
|
70
|
+
// running bundle to "verified" so it becomes the rollback target instead of being
|
|
71
|
+
// rolled back on the next launch.
|
|
62
72
|
UbriotUpdates.markLaunchSuccessful();
|
|
73
|
+
|
|
74
|
+
// Fallback only: mark the launch after a delay if you cannot hook a render.
|
|
75
|
+
// Returns a cancel function. An explicit markLaunchSuccessful() cancels it.
|
|
76
|
+
const cancel = UbriotUpdates.markLaunchSuccessfulAfter(4000);
|
|
63
77
|
```
|
|
64
78
|
|
|
65
|
-
|
|
79
|
+
Import the package once at the top of your entry file (`index.js` / `App.tsx`) so its
|
|
80
|
+
asset resolver is installed before any font or image is resolved:
|
|
66
81
|
|
|
67
82
|
```ts
|
|
68
|
-
|
|
69
|
-
UbriotUpdates.configure({ /* ... */ });
|
|
70
|
-
UbriotUpdates.sync().catch(() => {});
|
|
71
|
-
const t = setTimeout(() => UbriotUpdates.markLaunchSuccessful(), 4000);
|
|
72
|
-
return () => clearTimeout(t);
|
|
73
|
-
}, []);
|
|
83
|
+
import * as UbriotUpdates from "ubriot-updates-rn";
|
|
74
84
|
```
|
|
75
85
|
|
|
86
|
+
Recommended app-start pattern:
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
function Root() {
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
UbriotUpdates.configure({ /* ... */ });
|
|
92
|
+
UbriotUpdates.sync().catch(() => {});
|
|
93
|
+
}, []);
|
|
94
|
+
return <App onFirstScreenRendered={() => UbriotUpdates.markLaunchSuccessful()} />;
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Call `markLaunchSuccessful()` from the first screen that proves the app works (for
|
|
99
|
+
example in a `useEffect` of the signed-in or signed-out home screen, after fonts have
|
|
100
|
+
loaded). Do not call it from a timer on the splash: a timer marks a hung or broken
|
|
101
|
+
update as good, and then it is never rolled back. `markLaunchSuccessfulAfter(ms)` exists
|
|
102
|
+
only as a fallback for apps that cannot hook a render; it gives crash-on-launch
|
|
103
|
+
protection but not hang protection.
|
|
104
|
+
|
|
76
105
|
`sync()` downloads and stages the update; it applies on the next launch. Use
|
|
77
106
|
`applyUpdate({ restart: true })` if you want to reload immediately.
|
|
78
107
|
|
|
108
|
+
`nativeBuildId` and `nativeBuildCreatedAt` are optional and let the server-side
|
|
109
|
+
staleness guard tell a stale native shell apart from a stale OTA bundle. Expo apps can
|
|
110
|
+
read these from `Constants.expoConfig?.extra?.ubriotBuild` (set by the config plugin at
|
|
111
|
+
build time) and pass them straight into `configure()`. Bare RN apps that don't use Expo
|
|
112
|
+
config can supply their own build metadata the same way. `nativeBuildCreatedAt` must be
|
|
113
|
+
a UTC ISO string (e.g. `2026-09-26T00:00:00.000Z`).
|
|
114
|
+
|
|
115
|
+
Apps that use a dynamic Expo config (`app.config.js` / `app.config.ts` instead of
|
|
116
|
+
`app.json` / `app.config.json`) are not auto-stamped: the Ubriot build worker only
|
|
117
|
+
writes `expo.extra.ubriotBuild` into static JSON config files, and skips dynamic config
|
|
118
|
+
with a warning in the build logs. Those apps must supply `nativeBuildId` and
|
|
119
|
+
`nativeBuildCreatedAt` to `configure()` themselves (for example, by reading build
|
|
120
|
+
metadata baked in through `app.config.js`'s own logic), otherwise per-device OTA
|
|
121
|
+
staleness checks are skipped for that app.
|
|
122
|
+
|
|
79
123
|
## Rollback model
|
|
80
124
|
|
|
81
125
|
- A freshly applied bundle is `active` but unverified.
|
|
@@ -84,8 +128,19 @@ useEffect(() => {
|
|
|
84
128
|
- If the app boots that bundle again while still unverified (meaning the previous launch
|
|
85
129
|
never reached `markLaunchSuccessful()`, e.g. it crashed on load), the client reverts to
|
|
86
130
|
the last known good bundle (or the embedded bundle) instead.
|
|
87
|
-
- `markLaunchSuccessful()` marks the
|
|
88
|
-
known good.
|
|
131
|
+
- `markLaunchSuccessful()` marks the bundle that is actually running verified and records
|
|
132
|
+
it as the new last known good. An update that was downloaded and applied during this
|
|
133
|
+
session (and has not run yet) is never marked verified by it.
|
|
134
|
+
- An update that was rolled back is remembered (`failedUpdateIds`) and is not downloaded
|
|
135
|
+
again, so a bad update cannot loop.
|
|
136
|
+
|
|
137
|
+
## Upgrading from 0.1.x
|
|
138
|
+
|
|
139
|
+
0.2.0 changes native code, so it needs a new native build (TestFlight / Play). On an old
|
|
140
|
+
native build the new JS falls back to the old bundle-only download. Replace any
|
|
141
|
+
`setTimeout(() => markLaunchSuccessful(), 4000)` with a call from your first rendered
|
|
142
|
+
screen. If you added your own asset source transformer as a workaround for missing OTA
|
|
143
|
+
assets, remove it, or register it after importing this package.
|
|
89
144
|
|
|
90
145
|
## Native integration
|
|
91
146
|
|
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
package dev.ubriot.updates
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import android.util.Base64
|
|
4
5
|
import com.facebook.react.ReactApplication
|
|
5
6
|
import com.facebook.react.bridge.Arguments
|
|
6
7
|
import com.facebook.react.bridge.Promise
|
|
7
8
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
9
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
9
10
|
import com.facebook.react.bridge.ReactMethod
|
|
11
|
+
import com.facebook.react.bridge.ReadableMap
|
|
12
|
+
import com.facebook.react.bridge.ReadableType
|
|
10
13
|
import com.facebook.react.bridge.UiThreadUtil
|
|
11
14
|
import com.facebook.react.bridge.WritableMap
|
|
15
|
+
import org.json.JSONArray
|
|
12
16
|
import org.json.JSONObject
|
|
13
17
|
import java.io.File
|
|
14
18
|
import java.net.HttpURLConnection
|
|
15
19
|
import java.net.URL
|
|
20
|
+
import java.security.MessageDigest
|
|
21
|
+
import java.util.UUID
|
|
22
|
+
import java.util.concurrent.Executors
|
|
16
23
|
|
|
17
24
|
data class UbriotUpdateEntry(val id: String, val bundlePath: String)
|
|
18
25
|
|
|
26
|
+
data class UbriotDownloadAsset(val key: String, val url: String, val hash: String?, val fileExtension: String)
|
|
27
|
+
|
|
19
28
|
class UbriotUpdateState(
|
|
20
29
|
var runningId: String? = null,
|
|
21
30
|
var active: UbriotUpdateEntry? = null,
|
|
@@ -24,6 +33,7 @@ class UbriotUpdateState(
|
|
|
24
33
|
var lastKnownGood: UbriotUpdateEntry? = null,
|
|
25
34
|
var launchCount: Int = 0,
|
|
26
35
|
var revertedFromId: String? = null,
|
|
36
|
+
var failedUpdateIds: MutableList<String> = mutableListOf(),
|
|
27
37
|
)
|
|
28
38
|
|
|
29
39
|
class UbriotUpdatesModule(
|
|
@@ -34,6 +44,19 @@ class UbriotUpdatesModule(
|
|
|
34
44
|
|
|
35
45
|
private fun filesDir(): File = reactContext.applicationContext.filesDir
|
|
36
46
|
|
|
47
|
+
override fun getConstants(): MutableMap<String, Any?> {
|
|
48
|
+
val constants = HashMap<String, Any?>()
|
|
49
|
+
constants["embeddedBundleUrl"] = null
|
|
50
|
+
constants["supportsAssets"] = true
|
|
51
|
+
constants["runningUpdate"] = try {
|
|
52
|
+
val entry = runningEntry
|
|
53
|
+
if (entry == null) null else hashMapOf<String, Any?>("id" to entry.id, "assets" to assetFileUrls(entry.bundlePath, filesDir()))
|
|
54
|
+
} catch (_: Throwable) {
|
|
55
|
+
null
|
|
56
|
+
}
|
|
57
|
+
return constants
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
@ReactMethod
|
|
38
61
|
fun getState(promise: Promise) {
|
|
39
62
|
try {
|
|
@@ -43,6 +66,73 @@ class UbriotUpdatesModule(
|
|
|
43
66
|
}
|
|
44
67
|
}
|
|
45
68
|
|
|
69
|
+
@ReactMethod
|
|
70
|
+
fun downloadUpdate(request: ReadableMap, promise: Promise) {
|
|
71
|
+
val manifestId = if (request.hasKey("id") && request.getType("id") == ReadableType.String) request.getString("id") else null
|
|
72
|
+
if (manifestId.isNullOrEmpty()) {
|
|
73
|
+
promise.reject("ubriot_updates_bad_args", "id is required")
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
val launchAsset = if (request.hasKey("launchAsset") && request.getType("launchAsset") == ReadableType.Map) request.getMap("launchAsset") else null
|
|
77
|
+
val launchUrl = launchAsset?.let { optString(it, "url") }
|
|
78
|
+
if (launchUrl.isNullOrEmpty()) {
|
|
79
|
+
promise.reject("ubriot_updates_bad_args", "launchAsset.url is required")
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
val launchHash = launchAsset?.let { optString(it, "hash") }
|
|
83
|
+
val assets = mutableListOf<UbriotDownloadAsset>()
|
|
84
|
+
if (request.hasKey("assets") && request.getType("assets") == ReadableType.Array) {
|
|
85
|
+
val array = request.getArray("assets")
|
|
86
|
+
if (array != null) {
|
|
87
|
+
for (index in 0 until array.size()) {
|
|
88
|
+
val item = if (array.getType(index) == ReadableType.Map) array.getMap(index) else null
|
|
89
|
+
val key = item?.let { optString(it, "key") }
|
|
90
|
+
val url = item?.let { optString(it, "url") }
|
|
91
|
+
if (item == null || key.isNullOrEmpty() || url.isNullOrEmpty()) {
|
|
92
|
+
promise.reject("ubriot_updates_bad_args", "Asset key and url are required")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
assets.add(UbriotDownloadAsset(key, url, optString(item, "hash"), optString(item, "fileExtension") ?: ""))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
downloadExecutor.execute {
|
|
101
|
+
var staging: File? = null
|
|
102
|
+
try {
|
|
103
|
+
val root = updatesDir(filesDir())
|
|
104
|
+
if (!root.exists()) root.mkdirs()
|
|
105
|
+
val safeId = sanitize(manifestId)
|
|
106
|
+
staging = File(root, ".staging-$safeId-${UUID.randomUUID()}")
|
|
107
|
+
val failure = stageUpdate(staging, launchUrl, launchHash, assets)
|
|
108
|
+
if (failure != null) {
|
|
109
|
+
staging.deleteRecursively()
|
|
110
|
+
promise.reject("ubriot_updates_download_failed", failure)
|
|
111
|
+
return@execute
|
|
112
|
+
}
|
|
113
|
+
val finalDirectory = File(root, safeId)
|
|
114
|
+
if (finalDirectory.exists()) finalDirectory.deleteRecursively()
|
|
115
|
+
if (!staging.renameTo(finalDirectory)) {
|
|
116
|
+
staging.deleteRecursively()
|
|
117
|
+
promise.reject("ubriot_updates_write_failed", "Could not persist update")
|
|
118
|
+
return@execute
|
|
119
|
+
}
|
|
120
|
+
val bundle = File(finalDirectory, BUNDLE_FILE)
|
|
121
|
+
val state = readState(filesDir())
|
|
122
|
+
state.pending = UbriotUpdateEntry(manifestId, bundle.absolutePath)
|
|
123
|
+
writeState(filesDir(), state)
|
|
124
|
+
|
|
125
|
+
val result = Arguments.createMap()
|
|
126
|
+
result.putBoolean("ready", true)
|
|
127
|
+
result.putString("bundlePath", bundle.absolutePath)
|
|
128
|
+
promise.resolve(result)
|
|
129
|
+
} catch (error: Throwable) {
|
|
130
|
+
staging?.deleteRecursively()
|
|
131
|
+
promise.reject("ubriot_updates_download_failed", error)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
46
136
|
@ReactMethod
|
|
47
137
|
fun downloadBundle(url: String, manifestId: String, promise: Promise) {
|
|
48
138
|
Thread {
|
|
@@ -113,11 +203,17 @@ class UbriotUpdatesModule(
|
|
|
113
203
|
try {
|
|
114
204
|
val state = readState(filesDir())
|
|
115
205
|
val active = state.active
|
|
116
|
-
|
|
206
|
+
val runningId = state.runningId
|
|
207
|
+
val activeIsRunning = active != null && runningId != null && active.id == runningId
|
|
208
|
+
val running = runningEntry
|
|
209
|
+
if (activeIsRunning && !state.verified) {
|
|
117
210
|
state.verified = true
|
|
118
211
|
state.lastKnownGood = active
|
|
119
212
|
state.launchCount = 0
|
|
120
213
|
writeState(filesDir(), state)
|
|
214
|
+
} else if (!activeIsRunning && runningId != null && state.lastKnownGood?.id != runningId && running != null && running.id == runningId) {
|
|
215
|
+
state.lastKnownGood = running
|
|
216
|
+
writeState(filesDir(), state)
|
|
121
217
|
}
|
|
122
218
|
promise.resolve(null)
|
|
123
219
|
} catch (error: Throwable) {
|
|
@@ -144,7 +240,7 @@ class UbriotUpdatesModule(
|
|
|
144
240
|
UiThreadUtil.runOnUiThread {
|
|
145
241
|
try {
|
|
146
242
|
if (application is ReactApplication) {
|
|
147
|
-
application.reactHost
|
|
243
|
+
application.reactHost?.reload("Ubriot OTA update")
|
|
148
244
|
}
|
|
149
245
|
} catch (_: Throwable) {
|
|
150
246
|
}
|
|
@@ -164,6 +260,9 @@ class UbriotUpdatesModule(
|
|
|
164
260
|
map.putMap("lastKnownGood", entryToWritableMap(state.lastKnownGood))
|
|
165
261
|
map.putInt("launchCount", state.launchCount)
|
|
166
262
|
map.putString("revertedFromId", state.revertedFromId)
|
|
263
|
+
val failed = Arguments.createArray()
|
|
264
|
+
state.failedUpdateIds.forEach { failed.pushString(it) }
|
|
265
|
+
map.putArray("failedUpdateIds", failed)
|
|
167
266
|
return map
|
|
168
267
|
}
|
|
169
268
|
|
|
@@ -178,6 +277,17 @@ class UbriotUpdatesModule(
|
|
|
178
277
|
companion object {
|
|
179
278
|
private const val DIR_NAME = "ubriot-updates"
|
|
180
279
|
private const val STATE_FILE = "state.json"
|
|
280
|
+
private const val BUNDLE_FILE = "index.jsbundle"
|
|
281
|
+
private const val ASSET_INDEX_FILE = "assets.json"
|
|
282
|
+
private const val MAX_FAILED_IDS = 20
|
|
283
|
+
|
|
284
|
+
private val downloadExecutor = Executors.newSingleThreadExecutor()
|
|
285
|
+
|
|
286
|
+
@Volatile
|
|
287
|
+
private var runningEntry: UbriotUpdateEntry? = null
|
|
288
|
+
|
|
289
|
+
@Volatile
|
|
290
|
+
private var bootedActive: UbriotUpdateEntry? = null
|
|
181
291
|
|
|
182
292
|
private fun updatesDir(filesDir: File): File = File(filesDir, DIR_NAME)
|
|
183
293
|
|
|
@@ -188,6 +298,111 @@ class UbriotUpdatesModule(
|
|
|
188
298
|
return if (cleaned.isEmpty()) "update" else cleaned
|
|
189
299
|
}
|
|
190
300
|
|
|
301
|
+
private fun optString(map: ReadableMap, key: String): String? {
|
|
302
|
+
if (!map.hasKey(key) || map.getType(key) != ReadableType.String) return null
|
|
303
|
+
return map.getString(key)?.ifEmpty { null }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun sha256(file: File): ByteArray {
|
|
307
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
308
|
+
file.inputStream().use { input ->
|
|
309
|
+
val buffer = ByteArray(64 * 1024)
|
|
310
|
+
while (true) {
|
|
311
|
+
val read = input.read(buffer)
|
|
312
|
+
if (read < 0) break
|
|
313
|
+
digest.update(buffer, 0, read)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return digest.digest()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private fun matchesHash(file: File, expected: String?): Boolean {
|
|
320
|
+
if (expected.isNullOrEmpty()) return true
|
|
321
|
+
val digest = sha256(file)
|
|
322
|
+
val base64Url = Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
|
323
|
+
val hex = digest.joinToString("") { "%02x".format(it) }
|
|
324
|
+
val normalized = expected.replace('+', '-').replace('/', '_').trimEnd('=')
|
|
325
|
+
return normalized == base64Url || expected.lowercase() == hex
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private fun downloadFile(url: String, destination: File, expectedHash: String?): String? {
|
|
329
|
+
var connection: HttpURLConnection? = null
|
|
330
|
+
try {
|
|
331
|
+
var current = URL(url)
|
|
332
|
+
var redirects = 0
|
|
333
|
+
while (true) {
|
|
334
|
+
connection = (current.openConnection() as HttpURLConnection).apply {
|
|
335
|
+
instanceFollowRedirects = false
|
|
336
|
+
connectTimeout = 30000
|
|
337
|
+
readTimeout = 60000
|
|
338
|
+
requestMethod = "GET"
|
|
339
|
+
useCaches = false
|
|
340
|
+
}
|
|
341
|
+
val status = connection.responseCode
|
|
342
|
+
if (status in 300..399) {
|
|
343
|
+
val location = connection.getHeaderField("Location")
|
|
344
|
+
connection.disconnect()
|
|
345
|
+
connection = null
|
|
346
|
+
if (location.isNullOrEmpty() || redirects >= 5) return "$url: bad redirect"
|
|
347
|
+
current = URL(current, location)
|
|
348
|
+
redirects += 1
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
if (status < 200 || status >= 300) return "$url: HTTP $status"
|
|
352
|
+
destination.parentFile?.mkdirs()
|
|
353
|
+
connection.inputStream.use { input ->
|
|
354
|
+
destination.outputStream().use { output -> input.copyTo(output) }
|
|
355
|
+
}
|
|
356
|
+
break
|
|
357
|
+
}
|
|
358
|
+
if (!destination.exists() || destination.length() == 0L) return "$url: empty file"
|
|
359
|
+
if (!matchesHash(destination, expectedHash)) return "$url: hash mismatch"
|
|
360
|
+
return null
|
|
361
|
+
} catch (error: Throwable) {
|
|
362
|
+
return "$url: ${error.message ?: error.javaClass.simpleName}"
|
|
363
|
+
} finally {
|
|
364
|
+
connection?.disconnect()
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private fun stageUpdate(staging: File, launchUrl: String, launchHash: String?, assets: List<UbriotDownloadAsset>): String? {
|
|
369
|
+
val assetsDirectory = File(staging, "assets")
|
|
370
|
+
if (!assetsDirectory.mkdirs() && !assetsDirectory.isDirectory) return "Could not create update directory"
|
|
371
|
+
downloadFile(launchUrl, File(staging, BUNDLE_FILE), launchHash)?.let { return it }
|
|
372
|
+
val index = JSONObject()
|
|
373
|
+
for (asset in assets) {
|
|
374
|
+
if (index.has(asset.key)) continue
|
|
375
|
+
val extension = if (asset.fileExtension.isEmpty()) "" else sanitize(asset.fileExtension)
|
|
376
|
+
val relative = "assets/${sanitize(asset.key)}$extension"
|
|
377
|
+
downloadFile(asset.url, File(staging, relative), asset.hash)?.let { return it }
|
|
378
|
+
index.put(asset.key, relative)
|
|
379
|
+
}
|
|
380
|
+
return try {
|
|
381
|
+
File(staging, ASSET_INDEX_FILE).writeText(index.toString())
|
|
382
|
+
null
|
|
383
|
+
} catch (_: Throwable) {
|
|
384
|
+
"Could not write asset index"
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private fun assetFileUrls(bundlePath: String, filesDir: File): HashMap<String, Any?> {
|
|
389
|
+
val urls = HashMap<String, Any?>()
|
|
390
|
+
val directory = File(bundlePath).parentFile ?: return urls
|
|
391
|
+
if (directory.absolutePath == updatesDir(filesDir).absolutePath) return urls
|
|
392
|
+
val indexFile = File(directory, ASSET_INDEX_FILE)
|
|
393
|
+
if (!indexFile.exists()) return urls
|
|
394
|
+
val index = JSONObject(indexFile.readText())
|
|
395
|
+
val keys = index.keys()
|
|
396
|
+
while (keys.hasNext()) {
|
|
397
|
+
val key = keys.next()
|
|
398
|
+
val relative = index.optString(key, "")
|
|
399
|
+
if (relative.isEmpty()) continue
|
|
400
|
+
val file = File(directory, relative)
|
|
401
|
+
if (file.exists()) urls[key] = "file://${file.absolutePath}"
|
|
402
|
+
}
|
|
403
|
+
return urls
|
|
404
|
+
}
|
|
405
|
+
|
|
191
406
|
private fun entryFromJson(obj: JSONObject?): UbriotUpdateEntry? {
|
|
192
407
|
if (obj == null) return null
|
|
193
408
|
val id = obj.optString("id", "")
|
|
@@ -201,6 +416,22 @@ class UbriotUpdatesModule(
|
|
|
201
416
|
return JSONObject().put("id", entry.id).put("bundlePath", entry.bundlePath)
|
|
202
417
|
}
|
|
203
418
|
|
|
419
|
+
private fun failedIdsFromJson(array: JSONArray?): MutableList<String> {
|
|
420
|
+
val ids = mutableListOf<String>()
|
|
421
|
+
if (array == null) return ids
|
|
422
|
+
for (index in 0 until array.length()) {
|
|
423
|
+
val value = array.optString(index, "")
|
|
424
|
+
if (value.isNotEmpty()) ids.add(value)
|
|
425
|
+
}
|
|
426
|
+
return ids
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private fun recordFailedId(state: UbriotUpdateState, id: String) {
|
|
430
|
+
state.failedUpdateIds.remove(id)
|
|
431
|
+
state.failedUpdateIds.add(id)
|
|
432
|
+
while (state.failedUpdateIds.size > MAX_FAILED_IDS) state.failedUpdateIds.removeAt(0)
|
|
433
|
+
}
|
|
434
|
+
|
|
204
435
|
@Synchronized
|
|
205
436
|
fun readState(filesDir: File): UbriotUpdateState {
|
|
206
437
|
return try {
|
|
@@ -215,6 +446,7 @@ class UbriotUpdatesModule(
|
|
|
215
446
|
lastKnownGood = entryFromJson(json.optJSONObject("lastKnownGood")),
|
|
216
447
|
launchCount = json.optInt("launchCount", 0),
|
|
217
448
|
revertedFromId = json.optString("revertedFromId", "").ifEmpty { null },
|
|
449
|
+
failedUpdateIds = failedIdsFromJson(json.optJSONArray("failedUpdateIds")),
|
|
218
450
|
)
|
|
219
451
|
} catch (_: Throwable) {
|
|
220
452
|
UbriotUpdateState()
|
|
@@ -234,7 +466,14 @@ class UbriotUpdatesModule(
|
|
|
234
466
|
.put("lastKnownGood", entryToJson(state.lastKnownGood))
|
|
235
467
|
.put("launchCount", state.launchCount)
|
|
236
468
|
.put("revertedFromId", state.revertedFromId ?: JSONObject.NULL)
|
|
237
|
-
|
|
469
|
+
.put("failedUpdateIds", JSONArray(state.failedUpdateIds))
|
|
470
|
+
val target = stateFile(filesDir)
|
|
471
|
+
val temp = File(directory, "$STATE_FILE.tmp")
|
|
472
|
+
temp.writeText(json.toString())
|
|
473
|
+
if (!temp.renameTo(target)) {
|
|
474
|
+
target.writeText(json.toString())
|
|
475
|
+
temp.delete()
|
|
476
|
+
}
|
|
238
477
|
} catch (_: Throwable) {
|
|
239
478
|
}
|
|
240
479
|
}
|
|
@@ -246,18 +485,28 @@ class UbriotUpdatesModule(
|
|
|
246
485
|
val state = readState(filesDir)
|
|
247
486
|
val active = state.active
|
|
248
487
|
if (active != null && File(active.bundlePath).exists()) {
|
|
488
|
+
val booted = bootedActive
|
|
489
|
+
if (booted != null && booted.id == active.id) {
|
|
490
|
+
runningEntry = active
|
|
491
|
+
return booted.bundlePath
|
|
492
|
+
}
|
|
249
493
|
if (state.verified) {
|
|
250
494
|
state.runningId = active.id
|
|
251
495
|
writeState(filesDir, state)
|
|
496
|
+
runningEntry = active
|
|
252
497
|
return active.bundlePath
|
|
253
498
|
}
|
|
254
499
|
if (state.launchCount < 1) {
|
|
255
500
|
state.launchCount = 1
|
|
256
501
|
state.runningId = active.id
|
|
257
502
|
writeState(filesDir, state)
|
|
503
|
+
bootedActive = active
|
|
504
|
+
runningEntry = active
|
|
258
505
|
return active.bundlePath
|
|
259
506
|
}
|
|
260
507
|
state.revertedFromId = active.id
|
|
508
|
+
recordFailedId(state, active.id)
|
|
509
|
+
if (state.lastKnownGood?.id == active.id) state.lastKnownGood = null
|
|
261
510
|
state.active = null
|
|
262
511
|
state.verified = false
|
|
263
512
|
state.launchCount = 0
|
|
@@ -271,13 +520,16 @@ class UbriotUpdatesModule(
|
|
|
271
520
|
if (lastKnownGood != null && File(lastKnownGood.bundlePath).exists()) {
|
|
272
521
|
state.runningId = lastKnownGood.id
|
|
273
522
|
writeState(filesDir, state)
|
|
523
|
+
runningEntry = lastKnownGood
|
|
274
524
|
return lastKnownGood.bundlePath
|
|
275
525
|
}
|
|
276
526
|
|
|
277
527
|
state.runningId = null
|
|
278
528
|
writeState(filesDir, state)
|
|
529
|
+
runningEntry = null
|
|
279
530
|
null
|
|
280
531
|
} catch (_: Throwable) {
|
|
532
|
+
runningEntry = null
|
|
281
533
|
null
|
|
282
534
|
}
|
|
283
535
|
}
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require("./plugin/withUbriotUpdates");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type RunningUpdate = {
|
|
2
|
+
id: string;
|
|
3
|
+
assets: Record<string, string>;
|
|
4
|
+
};
|
|
5
|
+
export type PackagerAssetLike = {
|
|
6
|
+
scales: number[];
|
|
7
|
+
hash: string;
|
|
8
|
+
fileHashes?: string[];
|
|
9
|
+
name: string;
|
|
10
|
+
type: string;
|
|
11
|
+
width?: number | null;
|
|
12
|
+
height?: number | null;
|
|
13
|
+
};
|
|
14
|
+
export type ResolvedSource = {
|
|
15
|
+
uri: string;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
};
|
|
18
|
+
export type AssetResolverLike = {
|
|
19
|
+
asset: PackagerAssetLike;
|
|
20
|
+
isLoadedFromServer(): boolean;
|
|
21
|
+
defaultAsset(): ResolvedSource;
|
|
22
|
+
};
|
|
23
|
+
export type OtaAssetTransformerOptions = {
|
|
24
|
+
getRunning(): RunningUpdate | null;
|
|
25
|
+
embeddedBundleUrl: string | null;
|
|
26
|
+
pixelRatio(): number;
|
|
27
|
+
};
|
|
28
|
+
export declare function createOtaAssetTransformer(options: OtaAssetTransformerOptions): (resolver: AssetResolverLike) => ResolvedSource | null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createOtaAssetTransformer = createOtaAssetTransformer;
|
|
4
|
+
function pickScale(scales, deviceScale) {
|
|
5
|
+
for (const scale of scales) {
|
|
6
|
+
if (scale >= deviceScale)
|
|
7
|
+
return scale;
|
|
8
|
+
}
|
|
9
|
+
return scales[scales.length - 1] || 1;
|
|
10
|
+
}
|
|
11
|
+
function candidateIndexes(scales, preferred) {
|
|
12
|
+
const preferredIndex = scales.indexOf(preferred);
|
|
13
|
+
const rest = scales.map((_, index) => index).filter((index) => index !== preferredIndex);
|
|
14
|
+
return preferredIndex >= 0 ? [preferredIndex, ...rest] : rest;
|
|
15
|
+
}
|
|
16
|
+
function hashAt(asset, index) {
|
|
17
|
+
if (asset.fileHashes && asset.fileHashes.length > 0)
|
|
18
|
+
return asset.fileHashes[index];
|
|
19
|
+
return index === 0 ? asset.hash : undefined;
|
|
20
|
+
}
|
|
21
|
+
function downloadedSource(asset, running, deviceScale) {
|
|
22
|
+
const scales = asset.scales && asset.scales.length > 0 ? asset.scales : [1];
|
|
23
|
+
const preferred = pickScale(scales, deviceScale);
|
|
24
|
+
for (const index of candidateIndexes(scales, preferred)) {
|
|
25
|
+
const hash = hashAt(asset, index);
|
|
26
|
+
const uri = hash ? running.assets[hash] : undefined;
|
|
27
|
+
if (uri) {
|
|
28
|
+
return {
|
|
29
|
+
__packager_asset: true,
|
|
30
|
+
width: asset.width,
|
|
31
|
+
height: asset.height,
|
|
32
|
+
uri,
|
|
33
|
+
scale: scales[index],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function embeddedSource(resolver, embeddedBundleUrl) {
|
|
40
|
+
const Resolver = resolver.constructor;
|
|
41
|
+
return new Resolver(null, embeddedBundleUrl, resolver.asset).defaultAsset();
|
|
42
|
+
}
|
|
43
|
+
function createOtaAssetTransformer(options) {
|
|
44
|
+
return (resolver) => {
|
|
45
|
+
var _a;
|
|
46
|
+
try {
|
|
47
|
+
const running = options.getRunning();
|
|
48
|
+
if (!running)
|
|
49
|
+
return null;
|
|
50
|
+
if (resolver.isLoadedFromServer())
|
|
51
|
+
return null;
|
|
52
|
+
return (_a = downloadedSource(resolver.asset, running, options.pixelRatio())) !== null && _a !== void 0 ? _a : embeddedSource(resolver, options.embeddedBundleUrl);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { UbriotUpdatesConfig } from "./manifest-headers";
|
|
2
|
+
export type UpdateCheckResult = {
|
|
3
|
+
available: boolean;
|
|
4
|
+
manifestId?: string;
|
|
5
|
+
};
|
|
6
|
+
export type DownloadResult = {
|
|
7
|
+
ready: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type ApplyOptions = {
|
|
10
|
+
restart?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type SyncResult = "up-to-date" | "updated";
|
|
13
|
+
export type LaunchOutcome = "launched" | "crashed";
|
|
14
|
+
export type NativeEntry = {
|
|
15
|
+
id: string;
|
|
16
|
+
bundlePath: string;
|
|
17
|
+
} | null;
|
|
18
|
+
export type NativeState = {
|
|
19
|
+
runningId: string | null;
|
|
20
|
+
active: NativeEntry;
|
|
21
|
+
verified: boolean;
|
|
22
|
+
pending: NativeEntry;
|
|
23
|
+
lastKnownGood: NativeEntry;
|
|
24
|
+
launchCount: number;
|
|
25
|
+
revertedFromId?: string | null;
|
|
26
|
+
failedUpdateId?: string | null;
|
|
27
|
+
lastRevertedId?: string | null;
|
|
28
|
+
failedUpdateIds?: string[] | null;
|
|
29
|
+
};
|
|
30
|
+
export type NativeDownloadRequest = {
|
|
31
|
+
id: string;
|
|
32
|
+
launchAsset: {
|
|
33
|
+
url: string;
|
|
34
|
+
hash: string | null;
|
|
35
|
+
};
|
|
36
|
+
assets: {
|
|
37
|
+
key: string;
|
|
38
|
+
url: string;
|
|
39
|
+
hash: string | null;
|
|
40
|
+
fileExtension: string;
|
|
41
|
+
}[];
|
|
42
|
+
};
|
|
43
|
+
export type NativeUbriotUpdates = {
|
|
44
|
+
getState(): Promise<NativeState>;
|
|
45
|
+
downloadUpdate?(request: NativeDownloadRequest): Promise<{
|
|
46
|
+
ready: boolean;
|
|
47
|
+
bundlePath?: string;
|
|
48
|
+
}>;
|
|
49
|
+
downloadBundle?(url: string, manifestId: string): Promise<{
|
|
50
|
+
ready: boolean;
|
|
51
|
+
bundlePath: string;
|
|
52
|
+
}>;
|
|
53
|
+
applyPending(): Promise<void>;
|
|
54
|
+
markLaunchSuccessful(): Promise<void>;
|
|
55
|
+
reload(): Promise<void>;
|
|
56
|
+
clearRevertedFrom?(): Promise<void>;
|
|
57
|
+
};
|
|
58
|
+
type TimerHandle = unknown;
|
|
59
|
+
export type UpdatesClientDeps = {
|
|
60
|
+
getNative(): NativeUbriotUpdates | undefined;
|
|
61
|
+
fetch: typeof fetch;
|
|
62
|
+
platform: "ios" | "android";
|
|
63
|
+
setTimeout(callback: () => void, ms: number): TimerHandle;
|
|
64
|
+
clearTimeout(handle: TimerHandle): void;
|
|
65
|
+
};
|
|
66
|
+
export declare const DEFAULT_LAUNCH_FALLBACK_MS = 4000;
|
|
67
|
+
export declare function createUpdatesClient(deps: UpdatesClientDeps): {
|
|
68
|
+
configure: (next: UbriotUpdatesConfig) => void;
|
|
69
|
+
checkForUpdate: () => Promise<UpdateCheckResult>;
|
|
70
|
+
downloadUpdate: () => Promise<DownloadResult>;
|
|
71
|
+
applyUpdate: (options?: ApplyOptions) => Promise<void>;
|
|
72
|
+
sync: () => Promise<SyncResult>;
|
|
73
|
+
markLaunchSuccessful: () => void;
|
|
74
|
+
markLaunchSuccessfulAfter: (ms?: number) => () => void;
|
|
75
|
+
};
|
|
76
|
+
export {};
|