@rovq/react-native 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -2
  3. package/Rovq.podspec +19 -0
  4. package/android/build.gradle +86 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
  7. package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +230 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +199 -0
  9. package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
  10. package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
  11. package/android/src/main/java/com/rovq/engine/RovqEngine.kt +550 -0
  12. package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
  13. package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
  14. package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
  15. package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +347 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +175 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
  19. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +102 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  22. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  23. package/ios/Engine/EngineState.swift +119 -0
  24. package/ios/Engine/ManifestVerifier.swift +279 -0
  25. package/ios/Engine/PackageDownloader.swift +249 -0
  26. package/ios/Engine/PackageRules.swift +38 -0
  27. package/ios/Engine/ProgressThrottle.swift +20 -0
  28. package/ios/Engine/RovqEngine.swift +646 -0
  29. package/ios/Engine/RovqError.swift +35 -0
  30. package/ios/Engine/SafePackageExtractor.swift +107 -0
  31. package/ios/Engine/UpdateInstaller.swift +348 -0
  32. package/ios/Engine/UpdateService.swift +185 -0
  33. package/ios/Engine/ZipReader.swift +378 -0
  34. package/ios/React/RovqHost.swift +255 -0
  35. package/ios/React/RovqModule.mm +91 -0
  36. package/package.json +78 -3
  37. package/react-native.config.js +11 -0
  38. package/src/NativeRovq.ts +72 -0
  39. package/src/api.ts +228 -0
  40. package/src/errors.ts +44 -0
  41. package/src/index.ts +10 -0
  42. package/src/react.tsx +158 -0
  43. package/src/update-flow.ts +91 -0
