@appsonair.ir/react-native 1.0.1 → 1.0.3
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/lib/index.d.ts +2 -2
- package/lib/index.js +11 -9
- package/lib/sync.d.ts +18 -1
- package/lib/sync.js +345 -85
- package/lib/types.d.ts +25 -4
- package/lib/types.js +1 -0
- package/lib/ui/OTAUpdateModal.d.ts +3 -2
- package/lib/ui/OTAUpdateModal.js +118 -14
- package/lib/ui/OTAUpdateProvider.js +1 -1
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { configure, getConfig } from './config';
|
|
2
2
|
import { configureFromProject, getDeploymentKey, isOtaConfigured, resolveUpdaterConfig } from './projectConfig';
|
|
3
|
-
import { sync, restartApp, installApk, resumePendingApkInstall, clearUpdate, getInstalledBundlePath, notifyAppReady
|
|
3
|
+
import { sync, restartApp, installApk, resumePendingApkInstall, clearUpdate, getInstalledBundlePath, notifyAppReady } from './sync';
|
|
4
4
|
import { UpdateCheckResult } from './types';
|
|
5
5
|
export { configure, getConfig, getServerUrl } from './config';
|
|
6
6
|
export { DEFAULT_SERVER_URL } from './constants';
|
|
@@ -8,7 +8,7 @@ export { configureFromProject, getDeploymentKey, isOtaConfigured, resolveUpdater
|
|
|
8
8
|
export type { ProjectOtaConfig } from './types';
|
|
9
9
|
export declare function getCurrentLabel(): Promise<string | null>;
|
|
10
10
|
export declare function checkUpdate(): Promise<UpdateCheckResult>;
|
|
11
|
-
export { sync, restartApp, installApk, resumePendingApkInstall, clearUpdate, getInstalledBundlePath, notifyAppReady, isSyncInProgress };
|
|
11
|
+
export { sync, restartApp, installApk, resumePendingApkInstall, clearUpdate, getInstalledBundlePath, notifyAppReady, isSyncInProgress, respondToUpdatePrompt, } from './sync';
|
|
12
12
|
export { OTAUpdateModal } from './ui/OTAUpdateModal';
|
|
13
13
|
export { OTAUpdateProvider } from './ui/OTAUpdateProvider';
|
|
14
14
|
export declare const OTAUpdater: {
|
package/lib/index.js
CHANGED
|
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
17
17
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
18
|
};
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
-
exports.OTAUpdater = exports.OTAUpdateProvider = exports.OTAUpdateModal = exports.isSyncInProgress = exports.notifyAppReady = exports.getInstalledBundlePath = exports.clearUpdate = exports.resumePendingApkInstall = exports.installApk = exports.restartApp = exports.sync = exports.resolveUpdaterConfig = exports.isOtaConfigured = exports.getDeploymentKey = exports.configureFromProject = exports.DEFAULT_SERVER_URL = exports.getServerUrl = exports.getConfig = exports.configure = void 0;
|
|
20
|
+
exports.OTAUpdater = exports.OTAUpdateProvider = exports.OTAUpdateModal = exports.respondToUpdatePrompt = exports.isSyncInProgress = exports.notifyAppReady = exports.getInstalledBundlePath = exports.clearUpdate = exports.resumePendingApkInstall = exports.installApk = exports.restartApp = exports.sync = exports.resolveUpdaterConfig = exports.isOtaConfigured = exports.getDeploymentKey = exports.configureFromProject = exports.DEFAULT_SERVER_URL = exports.getServerUrl = exports.getConfig = exports.configure = void 0;
|
|
21
21
|
exports.getCurrentLabel = getCurrentLabel;
|
|
22
22
|
exports.checkUpdate = checkUpdate;
|
|
23
23
|
const react_native_device_info_1 = __importDefault(require("react-native-device-info"));
|
|
@@ -29,14 +29,6 @@ Object.defineProperty(exports, "getDeploymentKey", { enumerable: true, get: func
|
|
|
29
29
|
Object.defineProperty(exports, "isOtaConfigured", { enumerable: true, get: function () { return projectConfig_1.isOtaConfigured; } });
|
|
30
30
|
Object.defineProperty(exports, "resolveUpdaterConfig", { enumerable: true, get: function () { return projectConfig_1.resolveUpdaterConfig; } });
|
|
31
31
|
const sync_1 = require("./sync");
|
|
32
|
-
Object.defineProperty(exports, "sync", { enumerable: true, get: function () { return sync_1.sync; } });
|
|
33
|
-
Object.defineProperty(exports, "restartApp", { enumerable: true, get: function () { return sync_1.restartApp; } });
|
|
34
|
-
Object.defineProperty(exports, "installApk", { enumerable: true, get: function () { return sync_1.installApk; } });
|
|
35
|
-
Object.defineProperty(exports, "resumePendingApkInstall", { enumerable: true, get: function () { return sync_1.resumePendingApkInstall; } });
|
|
36
|
-
Object.defineProperty(exports, "clearUpdate", { enumerable: true, get: function () { return sync_1.clearUpdate; } });
|
|
37
|
-
Object.defineProperty(exports, "getInstalledBundlePath", { enumerable: true, get: function () { return sync_1.getInstalledBundlePath; } });
|
|
38
|
-
Object.defineProperty(exports, "notifyAppReady", { enumerable: true, get: function () { return sync_1.notifyAppReady; } });
|
|
39
|
-
Object.defineProperty(exports, "isSyncInProgress", { enumerable: true, get: function () { return sync_1.isSyncInProgress; } });
|
|
40
32
|
var config_2 = require("./config");
|
|
41
33
|
Object.defineProperty(exports, "configure", { enumerable: true, get: function () { return config_2.configure; } });
|
|
42
34
|
Object.defineProperty(exports, "getConfig", { enumerable: true, get: function () { return config_2.getConfig; } });
|
|
@@ -58,6 +50,16 @@ async function checkUpdate() {
|
|
|
58
50
|
const deviceAbi = react_native_1.Platform.OS === 'android' ? await (0, sync_1.getDeviceAbi)() : undefined;
|
|
59
51
|
return (0, sync_1.checkForUpdate)((0, config_1.getServerUrl)(config), config.deploymentKey, appVersion, meta?.label, meta?.packageHash, clientUniqueId, react_native_1.Platform.OS === 'android' ? react_native_device_info_1.default.getBuildNumber() : undefined, deviceAbi);
|
|
60
52
|
}
|
|
53
|
+
var sync_2 = require("./sync");
|
|
54
|
+
Object.defineProperty(exports, "sync", { enumerable: true, get: function () { return sync_2.sync; } });
|
|
55
|
+
Object.defineProperty(exports, "restartApp", { enumerable: true, get: function () { return sync_2.restartApp; } });
|
|
56
|
+
Object.defineProperty(exports, "installApk", { enumerable: true, get: function () { return sync_2.installApk; } });
|
|
57
|
+
Object.defineProperty(exports, "resumePendingApkInstall", { enumerable: true, get: function () { return sync_2.resumePendingApkInstall; } });
|
|
58
|
+
Object.defineProperty(exports, "clearUpdate", { enumerable: true, get: function () { return sync_2.clearUpdate; } });
|
|
59
|
+
Object.defineProperty(exports, "getInstalledBundlePath", { enumerable: true, get: function () { return sync_2.getInstalledBundlePath; } });
|
|
60
|
+
Object.defineProperty(exports, "notifyAppReady", { enumerable: true, get: function () { return sync_2.notifyAppReady; } });
|
|
61
|
+
Object.defineProperty(exports, "isSyncInProgress", { enumerable: true, get: function () { return sync_2.isSyncInProgress; } });
|
|
62
|
+
Object.defineProperty(exports, "respondToUpdatePrompt", { enumerable: true, get: function () { return sync_2.respondToUpdatePrompt; } });
|
|
61
63
|
var OTAUpdateModal_1 = require("./ui/OTAUpdateModal");
|
|
62
64
|
Object.defineProperty(exports, "OTAUpdateModal", { enumerable: true, get: function () { return OTAUpdateModal_1.OTAUpdateModal; } });
|
|
63
65
|
var OTAUpdateProvider_1 = require("./ui/OTAUpdateProvider");
|
package/lib/sync.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { OTAProgressMessages, StoredBundleMeta, SyncOptions, SyncResult, SyncStatus, UpdateCheckResult } from './types';
|
|
1
|
+
import { InstallMode, OTAProgressMessages, StoredBundleMeta, SyncOptions, SyncResult, SyncStatus, UpdateCheckResult } from './types';
|
|
2
2
|
export interface OTAProgressState {
|
|
3
3
|
visible: boolean;
|
|
4
4
|
status: SyncStatus;
|
|
5
5
|
progress: number;
|
|
6
6
|
message: string;
|
|
7
7
|
label?: string;
|
|
8
|
+
/** Release changelog / notes */
|
|
8
9
|
description?: string;
|
|
10
|
+
isMandatory?: boolean;
|
|
11
|
+
installMode?: InstallMode;
|
|
12
|
+
/** Show Update / Skip actions (UPDATE_AVAILABLE or deferred done / error dismiss) */
|
|
13
|
+
showActions?: boolean;
|
|
14
|
+
canSkip?: boolean;
|
|
9
15
|
error?: string;
|
|
10
16
|
}
|
|
11
17
|
type ProgressListener = (state: OTAProgressState) => void;
|
|
@@ -19,11 +25,22 @@ export declare const defaultMessages: {
|
|
|
19
25
|
installing: string;
|
|
20
26
|
restarting: string;
|
|
21
27
|
error: string;
|
|
28
|
+
downloadInterrupted: string;
|
|
22
29
|
upToDate: string;
|
|
30
|
+
updateAvailable: string;
|
|
31
|
+
changelogTitle: string;
|
|
32
|
+
updateNow: string;
|
|
33
|
+
skip: string;
|
|
34
|
+
dismiss: string;
|
|
35
|
+
appliedNextLaunch: string;
|
|
36
|
+
mandatoryHint: string;
|
|
23
37
|
};
|
|
24
38
|
export declare function getMessageForStatus(status: SyncStatus, messages?: Partial<typeof defaultMessages>): string;
|
|
25
39
|
export declare function getModalTitle(messages?: Partial<typeof defaultMessages>): string;
|
|
26
40
|
export declare function delay(ms: number): Promise<void>;
|
|
41
|
+
type UpdatePromptAction = 'update' | 'skip' | 'dismiss';
|
|
42
|
+
/** Resolve the Update / Skip / Dismiss prompt shown by OTAUpdateModal. */
|
|
43
|
+
export declare function respondToUpdatePrompt(action: UpdatePromptAction): void;
|
|
27
44
|
export declare function getStoredBundleMeta(): Promise<StoredBundleMeta | null>;
|
|
28
45
|
export declare function setStoredBundleMeta(meta: StoredBundleMeta): Promise<void>;
|
|
29
46
|
export declare function getClientUniqueId(): Promise<string>;
|
package/lib/sync.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.hideProgress = hideProgress;
|
|
|
10
10
|
exports.getMessageForStatus = getMessageForStatus;
|
|
11
11
|
exports.getModalTitle = getModalTitle;
|
|
12
12
|
exports.delay = delay;
|
|
13
|
+
exports.respondToUpdatePrompt = respondToUpdatePrompt;
|
|
13
14
|
exports.getStoredBundleMeta = getStoredBundleMeta;
|
|
14
15
|
exports.setStoredBundleMeta = setStoredBundleMeta;
|
|
15
16
|
exports.getClientUniqueId = getClientUniqueId;
|
|
@@ -61,8 +62,10 @@ function hideProgress() {
|
|
|
61
62
|
progressListeners.forEach((listener) => listener(progressState));
|
|
62
63
|
}
|
|
63
64
|
function shouldShowModalForStatus(status) {
|
|
64
|
-
return (status === types_1.SyncStatus.
|
|
65
|
+
return (status === types_1.SyncStatus.UPDATE_AVAILABLE ||
|
|
66
|
+
status === types_1.SyncStatus.DOWNLOADING ||
|
|
65
67
|
status === types_1.SyncStatus.RESTARTING ||
|
|
68
|
+
status === types_1.SyncStatus.UPDATE_INSTALLED ||
|
|
66
69
|
status === types_1.SyncStatus.ERROR);
|
|
67
70
|
}
|
|
68
71
|
exports.defaultMessages = {
|
|
@@ -72,19 +75,31 @@ exports.defaultMessages = {
|
|
|
72
75
|
installing: 'در حال نصب به\u200cروزرسانی...',
|
|
73
76
|
restarting: 'در حال راه\u200cاندازی مجدد...',
|
|
74
77
|
error: 'خطا در به\u200cروزرسانی',
|
|
78
|
+
downloadInterrupted: 'دانلود قطع شد. لطفاً دوباره تلاش کنید',
|
|
75
79
|
upToDate: 'برنامه به\u200cروز است',
|
|
80
|
+
updateAvailable: 'نسخه جدیدی در دسترس است',
|
|
81
|
+
changelogTitle: 'تغییرات این نسخه',
|
|
82
|
+
updateNow: 'به\u200cروزرسانی',
|
|
83
|
+
skip: 'فعلاً نه',
|
|
84
|
+
dismiss: 'باشه',
|
|
85
|
+
appliedNextLaunch: 'به\u200cروزرسانی نصب شد و در اجرای بعدی برنامه اعمال می\u200cشود',
|
|
86
|
+
mandatoryHint: 'این به\u200cروزرسانی الزامی است',
|
|
76
87
|
};
|
|
77
88
|
function getMessageForStatus(status, messages = {}) {
|
|
78
89
|
const m = { ...exports.defaultMessages, ...messages };
|
|
79
90
|
switch (status) {
|
|
80
91
|
case types_1.SyncStatus.CHECKING:
|
|
81
92
|
return m.checking;
|
|
93
|
+
case types_1.SyncStatus.UPDATE_AVAILABLE:
|
|
94
|
+
return m.updateAvailable;
|
|
82
95
|
case types_1.SyncStatus.DOWNLOADING:
|
|
83
96
|
return m.downloading;
|
|
84
97
|
case types_1.SyncStatus.INSTALLING:
|
|
85
98
|
return m.installing;
|
|
86
99
|
case types_1.SyncStatus.RESTARTING:
|
|
87
100
|
return m.restarting;
|
|
101
|
+
case types_1.SyncStatus.UPDATE_INSTALLED:
|
|
102
|
+
return m.appliedNextLaunch;
|
|
88
103
|
case types_1.SyncStatus.ERROR:
|
|
89
104
|
return m.error;
|
|
90
105
|
default:
|
|
@@ -124,7 +139,41 @@ const CLIENT_ID_KEY = '@appsonair.ir/client-id';
|
|
|
124
139
|
const NATIVE_VERSION_KEY = '@appsonair.ir/native-version';
|
|
125
140
|
const NATIVE_BUILD_KEY = '@appsonair.ir/native-build';
|
|
126
141
|
const RESTART_COOLDOWN_KEY = '@appsonair.ir/last-restart';
|
|
142
|
+
const SKIPPED_UPDATE_KEY = '@appsonair.ir/skipped-update';
|
|
127
143
|
const RESTART_COOLDOWN_MS = 15000;
|
|
144
|
+
let pendingPrompt = null;
|
|
145
|
+
/** Resolve the Update / Skip / Dismiss prompt shown by OTAUpdateModal. */
|
|
146
|
+
function respondToUpdatePrompt(action) {
|
|
147
|
+
if (!pendingPrompt)
|
|
148
|
+
return;
|
|
149
|
+
const { resolve } = pendingPrompt;
|
|
150
|
+
pendingPrompt = null;
|
|
151
|
+
resolve(action);
|
|
152
|
+
}
|
|
153
|
+
function waitForUpdatePrompt() {
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
pendingPrompt = { resolve };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function resolveInstallMode(mode) {
|
|
159
|
+
if (mode === 'next-launch' || mode === 'on-next-restart' || mode === 'on-next-resume') {
|
|
160
|
+
return 'next-launch';
|
|
161
|
+
}
|
|
162
|
+
return 'immediate';
|
|
163
|
+
}
|
|
164
|
+
function getUpdateChangelog(update) {
|
|
165
|
+
const text = update.changelog ?? update.description;
|
|
166
|
+
return text?.trim() ? text.trim() : undefined;
|
|
167
|
+
}
|
|
168
|
+
async function getSkippedUpdateLabel() {
|
|
169
|
+
return async_storage_1.default.getItem(SKIPPED_UPDATE_KEY);
|
|
170
|
+
}
|
|
171
|
+
async function markUpdateSkipped(label) {
|
|
172
|
+
await async_storage_1.default.setItem(SKIPPED_UPDATE_KEY, label);
|
|
173
|
+
}
|
|
174
|
+
async function clearSkippedUpdate() {
|
|
175
|
+
await async_storage_1.default.removeItem(SKIPPED_UPDATE_KEY);
|
|
176
|
+
}
|
|
128
177
|
async function getStoredBundleMeta() {
|
|
129
178
|
const raw = await async_storage_1.default.getItem(STORAGE_KEY);
|
|
130
179
|
if (!raw)
|
|
@@ -365,6 +414,31 @@ async function ensureBundleDirectory() {
|
|
|
365
414
|
await ReactNativeBlobUtil.fs.mkdir(dir);
|
|
366
415
|
}
|
|
367
416
|
}
|
|
417
|
+
/** Keep only the active OTA package so leftover extracts don't fill disk / slow IO. */
|
|
418
|
+
async function cleanupOldUpdates(keepLabel) {
|
|
419
|
+
try {
|
|
420
|
+
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
421
|
+
const dir = getBundleDirectory();
|
|
422
|
+
const exists = await ReactNativeBlobUtil.fs.exists(dir);
|
|
423
|
+
if (!exists)
|
|
424
|
+
return;
|
|
425
|
+
const keep = new Set([
|
|
426
|
+
keepLabel,
|
|
427
|
+
`${keepLabel}.zip`,
|
|
428
|
+
`${keepLabel}.bundle`,
|
|
429
|
+
]);
|
|
430
|
+
const entries = await ReactNativeBlobUtil.fs.ls(dir);
|
|
431
|
+
await Promise.all(entries.map(async (entry) => {
|
|
432
|
+
if (keep.has(entry) || entry.endsWith('.apk')) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
await removePathRecursive(`${dir}/${entry}`);
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// Best-effort cleanup; a leftover folder must not fail install.
|
|
440
|
+
}
|
|
441
|
+
}
|
|
368
442
|
async function resolveDownloadSize(downloadUrl, deploymentKey, expectedSize, deviceAbi) {
|
|
369
443
|
if (expectedSize && expectedSize > 0) {
|
|
370
444
|
return expectedSize;
|
|
@@ -401,6 +475,80 @@ async function resolveDownloadSize(downloadUrl, deploymentKey, expectedSize, dev
|
|
|
401
475
|
}
|
|
402
476
|
return expectedSize ?? 0;
|
|
403
477
|
}
|
|
478
|
+
const DOWNLOAD_MAX_ATTEMPTS = 3;
|
|
479
|
+
function getErrorMessage(error) {
|
|
480
|
+
if (error instanceof Error)
|
|
481
|
+
return error.message;
|
|
482
|
+
if (typeof error === 'string')
|
|
483
|
+
return error;
|
|
484
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
485
|
+
return String(error.message);
|
|
486
|
+
}
|
|
487
|
+
return String(error);
|
|
488
|
+
}
|
|
489
|
+
function isRetryableDownloadError(error) {
|
|
490
|
+
const message = getErrorMessage(error).toLowerCase();
|
|
491
|
+
return (message.includes('download interrupted') ||
|
|
492
|
+
message.includes('network') ||
|
|
493
|
+
message.includes('timeout') ||
|
|
494
|
+
message.includes('timed out') ||
|
|
495
|
+
message.includes('connection') ||
|
|
496
|
+
message.includes('socket') ||
|
|
497
|
+
message.includes('econnreset') ||
|
|
498
|
+
message.includes('failed to connect') ||
|
|
499
|
+
message.includes('software caused connection abort'));
|
|
500
|
+
}
|
|
501
|
+
function toUserFacingDownloadError(error) {
|
|
502
|
+
if (isRetryableDownloadError(error)) {
|
|
503
|
+
return new Error(exports.defaultMessages.downloadInterrupted);
|
|
504
|
+
}
|
|
505
|
+
return error instanceof Error ? error : new Error(getErrorMessage(error));
|
|
506
|
+
}
|
|
507
|
+
async function cleanupPartialDownload(path) {
|
|
508
|
+
try {
|
|
509
|
+
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
510
|
+
const exists = await ReactNativeBlobUtil.fs.exists(path);
|
|
511
|
+
if (exists) {
|
|
512
|
+
await ReactNativeBlobUtil.fs.unlink(path);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// Ignore cleanup errors.
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* react-native-blob-util rejects with "Download interrupted." whenever
|
|
521
|
+
* bytesWritten !== Content-Length — including when Content-Length is wrong
|
|
522
|
+
* but the full file actually landed. Recover those cases via size/hash checks.
|
|
523
|
+
*/
|
|
524
|
+
async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label) {
|
|
525
|
+
try {
|
|
526
|
+
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
527
|
+
const exists = await ReactNativeBlobUtil.fs.exists(destPath);
|
|
528
|
+
if (!exists)
|
|
529
|
+
return null;
|
|
530
|
+
const stat = await ReactNativeBlobUtil.fs.stat(destPath);
|
|
531
|
+
const minSize = extension === 'apk' ? 1024 * 100 : 1024;
|
|
532
|
+
if (!stat || stat.size < minSize)
|
|
533
|
+
return null;
|
|
534
|
+
const sizeLooksComplete = totalBytes <= 0 || Math.abs(stat.size - totalBytes) <= 1;
|
|
535
|
+
if (expectedHash) {
|
|
536
|
+
const valid = await verifyBundleHash(destPath, expectedHash);
|
|
537
|
+
if (!valid)
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
else if (!sizeLooksComplete) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
|
|
544
|
+
return extractBundleUpdate(label, destPath);
|
|
545
|
+
}
|
|
546
|
+
return destPath;
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
404
552
|
async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, onProgress, extension = 'bundle', deviceAbi, packageFormat = 'bundle', expectedHash) {
|
|
405
553
|
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
406
554
|
const isZipBundle = extension === 'bundle' && packageFormat === 'zip';
|
|
@@ -414,76 +562,95 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
|
|
|
414
562
|
if (isZipBundle) {
|
|
415
563
|
await removePathRecursive(getBundleReleaseDir(label));
|
|
416
564
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
await
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
});
|
|
429
|
-
const reportDownloadProgress = (received, total) => {
|
|
430
|
-
if (!onProgress)
|
|
431
|
-
return;
|
|
432
|
-
const resolvedTotal = total > 0 ? total : totalBytes;
|
|
433
|
-
if (resolvedTotal <= 0)
|
|
434
|
-
return;
|
|
435
|
-
onProgress(Math.min(Math.max(received / resolvedTotal, 0), 0.99));
|
|
436
|
-
};
|
|
437
|
-
let pollTimer = null;
|
|
438
|
-
if (onProgress && totalBytes > 0) {
|
|
439
|
-
pollTimer = setInterval(async () => {
|
|
440
|
-
try {
|
|
441
|
-
const exists = await ReactNativeBlobUtil.fs.exists(destPath);
|
|
442
|
-
if (!exists)
|
|
443
|
-
return;
|
|
444
|
-
const stat = await ReactNativeBlobUtil.fs.stat(destPath);
|
|
445
|
-
reportDownloadProgress(stat.size, totalBytes);
|
|
446
|
-
}
|
|
447
|
-
catch {
|
|
448
|
-
// Ignore polling errors while the file is being written.
|
|
449
|
-
}
|
|
450
|
-
}, 150);
|
|
451
|
-
}
|
|
452
|
-
if (onProgress) {
|
|
453
|
-
task.progress({ interval: 100, count: 10 }, (received, total) => {
|
|
454
|
-
reportDownloadProgress(received, total);
|
|
565
|
+
let lastError;
|
|
566
|
+
for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
|
|
567
|
+
await cleanupPartialDownload(destPath);
|
|
568
|
+
const task = ReactNativeBlobUtil.config({
|
|
569
|
+
path: destPath,
|
|
570
|
+
fileCache: false,
|
|
571
|
+
overwrite: true,
|
|
572
|
+
timeout: 10 * 60 * 1000,
|
|
573
|
+
}).fetch('GET', downloadUrl, {
|
|
574
|
+
'X-Deployment-Key': deploymentKey,
|
|
575
|
+
...(deviceAbi ? { 'X-Device-Abi': deviceAbi } : {}),
|
|
455
576
|
});
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
577
|
+
const reportDownloadProgress = (received, total) => {
|
|
578
|
+
if (!onProgress)
|
|
579
|
+
return;
|
|
580
|
+
const resolvedTotal = total > 0 ? total : totalBytes;
|
|
581
|
+
if (resolvedTotal <= 0)
|
|
582
|
+
return;
|
|
583
|
+
onProgress(Math.min(Math.max(received / resolvedTotal, 0), 0.99));
|
|
584
|
+
};
|
|
585
|
+
let pollTimer = null;
|
|
586
|
+
if (onProgress && totalBytes > 0) {
|
|
587
|
+
pollTimer = setInterval(async () => {
|
|
588
|
+
try {
|
|
589
|
+
const exists = await ReactNativeBlobUtil.fs.exists(destPath);
|
|
590
|
+
if (!exists)
|
|
591
|
+
return;
|
|
592
|
+
const stat = await ReactNativeBlobUtil.fs.stat(destPath);
|
|
593
|
+
reportDownloadProgress(stat.size, totalBytes);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// Ignore polling errors while the file is being written.
|
|
597
|
+
}
|
|
598
|
+
}, 150);
|
|
465
599
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
600
|
+
if (onProgress) {
|
|
601
|
+
task.progress({ interval: 100, count: 10 }, (received, total) => {
|
|
602
|
+
reportDownloadProgress(received, total);
|
|
603
|
+
});
|
|
470
604
|
}
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
if (
|
|
474
|
-
|
|
605
|
+
try {
|
|
606
|
+
const response = await task;
|
|
607
|
+
if (onProgress) {
|
|
608
|
+
onProgress(1);
|
|
609
|
+
}
|
|
610
|
+
const downloadedPath = String(response.path()).replace(/^file:\/\//, '');
|
|
611
|
+
if (downloadedPath !== destPath) {
|
|
612
|
+
await ReactNativeBlobUtil.fs.cp(downloadedPath, destPath);
|
|
475
613
|
}
|
|
614
|
+
const stat = await ReactNativeBlobUtil.fs.stat(destPath);
|
|
615
|
+
const minSize = extension === 'apk' ? 1024 * 100 : 1024;
|
|
616
|
+
if (!stat || stat.size < minSize) {
|
|
617
|
+
throw new Error(`Downloaded file is too small (${stat?.size ?? 0} bytes)`);
|
|
618
|
+
}
|
|
619
|
+
if (expectedHash) {
|
|
620
|
+
const valid = await verifyBundleHash(destPath, expectedHash);
|
|
621
|
+
if (!valid) {
|
|
622
|
+
throw new Error('Package hash verification failed');
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
|
|
626
|
+
return extractBundleUpdate(label, destPath);
|
|
627
|
+
}
|
|
628
|
+
return destPath;
|
|
476
629
|
}
|
|
477
|
-
|
|
478
|
-
|
|
630
|
+
catch (error) {
|
|
631
|
+
lastError = error;
|
|
632
|
+
if (isRetryableDownloadError(error)) {
|
|
633
|
+
const recovered = await tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label);
|
|
634
|
+
if (recovered) {
|
|
635
|
+
if (onProgress)
|
|
636
|
+
onProgress(1);
|
|
637
|
+
return recovered;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
await cleanupPartialDownload(destPath);
|
|
641
|
+
if (attempt < DOWNLOAD_MAX_ATTEMPTS && isRetryableDownloadError(error)) {
|
|
642
|
+
await delay(attempt * 750);
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
throw toUserFacingDownloadError(error);
|
|
479
646
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
clearInterval(pollTimer);
|
|
647
|
+
finally {
|
|
648
|
+
if (pollTimer) {
|
|
649
|
+
clearInterval(pollTimer);
|
|
650
|
+
}
|
|
485
651
|
}
|
|
486
652
|
}
|
|
653
|
+
throw toUserFacingDownloadError(lastError);
|
|
487
654
|
}
|
|
488
655
|
async function verifyBundleHash(filePath, expectedHash) {
|
|
489
656
|
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
@@ -503,24 +670,51 @@ function reportProgress(showUI, messages, onProgress, status, progress = 0, extr
|
|
|
503
670
|
onProgress?.(status, progress);
|
|
504
671
|
if (!showUI)
|
|
505
672
|
return;
|
|
506
|
-
// Silent phases — never show modal for check / install / up-to-date.
|
|
673
|
+
// Silent phases — never show modal for check / install / ignored / up-to-date.
|
|
507
674
|
if (status === types_1.SyncStatus.CHECKING ||
|
|
508
675
|
status === types_1.SyncStatus.UP_TO_DATE ||
|
|
509
676
|
status === types_1.SyncStatus.UPDATE_IGNORED ||
|
|
510
|
-
status === types_1.SyncStatus.INSTALLING
|
|
677
|
+
status === types_1.SyncStatus.INSTALLING ||
|
|
678
|
+
status === types_1.SyncStatus.APK_INSTALL_PENDING) {
|
|
511
679
|
return;
|
|
512
680
|
}
|
|
513
681
|
if (status === types_1.SyncStatus.ERROR) {
|
|
514
682
|
showProgress(status, progress, extra?.error ?? getMessageForStatus(status, messages), {
|
|
515
683
|
error: extra?.error,
|
|
684
|
+
showActions: true,
|
|
685
|
+
canSkip: false,
|
|
516
686
|
...extra,
|
|
517
687
|
});
|
|
518
688
|
return;
|
|
519
689
|
}
|
|
520
690
|
if (shouldShowModalForStatus(status)) {
|
|
521
|
-
showProgress(status, progress, getMessageForStatus(status, messages),
|
|
691
|
+
showProgress(status, progress, extra?.message ?? getMessageForStatus(status, messages), {
|
|
692
|
+
showActions: false,
|
|
693
|
+
canSkip: false,
|
|
694
|
+
...extra,
|
|
695
|
+
});
|
|
522
696
|
}
|
|
523
697
|
}
|
|
698
|
+
async function promptForUpdate(showUI, messages, onProgress, update, installMode) {
|
|
699
|
+
const changelog = getUpdateChangelog(update);
|
|
700
|
+
const isMandatory = update.isMandatory === true;
|
|
701
|
+
if (!showUI) {
|
|
702
|
+
return 'update';
|
|
703
|
+
}
|
|
704
|
+
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.UPDATE_AVAILABLE, 0, {
|
|
705
|
+
label: update.label,
|
|
706
|
+
description: changelog,
|
|
707
|
+
isMandatory,
|
|
708
|
+
installMode,
|
|
709
|
+
showActions: true,
|
|
710
|
+
canSkip: !isMandatory,
|
|
711
|
+
});
|
|
712
|
+
const action = await waitForUpdatePrompt();
|
|
713
|
+
if (action === 'skip' && !isMandatory) {
|
|
714
|
+
return 'skip';
|
|
715
|
+
}
|
|
716
|
+
return 'update';
|
|
717
|
+
}
|
|
524
718
|
async function restartApp() {
|
|
525
719
|
if (OTAUpdaterNative?.restartApp) {
|
|
526
720
|
await OTAUpdaterNative.restartApp();
|
|
@@ -590,16 +784,51 @@ async function performSync(options = {}, onProgress, messages) {
|
|
|
590
784
|
onProgress?.(types_1.SyncStatus.UP_TO_DATE, 1);
|
|
591
785
|
return { status: types_1.SyncStatus.UP_TO_DATE };
|
|
592
786
|
}
|
|
787
|
+
const installMode = resolveInstallMode(options.installMode ?? config.installMode ?? 'immediate');
|
|
788
|
+
const changelog = getUpdateChangelog(update);
|
|
789
|
+
if (!update.isMandatory) {
|
|
790
|
+
const skipped = await getSkippedUpdateLabel();
|
|
791
|
+
if (skipped && skipped === update.label) {
|
|
792
|
+
if (showUI)
|
|
793
|
+
hideProgress();
|
|
794
|
+
onProgress?.(types_1.SyncStatus.UPDATE_IGNORED, 1);
|
|
795
|
+
return {
|
|
796
|
+
status: types_1.SyncStatus.UPDATE_IGNORED,
|
|
797
|
+
label: update.label,
|
|
798
|
+
description: changelog,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
await clearSkippedUpdate();
|
|
804
|
+
}
|
|
805
|
+
const decision = await promptForUpdate(showUI, messages, onProgress, update, installMode);
|
|
806
|
+
if (decision === 'skip') {
|
|
807
|
+
await markUpdateSkipped(update.label);
|
|
808
|
+
if (showUI)
|
|
809
|
+
hideProgress();
|
|
810
|
+
onProgress?.(types_1.SyncStatus.UPDATE_IGNORED, 1);
|
|
811
|
+
return {
|
|
812
|
+
status: types_1.SyncStatus.UPDATE_IGNORED,
|
|
813
|
+
label: update.label,
|
|
814
|
+
description: changelog,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
await clearSkippedUpdate();
|
|
593
818
|
if (update.updateType === 'apk') {
|
|
594
|
-
return performApkSync(update, showUI, messages, onProgress);
|
|
819
|
+
return performApkSync(update, showUI, messages, onProgress, changelog);
|
|
595
820
|
}
|
|
596
821
|
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, 0, {
|
|
597
822
|
label: update.label,
|
|
598
|
-
description:
|
|
823
|
+
description: changelog,
|
|
824
|
+
isMandatory: update.isMandatory,
|
|
825
|
+
installMode,
|
|
599
826
|
});
|
|
600
827
|
const bundlePath = await downloadBundle(update.downloadUrl, config.deploymentKey, update.label, update.size, (p) => reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, p, {
|
|
601
828
|
label: update.label,
|
|
602
|
-
description:
|
|
829
|
+
description: changelog,
|
|
830
|
+
isMandatory: update.isMandatory,
|
|
831
|
+
installMode,
|
|
603
832
|
}), 'bundle', undefined, update.packageFormat ?? 'bundle', update.packageHash);
|
|
604
833
|
onProgress?.(types_1.SyncStatus.INSTALLING, 1);
|
|
605
834
|
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
@@ -615,12 +844,14 @@ async function performSync(options = {}, onProgress, messages) {
|
|
|
615
844
|
packageHash: update.packageHash,
|
|
616
845
|
installedAt: new Date().toISOString(),
|
|
617
846
|
});
|
|
618
|
-
|
|
847
|
+
await cleanupOldUpdates(update.label);
|
|
619
848
|
const shouldRestartNow = installMode === 'immediate' || update.isMandatory === true;
|
|
620
849
|
if (shouldRestartNow) {
|
|
621
850
|
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.RESTARTING, 1, {
|
|
622
851
|
label: update.label,
|
|
623
|
-
description:
|
|
852
|
+
description: changelog,
|
|
853
|
+
isMandatory: update.isMandatory,
|
|
854
|
+
installMode,
|
|
624
855
|
});
|
|
625
856
|
await markRestartPending();
|
|
626
857
|
await delay(300);
|
|
@@ -628,46 +859,62 @@ async function performSync(options = {}, onProgress, messages) {
|
|
|
628
859
|
return {
|
|
629
860
|
status: types_1.SyncStatus.UPDATE_INSTALLED,
|
|
630
861
|
label: update.label,
|
|
631
|
-
description:
|
|
862
|
+
description: changelog,
|
|
632
863
|
};
|
|
633
864
|
}
|
|
634
|
-
if (showUI)
|
|
865
|
+
if (showUI) {
|
|
866
|
+
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.UPDATE_INSTALLED, 1, {
|
|
867
|
+
label: update.label,
|
|
868
|
+
description: changelog,
|
|
869
|
+
isMandatory: update.isMandatory,
|
|
870
|
+
installMode,
|
|
871
|
+
showActions: true,
|
|
872
|
+
canSkip: false,
|
|
873
|
+
message: getMessageForStatus(types_1.SyncStatus.UPDATE_INSTALLED, messages),
|
|
874
|
+
});
|
|
875
|
+
await waitForUpdatePrompt();
|
|
635
876
|
hideProgress();
|
|
877
|
+
}
|
|
636
878
|
return {
|
|
637
879
|
status: types_1.SyncStatus.UPDATE_INSTALLED,
|
|
638
880
|
label: update.label,
|
|
639
|
-
description:
|
|
881
|
+
description: changelog,
|
|
640
882
|
};
|
|
641
883
|
}
|
|
642
884
|
catch (error) {
|
|
643
885
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
644
886
|
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.ERROR, 0, {
|
|
645
887
|
error: err.message,
|
|
888
|
+
showActions: true,
|
|
889
|
+
canSkip: false,
|
|
646
890
|
});
|
|
891
|
+
if (showUI) {
|
|
892
|
+
await waitForUpdatePrompt();
|
|
893
|
+
hideProgress();
|
|
894
|
+
}
|
|
647
895
|
return {
|
|
648
896
|
status: types_1.SyncStatus.ERROR,
|
|
649
897
|
error: err,
|
|
650
898
|
};
|
|
651
899
|
}
|
|
652
900
|
}
|
|
653
|
-
async function performApkSync(update, showUI, messages, onProgress) {
|
|
901
|
+
async function performApkSync(update, showUI, messages, onProgress, changelog) {
|
|
654
902
|
if (!update.downloadUrl || !update.label || !update.packageHash) {
|
|
655
903
|
return { status: types_1.SyncStatus.UP_TO_DATE };
|
|
656
904
|
}
|
|
905
|
+
const notes = changelog ?? getUpdateChangelog(update);
|
|
657
906
|
try {
|
|
658
907
|
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, 0, {
|
|
659
908
|
label: update.label,
|
|
660
|
-
description:
|
|
909
|
+
description: notes,
|
|
910
|
+
isMandatory: update.isMandatory,
|
|
661
911
|
});
|
|
662
912
|
const apkPath = await downloadBundle(update.downloadUrl, (0, config_1.getConfig)().deploymentKey, update.label, update.size, (p) => reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, p, {
|
|
663
913
|
label: update.label,
|
|
664
|
-
description:
|
|
665
|
-
|
|
914
|
+
description: notes,
|
|
915
|
+
isMandatory: update.isMandatory,
|
|
916
|
+
}), 'apk', update.deviceAbi, 'bundle', update.packageHash);
|
|
666
917
|
onProgress?.(types_1.SyncStatus.INSTALLING, 1);
|
|
667
|
-
const valid = await verifyBundleHash(apkPath, update.packageHash);
|
|
668
|
-
if (!valid) {
|
|
669
|
-
throw new Error('APK hash verification failed');
|
|
670
|
-
}
|
|
671
918
|
// Drop the OTA JS path before opening the installer so the new APK does not
|
|
672
919
|
// keep loading the previous bundle on first launch after install.
|
|
673
920
|
await clearUpdate();
|
|
@@ -675,12 +922,13 @@ async function performApkSync(update, showUI, messages, onProgress) {
|
|
|
675
922
|
if (installResult === 'permission_required') {
|
|
676
923
|
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.INSTALLING, 1, {
|
|
677
924
|
label: update.label,
|
|
678
|
-
description:
|
|
925
|
+
description: notes,
|
|
926
|
+
isMandatory: update.isMandatory,
|
|
679
927
|
});
|
|
680
928
|
return {
|
|
681
929
|
status: types_1.SyncStatus.APK_INSTALL_PENDING,
|
|
682
930
|
label: update.label,
|
|
683
|
-
description:
|
|
931
|
+
description: notes,
|
|
684
932
|
};
|
|
685
933
|
}
|
|
686
934
|
if (showUI)
|
|
@@ -689,14 +937,20 @@ async function performApkSync(update, showUI, messages, onProgress) {
|
|
|
689
937
|
return {
|
|
690
938
|
status: types_1.SyncStatus.APK_INSTALL_PENDING,
|
|
691
939
|
label: update.label,
|
|
692
|
-
description:
|
|
940
|
+
description: notes,
|
|
693
941
|
};
|
|
694
942
|
}
|
|
695
943
|
catch (error) {
|
|
696
944
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
697
945
|
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.ERROR, 0, {
|
|
698
946
|
error: err.message,
|
|
947
|
+
showActions: true,
|
|
948
|
+
canSkip: false,
|
|
699
949
|
});
|
|
950
|
+
if (showUI) {
|
|
951
|
+
await waitForUpdatePrompt();
|
|
952
|
+
hideProgress();
|
|
953
|
+
}
|
|
700
954
|
return {
|
|
701
955
|
status: types_1.SyncStatus.ERROR,
|
|
702
956
|
error: err,
|
|
@@ -717,10 +971,16 @@ async function getInstalledBundlePath() {
|
|
|
717
971
|
return null;
|
|
718
972
|
}
|
|
719
973
|
async function clearUpdate() {
|
|
974
|
+
const meta = await getStoredBundleMeta();
|
|
720
975
|
await clearStoredBundleMeta();
|
|
721
976
|
if (OTAUpdaterNative?.clearBundlePath) {
|
|
722
977
|
await OTAUpdaterNative.clearBundlePath();
|
|
723
978
|
}
|
|
979
|
+
if (meta?.label) {
|
|
980
|
+
await removePathRecursive(getBundleReleaseDir(meta.label));
|
|
981
|
+
await cleanupPartialDownload(getBundleZipPath(meta.label));
|
|
982
|
+
await cleanupPartialDownload(getLegacyBundleFilePath(meta.label));
|
|
983
|
+
}
|
|
724
984
|
}
|
|
725
985
|
async function notifyAppReady() {
|
|
726
986
|
const meta = await getStoredBundleMeta();
|
package/lib/types.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
/** When to apply a downloaded JS bundle update. */
|
|
3
|
+
export type InstallMode = 'immediate' | 'next-launch'
|
|
4
|
+
/** @deprecated use `next-launch` */
|
|
5
|
+
| 'on-next-restart'
|
|
6
|
+
/** @deprecated use `next-launch` */
|
|
7
|
+
| 'on-next-resume';
|
|
2
8
|
export interface ProjectOtaConfig {
|
|
3
9
|
/** Optional — defaults to https://appsonair.ir */
|
|
4
10
|
serverUrl?: string;
|
|
5
11
|
entryFile?: string;
|
|
6
|
-
installMode?:
|
|
12
|
+
installMode?: InstallMode;
|
|
7
13
|
deployments: {
|
|
8
14
|
staging: string;
|
|
9
15
|
production: string;
|
|
@@ -14,8 +20,12 @@ export interface OTAUpdaterConfig {
|
|
|
14
20
|
serverUrl?: string;
|
|
15
21
|
/** Deployment key from the OTA server (Staging or Production) */
|
|
16
22
|
deploymentKey: string;
|
|
17
|
-
/**
|
|
18
|
-
|
|
23
|
+
/**
|
|
24
|
+
* When to apply downloaded JS updates (default: immediate).
|
|
25
|
+
* - immediate: restart right after install
|
|
26
|
+
* - next-launch: install now, take effect on next app start
|
|
27
|
+
*/
|
|
28
|
+
installMode?: InstallMode;
|
|
19
29
|
/** Check interval in milliseconds when using auto-check (default: disabled) */
|
|
20
30
|
checkFrequency?: number;
|
|
21
31
|
/** Show built-in progress popup during sync (default: false, enabled by OTAUpdateProvider) */
|
|
@@ -31,6 +41,14 @@ export interface OTAProgressMessages {
|
|
|
31
41
|
restarting?: string;
|
|
32
42
|
error?: string;
|
|
33
43
|
upToDate?: string;
|
|
44
|
+
/** Prompt when an update is available */
|
|
45
|
+
updateAvailable?: string;
|
|
46
|
+
changelogTitle?: string;
|
|
47
|
+
updateNow?: string;
|
|
48
|
+
skip?: string;
|
|
49
|
+
dismiss?: string;
|
|
50
|
+
appliedNextLaunch?: string;
|
|
51
|
+
mandatoryHint?: string;
|
|
34
52
|
}
|
|
35
53
|
export interface OTAUpdateProviderProps {
|
|
36
54
|
children: ReactNode;
|
|
@@ -55,6 +73,8 @@ export interface UpdateCheckResult {
|
|
|
55
73
|
targetAppVersion?: string;
|
|
56
74
|
downloadUrl?: string;
|
|
57
75
|
label?: string;
|
|
76
|
+
/** Release notes / changelog (alias of description) */
|
|
77
|
+
changelog?: string;
|
|
58
78
|
description?: string;
|
|
59
79
|
isMandatory?: boolean;
|
|
60
80
|
appVersion?: string;
|
|
@@ -66,7 +86,7 @@ export interface UpdateCheckResult {
|
|
|
66
86
|
}
|
|
67
87
|
export interface SyncOptions {
|
|
68
88
|
/** When to apply the update (default: immediate, or value from configure()) */
|
|
69
|
-
installMode?:
|
|
89
|
+
installMode?: InstallMode;
|
|
70
90
|
/** Minimum background duration before checking on resume (ms) */
|
|
71
91
|
minimumBackgroundDuration?: number;
|
|
72
92
|
/** Show built-in progress popup (default: from configure() or false) */
|
|
@@ -76,6 +96,7 @@ export interface SyncOptions {
|
|
|
76
96
|
}
|
|
77
97
|
export declare enum SyncStatus {
|
|
78
98
|
UP_TO_DATE = "UP_TO_DATE",
|
|
99
|
+
UPDATE_AVAILABLE = "UPDATE_AVAILABLE",
|
|
79
100
|
UPDATE_INSTALLED = "UPDATE_INSTALLED",
|
|
80
101
|
UPDATE_IGNORED = "UPDATE_IGNORED",
|
|
81
102
|
APK_INSTALL_PENDING = "APK_INSTALL_PENDING",
|
package/lib/types.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.SyncStatus = void 0;
|
|
|
4
4
|
var SyncStatus;
|
|
5
5
|
(function (SyncStatus) {
|
|
6
6
|
SyncStatus["UP_TO_DATE"] = "UP_TO_DATE";
|
|
7
|
+
SyncStatus["UPDATE_AVAILABLE"] = "UPDATE_AVAILABLE";
|
|
7
8
|
SyncStatus["UPDATE_INSTALLED"] = "UPDATE_INSTALLED";
|
|
8
9
|
SyncStatus["UPDATE_IGNORED"] = "UPDATE_IGNORED";
|
|
9
10
|
SyncStatus["APK_INSTALL_PENDING"] = "APK_INSTALL_PENDING";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { OTAProgressState } from '../
|
|
2
|
+
import { defaultMessages, OTAProgressState } from '../sync';
|
|
3
3
|
interface OTAUpdateModalProps extends OTAProgressState {
|
|
4
4
|
title?: string;
|
|
5
5
|
fontFamily?: string;
|
|
6
|
+
messages?: Partial<typeof defaultMessages>;
|
|
6
7
|
}
|
|
7
|
-
export declare function OTAUpdateModal({ visible, status, progress, message, description, error, title, fontFamily, }: OTAUpdateModalProps): React.JSX.Element;
|
|
8
|
+
export declare function OTAUpdateModal({ visible, status, progress, message, description, isMandatory, error, showActions, canSkip, title, fontFamily, messages, }: OTAUpdateModalProps): React.JSX.Element;
|
|
8
9
|
export {};
|
package/lib/ui/OTAUpdateModal.js
CHANGED
|
@@ -36,9 +36,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.OTAUpdateModal = OTAUpdateModal;
|
|
37
37
|
const react_1 = __importStar(require("react"));
|
|
38
38
|
const react_native_1 = require("react-native");
|
|
39
|
-
const
|
|
39
|
+
const sync_1 = require("../sync");
|
|
40
40
|
const types_1 = require("../types");
|
|
41
|
-
function OTAUpdateModal({ visible, status, progress, message, description, error, title =
|
|
41
|
+
function OTAUpdateModal({ visible, status, progress, message, description, isMandatory, error, showActions, canSkip, title = sync_1.defaultMessages.title, fontFamily, messages, }) {
|
|
42
|
+
const copy = (0, react_1.useMemo)(() => ({ ...sync_1.defaultMessages, ...messages }), [messages]);
|
|
42
43
|
const customFontStyle = (0, react_1.useMemo)(() => fontFamily
|
|
43
44
|
? {
|
|
44
45
|
fontFamily,
|
|
@@ -52,23 +53,74 @@ function OTAUpdateModal({ visible, status, progress, message, description, error
|
|
|
52
53
|
if (!visible)
|
|
53
54
|
return null;
|
|
54
55
|
const progressPercent = Math.round(Math.min(Math.max(progress, 0), 1) * 100);
|
|
56
|
+
const isPrompt = status === types_1.SyncStatus.UPDATE_AVAILABLE;
|
|
57
|
+
const isDeferredDone = status === types_1.SyncStatus.UPDATE_INSTALLED;
|
|
58
|
+
const isError = status === types_1.SyncStatus.ERROR;
|
|
55
59
|
const showProgressBar = status === types_1.SyncStatus.DOWNLOADING;
|
|
56
|
-
|
|
60
|
+
const showSpinner = status === types_1.SyncStatus.RESTARTING ||
|
|
61
|
+
status === types_1.SyncStatus.INSTALLING ||
|
|
62
|
+
(!showProgressBar && !isPrompt && !isDeferredDone && !isError);
|
|
63
|
+
return (<react_native_1.Modal visible transparent animationType="fade" statusBarTranslucent onRequestClose={() => {
|
|
64
|
+
if (canSkip)
|
|
65
|
+
(0, sync_1.respondToUpdatePrompt)('skip');
|
|
66
|
+
else if (showActions && (isDeferredDone || isError)) {
|
|
67
|
+
(0, sync_1.respondToUpdatePrompt)('dismiss');
|
|
68
|
+
}
|
|
69
|
+
}}>
|
|
57
70
|
<react_native_1.View style={styles.overlay}>
|
|
58
71
|
<react_native_1.View style={styles.card}>
|
|
59
72
|
<react_native_1.Text style={[styles.title, !fontFamily && styles.titleBold, textStyle]}>
|
|
60
73
|
{title}
|
|
61
74
|
</react_native_1.Text>
|
|
75
|
+
|
|
62
76
|
<react_native_1.Text style={[styles.message, textStyle]}>{error ?? message}</react_native_1.Text>
|
|
63
|
-
{description && !error ? (<react_native_1.Text style={[styles.description, textStyle]}>{description}</react_native_1.Text>) : null}
|
|
64
77
|
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
{isMandatory && isPrompt ? (<react_native_1.Text style={[styles.mandatory, textStyle]}>{copy.mandatoryHint}</react_native_1.Text>) : null}
|
|
79
|
+
|
|
80
|
+
{description && !error ? (<react_native_1.View style={styles.changelogBox}>
|
|
81
|
+
<react_native_1.Text style={[styles.changelogTitle, !fontFamily && styles.titleBold, textStyle]}>
|
|
82
|
+
{copy.changelogTitle}
|
|
83
|
+
</react_native_1.Text>
|
|
84
|
+
<react_native_1.ScrollView style={styles.changelogScroll} contentContainerStyle={styles.changelogContent} nestedScrollEnabled>
|
|
85
|
+
<react_native_1.Text style={[styles.description, textStyle]}>{description}</react_native_1.Text>
|
|
86
|
+
</react_native_1.ScrollView>
|
|
87
|
+
</react_native_1.View>) : null}
|
|
88
|
+
|
|
89
|
+
{showProgressBar ? (<>
|
|
90
|
+
<react_native_1.View style={styles.progressTrack}>
|
|
91
|
+
<react_native_1.View style={[styles.progressFill, { width: `${progressPercent}%` }]}/>
|
|
92
|
+
</react_native_1.View>
|
|
93
|
+
<react_native_1.Text style={[styles.percent, !fontFamily && styles.percentBold, textStyle]}>
|
|
94
|
+
{progressPercent}٪
|
|
95
|
+
</react_native_1.Text>
|
|
96
|
+
</>) : null}
|
|
97
|
+
|
|
98
|
+
{showSpinner ? (<react_native_1.ActivityIndicator size="large" color="#2563eb" style={styles.spinner}/>) : null}
|
|
68
99
|
|
|
69
|
-
{
|
|
70
|
-
{
|
|
71
|
-
|
|
100
|
+
{showActions ? (<react_native_1.View style={styles.actions}>
|
|
101
|
+
{isPrompt ? (<>
|
|
102
|
+
<react_native_1.Pressable style={[styles.button, styles.buttonPrimary]} onPress={() => (0, sync_1.respondToUpdatePrompt)('update')}>
|
|
103
|
+
<react_native_1.Text style={[
|
|
104
|
+
styles.buttonPrimaryText,
|
|
105
|
+
!fontFamily && styles.titleBold,
|
|
106
|
+
textStyle,
|
|
107
|
+
]}>
|
|
108
|
+
{copy.updateNow}
|
|
109
|
+
</react_native_1.Text>
|
|
110
|
+
</react_native_1.Pressable>
|
|
111
|
+
{canSkip ? (<react_native_1.Pressable style={[styles.button, styles.buttonGhost]} onPress={() => (0, sync_1.respondToUpdatePrompt)('skip')}>
|
|
112
|
+
<react_native_1.Text style={[styles.buttonGhostText, textStyle]}>{copy.skip}</react_native_1.Text>
|
|
113
|
+
</react_native_1.Pressable>) : null}
|
|
114
|
+
</>) : (<react_native_1.Pressable style={[styles.button, styles.buttonPrimary]} onPress={() => (0, sync_1.respondToUpdatePrompt)('dismiss')}>
|
|
115
|
+
<react_native_1.Text style={[
|
|
116
|
+
styles.buttonPrimaryText,
|
|
117
|
+
!fontFamily && styles.titleBold,
|
|
118
|
+
textStyle,
|
|
119
|
+
]}>
|
|
120
|
+
{copy.dismiss}
|
|
121
|
+
</react_native_1.Text>
|
|
122
|
+
</react_native_1.Pressable>)}
|
|
123
|
+
</react_native_1.View>) : null}
|
|
72
124
|
</react_native_1.View>
|
|
73
125
|
</react_native_1.View>
|
|
74
126
|
</react_native_1.Modal>);
|
|
@@ -83,11 +135,11 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
83
135
|
},
|
|
84
136
|
card: {
|
|
85
137
|
width: '100%',
|
|
86
|
-
maxWidth:
|
|
138
|
+
maxWidth: 340,
|
|
87
139
|
backgroundColor: '#fff',
|
|
88
140
|
borderRadius: 16,
|
|
89
141
|
padding: 24,
|
|
90
|
-
alignItems: '
|
|
142
|
+
alignItems: 'stretch',
|
|
91
143
|
},
|
|
92
144
|
title: {
|
|
93
145
|
fontSize: 18,
|
|
@@ -104,11 +156,38 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
104
156
|
textAlign: 'center',
|
|
105
157
|
marginBottom: 8,
|
|
106
158
|
},
|
|
159
|
+
mandatory: {
|
|
160
|
+
fontSize: 13,
|
|
161
|
+
color: '#b45309',
|
|
162
|
+
textAlign: 'center',
|
|
163
|
+
marginBottom: 8,
|
|
164
|
+
},
|
|
165
|
+
changelogBox: {
|
|
166
|
+
width: '100%',
|
|
167
|
+
marginTop: 4,
|
|
168
|
+
marginBottom: 12,
|
|
169
|
+
backgroundColor: '#f9fafb',
|
|
170
|
+
borderRadius: 12,
|
|
171
|
+
padding: 12,
|
|
172
|
+
maxHeight: 180,
|
|
173
|
+
},
|
|
174
|
+
changelogTitle: {
|
|
175
|
+
fontSize: 13,
|
|
176
|
+
color: '#111827',
|
|
177
|
+
marginBottom: 6,
|
|
178
|
+
textAlign: 'center',
|
|
179
|
+
},
|
|
180
|
+
changelogScroll: {
|
|
181
|
+
maxHeight: 140,
|
|
182
|
+
},
|
|
183
|
+
changelogContent: {
|
|
184
|
+
paddingBottom: 4,
|
|
185
|
+
},
|
|
107
186
|
description: {
|
|
108
187
|
fontSize: 13,
|
|
109
|
-
color: '#
|
|
188
|
+
color: '#4b5563',
|
|
110
189
|
textAlign: 'center',
|
|
111
|
-
|
|
190
|
+
lineHeight: 20,
|
|
112
191
|
},
|
|
113
192
|
progressTrack: {
|
|
114
193
|
width: '100%',
|
|
@@ -134,5 +213,30 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
134
213
|
},
|
|
135
214
|
spinner: {
|
|
136
215
|
marginTop: 12,
|
|
216
|
+
alignSelf: 'center',
|
|
217
|
+
},
|
|
218
|
+
actions: {
|
|
219
|
+
marginTop: 16,
|
|
220
|
+
gap: 8,
|
|
221
|
+
},
|
|
222
|
+
button: {
|
|
223
|
+
borderRadius: 10,
|
|
224
|
+
paddingVertical: 12,
|
|
225
|
+
paddingHorizontal: 16,
|
|
226
|
+
alignItems: 'center',
|
|
227
|
+
},
|
|
228
|
+
buttonPrimary: {
|
|
229
|
+
backgroundColor: '#2563eb',
|
|
230
|
+
},
|
|
231
|
+
buttonPrimaryText: {
|
|
232
|
+
color: '#fff',
|
|
233
|
+
fontSize: 15,
|
|
234
|
+
},
|
|
235
|
+
buttonGhost: {
|
|
236
|
+
backgroundColor: 'transparent',
|
|
237
|
+
},
|
|
238
|
+
buttonGhostText: {
|
|
239
|
+
color: '#6b7280',
|
|
240
|
+
fontSize: 14,
|
|
137
241
|
},
|
|
138
242
|
});
|
|
@@ -97,6 +97,6 @@ function OTAUpdateProvider({ children, projectConfig, autoSync = true, skipInDev
|
|
|
97
97
|
}, []);
|
|
98
98
|
return (<>
|
|
99
99
|
{children}
|
|
100
|
-
<OTAUpdateModal_1.OTAUpdateModal {...progress} title={modalTitle} fontFamily={fontFamily}/>
|
|
100
|
+
<OTAUpdateModal_1.OTAUpdateModal {...progress} title={modalTitle} fontFamily={fontFamily} messages={messages}/>
|
|
101
101
|
</>);
|
|
102
102
|
}
|