ubriot-updates-rn 0.1.0 → 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 +272 -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 -23
- package/dist/index.js +53 -144
- 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 +302 -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 -200
- 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,
|
|
@@ -23,6 +32,8 @@ class UbriotUpdateState(
|
|
|
23
32
|
var pending: UbriotUpdateEntry? = null,
|
|
24
33
|
var lastKnownGood: UbriotUpdateEntry? = null,
|
|
25
34
|
var launchCount: Int = 0,
|
|
35
|
+
var revertedFromId: String? = null,
|
|
36
|
+
var failedUpdateIds: MutableList<String> = mutableListOf(),
|
|
26
37
|
)
|
|
27
38
|
|
|
28
39
|
class UbriotUpdatesModule(
|
|
@@ -33,6 +44,19 @@ class UbriotUpdatesModule(
|
|
|
33
44
|
|
|
34
45
|
private fun filesDir(): File = reactContext.applicationContext.filesDir
|
|
35
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
|
+
|
|
36
60
|
@ReactMethod
|
|
37
61
|
fun getState(promise: Promise) {
|
|
38
62
|
try {
|
|
@@ -42,6 +66,73 @@ class UbriotUpdatesModule(
|
|
|
42
66
|
}
|
|
43
67
|
}
|
|
44
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
|
+
|
|
45
136
|
@ReactMethod
|
|
46
137
|
fun downloadBundle(url: String, manifestId: String, promise: Promise) {
|
|
47
138
|
Thread {
|
|
@@ -112,11 +203,17 @@ class UbriotUpdatesModule(
|
|
|
112
203
|
try {
|
|
113
204
|
val state = readState(filesDir())
|
|
114
205
|
val active = state.active
|
|
115
|
-
|
|
206
|
+
val runningId = state.runningId
|
|
207
|
+
val activeIsRunning = active != null && runningId != null && active.id == runningId
|
|
208
|
+
val running = runningEntry
|
|
209
|
+
if (activeIsRunning && !state.verified) {
|
|
116
210
|
state.verified = true
|
|
117
211
|
state.lastKnownGood = active
|
|
118
212
|
state.launchCount = 0
|
|
119
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)
|
|
120
217
|
}
|
|
121
218
|
promise.resolve(null)
|
|
122
219
|
} catch (error: Throwable) {
|
|
@@ -124,6 +221,18 @@ class UbriotUpdatesModule(
|
|
|
124
221
|
}
|
|
125
222
|
}
|
|
126
223
|
|
|
224
|
+
@ReactMethod
|
|
225
|
+
fun clearRevertedFrom(promise: Promise) {
|
|
226
|
+
try {
|
|
227
|
+
val state = readState(filesDir())
|
|
228
|
+
state.revertedFromId = null
|
|
229
|
+
writeState(filesDir(), state)
|
|
230
|
+
promise.resolve(null)
|
|
231
|
+
} catch (error: Throwable) {
|
|
232
|
+
promise.reject("ubriot_updates_clear_reverted_failed", error)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
127
236
|
@ReactMethod
|
|
128
237
|
fun reload(promise: Promise) {
|
|
129
238
|
try {
|
|
@@ -131,7 +240,7 @@ class UbriotUpdatesModule(
|
|
|
131
240
|
UiThreadUtil.runOnUiThread {
|
|
132
241
|
try {
|
|
133
242
|
if (application is ReactApplication) {
|
|
134
|
-
application.reactHost
|
|
243
|
+
application.reactHost?.reload("Ubriot OTA update")
|
|
135
244
|
}
|
|
136
245
|
} catch (_: Throwable) {
|
|
137
246
|
}
|
|
@@ -150,6 +259,10 @@ class UbriotUpdatesModule(
|
|
|
150
259
|
map.putMap("pending", entryToWritableMap(state.pending))
|
|
151
260
|
map.putMap("lastKnownGood", entryToWritableMap(state.lastKnownGood))
|
|
152
261
|
map.putInt("launchCount", state.launchCount)
|
|
262
|
+
map.putString("revertedFromId", state.revertedFromId)
|
|
263
|
+
val failed = Arguments.createArray()
|
|
264
|
+
state.failedUpdateIds.forEach { failed.pushString(it) }
|
|
265
|
+
map.putArray("failedUpdateIds", failed)
|
|
153
266
|
return map
|
|
154
267
|
}
|
|
155
268
|
|
|
@@ -164,6 +277,17 @@ class UbriotUpdatesModule(
|
|
|
164
277
|
companion object {
|
|
165
278
|
private const val DIR_NAME = "ubriot-updates"
|
|
166
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
|
|
167
291
|
|
|
168
292
|
private fun updatesDir(filesDir: File): File = File(filesDir, DIR_NAME)
|
|
169
293
|
|
|
@@ -174,6 +298,111 @@ class UbriotUpdatesModule(
|
|
|
174
298
|
return if (cleaned.isEmpty()) "update" else cleaned
|
|
175
299
|
}
|
|
176
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
|
+
|
|
177
406
|
private fun entryFromJson(obj: JSONObject?): UbriotUpdateEntry? {
|
|
178
407
|
if (obj == null) return null
|
|
179
408
|
val id = obj.optString("id", "")
|
|
@@ -187,6 +416,22 @@ class UbriotUpdatesModule(
|
|
|
187
416
|
return JSONObject().put("id", entry.id).put("bundlePath", entry.bundlePath)
|
|
188
417
|
}
|
|
189
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
|
+
|
|
190
435
|
@Synchronized
|
|
191
436
|
fun readState(filesDir: File): UbriotUpdateState {
|
|
192
437
|
return try {
|
|
@@ -200,6 +445,8 @@ class UbriotUpdatesModule(
|
|
|
200
445
|
pending = entryFromJson(json.optJSONObject("pending")),
|
|
201
446
|
lastKnownGood = entryFromJson(json.optJSONObject("lastKnownGood")),
|
|
202
447
|
launchCount = json.optInt("launchCount", 0),
|
|
448
|
+
revertedFromId = json.optString("revertedFromId", "").ifEmpty { null },
|
|
449
|
+
failedUpdateIds = failedIdsFromJson(json.optJSONArray("failedUpdateIds")),
|
|
203
450
|
)
|
|
204
451
|
} catch (_: Throwable) {
|
|
205
452
|
UbriotUpdateState()
|
|
@@ -218,7 +465,15 @@ class UbriotUpdatesModule(
|
|
|
218
465
|
.put("pending", entryToJson(state.pending))
|
|
219
466
|
.put("lastKnownGood", entryToJson(state.lastKnownGood))
|
|
220
467
|
.put("launchCount", state.launchCount)
|
|
221
|
-
|
|
468
|
+
.put("revertedFromId", state.revertedFromId ?: JSONObject.NULL)
|
|
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
|
+
}
|
|
222
477
|
} catch (_: Throwable) {
|
|
223
478
|
}
|
|
224
479
|
}
|
|
@@ -230,17 +485,28 @@ class UbriotUpdatesModule(
|
|
|
230
485
|
val state = readState(filesDir)
|
|
231
486
|
val active = state.active
|
|
232
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
|
+
}
|
|
233
493
|
if (state.verified) {
|
|
234
494
|
state.runningId = active.id
|
|
235
495
|
writeState(filesDir, state)
|
|
496
|
+
runningEntry = active
|
|
236
497
|
return active.bundlePath
|
|
237
498
|
}
|
|
238
499
|
if (state.launchCount < 1) {
|
|
239
500
|
state.launchCount = 1
|
|
240
501
|
state.runningId = active.id
|
|
241
502
|
writeState(filesDir, state)
|
|
503
|
+
bootedActive = active
|
|
504
|
+
runningEntry = active
|
|
242
505
|
return active.bundlePath
|
|
243
506
|
}
|
|
507
|
+
state.revertedFromId = active.id
|
|
508
|
+
recordFailedId(state, active.id)
|
|
509
|
+
if (state.lastKnownGood?.id == active.id) state.lastKnownGood = null
|
|
244
510
|
state.active = null
|
|
245
511
|
state.verified = false
|
|
246
512
|
state.launchCount = 0
|
|
@@ -254,13 +520,16 @@ class UbriotUpdatesModule(
|
|
|
254
520
|
if (lastKnownGood != null && File(lastKnownGood.bundlePath).exists()) {
|
|
255
521
|
state.runningId = lastKnownGood.id
|
|
256
522
|
writeState(filesDir, state)
|
|
523
|
+
runningEntry = lastKnownGood
|
|
257
524
|
return lastKnownGood.bundlePath
|
|
258
525
|
}
|
|
259
526
|
|
|
260
527
|
state.runningId = null
|
|
261
528
|
writeState(filesDir, state)
|
|
529
|
+
runningEntry = null
|
|
262
530
|
null
|
|
263
531
|
} catch (_: Throwable) {
|
|
532
|
+
runningEntry = null
|
|
264
533
|
null
|
|
265
534
|
}
|
|
266
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
|
+
}
|