@fireota/react-native 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FireOTA
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # @fireota/react-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Over-the-air JavaScript updates for React Native, served from **your own Firebase project** by [FireOTA](https://www.npmjs.com/package/@fireota/cli).
4
+
5
+ - SHA-256 verified downloads, straight from your Firebase Storage bucket
6
+ - Staged rollouts, pause and rollback from the FireOTA dashboard
7
+ - Automatic rollback when a new bundle crashes before it starts
8
+ - Old updates are discarded when a new store build is installed
9
+ - No third-party native dependencies
10
+
11
+ > **Beta (0.x).** Verified on Android with React Native 0.87. iOS support is implemented but not yet device-tested.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install @fireota/react-native
17
+ npm install --save-dev @fireota/cli
18
+ cd ios && pod install
19
+ ```
20
+
21
+ Requires a bare React Native app, 0.73 or newer (Expo: run `npx expo prebuild`).
22
+
23
+ ## Android
24
+
25
+ In `MainApplication.kt` (React Native 0.76+ template):
26
+
27
+ ```kotlin
28
+ import dev.fireota.reactnative.FireOTA
29
+
30
+ override val reactHost: ReactHost by lazy {
31
+ getDefaultReactHost(
32
+ context = applicationContext,
33
+ packageList = PackageList(this).packages,
34
+ jsBundleFilePath = if (BuildConfig.DEBUG) null else FireOTA.getJSBundleFile(applicationContext),
35
+ )
36
+ }
37
+ ```
38
+
39
+ Older templates that override `reactNativeHost`:
40
+
41
+ ```kotlin
42
+ override fun getJSBundleFile(): String? =
43
+ if (BuildConfig.DEBUG) super.getJSBundleFile()
44
+ else FireOTA.getJSBundleFile(applicationContext) ?: super.getJSBundleFile()
45
+ ```
46
+
47
+ ## iOS
48
+
49
+ ```objc
50
+ // AppDelegate.mm
51
+ #import <FireOTA.h>
52
+
53
+ - (NSURL *)bundleURL
54
+ {
55
+ #if DEBUG
56
+ return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
57
+ #else
58
+ return [FireOTA bundleURL] ?: [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
59
+ #endif
60
+ }
61
+ ```
62
+
63
+ Swift: add `#import <FireOTA.h>` to the bridging header and return `FireOTA.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")` from `bundleURL()` in release builds.
64
+
65
+ ## Configure
66
+
67
+ Call once, early and unconditionally (e.g. `index.js`). Reaching this call confirms a new bundle starts; a bundle that never reaches it is rolled back on the next launch.
68
+
69
+ ```js
70
+ import FireOTA from "@fireota/react-native";
71
+
72
+ if (!__DEV__) {
73
+ FireOTA.configure({
74
+ appId: "YOUR_APP_ID",
75
+ serverUrl: "https://ota.example.com",
76
+ runtimeVersion: "1.3.0", // must equal this store build; bump when native code changes
77
+ channel: "production",
78
+ });
79
+ }
80
+ ```
81
+
82
+ By default updates download silently and apply on the next cold start; mandatory updates restart immediately. To show your own prompt, set `checkOnAppStart: false` and use the API:
83
+
84
+ ```js
85
+ const result = await FireOTA.checkForUpdate();
86
+ if (result.updateAvailable) {
87
+ // show your UI with result.release.version / result.release.notes, then:
88
+ await FireOTA.downloadUpdate(result.release);
89
+ await FireOTA.installUpdate(result.release, "immediate");
90
+ }
91
+ ```
92
+
93
+ ## API
94
+
95
+ | Method | Description |
96
+ | --- | --- |
97
+ | `configure(options)` | Initialize once at startup (required). |
98
+ | `sync()` | Check, download and install, honouring install modes. |
99
+ | `checkForUpdate()` | `{ updateAvailable, reason?, release? }` |
100
+ | `downloadUpdate(release)` | Download and verify; the running bundle is untouched. |
101
+ | `installUpdate(release, mode)` | `"nextRestart"` or `"immediate"`. |
102
+ | `reload()` | Restart now to apply a pending update. |
103
+ | `clearPendingUpdate()` | Drop a downloaded update that is not applied yet. |
104
+ | `getCurrentRelease()` | `{ releaseId, pendingReleaseId, installationId }` |
105
+ | `getUpdateStatus()` | Current status string. |
106
+
107
+ Options: `appId`, `serverUrl`, `runtimeVersion` (required), `channel`, `checkOnAppStart`, `installMode`, `mandatoryInstallMode`, `onStatusChange(status, detail)`.
108
+
109
+ ## Publishing updates
110
+
111
+ Use [`@fireota/cli`](https://www.npmjs.com/package/@fireota/cli):
112
+
113
+ ```sh
114
+ npx fireota publish --app YOUR_APP_ID --platform android --version 1.3.1 --runtime 1.3.0 --channel production --rollout 10
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,51 @@
1
+ buildscript {
2
+ ext.safeExtGet = { prop, fallback -> rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback }
3
+ repositories { google(); mavenCentral() }
4
+ dependencies {
5
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${safeExtGet('kotlinVersion', '2.1.20')}"
6
+ }
7
+ }
8
+
9
+ apply plugin: "com.android.library"
10
+
11
+ /**
12
+ * AGP 9 compiles Kotlin itself ("built-in Kotlin", on by default) and registers the `kotlin`
13
+ * extension; applying kotlin-android on top fails with "Cannot add extension with name 'kotlin'".
14
+ * Only apply it on AGP 8 or when the app turned built-in Kotlin off (android.builtInKotlin=false).
15
+ */
16
+ def needsKotlinPlugin() {
17
+ def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger()
18
+ if (agpMajor <= 8) return true
19
+ def builtIn = providers.gradleProperty("android.builtInKotlin").orNull
20
+ return builtIn != null && !builtIn.toBoolean()
21
+ }
22
+
23
+ def useKotlinPlugin = needsKotlinPlugin()
24
+ if (useKotlinPlugin) {
25
+ apply plugin: "kotlin-android"
26
+ }
27
+
28
+ android {
29
+ namespace "dev.fireota.reactnative"
30
+ compileSdk safeExtGet("compileSdkVersion", 35)
31
+ defaultConfig {
32
+ minSdk safeExtGet("minSdkVersion", 24)
33
+ }
34
+ compileOptions {
35
+ sourceCompatibility JavaVersion.VERSION_17
36
+ targetCompatibility JavaVersion.VERSION_17
37
+ }
38
+ }
39
+
40
+ if (useKotlinPlugin) {
41
+ // Built-in Kotlin derives this from compileOptions; the standalone plugin needs it explicitly.
42
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
43
+ kotlinOptions.jvmTarget = "17"
44
+ }
45
+ }
46
+
47
+ repositories { google(); mavenCentral() }
48
+
49
+ dependencies {
50
+ implementation "com.facebook.react:react-android"
51
+ }
@@ -0,0 +1,10 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ <application>
4
+ <activity
5
+ android:name="dev.fireota.reactnative.FireOTARestartActivity"
6
+ android:exported="false"
7
+ android:process=":fireota_restart"
8
+ android:theme="@android:style/Theme.Translucent.NoTitleBar" />
9
+ </application>
10
+ </manifest>
@@ -0,0 +1,33 @@
1
+ package dev.fireota.reactnative
2
+
3
+ import android.content.Context
4
+
5
+ /**
6
+ * Entry point for MainApplication. Returns the path of the OTA bundle to load, or null to
7
+ * fall back to the bundle shipped inside the APK/AAB.
8
+ *
9
+ * override fun getJSBundleFile(): String? =
10
+ * FireOTA.getJSBundleFile(applicationContext) ?: super.getJSBundleFile()
11
+ */
12
+ object FireOTA {
13
+ @Volatile private var resolved = false
14
+ @Volatile private var bundlePath: String? = null
15
+
16
+ @JvmStatic
17
+ fun getJSBundleFile(context: Context): String? {
18
+ // Resolve once per process: launch attempts are counted per process start.
19
+ if (!resolved) {
20
+ synchronized(this) {
21
+ if (!resolved) {
22
+ bundlePath = try {
23
+ FireOTAStore(context.applicationContext).resolveLaunchBundle()
24
+ } catch (_: Throwable) {
25
+ null // never block startup: fall back to the embedded bundle
26
+ }
27
+ resolved = true
28
+ }
29
+ }
30
+ }
31
+ return bundlePath
32
+ }
33
+ }
@@ -0,0 +1,105 @@
1
+ package dev.fireota.reactnative
2
+
3
+ import android.content.Intent
4
+ import android.os.Process
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 java.util.concurrent.Executors
11
+
12
+ class FireOTAModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
13
+ private val store = FireOTAStore(reactContext)
14
+ private val io = Executors.newSingleThreadExecutor()
15
+
16
+ override fun getName() = "FireOTA"
17
+
18
+ @ReactMethod
19
+ fun getState(promise: Promise) {
20
+ try {
21
+ val map = Arguments.createMap()
22
+ map.putString("installationId", store.installationId)
23
+ map.putString("nativeVersion", store.nativeVersion())
24
+ map.putString("appVersion", store.nativeVersion())
25
+ map.putString("currentReleaseId", store.get(FireOTAStore.KEY_CURRENT))
26
+ map.putString("pendingReleaseId", store.get(FireOTAStore.KEY_PENDING))
27
+ map.putString("previousReleaseId", store.get(FireOTAStore.KEY_PREVIOUS))
28
+ map.putString("failedReleaseId", store.get(FireOTAStore.KEY_FAILED))
29
+ map.putBoolean("isFirstRun", store.isUnconfirmed())
30
+ promise.resolve(map)
31
+ } catch (e: Throwable) {
32
+ promise.reject("FIREOTA_STATE", e.message, e)
33
+ }
34
+ }
35
+
36
+ @ReactMethod
37
+ fun downloadUpdate(url: String, sha256: String, releaseId: String, promise: Promise) {
38
+ io.execute {
39
+ try {
40
+ store.download(url, sha256, releaseId)
41
+ promise.resolve(null)
42
+ } catch (e: SecurityException) {
43
+ promise.reject("FIREOTA_CHECKSUM", e.message, e)
44
+ } catch (e: Throwable) {
45
+ promise.reject("FIREOTA_DOWNLOAD", e.message, e)
46
+ }
47
+ }
48
+ }
49
+
50
+ @ReactMethod
51
+ fun installUpdate(releaseId: String, promise: Promise) {
52
+ try {
53
+ store.setPending(releaseId)
54
+ promise.resolve(null)
55
+ } catch (e: Throwable) {
56
+ promise.reject("FIREOTA_INSTALL", e.message, e)
57
+ }
58
+ }
59
+
60
+ @ReactMethod
61
+ fun markSuccess(promise: Promise) {
62
+ io.execute {
63
+ try {
64
+ store.markSuccess()
65
+ promise.resolve(null)
66
+ } catch (e: Throwable) {
67
+ promise.reject("FIREOTA_CONFIRM", e.message, e)
68
+ }
69
+ }
70
+ }
71
+
72
+ @ReactMethod
73
+ fun clearPending(promise: Promise) {
74
+ store.clear(FireOTAStore.KEY_PENDING)
75
+ store.clear(FireOTAStore.KEY_DOWNLOADED)
76
+ promise.resolve(null)
77
+ }
78
+
79
+ @ReactMethod
80
+ fun clearFailed(promise: Promise) {
81
+ store.clear(FireOTAStore.KEY_FAILED)
82
+ promise.resolve(null)
83
+ }
84
+
85
+ /**
86
+ * Restarts the process so the pending bundle loads. React Native reuses its bundle loader on
87
+ * a JS reload, so a cold restart is the reliable way to switch bundles on Android.
88
+ */
89
+ @ReactMethod
90
+ fun restartApp(promise: Promise) {
91
+ val launch = reactContext.packageManager.getLaunchIntentForPackage(reactContext.packageName)
92
+ if (launch == null) {
93
+ promise.reject("FIREOTA_RESTART", "No launch activity found.")
94
+ return
95
+ }
96
+ promise.resolve(null)
97
+ val intent = Intent(reactContext, FireOTARestartActivity::class.java)
98
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
99
+ .putExtra(FireOTARestartActivity.EXTRA_LAUNCH, launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK))
100
+ .putExtra(FireOTARestartActivity.EXTRA_PID, Process.myPid())
101
+ reactContext.startActivity(intent)
102
+ reactContext.currentActivity?.finishAffinity()
103
+ Runtime.getRuntime().exit(0)
104
+ }
105
+ }
@@ -0,0 +1,13 @@
1
+ package dev.fireota.reactnative
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
+ // The classic ReactPackage API is deprecated in RN 0.87 but still supported through the interop layer.
9
+ @Suppress("OVERRIDE_DEPRECATION", "DEPRECATION")
10
+ class FireOTAPackage : ReactPackage {
11
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> = listOf(FireOTAModule(reactContext))
12
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
13
+ }
@@ -0,0 +1,28 @@
1
+ package dev.fireota.reactnative
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.os.Build
6
+ import android.os.Bundle
7
+ import android.os.Process
8
+
9
+ /** Runs in its own process (":fireota_restart"): kills the app process, then relaunches it. */
10
+ class FireOTARestartActivity : Activity() {
11
+ override fun onCreate(savedInstanceState: Bundle?) {
12
+ super.onCreate(savedInstanceState)
13
+ Process.killProcess(intent.getIntExtra(EXTRA_PID, -1))
14
+ val launch: Intent? = if (Build.VERSION.SDK_INT >= 33) {
15
+ intent.getParcelableExtra(EXTRA_LAUNCH, Intent::class.java)
16
+ } else {
17
+ @Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_LAUNCH)
18
+ }
19
+ launch?.let { startActivity(it) }
20
+ finish()
21
+ Runtime.getRuntime().exit(0)
22
+ }
23
+
24
+ companion object {
25
+ const val EXTRA_LAUNCH = "dev.fireota.launch"
26
+ const val EXTRA_PID = "dev.fireota.pid"
27
+ }
28
+ }
@@ -0,0 +1,207 @@
1
+ package dev.fireota.reactnative
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.content.pm.PackageManager
6
+ import android.os.Build
7
+ import java.io.DataInputStream
8
+ import java.io.File
9
+ import java.io.FileInputStream
10
+ import java.io.FileOutputStream
11
+ import java.security.MessageDigest
12
+ import java.util.UUID
13
+ import java.util.zip.GZIPInputStream
14
+ import org.json.JSONObject
15
+
16
+ /**
17
+ * On-device OTA state. Bundles live in app-private storage:
18
+ * filesDir/fireota/releases/<releaseId>/index.android.bundle (+ drawable-* assets)
19
+ *
20
+ * Launch rules (resolveLaunchBundle, once per process):
21
+ * - a new native binary (store update / reinstall) discards every OTA bundle;
22
+ * - a pending release is promoted to current and must confirm (markSuccess) during this launch;
23
+ * - a current release that reaches a second launch unconfirmed is rolled back to the previous one.
24
+ */
25
+ internal class FireOTAStore(private val context: Context) {
26
+ private val prefs: SharedPreferences = context.getSharedPreferences("dev.fireota", Context.MODE_PRIVATE)
27
+ private val root = File(context.filesDir, "fireota/releases")
28
+
29
+ fun releaseDir(releaseId: String) = File(root, safe(releaseId))
30
+ fun bundleFile(releaseId: String) = File(releaseDir(releaseId), BUNDLE_NAME)
31
+
32
+ val installationId: String
33
+ get() = prefs.getString(KEY_INSTALLATION, null) ?: UUID.randomUUID().toString().also {
34
+ prefs.edit().putString(KEY_INSTALLATION, it).apply()
35
+ }
36
+
37
+ fun get(key: String): String? = prefs.getString(key, null)
38
+ fun isUnconfirmed() = prefs.getBoolean(KEY_UNCONFIRMED, false)
39
+
40
+ @Synchronized
41
+ fun resolveLaunchBundle(): String? {
42
+ val binary = binaryVersion()
43
+ if (prefs.getString(KEY_BINARY, null) != binary) {
44
+ root.deleteRecursively()
45
+ prefs.edit()
46
+ .remove(KEY_CURRENT).remove(KEY_PREVIOUS).remove(KEY_PENDING).remove(KEY_DOWNLOADED)
47
+ .putBoolean(KEY_UNCONFIRMED, false).putInt(KEY_ATTEMPTS, 0)
48
+ .putString(KEY_BINARY, binary)
49
+ .commit()
50
+ }
51
+
52
+ val edit = prefs.edit()
53
+ val pending = prefs.getString(KEY_PENDING, null)
54
+ if (pending != null && bundleFile(pending).exists()) {
55
+ edit.putString(KEY_PREVIOUS, prefs.getString(KEY_CURRENT, null))
56
+ .putString(KEY_CURRENT, pending)
57
+ .putBoolean(KEY_UNCONFIRMED, true)
58
+ .putInt(KEY_ATTEMPTS, 0)
59
+ }
60
+ edit.remove(KEY_PENDING).commit()
61
+
62
+ if (prefs.getBoolean(KEY_UNCONFIRMED, false)) {
63
+ val attempts = prefs.getInt(KEY_ATTEMPTS, 0) + 1
64
+ if (attempts >= 2) {
65
+ val failed = prefs.getString(KEY_CURRENT, null)
66
+ prefs.edit()
67
+ .putString(KEY_FAILED, failed)
68
+ .putString(KEY_CURRENT, prefs.getString(KEY_PREVIOUS, null))
69
+ .remove(KEY_PREVIOUS)
70
+ .putBoolean(KEY_UNCONFIRMED, false)
71
+ .putInt(KEY_ATTEMPTS, 0)
72
+ .commit()
73
+ failed?.let { releaseDir(it).deleteRecursively() }
74
+ } else {
75
+ prefs.edit().putInt(KEY_ATTEMPTS, attempts).commit()
76
+ }
77
+ }
78
+
79
+ val current = prefs.getString(KEY_CURRENT, null) ?: return null
80
+ val file = bundleFile(current)
81
+ if (!file.exists()) {
82
+ prefs.edit().remove(KEY_CURRENT).putBoolean(KEY_UNCONFIRMED, false).commit()
83
+ return null
84
+ }
85
+ return file.absolutePath
86
+ }
87
+
88
+ fun markSuccess() {
89
+ prefs.edit().putBoolean(KEY_UNCONFIRMED, false).putInt(KEY_ATTEMPTS, 0).commit()
90
+ val keep = setOf(get(KEY_CURRENT), get(KEY_PREVIOUS), get(KEY_PENDING), get(KEY_DOWNLOADED)).filterNotNull().map(::safe)
91
+ root.listFiles()?.filter { it.name !in keep }?.forEach { it.deleteRecursively() }
92
+ }
93
+
94
+ fun setPending(releaseId: String) {
95
+ require(bundleFile(releaseId).exists()) { "Release $releaseId has not been downloaded." }
96
+ prefs.edit().putString(KEY_PENDING, releaseId).remove(KEY_DOWNLOADED).commit()
97
+ }
98
+
99
+ fun clear(key: String) = prefs.edit().remove(key).commit()
100
+
101
+ /** Streams the package to disk, verifies its SHA-256, then unpacks it atomically. */
102
+ fun download(url: String, expectedSha256: String, releaseId: String) {
103
+ require(url.startsWith("https://")) { "Updates must be downloaded over HTTPS." }
104
+ root.mkdirs()
105
+ val tmp = File(root, ".${safe(releaseId)}.download")
106
+ val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
107
+ connection.connectTimeout = 15_000
108
+ connection.readTimeout = 60_000
109
+ connection.instanceFollowRedirects = true
110
+ try {
111
+ if (connection.responseCode !in 200..299) throw IllegalStateException("Download failed: HTTP ${connection.responseCode}")
112
+ val digest = MessageDigest.getInstance("SHA-256")
113
+ connection.inputStream.use { input ->
114
+ FileOutputStream(tmp).use { output ->
115
+ val buffer = ByteArray(64 * 1024)
116
+ while (true) {
117
+ val read = input.read(buffer)
118
+ if (read < 0) break
119
+ digest.update(buffer, 0, read)
120
+ output.write(buffer, 0, read)
121
+ }
122
+ }
123
+ }
124
+ val actual = digest.digest().joinToString("") { "%02x".format(it) }
125
+ if (!actual.equals(expectedSha256, ignoreCase = true)) throw SecurityException("Checksum mismatch: the update was rejected.")
126
+
127
+ val staging = File(root, ".${safe(releaseId)}.staging")
128
+ staging.deleteRecursively()
129
+ unpack(tmp, staging)
130
+ if (!File(staging, BUNDLE_NAME).exists()) throw IllegalStateException("The update package has no $BUNDLE_NAME.")
131
+ val target = releaseDir(releaseId)
132
+ target.deleteRecursively()
133
+ if (!staging.renameTo(target)) throw IllegalStateException("Could not store the update.")
134
+ prefs.edit().putString(KEY_DOWNLOADED, releaseId).commit()
135
+ } finally {
136
+ connection.disconnect()
137
+ tmp.delete()
138
+ }
139
+ }
140
+
141
+ /**
142
+ * FireOTA package: gzip( "FOTA1\n" | u32 header length | JSON {files:[{path,size}]} | file bytes... ).
143
+ * Paths are validated so a package can never write outside its release directory.
144
+ */
145
+ private fun unpack(archive: File, into: File) {
146
+ DataInputStream(GZIPInputStream(FileInputStream(archive), 64 * 1024)).use { input ->
147
+ val magic = ByteArray(6)
148
+ input.readFully(magic)
149
+ if (String(magic, Charsets.US_ASCII) != "FOTA1\n") throw IllegalStateException("Not a FireOTA package.")
150
+ val headerLength = input.readInt()
151
+ if (headerLength <= 0 || headerLength > 4 * 1024 * 1024) throw IllegalStateException("Invalid package header.")
152
+ val header = ByteArray(headerLength)
153
+ input.readFully(header)
154
+ val files = JSONObject(String(header, Charsets.UTF_8)).getJSONArray("files")
155
+ val base = into.canonicalFile
156
+ for (i in 0 until files.length()) {
157
+ val entry = files.getJSONObject(i)
158
+ val path = entry.getString("path")
159
+ val size = entry.getLong("size")
160
+ val out = File(base, path).canonicalFile
161
+ if (path.startsWith("/") || path.contains("..") || !out.path.startsWith(base.path + File.separator)) {
162
+ throw SecurityException("Unsafe path in update package: $path")
163
+ }
164
+ out.parentFile?.mkdirs()
165
+ FileOutputStream(out).use { output ->
166
+ var remaining = size
167
+ val buffer = ByteArray(64 * 1024)
168
+ while (remaining > 0) {
169
+ val read = input.read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt())
170
+ if (read < 0) throw IllegalStateException("Truncated update package.")
171
+ output.write(buffer, 0, read)
172
+ remaining -= read
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ fun nativeVersion(): String = packageInfo()?.versionName ?: ""
180
+
181
+ private fun binaryVersion(): String {
182
+ val info = packageInfo() ?: return ""
183
+ val code = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else @Suppress("DEPRECATION") info.versionCode.toLong()
184
+ return "${info.versionName}-$code-${info.lastUpdateTime}"
185
+ }
186
+
187
+ private fun packageInfo() = try {
188
+ context.packageManager.getPackageInfo(context.packageName, 0)
189
+ } catch (_: PackageManager.NameNotFoundException) {
190
+ null
191
+ }
192
+
193
+ private fun safe(id: String) = id.replace(Regex("[^A-Za-z0-9_-]"), "")
194
+
195
+ companion object {
196
+ const val BUNDLE_NAME = "index.android.bundle"
197
+ const val KEY_INSTALLATION = "installationId"
198
+ const val KEY_BINARY = "binary"
199
+ const val KEY_CURRENT = "current"
200
+ const val KEY_PREVIOUS = "previous"
201
+ const val KEY_PENDING = "pending"
202
+ const val KEY_DOWNLOADED = "downloaded"
203
+ const val KEY_FAILED = "failed"
204
+ const val KEY_UNCONFIRMED = "unconfirmed"
205
+ const val KEY_ATTEMPTS = "attempts"
206
+ }
207
+ }
@@ -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 = "fireota-react-native"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"] || "https://www.npmjs.com/package/@fireota/react-native"
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+ s.platforms = { :ios => "13.4" }
13
+ # Installed from node_modules by React Native autolinking; the git source is only used for trunk.
14
+ repo = (package["repository"].is_a?(Hash) ? package["repository"]["url"] : package["repository"]).to_s.sub(/^git\+/, "")
15
+ s.source = { :git => repo, :tag => "v#{s.version}" }
16
+ s.source_files = "ios/**/*.{h,m}"
17
+ s.libraries = "z"
18
+ s.dependency "React-Core"
19
+ end
package/ios/FireOTA.h ADDED
@@ -0,0 +1,20 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <React/RCTBridgeModule.h>
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+
6
+ /**
7
+ * FireOTA native module. In AppDelegate (release builds):
8
+ *
9
+ * - (NSURL *)bundleURL {
10
+ * return [FireOTA bundleURL] ?: [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
11
+ * }
12
+ */
13
+ @interface FireOTA : NSObject <RCTBridgeModule>
14
+
15
+ /// The OTA bundle to load, or nil to use the bundle shipped in the app binary.
16
+ + (nullable NSURL *)bundleURL;
17
+
18
+ @end
19
+
20
+ NS_ASSUME_NONNULL_END
package/ios/FireOTA.m ADDED
@@ -0,0 +1,346 @@
1
+ #import "FireOTA.h"
2
+
3
+ #import <CommonCrypto/CommonDigest.h>
4
+ #import <React/RCTReloadCommand.h>
5
+ #import <zlib.h>
6
+
7
+ static NSString *const kBundleName = @"main.jsbundle";
8
+ static NSString *const kSuite = @"dev.fireota";
9
+ static NSString *const kInstallation = @"installationId";
10
+ static NSString *const kBinary = @"binary";
11
+ static NSString *const kCurrent = @"current";
12
+ static NSString *const kPrevious = @"previous";
13
+ static NSString *const kPending = @"pending";
14
+ static NSString *const kDownloaded = @"downloaded";
15
+ static NSString *const kFailed = @"failed";
16
+ static NSString *const kUnconfirmed = @"unconfirmed";
17
+ static NSString *const kAttempts = @"attempts";
18
+
19
+ static NSUserDefaults *FOTADefaults(void) { return [[NSUserDefaults alloc] initWithSuiteName:kSuite]; }
20
+
21
+ static NSString *FOTASafe(NSString *value)
22
+ {
23
+ NSCharacterSet *allowed = [NSCharacterSet characterSetWithCharactersInString:
24
+ @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"];
25
+ return [[value componentsSeparatedByCharactersInSet:allowed.invertedSet] componentsJoinedByString:@""];
26
+ }
27
+
28
+ static NSURL *FOTARoot(void)
29
+ {
30
+ NSURL *support = [[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory
31
+ inDomains:NSUserDomainMask].firstObject;
32
+ NSURL *root = [support URLByAppendingPathComponent:@"fireota/releases" isDirectory:YES];
33
+ [[NSFileManager defaultManager] createDirectoryAtURL:root withIntermediateDirectories:YES attributes:nil error:nil];
34
+ [root setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
35
+ return root;
36
+ }
37
+
38
+ static NSURL *FOTAReleaseDir(NSString *releaseId)
39
+ {
40
+ return [FOTARoot() URLByAppendingPathComponent:FOTASafe(releaseId) isDirectory:YES];
41
+ }
42
+
43
+ static NSURL *FOTABundleFile(NSString *releaseId)
44
+ {
45
+ return [FOTAReleaseDir(releaseId) URLByAppendingPathComponent:kBundleName];
46
+ }
47
+
48
+ /// Changes on every App Store / TestFlight install, which invalidates bundles built for the old binary.
49
+ static NSString *FOTABinaryVersion(void)
50
+ {
51
+ NSDictionary *info = [NSBundle mainBundle].infoDictionary;
52
+ NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:[NSBundle mainBundle].executablePath error:nil];
53
+ return [NSString stringWithFormat:@"%@-%@-%.0f", info[@"CFBundleShortVersionString"] ?: @"", info[@"CFBundleVersion"] ?: @"",
54
+ [attrs.fileModificationDate timeIntervalSince1970]];
55
+ }
56
+
57
+ static BOOL FOTAExists(NSURL *url) { return [[NSFileManager defaultManager] fileExistsAtPath:url.path]; }
58
+
59
+ static BOOL FOTARead(gzFile gz, void *into, unsigned length)
60
+ {
61
+ unsigned done = 0;
62
+ while (done < length) {
63
+ int n = gzread(gz, (char *)into + done, length - done);
64
+ if (n <= 0) return NO;
65
+ done += (unsigned)n;
66
+ }
67
+ return YES;
68
+ }
69
+
70
+ @implementation FireOTA
71
+
72
+ RCT_EXPORT_MODULE(FireOTA)
73
+
74
+ + (BOOL)requiresMainQueueSetup { return NO; }
75
+
76
+ /// Same launch rules as Android: a new binary clears OTA state, a pending release is promoted and
77
+ /// must confirm, and a release reaching a second launch unconfirmed is rolled back.
78
+ + (nullable NSURL *)bundleURL
79
+ {
80
+ static NSURL *resolved = nil;
81
+ static BOOL didResolve = NO;
82
+ @synchronized(self) {
83
+ NSUserDefaults *d = FOTADefaults();
84
+ BOOL hasPending = [d stringForKey:kPending] != nil;
85
+ if (didResolve && !hasPending) return resolved; // once per launch, unless a reload applies an update
86
+
87
+ NSString *binary = FOTABinaryVersion();
88
+ if (![[d stringForKey:kBinary] isEqualToString:binary]) {
89
+ [[NSFileManager defaultManager] removeItemAtURL:FOTARoot() error:nil];
90
+ for (NSString *key in @[ kCurrent, kPrevious, kPending, kDownloaded ]) [d removeObjectForKey:key];
91
+ [d setBool:NO forKey:kUnconfirmed];
92
+ [d setInteger:0 forKey:kAttempts];
93
+ [d setObject:binary forKey:kBinary];
94
+ }
95
+
96
+ NSString *pending = [d stringForKey:kPending];
97
+ if (pending && FOTAExists(FOTABundleFile(pending))) {
98
+ NSString *current = [d stringForKey:kCurrent];
99
+ if (current) [d setObject:current forKey:kPrevious]; else [d removeObjectForKey:kPrevious];
100
+ [d setObject:pending forKey:kCurrent];
101
+ [d setBool:YES forKey:kUnconfirmed];
102
+ [d setInteger:0 forKey:kAttempts];
103
+ }
104
+ [d removeObjectForKey:kPending];
105
+
106
+ if ([d boolForKey:kUnconfirmed]) {
107
+ NSInteger attempts = [d integerForKey:kAttempts] + 1;
108
+ if (attempts >= 2) {
109
+ NSString *failed = [d stringForKey:kCurrent];
110
+ if (failed) {
111
+ [d setObject:failed forKey:kFailed];
112
+ [[NSFileManager defaultManager] removeItemAtURL:FOTAReleaseDir(failed) error:nil];
113
+ }
114
+ NSString *previous = [d stringForKey:kPrevious];
115
+ if (previous) [d setObject:previous forKey:kCurrent]; else [d removeObjectForKey:kCurrent];
116
+ [d removeObjectForKey:kPrevious];
117
+ [d setBool:NO forKey:kUnconfirmed];
118
+ [d setInteger:0 forKey:kAttempts];
119
+ } else {
120
+ [d setInteger:attempts forKey:kAttempts];
121
+ }
122
+ }
123
+
124
+ resolved = nil;
125
+ NSString *current = [d stringForKey:kCurrent];
126
+ if (current) {
127
+ NSURL *file = FOTABundleFile(current);
128
+ if (FOTAExists(file)) {
129
+ resolved = file;
130
+ } else {
131
+ [d removeObjectForKey:kCurrent];
132
+ [d setBool:NO forKey:kUnconfirmed];
133
+ }
134
+ }
135
+ didResolve = YES;
136
+ return resolved;
137
+ }
138
+ }
139
+
140
+ RCT_EXPORT_METHOD(getState:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
141
+ {
142
+ NSUserDefaults *d = FOTADefaults();
143
+ NSString *installation = [d stringForKey:kInstallation];
144
+ if (!installation) {
145
+ installation = [NSUUID UUID].UUIDString.lowercaseString;
146
+ [d setObject:installation forKey:kInstallation];
147
+ }
148
+ NSString *version = [NSBundle mainBundle].infoDictionary[@"CFBundleShortVersionString"] ?: @"";
149
+ resolve(@{
150
+ @"installationId" : installation,
151
+ @"nativeVersion" : version,
152
+ @"appVersion" : version,
153
+ @"currentReleaseId" : [d stringForKey:kCurrent] ?: [NSNull null],
154
+ @"pendingReleaseId" : [d stringForKey:kPending] ?: [NSNull null],
155
+ @"previousReleaseId" : [d stringForKey:kPrevious] ?: [NSNull null],
156
+ @"failedReleaseId" : [d stringForKey:kFailed] ?: [NSNull null],
157
+ @"isFirstRun" : @([d boolForKey:kUnconfirmed]),
158
+ });
159
+ }
160
+
161
+ RCT_EXPORT_METHOD(downloadUpdate:(NSString *)urlString
162
+ sha256:(NSString *)expected
163
+ releaseId:(NSString *)releaseId
164
+ resolve:(RCTPromiseResolveBlock)resolve
165
+ reject:(RCTPromiseRejectBlock)reject)
166
+ {
167
+ NSURL *url = [NSURL URLWithString:urlString];
168
+ if (!url || ![url.scheme isEqualToString:@"https"]) {
169
+ reject(@"FIREOTA_DOWNLOAD", @"Updates must be downloaded over HTTPS.", nil);
170
+ return;
171
+ }
172
+ NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
173
+ config.timeoutIntervalForRequest = 60;
174
+ NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
175
+ [[session downloadTaskWithURL:url
176
+ completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
177
+ NSInteger status =
178
+ [response isKindOfClass:[NSHTTPURLResponse class]] ? ((NSHTTPURLResponse *)response).statusCode : 0;
179
+ if (error || !location || status < 200 || status > 299) {
180
+ reject(@"FIREOTA_DOWNLOAD", [NSString stringWithFormat:@"Download failed (HTTP %ld).", (long)status], error);
181
+ return;
182
+ }
183
+ if (![[FireOTA sha256OfFile:location] isEqualToString:expected.lowercaseString]) {
184
+ reject(@"FIREOTA_CHECKSUM", @"Checksum mismatch: the update was rejected.", nil);
185
+ return;
186
+ }
187
+ NSFileManager *fm = [NSFileManager defaultManager];
188
+ NSURL *staging =
189
+ [FOTARoot() URLByAppendingPathComponent:[NSString stringWithFormat:@".%@.staging", FOTASafe(releaseId)]];
190
+ [fm removeItemAtURL:staging error:nil];
191
+ NSString *problem = [FireOTA unpack:location into:staging];
192
+ if (!problem && !FOTAExists([staging URLByAppendingPathComponent:kBundleName])) {
193
+ problem = @"The update package has no main.jsbundle.";
194
+ }
195
+ if (problem) {
196
+ [fm removeItemAtURL:staging error:nil];
197
+ reject(@"FIREOTA_DOWNLOAD", problem, nil);
198
+ return;
199
+ }
200
+ NSURL *target = FOTAReleaseDir(releaseId);
201
+ [fm removeItemAtURL:target error:nil];
202
+ NSError *moveError = nil;
203
+ if (![fm moveItemAtURL:staging toURL:target error:&moveError]) {
204
+ reject(@"FIREOTA_DOWNLOAD", @"Could not store the update.", moveError);
205
+ return;
206
+ }
207
+ [FOTADefaults() setObject:releaseId forKey:kDownloaded];
208
+ resolve(nil);
209
+ }] resume];
210
+ }
211
+
212
+ RCT_EXPORT_METHOD(installUpdate:(NSString *)releaseId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
213
+ {
214
+ if (!FOTAExists(FOTABundleFile(releaseId))) {
215
+ reject(@"FIREOTA_INSTALL", @"This release has not been downloaded.", nil);
216
+ return;
217
+ }
218
+ NSUserDefaults *d = FOTADefaults();
219
+ [d setObject:releaseId forKey:kPending];
220
+ [d removeObjectForKey:kDownloaded];
221
+ resolve(nil);
222
+ }
223
+
224
+ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
225
+ {
226
+ NSUserDefaults *d = FOTADefaults();
227
+ [d setBool:NO forKey:kUnconfirmed];
228
+ [d setInteger:0 forKey:kAttempts];
229
+ NSMutableSet *keep = [NSMutableSet set];
230
+ for (NSString *key in @[ kCurrent, kPrevious, kPending, kDownloaded ]) {
231
+ NSString *value = [d stringForKey:key];
232
+ if (value) [keep addObject:FOTASafe(value)];
233
+ }
234
+ NSFileManager *fm = [NSFileManager defaultManager];
235
+ for (NSURL *dir in [fm contentsOfDirectoryAtURL:FOTARoot() includingPropertiesForKeys:nil options:0 error:nil]) {
236
+ if (![keep containsObject:dir.lastPathComponent]) [fm removeItemAtURL:dir error:nil];
237
+ }
238
+ resolve(nil);
239
+ }
240
+
241
+ RCT_EXPORT_METHOD(clearPending:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
242
+ {
243
+ [FOTADefaults() removeObjectForKey:kPending];
244
+ [FOTADefaults() removeObjectForKey:kDownloaded];
245
+ resolve(nil);
246
+ }
247
+
248
+ RCT_EXPORT_METHOD(clearFailed:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
249
+ {
250
+ [FOTADefaults() removeObjectForKey:kFailed];
251
+ resolve(nil);
252
+ }
253
+
254
+ /// Reloads JavaScript; React Native asks AppDelegate.bundleURL again, which applies the pending release.
255
+ RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
256
+ {
257
+ resolve(nil);
258
+ dispatch_async(dispatch_get_main_queue(), ^{
259
+ RCTTriggerReloadCommandListeners(@"FireOTA update");
260
+ });
261
+ }
262
+
263
+ + (NSString *)sha256OfFile:(NSURL *)url
264
+ {
265
+ NSInputStream *stream = [NSInputStream inputStreamWithURL:url];
266
+ [stream open];
267
+ CC_SHA256_CTX ctx;
268
+ CC_SHA256_Init(&ctx);
269
+ uint8_t buffer[65536];
270
+ NSInteger read;
271
+ while ((read = [stream read:buffer maxLength:sizeof(buffer)]) > 0) CC_SHA256_Update(&ctx, buffer, (CC_LONG)read);
272
+ [stream close];
273
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
274
+ CC_SHA256_Final(digest, &ctx);
275
+ NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
276
+ for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", digest[i]];
277
+ return hex;
278
+ }
279
+
280
+ /// FireOTA package: gzip("FOTA1\n" | u32 big-endian header length | JSON {files:[{path,size}]} | bytes).
281
+ /// Paths are validated so a package can never write outside its release directory.
282
+ + (nullable NSString *)unpack:(NSURL *)archive into:(NSURL *)dir
283
+ {
284
+ gzFile gz = gzopen(archive.fileSystemRepresentation, "rb");
285
+ if (!gz) return @"Could not open the update package.";
286
+ char magic[6];
287
+ uint8_t lengthBytes[4];
288
+ if (!FOTARead(gz, magic, 6) || memcmp(magic, "FOTA1\n", 6) != 0 || !FOTARead(gz, lengthBytes, 4)) {
289
+ gzclose(gz);
290
+ return @"Not a FireOTA package.";
291
+ }
292
+ uint32_t headerLength = ((uint32_t)lengthBytes[0] << 24) | ((uint32_t)lengthBytes[1] << 16) |
293
+ ((uint32_t)lengthBytes[2] << 8) | lengthBytes[3];
294
+ if (headerLength == 0 || headerLength > 4 * 1024 * 1024) {
295
+ gzclose(gz);
296
+ return @"Invalid package header.";
297
+ }
298
+ NSMutableData *header = [NSMutableData dataWithLength:headerLength];
299
+ if (!FOTARead(gz, header.mutableBytes, headerLength)) {
300
+ gzclose(gz);
301
+ return @"Truncated update package.";
302
+ }
303
+ id json = [NSJSONSerialization JSONObjectWithData:header options:0 error:nil];
304
+ NSArray *files = [json isKindOfClass:[NSDictionary class]] ? json[@"files"] : nil;
305
+ if (![files isKindOfClass:[NSArray class]]) {
306
+ gzclose(gz);
307
+ return @"Invalid package header.";
308
+ }
309
+
310
+ NSString *problem = nil;
311
+ NSFileManager *fm = [NSFileManager defaultManager];
312
+ NSString *base = [dir.path stringByStandardizingPath];
313
+ [fm createDirectoryAtURL:dir withIntermediateDirectories:YES attributes:nil error:nil];
314
+ uint8_t buffer[65536];
315
+ for (NSDictionary *entry in files) {
316
+ NSString *path = entry[@"path"];
317
+ if (![path isKindOfClass:[NSString class]]) {
318
+ problem = @"Invalid package header.";
319
+ break;
320
+ }
321
+ NSString *out = [[base stringByAppendingPathComponent:path] stringByStandardizingPath];
322
+ if ([path hasPrefix:@"/"] || [path containsString:@".."] || ![out hasPrefix:[base stringByAppendingString:@"/"]]) {
323
+ problem = [NSString stringWithFormat:@"Unsafe path in update package: %@", path];
324
+ break;
325
+ }
326
+ [fm createDirectoryAtPath:[out stringByDeletingLastPathComponent] withIntermediateDirectories:YES attributes:nil error:nil];
327
+ [fm createFileAtPath:out contents:nil attributes:nil];
328
+ NSFileHandle *handle = [NSFileHandle fileHandleForWritingAtPath:out];
329
+ unsigned long long remaining = [entry[@"size"] unsignedLongLongValue];
330
+ while (remaining > 0) {
331
+ unsigned chunk = (unsigned)MIN((unsigned long long)sizeof(buffer), remaining);
332
+ if (!FOTARead(gz, buffer, chunk)) {
333
+ problem = @"Truncated update package.";
334
+ break;
335
+ }
336
+ [handle writeData:[NSData dataWithBytes:buffer length:chunk]];
337
+ remaining -= chunk;
338
+ }
339
+ [handle closeFile];
340
+ if (problem) break;
341
+ }
342
+ gzclose(gz);
343
+ return problem;
344
+ }
345
+
346
+ @end
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@fireota/react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Over-the-air JavaScript updates for React Native apps, served from your own Firebase project by FireOTA.",
5
+ "license": "MIT",
6
+ "author": "FireOTA",
7
+ "main": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "react-native": "src/index.ts",
10
+ "files": [
11
+ "src",
12
+ "android/build.gradle",
13
+ "android/src",
14
+ "ios",
15
+ "fireota-react-native.podspec",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "typecheck": "tsc -p tsconfig.json --noEmit"
21
+ },
22
+ "peerDependencies": {
23
+ "react-native": ">=0.73.0"
24
+ },
25
+ "dependencies": {
26
+ "@fireota/react-native": "file:fireota-react-native-0.1.0.tgz"
27
+ },
28
+ "keywords": [
29
+ "react-native",
30
+ "ota",
31
+ "over-the-air",
32
+ "code-push",
33
+ "hot-update",
34
+ "firebase",
35
+ "updates",
36
+ "hermes"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/Sujay-Dynamisch/FireOTA.git",
44
+ "directory": "packages/react-native"
45
+ },
46
+ "homepage": "https://github.com/Sujay-Dynamisch/FireOTA/tree/main/packages/react-native#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/Sujay-Dynamisch/FireOTA/issues"
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * FireOTA React Native SDK.
3
+ *
4
+ * Flow: check -> download (native, SHA-256 verified) -> install (pending) -> applied on the
5
+ * next start (or immediately for mandatory updates) -> the new bundle confirms itself on
6
+ * launch; a bundle that never confirms is rolled back to the previous one by the native loader.
7
+ */
8
+ import { NativeModules, Platform } from "react-native";
9
+
10
+ export type InstallMode = "nextRestart" | "immediate";
11
+ export type UpdateStatus =
12
+ | "idle"
13
+ | "checking"
14
+ | "upToDate"
15
+ | "downloading"
16
+ | "downloaded"
17
+ | "installed"
18
+ | "error";
19
+
20
+ export interface FireOTAConfig {
21
+ /** App ID from the FireOTA dashboard (Applications -> your app). */
22
+ appId: string;
23
+ /** Your FireOTA deployment, e.g. https://ota.example.com */
24
+ serverUrl: string;
25
+ /** Must equal the runtime version of the release; bump it whenever native code changes. */
26
+ runtimeVersion: string;
27
+ channel?: string;
28
+ /** Check (and download) automatically when configure() runs. Default true. */
29
+ checkOnAppStart?: boolean;
30
+ /** When a normal update is applied. Default "nextRestart". */
31
+ installMode?: InstallMode;
32
+ /** When a mandatory update is applied. Default "immediate". */
33
+ mandatoryInstallMode?: InstallMode;
34
+ onStatusChange?: (status: UpdateStatus, detail?: { releaseId?: string; error?: string }) => void;
35
+ }
36
+
37
+ export interface AvailableUpdate {
38
+ id: string;
39
+ version: string;
40
+ runtimeVersion: string;
41
+ mandatory: boolean;
42
+ notes: string;
43
+ bundle: { url: string; hash: string; size: number };
44
+ }
45
+
46
+ export interface CheckResult {
47
+ updateAvailable: boolean;
48
+ reason?: string;
49
+ release?: AvailableUpdate;
50
+ }
51
+
52
+ interface NativeState {
53
+ installationId: string;
54
+ nativeVersion: string;
55
+ appVersion: string;
56
+ currentReleaseId: string | null;
57
+ pendingReleaseId: string | null;
58
+ previousReleaseId: string | null;
59
+ /** Release rolled back by the native loader because it never confirmed a launch. */
60
+ failedReleaseId: string | null;
61
+ /** The running bundle has not confirmed a successful launch yet. */
62
+ isFirstRun: boolean;
63
+ }
64
+
65
+ interface FireOTANative {
66
+ getState(): Promise<NativeState>;
67
+ downloadUpdate(url: string, sha256: string, releaseId: string): Promise<void>;
68
+ installUpdate(releaseId: string): Promise<void>;
69
+ markSuccess(): Promise<void>;
70
+ clearPending(): Promise<void>;
71
+ clearFailed(): Promise<void>;
72
+ restartApp(): Promise<void>;
73
+ }
74
+
75
+ const native = NativeModules["FireOTA"] as FireOTANative | undefined;
76
+ const PROTOCOL_VERSION = "1";
77
+
78
+ let config: Required<Omit<FireOTAConfig, "onStatusChange">> & Pick<FireOTAConfig, "onStatusChange">;
79
+ let status: UpdateStatus = "idle";
80
+ let running: Promise<CheckResult> | null = null;
81
+
82
+ function requireNative(): FireOTANative {
83
+ if (!native) {
84
+ throw new Error(
85
+ "FireOTA native module not found. Rebuild the app after installing @fireota/react-native (cd ios && pod install).",
86
+ );
87
+ }
88
+ return native;
89
+ }
90
+
91
+ function requireConfig() {
92
+ if (!config) throw new Error("Call FireOTA.configure() before using the SDK.");
93
+ return config;
94
+ }
95
+
96
+ function setStatus(next: UpdateStatus, detail?: { releaseId?: string; error?: string }) {
97
+ status = next;
98
+ config?.onStatusChange?.(next, detail);
99
+ }
100
+
101
+ function randomId() {
102
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
103
+ }
104
+
105
+ async function post<T>(path: string, body: unknown): Promise<T> {
106
+ const response = await fetch(`${requireConfig().serverUrl.replace(/\/+$/, "")}${path}`, {
107
+ method: "POST",
108
+ headers: { "content-type": "application/json", "x-fireota-sdk": `react-native/${PROTOCOL_VERSION}` },
109
+ body: JSON.stringify(body),
110
+ });
111
+ const json = (await response.json()) as { success: boolean; data?: T; error?: { code: string; message: string } };
112
+ if (!json.success) throw new Error(json.error?.message ?? `FireOTA request failed (${response.status})`);
113
+ return json.data as T;
114
+ }
115
+
116
+ /** Telemetry never blocks or fails an update. */
117
+ async function report(event: string, releaseId: string, metadata?: Record<string, unknown>) {
118
+ try {
119
+ const state = await requireNative().getState();
120
+ await post("/api/public/v1/updates/events", {
121
+ app: config.appId,
122
+ releaseId,
123
+ platform: Platform.OS,
124
+ installationId: state.installationId,
125
+ runtimeVersion: config.runtimeVersion,
126
+ nativeVersion: state.nativeVersion,
127
+ appVersion: state.appVersion,
128
+ event,
129
+ eventId: `${event}:${releaseId}:${state.installationId}`,
130
+ ...(metadata ? { metadata } : {}),
131
+ });
132
+ } catch {
133
+ // ignored by design
134
+ }
135
+ }
136
+
137
+ /** Asks FireOTA whether a compatible release exists for this device. */
138
+ export async function checkForUpdate(): Promise<CheckResult> {
139
+ const cfg = requireConfig();
140
+ const state = await requireNative().getState();
141
+ setStatus("checking");
142
+ const result = await post<CheckResult>("/api/public/v1/updates/check", {
143
+ app: cfg.appId,
144
+ channel: cfg.channel,
145
+ platform: Platform.OS,
146
+ installationId: state.installationId,
147
+ runtimeVersion: cfg.runtimeVersion,
148
+ nativeVersion: state.nativeVersion,
149
+ appVersion: state.appVersion,
150
+ currentReleaseId: state.pendingReleaseId ?? state.currentReleaseId,
151
+ });
152
+ // Defence in depth: never accept a release built for another runtime.
153
+ if (result.updateAvailable && result.release?.runtimeVersion !== cfg.runtimeVersion) {
154
+ return { updateAvailable: false, reason: "runtime_incompatible" };
155
+ }
156
+ if (!result.updateAvailable) setStatus("upToDate");
157
+ return result;
158
+ }
159
+
160
+ /** Downloads and verifies (SHA-256) an update. The running bundle is untouched. */
161
+ export async function downloadUpdate(release: AvailableUpdate) {
162
+ if (!/^[0-9a-f]{64}$/i.test(release.bundle.hash)) throw new Error("The update has no valid checksum.");
163
+ setStatus("downloading", { releaseId: release.id });
164
+ void report("download_started", release.id);
165
+ await requireNative().downloadUpdate(release.bundle.url, release.bundle.hash.toLowerCase(), release.id);
166
+ void report("download_completed", release.id);
167
+ setStatus("downloaded", { releaseId: release.id });
168
+ }
169
+
170
+ /** Marks a downloaded update to load on the next start, or restarts now for "immediate". */
171
+ export async function installUpdate(release: AvailableUpdate, mode: InstallMode = "nextRestart") {
172
+ await requireNative().installUpdate(release.id);
173
+ setStatus("installed", { releaseId: release.id });
174
+ if (mode === "immediate") await requireNative().restartApp();
175
+ }
176
+
177
+ /** Check + download + install in one call, honouring the configured install modes. */
178
+ export function sync(): Promise<CheckResult> {
179
+ if (running) return running;
180
+ running = (async () => {
181
+ try {
182
+ const result = await checkForUpdate();
183
+ if (result.updateAvailable && result.release) {
184
+ await downloadUpdate(result.release);
185
+ await installUpdate(
186
+ result.release,
187
+ result.release.mandatory ? config.mandatoryInstallMode : config.installMode,
188
+ );
189
+ }
190
+ return result;
191
+ } catch (error) {
192
+ setStatus("error", { error: error instanceof Error ? error.message : String(error) });
193
+ // Never crash the host app: it keeps running the bundle it already has.
194
+ return { updateAvailable: false, reason: "error" };
195
+ } finally {
196
+ running = null;
197
+ }
198
+ })();
199
+ return running;
200
+ }
201
+
202
+ /** Restarts the app so a pending update loads now. */
203
+ export function reload() {
204
+ return requireNative().restartApp();
205
+ }
206
+
207
+ /** Drops a downloaded-but-not-yet-applied update. */
208
+ export function clearPendingUpdate() {
209
+ return requireNative().clearPending();
210
+ }
211
+
212
+ /** The release the app is running, or null for the bundle shipped in the binary. */
213
+ export async function getCurrentRelease() {
214
+ const state = await requireNative().getState();
215
+ return { releaseId: state.currentReleaseId, pendingReleaseId: state.pendingReleaseId, installationId: state.installationId };
216
+ }
217
+
218
+ export function getUpdateStatus(): UpdateStatus {
219
+ return status;
220
+ }
221
+
222
+ /**
223
+ * Configures the SDK. Call once, as early as possible (e.g. index.js or the root component).
224
+ * Reaching this call proves the running bundle starts, so it is confirmed here; a bundle that
225
+ * crashes before this point is rolled back automatically on the next launch.
226
+ */
227
+ export async function configure(options: FireOTAConfig) {
228
+ if (!options.appId || !options.serverUrl || !options.runtimeVersion) {
229
+ throw new Error("FireOTA.configure() needs appId, serverUrl and runtimeVersion.");
230
+ }
231
+ config = {
232
+ channel: "production",
233
+ checkOnAppStart: true,
234
+ installMode: "nextRestart",
235
+ mandatoryInstallMode: "immediate",
236
+ ...options,
237
+ };
238
+ const nativeModule = requireNative();
239
+ const state = await nativeModule.getState();
240
+
241
+ if (state.failedReleaseId) {
242
+ void report("rollback", state.failedReleaseId, { reason: "launch_not_confirmed" });
243
+ void report("install_failed", state.failedReleaseId, { reason: "launch_not_confirmed" });
244
+ await nativeModule.clearFailed();
245
+ }
246
+ if (state.isFirstRun && state.currentReleaseId) {
247
+ await nativeModule.markSuccess();
248
+ void report("install_succeeded", state.currentReleaseId);
249
+ }
250
+ if (config.checkOnAppStart) void sync();
251
+ }
252
+
253
+ export const FireOTA = {
254
+ configure,
255
+ checkForUpdate,
256
+ downloadUpdate,
257
+ installUpdate,
258
+ sync,
259
+ reload,
260
+ clearPendingUpdate,
261
+ getCurrentRelease,
262
+ getUpdateStatus,
263
+ };
264
+
265
+ export default FireOTA;