@@ -0,0 +1,91 @@
1
+ // Objective-C++ because a TurboModule must return a C++ object. The work happens in Swift.
2
+ #import <Foundation/Foundation.h>
3
+ #import <RovqSpec/RovqSpec.h>
4
+ #import <React/RCTReloadCommand.h>
5
+ #import <React/RCTUtils.h>
6
+
7
+ #if __has_include(<Rovq/Rovq-Swift.h>)
8
+ #import <Rovq/Rovq-Swift.h>
9
+ #else
10
+ #import "Rovq-Swift.h"
11
+ #endif
12
+
13
+ @interface RovqModule : NativeRovqSpecBase <NativeRovqSpec>
14
+ @end
15
+
16
+ @implementation RovqModule
17
+
18
+ RCT_EXPORT_MODULE(Rovq)
19
+
20
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
21
+ (const facebook::react::ObjCTurboModule::InitParams &)params
22
+ {
23
+ return std::make_shared<facebook::react::NativeRovqSpecJSI>(params);
24
+ }
25
+
26
+ - (void)checkForUpdate:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
27
+ {
28
+ [RovqHost checkForUpdateWithCompletion:^(NSDictionary<NSString *, id> *result) {
29
+ resolve(result);
30
+ }];
31
+ }
32
+
33
+ - (void)downloadUpdate:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
34
+ {
35
+ [RovqHost downloadUpdateOnProgress:[self progressReporter]
36
+ completion:^(NSDictionary<NSString *, id> *result) {
37
+ resolve(result);
38
+ }];
39
+ }
40
+
41
+ - (void)installUpdate:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
42
+ {
43
+ [RovqHost installUpdateWithCompletion:^(NSDictionary<NSString *, id> *result) {
44
+ resolve(result);
45
+ }];
46
+ }
47
+
48
+ - (void)sync:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
49
+ {
50
+ [RovqHost syncOnProgress:[self progressReporter]
51
+ completion:^(NSDictionary<NSString *, id> *result) {
52
+ resolve(result);
53
+ }];
54
+ }
55
+
56
+ // Weak, because a download keeps running through a reload, when this module has gone.
57
+ - (void (^)(int64_t, int64_t))progressReporter
58
+ {
59
+ __weak RovqModule *weakSelf = self;
60
+ return ^(int64_t received, int64_t total) {
61
+ [weakSelf emitOnDownloadProgress:@{@"receivedBytes" : @(received), @"totalBytes" : @(total)}];
62
+ };
63
+ }
64
+
65
+ - (void)notifyApplicationReady
66
+ {
67
+ [RovqHost notifyReady];
68
+ }
69
+
70
+ - (void)restartApp
71
+ {
72
+ // Always later, never at once: RCTExecuteOnMainQueue runs immediately when already on the main
73
+ // thread, which reloads JavaScript while it is still inside this call.
74
+ dispatch_async(dispatch_get_main_queue(), ^{
75
+ RCTTriggerReloadCommandListeners(@"ROVQ restart");
76
+ });
77
+ }
78
+
79
+ - (void)rollback:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
80
+ {
81
+ [RovqHost rollbackWithCompletion:^(BOOL result) {
82
+ resolve(@(result));
83
+ }];
84
+ }
85
+
86
+ - (void)getStatus:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
87
+ {
88
+ resolve([RovqHost status]);
89
+ }
90
+
91
+ @end
package/package.json CHANGED
@@ -1,6 +1,81 @@
1
1
  {
2
2
  "name": "@rovq/react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Over-the-air updates for React Native, with signed releases and automatic rollback",
5
+ "keywords": [
6
+ "react-native",
7
+ "ota",
8
+ "over-the-air",
9
+ "updates",
10
+ "codepush",
11
+ "hot-update",
12
+ "rollback"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "moatazMahdi",
16
+ "homepage": "https://github.com/moatazMahdi/rovq#readme",
17
+ "bugs": "https://github.com/moatazMahdi/rovq/issues",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/moatazMahdi/rovq.git",
21
+ "directory": "packages/react-native"
22
+ },
23
+ "main": "src/index.ts",
24
+ "files": [
25
+ "src",
26
+ "android/src/main",
27
+ "android/src/react",
28
+ "android/build.gradle",
29
+ "ios/Engine",
30
+ "ios/React",
31
+ "Rovq.podspec",
32
+ "react-native.config.js",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "peerDependencies": {
37
+ "react": "*",
38
+ "react-native": ">=0.82"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "react-native": {
42
+ "optional": true
43
+ },
44
+ "react": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "codegenConfig": {
49
+ "name": "RovqSpec",
50
+ "type": "modules",
51
+ "jsSrcsDir": "src",
52
+ "android": {
53
+ "javaPackageName": "com.rovq.engine.react"
54
+ },
55
+ "ios": {
56
+ "modulesProvider": {
57
+ "Rovq": "RovqModule"
58
+ }
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@swc/core": "1.16.22",
63
+ "@swc/jest": "0.2.39",
64
+ "@types/jest": "30.0.0",
65
+ "@types/react": "^19.3.0",
66
+ "jest": "30.5.2",
67
+ "react": "19.3.0",
68
+ "react-native": "0.88.1",
69
+ "typescript": "~6.0.3",
70
+ "@types/node": "^24.19.2"
71
+ },
72
+ "react-native": "src/index.ts",
73
+ "types": "src/index.ts",
74
+ "publishConfig": {
75
+ "access": "public"
76
+ },
77
+ "scripts": {
78
+ "test": "jest",
79
+ "typecheck": "tsc --noEmit"
80
+ }
6
81
  }
@@ -0,0 +1,11 @@
1
+ module.exports = {
2
+ dependency: {
3
+ platforms: {
4
+ android: {
5
+ // The module lives apart from the engine, so autolinking cannot guess its package.
6
+ packageImportPath: 'import com.rovq.engine.react.RovqPackage;',
7
+ packageInstance: 'new RovqPackage()',
8
+ },
9
+ },
10
+ },
11
+ };
@@ -0,0 +1,72 @@
1
+ import type { CodegenTypes, TurboModule } from 'react-native';
2
+ import { TurboModuleRegistry } from 'react-native';
3
+
4
+ // React Native generates the native base classes from this file, and its code generator cannot
5
+ // express a union of object shapes. So these are flat, and index.ts turns them into the types
6
+ // that apps use. Only `react-native` and this file's own types may appear here.
7
+
8
+ export interface NativeUpdate {
9
+ releaseId: string;
10
+ sequence: number;
11
+ mandatory: boolean;
12
+ packageSize: number;
13
+ }
14
+
15
+ export interface NativeCheckResult {
16
+ result: string;
17
+ update?: NativeUpdate;
18
+ alreadyDownloaded?: boolean;
19
+ code?: string;
20
+ message?: string;
21
+ }
22
+
23
+ export interface NativeDownloadResult {
24
+ result: string;
25
+ update?: NativeUpdate;
26
+ code?: string;
27
+ message?: string;
28
+ }
29
+
30
+ export interface NativeInstallResult {
31
+ result: string;
32
+ releaseId?: string;
33
+ code?: string;
34
+ message?: string;
35
+ }
36
+
37
+ export interface NativeSyncResult {
38
+ result: string;
39
+ releaseId?: string;
40
+ mandatory?: boolean;
41
+ code?: string;
42
+ message?: string;
43
+ }
44
+
45
+ /** Empty if the engine is not running. */
46
+ export interface NativeStatus {
47
+ deviceId?: string;
48
+ currentReleaseId?: string | null;
49
+ downloadedReleaseId?: string | null;
50
+ pendingReleaseId?: string | null;
51
+ tryingReleaseId?: string | null;
52
+ highestSequence?: number;
53
+ }
54
+
55
+ export interface NativeDownloadProgress {
56
+ receivedBytes: number;
57
+ totalBytes: number;
58
+ }
59
+
60
+ export interface Spec extends TurboModule {
61
+ checkForUpdate(): Promise<NativeCheckResult>;
62
+ downloadUpdate(): Promise<NativeDownloadResult>;
63
+ installUpdate(): Promise<NativeInstallResult>;
64
+ sync(): Promise<NativeSyncResult>;
65
+ notifyApplicationReady(): void;
66
+ restartApp(): void;
67
+ rollback(): Promise<boolean>;
68
+ getStatus(): Promise<NativeStatus>;
69
+ readonly onDownloadProgress: CodegenTypes.EventEmitter<NativeDownloadProgress>;
70
+ }
71
+
72
+ export default TurboModuleRegistry.getEnforcing<Spec>('Rovq');
package/src/api.ts ADDED
@@ -0,0 +1,228 @@
1
+ import { RovqError, toRovqError } from './errors';
2
+ import Native from './NativeRovq';
3
+ import type { NativeUpdate } from './NativeRovq';
4
+
5
+ export { RovqError } from './errors';
6
+ export type { RovqErrorCode } from './errors';
7
+
8
+ /** Comes from a manifest whose signature the device has already verified. */
9
+ export interface UpdateInfo {
10
+ releaseId: string;
11
+ sequence: number;
12
+ mandatory: boolean;
13
+ packageSizeBytes: number;
14
+ }
15
+
16
+ export type CheckResult =
17
+ | { status: 'UP_TO_DATE' }
18
+ /** The server stopped the running release. It takes effect on the next restart. */
19
+ | { status: 'ROLLED_BACK' }
20
+ /** `alreadyDownloaded` means `downloadUpdate` has nothing left to fetch. */
21
+ | { status: 'UPDATE_AVAILABLE'; update: UpdateInfo; alreadyDownloaded: boolean };
22
+
23
+ export type SyncResult =
24
+ | { status: 'UP_TO_DATE' }
25
+ | { status: 'ROLLED_BACK' }
26
+ /** Installed. It loads on the next restart. */
27
+ | { status: 'UPDATE_READY'; releaseId: string; mandatory: boolean };
28
+
29
+ export interface DownloadProgress {
30
+ receivedBytes: number;
31
+ totalBytes: number;
32
+ }
33
+
34
+ export interface DownloadOptions {
35
+ /** Called when the whole percentage changes, so at most about 100 times. */
36
+ onProgress?: (progress: DownloadProgress) => void;
37
+ }
38
+
39
+ export interface CurrentUpdate {
40
+ releaseId: string;
41
+ /** False until `notifyApplicationReady`. Without it, the next start abandons this release. */
42
+ confirmed: boolean;
43
+ }
44
+
45
+ export interface EngineStatus {
46
+ deviceId: string;
47
+ currentReleaseId: string | null;
48
+ downloadedReleaseId: string | null;
49
+ pendingReleaseId: string | null;
50
+ tryingReleaseId: string | null;
51
+ highestSequence: number;
52
+ }
53
+
54
+ interface NativeFailure {
55
+ result: string;
56
+ code?: string;
57
+ message?: string;
58
+ }
59
+
60
+ function failureOf(answer: NativeFailure): RovqError {
61
+ return answer.result === 'FAILED'
62
+ ? toRovqError(answer.code, answer.message)
63
+ : new RovqError('UNKNOWN', `Unexpected answer from the update engine: ${answer.result}`);
64
+ }
65
+
66
+ function toUpdateInfo(update: NativeUpdate): UpdateInfo {
67
+ return {
68
+ releaseId: update.releaseId,
69
+ sequence: update.sequence,
70
+ mandatory: update.mandatory,
71
+ packageSizeBytes: update.packageSize,
72
+ };
73
+ }
74
+
75
+ /** Native promises reject only on an unexpected failure, which still gets the SDK's error type. */
76
+ async function callNative<T>(call: () => Promise<T>): Promise<T> {
77
+ try {
78
+ return await call();
79
+ } catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ throw new RovqError('UNKNOWN', message, { cause: error });
82
+ }
83
+ }
84
+
85
+ async function withProgress<T>(options: DownloadOptions, call: () => Promise<T>): Promise<T> {
86
+ const { onProgress } = options;
87
+ if (onProgress === undefined) return callNative(call);
88
+ const subscription = Native.onDownloadProgress((progress) => {
89
+ // A bug in a progress bar must not become a fatal error, which would roll back this release.
90
+ try {
91
+ onProgress({ receivedBytes: progress.receivedBytes, totalBytes: progress.totalBytes });
92
+ } catch (error) {
93
+ console.warn('ROVQ: onProgress threw an error', error);
94
+ }
95
+ });
96
+ try {
97
+ return await callNative(call);
98
+ } finally {
99
+ subscription.remove();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Asks the server for an update, and verifies any offer before returning it.
105
+ *
106
+ * If the server has disabled the running release, this also rolls it back, and that takes
107
+ * effect on the next restart.
108
+ *
109
+ * @throws RovqError
110
+ */
111
+ export async function checkForUpdate(): Promise<CheckResult> {
112
+ const answer = await callNative(() => Native.checkForUpdate());
113
+ switch (answer.result) {
114
+ case 'UP_TO_DATE':
115
+ case 'ROLLED_BACK':
116
+ return { status: answer.result };
117
+ case 'UPDATE_AVAILABLE':
118
+ if (answer.update === undefined) throw failureOf(answer);
119
+ return {
120
+ status: 'UPDATE_AVAILABLE',
121
+ update: toUpdateInfo(answer.update),
122
+ alreadyDownloaded: answer.alreadyDownloaded ?? false,
123
+ };
124
+ default:
125
+ throw failureOf(answer);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Downloads and verifies the update found by the last `checkForUpdate`. Nothing runs it until
131
+ * `installUpdate`.
132
+ *
133
+ * @throws RovqError with `NO_UPDATE_AVAILABLE` if there was no check since the app started.
134
+ */
135
+ export async function downloadUpdate(options: DownloadOptions = {}): Promise<UpdateInfo> {
136
+ const answer = await withProgress(options, () => Native.downloadUpdate());
137
+ if (answer.result !== 'DOWNLOADED' || answer.update === undefined) throw failureOf(answer);
138
+ return toUpdateInfo(answer.update);
139
+ }
140
+
141
+ /**
142
+ * Makes the downloaded update load on the next restart. It needs no network.
143
+ *
144
+ * @returns the ID of the release that will load.
145
+ * @throws RovqError with `NO_UPDATE_DOWNLOADED` if there is nothing to install.
146
+ */
147
+ export async function installUpdate(): Promise<string> {
148
+ const answer = await callNative(() => Native.installUpdate());
149
+ if (answer.result !== 'INSTALLED' || answer.releaseId === undefined) throw failureOf(answer);
150
+ return answer.releaseId;
151
+ }
152
+
153
+ /**
154
+ * Checks, downloads, and installs in one call, for an app that does not ask the user first.
155
+ *
156
+ * @throws RovqError
157
+ */
158
+ export async function sync(options: DownloadOptions = {}): Promise<SyncResult> {
159
+ const answer = await withProgress(options, () => Native.sync());
160
+ switch (answer.result) {
161
+ case 'UP_TO_DATE':
162
+ case 'ROLLED_BACK':
163
+ return { status: answer.result };
164
+ case 'UPDATE_READY':
165
+ if (answer.releaseId === undefined) throw failureOf(answer);
166
+ return {
167
+ status: 'UPDATE_READY',
168
+ releaseId: answer.releaseId,
169
+ mandatory: answer.mandatory ?? false,
170
+ };
171
+ default:
172
+ throw failureOf(answer);
173
+ }
174
+ }
175
+
176
+ /** Reloads JavaScript in the same process, which applies an installed update or rollback. */
177
+ export function restartApp(): void {
178
+ Native.restartApp();
179
+ }
180
+
181
+ /**
182
+ * Confirms that this release works. Call it once the app has started properly.
183
+ * **A release that never calls this is abandoned on the next start.**
184
+ */
185
+ export function notifyApplicationReady(): void {
186
+ Native.notifyApplicationReady();
187
+ }
188
+
189
+ /**
190
+ * Goes back to the previous confirmed release, or to the bundle inside the app. It takes
191
+ * effect on the next restart.
192
+ *
193
+ * @returns whether there was anything to leave.
194
+ */
195
+ export async function rollback(): Promise<boolean> {
196
+ return callNative(() => Native.rollback());
197
+ }
198
+
199
+ /**
200
+ * @throws RovqError with `ENGINE_UNAVAILABLE` if the native side was not set up.
201
+ */
202
+ export async function getStatus(): Promise<EngineStatus> {
203
+ const status = await callNative(() => Native.getStatus());
204
+ if (status.deviceId === undefined) {
205
+ throw new RovqError('ENGINE_UNAVAILABLE', 'The update engine is not running');
206
+ }
207
+ return {
208
+ deviceId: status.deviceId,
209
+ currentReleaseId: status.currentReleaseId ?? null,
210
+ downloadedReleaseId: status.downloadedReleaseId ?? null,
211
+ pendingReleaseId: status.pendingReleaseId ?? null,
212
+ tryingReleaseId: status.tryingReleaseId ?? null,
213
+ highestSequence: status.highestSequence ?? 0,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * @returns the update running now, or null for the bundle inside the app.
219
+ * @throws RovqError with `ENGINE_UNAVAILABLE` if the native side was not set up.
220
+ */
221
+ export async function getCurrentUpdate(): Promise<CurrentUpdate | null> {
222
+ const status = await getStatus();
223
+ if (status.currentReleaseId === null) return null;
224
+ return {
225
+ releaseId: status.currentReleaseId,
226
+ confirmed: status.tryingReleaseId === null,
227
+ };
228
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,44 @@
1
+ /** Every reason an SDK call can fail. Native code has the same list, and a test keeps them equal. */
2
+ export const ROVQ_ERROR_CODES = [
3
+ // The update failed its security checks, so the device refused it.
4
+ 'UNKNOWN_SIGNING_KEY',
5
+ 'REVOKED_SIGNING_KEY',
6
+ 'INVALID_SIGNATURE',
7
+ 'INVALID_MANIFEST',
8
+ 'MANIFEST_MISMATCH',
9
+ 'STALE_SEQUENCE',
10
+ 'INSECURE_URL',
11
+ 'SIZE_MISMATCH',
12
+ 'HASH_MISMATCH',
13
+ 'INVALID_PACKAGE',
14
+ // The network or the disk failed, so trying again later can work.
15
+ 'DOWNLOAD_FAILED',
16
+ 'STORAGE_FAILED',
17
+ // Failed its checks recently, so the engine waits before downloading the same file again.
18
+ 'DOWNLOAD_POSTPONED',
19
+ // The app called the SDK out of order, or without setting up the native side.
20
+ 'NO_UPDATE_AVAILABLE',
21
+ 'NO_UPDATE_DOWNLOADED',
22
+ 'ENGINE_UNAVAILABLE',
23
+ ] as const;
24
+
25
+ export type RovqErrorCode = (typeof ROVQ_ERROR_CODES)[number] | 'UNKNOWN';
26
+
27
+ const known: ReadonlySet<string> = new Set(ROVQ_ERROR_CODES);
28
+
29
+ /** Compare `code`, not `message`. Codes are stable, and messages are for logs. */
30
+ export class RovqError extends Error {
31
+ readonly code: RovqErrorCode;
32
+
33
+ constructor(code: RovqErrorCode, message: string, options?: ErrorOptions) {
34
+ super(message, options);
35
+ this.name = 'RovqError';
36
+ this.code = code;
37
+ }
38
+ }
39
+
40
+ /** A code this SDK does not know means the native side is newer, so it is reported as unknown. */
41
+ export function toRovqError(code: string | undefined, message: string | undefined): RovqError {
42
+ const safeCode = code !== undefined && known.has(code) ? (code as RovqErrorCode) : 'UNKNOWN';
43
+ return new RovqError(safeCode, message ?? code ?? 'The update engine reported a failure');
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './api';
2
+ export { runUpdateFlow } from './update-flow';
3
+ export type { UpdateMode, UpdatePhase, UpdateState } from './update-flow';
4
+ export { RovqUpdateGate, RovqUpdateProgress, useRovqUpdate } from './react';
5
+ export type {
6
+ RovqUpdateGateProps,
7
+ RovqUpdateProgressProps,
8
+ UseRovqUpdate,
9
+ UseRovqUpdateOptions,
10
+ } from './react';
package/src/react.tsx ADDED
@@ -0,0 +1,158 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { StyleSheet, Text, View } from 'react-native';
4
+ import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
5
+ import { notifyApplicationReady } from './api';
6
+ import { INITIAL_STATE, runUpdateFlow } from './update-flow';
7
+ import type { UpdateMode, UpdateState } from './update-flow';
8
+
9
+ export interface UseRovqUpdateOptions {
10
+ /** Defaults to `auto`, which follows the `--mandatory` flag of each release. */
11
+ mode?: UpdateMode;
12
+ /** Checks as soon as the component mounts. Defaults to true. */
13
+ checkOnMount?: boolean;
14
+ /**
15
+ * Confirms that this release works, which a release must do or it is abandoned on the next
16
+ * start. Defaults to true. Turn it off to call `notifyApplicationReady` yourself, later.
17
+ */
18
+ notifyReady?: boolean;
19
+ }
20
+
21
+ export interface UseRovqUpdate extends UpdateState {
22
+ /** Checks again. Does nothing while a check is already running. */
23
+ check: () => Promise<void>;
24
+ }
25
+
26
+ export function useRovqUpdate(options: UseRovqUpdateOptions = {}): UseRovqUpdate {
27
+ const { mode = 'auto', checkOnMount = true, notifyReady = true } = options;
28
+ const [state, setState] = useState<UpdateState>(INITIAL_STATE);
29
+ const running = useRef(false);
30
+ const mounted = useRef(true);
31
+
32
+ const check = useCallback(async () => {
33
+ if (running.current) return;
34
+ running.current = true;
35
+ try {
36
+ await runUpdateFlow(mode, (next) => {
37
+ if (mounted.current) setState(next);
38
+ });
39
+ } finally {
40
+ running.current = false;
41
+ }
42
+ }, [mode]);
43
+
44
+ useEffect(() => {
45
+ mounted.current = true;
46
+ if (notifyReady) notifyApplicationReady();
47
+ if (checkOnMount) void check();
48
+ return () => {
49
+ mounted.current = false;
50
+ };
51
+ // Runs once, on mount.
52
+ }, []);
53
+
54
+ return { ...state, check };
55
+ }
56
+
57
+ export interface RovqUpdateProgressProps {
58
+ /** From 0 to 1. */
59
+ progress: number;
60
+ /** `bar` draws a line that fills up, and `percent` shows a number. Defaults to `bar`. */
61
+ variant?: 'bar' | 'percent';
62
+ color?: string;
63
+ trackColor?: string;
64
+ /** Height of the bar. */
65
+ height?: number;
66
+ style?: StyleProp<ViewStyle>;
67
+ textStyle?: StyleProp<TextStyle>;
68
+ }
69
+
70
+ export function RovqUpdateProgress({
71
+ progress,
72
+ variant = 'bar',
73
+ color = '#16a34a',
74
+ trackColor = '#e4e4e7',
75
+ height = 6,
76
+ style,
77
+ textStyle,
78
+ }: RovqUpdateProgressProps) {
79
+ const percent = Math.max(0, Math.min(100, Math.round(progress * 100)));
80
+ if (variant === 'percent') {
81
+ return (
82
+ <Text
83
+ style={[styles.percent, textStyle]}
84
+ accessibilityRole="progressbar"
85
+ accessibilityValue={{ min: 0, max: 100, now: percent }}
86
+ >
87
+ {percent}%
88
+ </Text>
89
+ );
90
+ }
91
+ return (
92
+ <View
93
+ style={[
94
+ styles.track,
95
+ { height, borderRadius: height / 2, backgroundColor: trackColor },
96
+ style,
97
+ ]}
98
+ accessibilityRole="progressbar"
99
+ accessibilityValue={{ min: 0, max: 100, now: percent }}
100
+ >
101
+ <View
102
+ style={{ width: `${percent}%`, height, borderRadius: height / 2, backgroundColor: color }}
103
+ />
104
+ </View>
105
+ );
106
+ }
107
+
108
+ export interface RovqUpdateGateProps extends UseRovqUpdateOptions {
109
+ children: ReactNode;
110
+ variant?: 'bar' | 'percent';
111
+ /** Shown above the progress. Defaults to "Updating…". */
112
+ title?: string;
113
+ color?: string;
114
+ backgroundColor?: string;
115
+ /** Draws your own screen in place of the default one. */
116
+ renderProgress?: (state: UpdateState) => ReactNode;
117
+ }
118
+
119
+ /**
120
+ * Wraps the app. A foreground update covers it with a progress screen, then restarts. Anything
121
+ * else shows the app as usual, including while checking, so a slow server never holds it up.
122
+ */
123
+ export function RovqUpdateGate({
124
+ children,
125
+ variant = 'bar',
126
+ title = 'Updating…',
127
+ color,
128
+ backgroundColor = '#ffffff',
129
+ renderProgress,
130
+ ...options
131
+ }: RovqUpdateGateProps) {
132
+ const state = useRovqUpdate(options);
133
+ const covering =
134
+ state.foreground &&
135
+ (state.phase === 'downloading' || state.phase === 'installing' || state.phase === 'restarting');
136
+ if (!covering) return <>{children}</>;
137
+ if (renderProgress) return <>{renderProgress(state)}</>;
138
+ return (
139
+ <View style={[styles.screen, { backgroundColor }]}>
140
+ <Text style={styles.title}>{title}</Text>
141
+ <View style={styles.progress}>
142
+ <RovqUpdateProgress
143
+ progress={state.progress ?? 1}
144
+ variant={variant}
145
+ {...(color ? { color } : {})}
146
+ />
147
+ </View>
148
+ </View>
149
+ );
150
+ }
151
+
152
+ const styles = StyleSheet.create({
153
+ percent: { fontSize: 16, fontVariant: ['tabular-nums'] },
154
+ track: { width: '100%', overflow: 'hidden' },
155
+ screen: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
156
+ title: { fontSize: 17, fontWeight: '600', marginBottom: 16 },
157
+ progress: { width: '100%', maxWidth: 280, alignItems: 'center' },
158
+ });