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 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,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ </manifest>
@@ -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
+ }
@@ -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,7 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface UbriotUpdates : NSObject <RCTBridgeModule>
4
+
5
+ + (NSURL *)bundleURL;
6
+
7
+ @end
@@ -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
+ }