@fireota/react-native 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +235 -3
- package/android/build.gradle +51 -0
- package/android/src/main/AndroidManifest.xml +10 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTA.kt +33 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTAModule.kt +105 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTAPackage.kt +13 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTARestartActivity.kt +28 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTAStore.kt +207 -0
- package/fireota-react-native.podspec +19 -0
- package/ios/FireOTA.h +20 -0
- package/ios/FireOTA.m +346 -0
- package/package.json +48 -4
- package/src/index.ts +265 -0
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;
|