react-native-codepush-sdk 1.0.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 +21 -0
- package/README.md +278 -0
- package/dist/hooks/useFrameworkReady.js +11 -0
- package/dist/index.js +36 -0
- package/dist/services/codepushService.js +164 -0
- package/dist/src/components/UpdateChecker.js +230 -0
- package/dist/src/sdk/CodePushProvider.js +181 -0
- package/dist/src/sdk/CustomCodePush.js +405 -0
- package/dist/src/utils/BundleManager.js +124 -0
- package/dist/types/codepush.js +2 -0
- package/hooks/useFrameworkReady.ts +17 -0
- package/index.ts +10 -0
- package/package.json +72 -0
- package/services/codepushService.ts +181 -0
- package/src/components/UpdateChecker.tsx +303 -0
- package/src/sdk/CodePushProvider.tsx +184 -0
- package/src/sdk/CustomCodePush.ts +526 -0
- package/src/utils/BundleManager.ts +140 -0
- package/types/codepush.ts +44 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
import DeviceInfo from 'react-native-device-info';
|
|
3
|
+
import RNFS from 'react-native-fs';
|
|
4
|
+
import { unzip } from 'react-native-zip-archive';
|
|
5
|
+
import { Platform, NativeModules } from 'react-native';
|
|
6
|
+
|
|
7
|
+
export interface CodePushConfiguration {
|
|
8
|
+
serverUrl: string;
|
|
9
|
+
deploymentKey: string;
|
|
10
|
+
appName: string;
|
|
11
|
+
checkFrequency?: 'ON_APP_START' | 'ON_APP_RESUME' | 'MANUAL';
|
|
12
|
+
installMode?: 'IMMEDIATE' | 'ON_NEXT_RESTART' | 'ON_NEXT_RESUME';
|
|
13
|
+
minimumBackgroundDuration?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UpdatePackage {
|
|
17
|
+
packageHash: string;
|
|
18
|
+
label: string;
|
|
19
|
+
appVersion: string;
|
|
20
|
+
description: string;
|
|
21
|
+
isMandatory: boolean;
|
|
22
|
+
packageSize: number;
|
|
23
|
+
downloadUrl: string;
|
|
24
|
+
rollout?: number;
|
|
25
|
+
isDisabled?: boolean;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SyncStatus {
|
|
30
|
+
status: 'CHECKING_FOR_UPDATE' | 'DOWNLOADING_PACKAGE' | 'INSTALLING_UPDATE' |
|
|
31
|
+
'UP_TO_DATE' | 'UPDATE_INSTALLED' | 'UPDATE_IGNORED' | 'UNKNOWN_ERROR' |
|
|
32
|
+
'AWAITING_USER_ACTION';
|
|
33
|
+
progress?: number;
|
|
34
|
+
downloadedBytes?: number;
|
|
35
|
+
totalBytes?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LocalPackage extends UpdatePackage {
|
|
39
|
+
localPath: string;
|
|
40
|
+
isFirstRun: boolean;
|
|
41
|
+
failedInstall: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SyncStatusCallback = (status: SyncStatus) => void;
|
|
45
|
+
export type DownloadProgressCallback = (progress: { receivedBytes: number; totalBytes: number }) => void;
|
|
46
|
+
|
|
47
|
+
class CustomCodePush {
|
|
48
|
+
private config: CodePushConfiguration;
|
|
49
|
+
private currentPackage: LocalPackage | null = null;
|
|
50
|
+
// Promise that resolves when directories are initialized and current package loaded
|
|
51
|
+
private readyPromise: Promise<void>;
|
|
52
|
+
private pendingUpdate: UpdatePackage | null = null;
|
|
53
|
+
private isCheckingForUpdate = false;
|
|
54
|
+
private isDownloading = false;
|
|
55
|
+
private isInstalling = false;
|
|
56
|
+
|
|
57
|
+
// Storage keys
|
|
58
|
+
private static readonly CURRENT_PACKAGE_KEY = 'CustomCodePush_CurrentPackage';
|
|
59
|
+
private static readonly PENDING_UPDATE_KEY = 'CustomCodePush_PendingUpdate';
|
|
60
|
+
private static readonly FAILED_UPDATES_KEY = 'CustomCodePush_FailedUpdates';
|
|
61
|
+
private static readonly UPDATE_METADATA_KEY = 'CustomCodePush_UpdateMetadata';
|
|
62
|
+
|
|
63
|
+
// File paths
|
|
64
|
+
private static readonly UPDATES_FOLDER = `${RNFS.DocumentDirectoryPath}/CustomCodePush`;
|
|
65
|
+
private static readonly BUNDLES_FOLDER = `${CustomCodePush.UPDATES_FOLDER}/bundles`;
|
|
66
|
+
private static readonly DOWNLOADS_FOLDER = `${CustomCodePush.UPDATES_FOLDER}/downloads`;
|
|
67
|
+
|
|
68
|
+
constructor(config: CodePushConfiguration) {
|
|
69
|
+
this.config = {
|
|
70
|
+
checkFrequency: 'ON_APP_START',
|
|
71
|
+
installMode: 'ON_NEXT_RESTART',
|
|
72
|
+
minimumBackgroundDuration: 0,
|
|
73
|
+
...config,
|
|
74
|
+
};
|
|
75
|
+
// Initialize directories and load stored package before SDK use
|
|
76
|
+
this.readyPromise = (async () => {
|
|
77
|
+
await this.initializeDirectories();
|
|
78
|
+
await this.loadCurrentPackage();
|
|
79
|
+
})();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Wait for SDK initialization (directories + stored package loaded)
|
|
84
|
+
*/
|
|
85
|
+
public async initialize(): Promise<void> {
|
|
86
|
+
return this.readyPromise;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async initializeDirectories(): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
await RNFS.mkdir(CustomCodePush.UPDATES_FOLDER);
|
|
92
|
+
await RNFS.mkdir(CustomCodePush.BUNDLES_FOLDER);
|
|
93
|
+
await RNFS.mkdir(CustomCodePush.DOWNLOADS_FOLDER);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.warn('Failed to create directories:', error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async loadCurrentPackage(): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
const packageData = await AsyncStorage.getItem(CustomCodePush.CURRENT_PACKAGE_KEY);
|
|
102
|
+
if (packageData) {
|
|
103
|
+
this.currentPackage = JSON.parse(packageData);
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.warn('Failed to load current package:', error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async saveCurrentPackage(packageInfo: LocalPackage): Promise<void> {
|
|
111
|
+
try {
|
|
112
|
+
await AsyncStorage.setItem(CustomCodePush.CURRENT_PACKAGE_KEY, JSON.stringify(packageInfo));
|
|
113
|
+
this.currentPackage = packageInfo;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.warn('Failed to save current package:', error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async getDeviceInfo(): Promise<any> {
|
|
120
|
+
return {
|
|
121
|
+
platform: Platform.OS,
|
|
122
|
+
platformVersion: Platform.Version,
|
|
123
|
+
appVersion: await DeviceInfo.getVersion(),
|
|
124
|
+
deviceId: await DeviceInfo.getUniqueId(),
|
|
125
|
+
deviceModel: await DeviceInfo.getModel(),
|
|
126
|
+
clientUniqueId: await DeviceInfo.getUniqueId(),
|
|
127
|
+
currentPackageHash: this.currentPackage?.packageHash || null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public async checkForUpdate(): Promise<UpdatePackage | null> {
|
|
132
|
+
if (this.isCheckingForUpdate) {
|
|
133
|
+
throw new Error('Already checking for update');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.isCheckingForUpdate = true;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const deviceInfo = await this.getDeviceInfo();
|
|
140
|
+
|
|
141
|
+
const response = await fetch(`${this.config.serverUrl}/v0.1/public/codepush/update_check`, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: {
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
deploymentKey: this.config.deploymentKey,
|
|
148
|
+
appVersion: deviceInfo.appVersion || '1.0.0',
|
|
149
|
+
packageHash: this.currentPackage?.packageHash,
|
|
150
|
+
clientUniqueId: deviceInfo.clientUniqueId,
|
|
151
|
+
label: this.currentPackage?.label,
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const data = await response.json();
|
|
160
|
+
|
|
161
|
+
if (data.updateInfo) {
|
|
162
|
+
const updatePackage: UpdatePackage = {
|
|
163
|
+
packageHash: data.updateInfo.packageHash,
|
|
164
|
+
label: data.updateInfo.label,
|
|
165
|
+
appVersion: data.updateInfo.appVersion,
|
|
166
|
+
description: data.updateInfo.description || '',
|
|
167
|
+
isMandatory: data.updateInfo.isMandatory || false,
|
|
168
|
+
packageSize: data.updateInfo.size || 0,
|
|
169
|
+
downloadUrl: data.updateInfo.downloadUrl,
|
|
170
|
+
rollout: data.updateInfo.rollout,
|
|
171
|
+
isDisabled: data.updateInfo.isDisabled,
|
|
172
|
+
timestamp: Date.now(),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
this.pendingUpdate = updatePackage;
|
|
176
|
+
return updatePackage;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return null;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.log('Error checking for update:', error);
|
|
182
|
+
console.error('Error checking for update:', error);
|
|
183
|
+
throw error;
|
|
184
|
+
} finally {
|
|
185
|
+
this.isCheckingForUpdate = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
public async downloadUpdate(
|
|
190
|
+
updatePackage: UpdatePackage,
|
|
191
|
+
progressCallback?: DownloadProgressCallback
|
|
192
|
+
): Promise<LocalPackage> {
|
|
193
|
+
if (this.isDownloading) {
|
|
194
|
+
throw new Error('Already downloading update');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.isDownloading = true;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
// Check if it's a JavaScript file (demo bundles)
|
|
201
|
+
const isJsFile = updatePackage.downloadUrl.endsWith('.js');
|
|
202
|
+
const fileExtension = isJsFile ? 'js' : 'zip';
|
|
203
|
+
const downloadPath = `${CustomCodePush.DOWNLOADS_FOLDER}/${updatePackage.packageHash}.${fileExtension}`;
|
|
204
|
+
|
|
205
|
+
// Clean up any existing download
|
|
206
|
+
if (await RNFS.exists(downloadPath)) {
|
|
207
|
+
await RNFS.unlink(downloadPath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const downloadResult = await RNFS.downloadFile({
|
|
211
|
+
fromUrl: updatePackage.downloadUrl,
|
|
212
|
+
toFile: downloadPath,
|
|
213
|
+
progress: (res) => {
|
|
214
|
+
if (progressCallback) {
|
|
215
|
+
progressCallback({
|
|
216
|
+
receivedBytes: res.bytesWritten,
|
|
217
|
+
totalBytes: res.contentLength,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
}).promise;
|
|
222
|
+
|
|
223
|
+
if (downloadResult.statusCode !== 200) {
|
|
224
|
+
throw new Error(`Download failed with status ${downloadResult.statusCode}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Verify file size (approximate for JS files)
|
|
228
|
+
const fileStats = await RNFS.stat(downloadPath);
|
|
229
|
+
if (isJsFile) {
|
|
230
|
+
// For JS files, just check if file exists and has content
|
|
231
|
+
if (fileStats.size === 0) {
|
|
232
|
+
throw new Error('Downloaded JavaScript file is empty');
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
// For zip files, check exact size
|
|
236
|
+
if (fileStats.size !== updatePackage.packageSize) {
|
|
237
|
+
throw new Error('Downloaded file size mismatch');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let localPath: string;
|
|
242
|
+
|
|
243
|
+
if (isJsFile) {
|
|
244
|
+
// For JavaScript files, create a simple structure
|
|
245
|
+
localPath = `${CustomCodePush.BUNDLES_FOLDER}/${updatePackage.packageHash}`;
|
|
246
|
+
await RNFS.mkdir(localPath);
|
|
247
|
+
|
|
248
|
+
// Copy the JS file to the bundle location
|
|
249
|
+
const bundlePath = `${localPath}/index.bundle`;
|
|
250
|
+
await RNFS.copyFile(downloadPath, bundlePath);
|
|
251
|
+
|
|
252
|
+
// Clean up download file
|
|
253
|
+
await RNFS.unlink(downloadPath);
|
|
254
|
+
} else {
|
|
255
|
+
// For zip files, extract as before
|
|
256
|
+
localPath = `${CustomCodePush.BUNDLES_FOLDER}/${updatePackage.packageHash}`;
|
|
257
|
+
await RNFS.mkdir(localPath);
|
|
258
|
+
await unzip(downloadPath, localPath);
|
|
259
|
+
|
|
260
|
+
// Clean up download file
|
|
261
|
+
await RNFS.unlink(downloadPath);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const localPackage: LocalPackage = {
|
|
265
|
+
...updatePackage,
|
|
266
|
+
localPath: localPath,
|
|
267
|
+
isFirstRun: false,
|
|
268
|
+
failedInstall: false,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Save update metadata
|
|
272
|
+
await AsyncStorage.setItem(
|
|
273
|
+
`${CustomCodePush.UPDATE_METADATA_KEY}_${updatePackage.packageHash}`,
|
|
274
|
+
JSON.stringify(localPackage)
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return localPackage;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error('Error downloading update:', error);
|
|
280
|
+
throw error;
|
|
281
|
+
} finally {
|
|
282
|
+
this.isDownloading = false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
public async installUpdate(localPackage: LocalPackage): Promise<void> {
|
|
287
|
+
if (this.isInstalling) {
|
|
288
|
+
throw new Error('Already installing update');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
this.isInstalling = true;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Validate the package
|
|
295
|
+
const bundlePath = `${localPackage.localPath}/index.bundle`;
|
|
296
|
+
if (!(await RNFS.exists(bundlePath))) {
|
|
297
|
+
throw new Error('Bundle file not found in update package');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Mark as current package
|
|
301
|
+
await this.saveCurrentPackage(localPackage);
|
|
302
|
+
|
|
303
|
+
// Clear pending update
|
|
304
|
+
this.pendingUpdate = null;
|
|
305
|
+
await AsyncStorage.removeItem(CustomCodePush.PENDING_UPDATE_KEY);
|
|
306
|
+
|
|
307
|
+
// Log installation
|
|
308
|
+
await this.logUpdateInstallation(localPackage, true);
|
|
309
|
+
|
|
310
|
+
} catch (error) {
|
|
311
|
+
console.error('Error installing update:', error);
|
|
312
|
+
await this.logUpdateInstallation(localPackage, false);
|
|
313
|
+
throw error;
|
|
314
|
+
} finally {
|
|
315
|
+
this.isInstalling = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async logUpdateInstallation(localPackage: LocalPackage, success: boolean): Promise<void> {
|
|
320
|
+
try {
|
|
321
|
+
const deviceInfo = await this.getDeviceInfo();
|
|
322
|
+
|
|
323
|
+
await fetch(`${this.config.serverUrl}/v0.1/public/codepush/report_status/deploy`, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
headers: {
|
|
326
|
+
'Content-Type': 'application/json',
|
|
327
|
+
},
|
|
328
|
+
body: JSON.stringify({
|
|
329
|
+
deploymentKey: this.config.deploymentKey,
|
|
330
|
+
label: localPackage.label,
|
|
331
|
+
status: success ? 'Deployed' : 'Failed',
|
|
332
|
+
clientUniqueId: deviceInfo.clientUniqueId,
|
|
333
|
+
}),
|
|
334
|
+
});
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.warn('Failed to log update installation:', error);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
public async sync(
|
|
341
|
+
options: {
|
|
342
|
+
installMode?: 'IMMEDIATE' | 'ON_NEXT_RESTART' | 'ON_NEXT_RESUME';
|
|
343
|
+
mandatoryInstallMode?: 'IMMEDIATE' | 'ON_NEXT_RESTART' | 'ON_NEXT_RESUME';
|
|
344
|
+
updateDialog?: boolean;
|
|
345
|
+
rollbackRetryOptions?: {
|
|
346
|
+
delayInHours?: number;
|
|
347
|
+
maxRetryAttempts?: number;
|
|
348
|
+
};
|
|
349
|
+
} = {},
|
|
350
|
+
statusCallback?: SyncStatusCallback,
|
|
351
|
+
downloadProgressCallback?: DownloadProgressCallback
|
|
352
|
+
): Promise<boolean> {
|
|
353
|
+
try {
|
|
354
|
+
// Check for update
|
|
355
|
+
statusCallback?.({ status: 'CHECKING_FOR_UPDATE' });
|
|
356
|
+
const updatePackage = await this.checkForUpdate();
|
|
357
|
+
|
|
358
|
+
if (!updatePackage) {
|
|
359
|
+
statusCallback?.({ status: 'UP_TO_DATE' });
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Show update dialog if needed
|
|
364
|
+
if (options.updateDialog && updatePackage.isMandatory) {
|
|
365
|
+
statusCallback?.({ status: 'AWAITING_USER_ACTION' });
|
|
366
|
+
// In a real implementation, you would show a native dialog here
|
|
367
|
+
// For now, we'll proceed automatically
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Download update
|
|
371
|
+
statusCallback?.({ status: 'DOWNLOADING_PACKAGE', progress: 0 });
|
|
372
|
+
const localPackage = await this.downloadUpdate(updatePackage, (progress) => {
|
|
373
|
+
const progressPercent = (progress.receivedBytes / progress.totalBytes) * 100;
|
|
374
|
+
statusCallback?.({
|
|
375
|
+
status: 'DOWNLOADING_PACKAGE',
|
|
376
|
+
progress: progressPercent,
|
|
377
|
+
downloadedBytes: progress.receivedBytes,
|
|
378
|
+
totalBytes: progress.totalBytes,
|
|
379
|
+
});
|
|
380
|
+
downloadProgressCallback?.(progress);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Install update
|
|
384
|
+
statusCallback?.({ status: 'INSTALLING_UPDATE' });
|
|
385
|
+
await this.installUpdate(localPackage);
|
|
386
|
+
|
|
387
|
+
const installMode = updatePackage.isMandatory
|
|
388
|
+
? (options.mandatoryInstallMode || 'IMMEDIATE')
|
|
389
|
+
: (options.installMode || this.config.installMode || 'ON_NEXT_RESTART');
|
|
390
|
+
|
|
391
|
+
if (installMode === 'IMMEDIATE') {
|
|
392
|
+
// Restart the app immediately
|
|
393
|
+
this.restartApp();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
statusCallback?.({ status: 'UPDATE_INSTALLED' });
|
|
397
|
+
return true;
|
|
398
|
+
|
|
399
|
+
} catch (error) {
|
|
400
|
+
console.error('Sync error:', error);
|
|
401
|
+
statusCallback?.({ status: 'UNKNOWN_ERROR' });
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
public async getCurrentPackage(): Promise<LocalPackage | null> {
|
|
407
|
+
return this.currentPackage;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
public async getUpdateMetadata(): Promise<LocalPackage | null> {
|
|
411
|
+
return this.currentPackage;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
public async clearUpdates(): Promise<void> {
|
|
415
|
+
try {
|
|
416
|
+
// Clear storage
|
|
417
|
+
await AsyncStorage.multiRemove([
|
|
418
|
+
CustomCodePush.CURRENT_PACKAGE_KEY,
|
|
419
|
+
CustomCodePush.PENDING_UPDATE_KEY,
|
|
420
|
+
CustomCodePush.FAILED_UPDATES_KEY,
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
// Clear files
|
|
424
|
+
if (await RNFS.exists(CustomCodePush.UPDATES_FOLDER)) {
|
|
425
|
+
await RNFS.unlink(CustomCodePush.UPDATES_FOLDER);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Reinitialize
|
|
429
|
+
await this.initializeDirectories();
|
|
430
|
+
this.currentPackage = null;
|
|
431
|
+
this.pendingUpdate = null;
|
|
432
|
+
|
|
433
|
+
} catch (error) {
|
|
434
|
+
console.error('Error clearing updates:', error);
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
public async rollback(): Promise<void> {
|
|
440
|
+
if (!this.currentPackage) {
|
|
441
|
+
throw new Error('No current package to rollback from');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
// Remove current package
|
|
446
|
+
const packagePath = this.currentPackage.localPath;
|
|
447
|
+
if (await RNFS.exists(packagePath)) {
|
|
448
|
+
await RNFS.unlink(packagePath);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Clear current package
|
|
452
|
+
await AsyncStorage.removeItem(CustomCodePush.CURRENT_PACKAGE_KEY);
|
|
453
|
+
this.currentPackage = null;
|
|
454
|
+
|
|
455
|
+
// Log rollback
|
|
456
|
+
await this.logRollback();
|
|
457
|
+
|
|
458
|
+
// Restart app to use original bundle
|
|
459
|
+
this.restartApp();
|
|
460
|
+
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.error('Error during rollback:', error);
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private async logRollback(): Promise<void> {
|
|
468
|
+
try {
|
|
469
|
+
const deviceInfo = await this.getDeviceInfo();
|
|
470
|
+
|
|
471
|
+
await fetch(`${this.config.serverUrl}/v0.1/public/codepush/report_status/deploy`, {
|
|
472
|
+
method: 'POST',
|
|
473
|
+
headers: {
|
|
474
|
+
'Content-Type': 'application/json',
|
|
475
|
+
},
|
|
476
|
+
body: JSON.stringify({
|
|
477
|
+
deploymentKey: this.config.deploymentKey,
|
|
478
|
+
label: this.currentPackage?.label || 'unknown',
|
|
479
|
+
status: 'Rollback',
|
|
480
|
+
clientUniqueId: deviceInfo.clientUniqueId,
|
|
481
|
+
}),
|
|
482
|
+
});
|
|
483
|
+
} catch (error) {
|
|
484
|
+
console.warn('Failed to log rollback:', error);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private restartApp(): void {
|
|
489
|
+
// In a real implementation, you would use a native module to restart the app
|
|
490
|
+
// For now, we'll just reload the React Native bundle
|
|
491
|
+
if (Platform.OS === 'android') {
|
|
492
|
+
// Android restart implementation
|
|
493
|
+
NativeModules.DevSettings?.reload();
|
|
494
|
+
} else {
|
|
495
|
+
// iOS restart implementation
|
|
496
|
+
NativeModules.DevSettings?.reload();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
public getBundleUrl(): string | null {
|
|
501
|
+
if (this.currentPackage && this.currentPackage.localPath) {
|
|
502
|
+
return `file://${this.currentPackage.localPath}/index.bundle`;
|
|
503
|
+
}
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Static methods for easy integration
|
|
508
|
+
public static configure(config: CodePushConfiguration): CustomCodePush {
|
|
509
|
+
return new CustomCodePush(config);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
public static async checkForUpdate(instance: CustomCodePush): Promise<UpdatePackage | null> {
|
|
513
|
+
return instance.checkForUpdate();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
public static async sync(
|
|
517
|
+
instance: CustomCodePush,
|
|
518
|
+
options?: any,
|
|
519
|
+
statusCallback?: SyncStatusCallback,
|
|
520
|
+
downloadProgressCallback?: DownloadProgressCallback
|
|
521
|
+
): Promise<boolean> {
|
|
522
|
+
return instance.sync(options, statusCallback, downloadProgressCallback);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export default CustomCodePush;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import RNFS from 'react-native-fs';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export class BundleManager {
|
|
5
|
+
private static readonly ORIGINAL_BUNDLE_PATH = Platform.select({
|
|
6
|
+
ios: `${RNFS.MainBundlePath}/main.jsbundle`,
|
|
7
|
+
android: 'assets://index.android.bundle',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
private static readonly CUSTOM_BUNDLE_PATH = `${RNFS.DocumentDirectoryPath}/CustomCodePush/current.bundle`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get the path to the current bundle that should be loaded
|
|
14
|
+
*/
|
|
15
|
+
public static getCurrentBundlePath(): string {
|
|
16
|
+
// In a real implementation, this would check if a custom bundle exists
|
|
17
|
+
// and return its path, otherwise return the original bundle path
|
|
18
|
+
return BundleManager.CUSTOM_BUNDLE_PATH;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if a custom bundle exists
|
|
23
|
+
*/
|
|
24
|
+
public static async hasCustomBundle(): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
return await RNFS.exists(BundleManager.CUSTOM_BUNDLE_PATH);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Copy a bundle from source to the current bundle location
|
|
34
|
+
*/
|
|
35
|
+
public static async installBundle(sourcePath: string): Promise<void> {
|
|
36
|
+
try {
|
|
37
|
+
const bundlePath = `${sourcePath}/index.bundle`;
|
|
38
|
+
|
|
39
|
+
if (!(await RNFS.exists(bundlePath))) {
|
|
40
|
+
throw new Error('Bundle file not found in update package');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Ensure directory exists
|
|
44
|
+
const bundleDir = BundleManager.CUSTOM_BUNDLE_PATH.substring(
|
|
45
|
+
0,
|
|
46
|
+
BundleManager.CUSTOM_BUNDLE_PATH.lastIndexOf('/')
|
|
47
|
+
);
|
|
48
|
+
await RNFS.mkdir(bundleDir);
|
|
49
|
+
|
|
50
|
+
// Copy bundle
|
|
51
|
+
await RNFS.copyFile(bundlePath, BundleManager.CUSTOM_BUNDLE_PATH);
|
|
52
|
+
|
|
53
|
+
// Copy assets if they exist
|
|
54
|
+
const assetsSourcePath = `${sourcePath}/assets`;
|
|
55
|
+
const assetsDestPath = `${bundleDir}/assets`;
|
|
56
|
+
|
|
57
|
+
if (await RNFS.exists(assetsSourcePath)) {
|
|
58
|
+
// Custom folder copy implementation needed here. For now, this is a placeholder.
|
|
59
|
+
// TODO: Implement folder copy logic or use a third-party utility.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('Failed to install bundle:', error);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Remove the current custom bundle and revert to original
|
|
70
|
+
*/
|
|
71
|
+
public static async removeCustomBundle(): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
if (await RNFS.exists(BundleManager.CUSTOM_BUNDLE_PATH)) {
|
|
74
|
+
await RNFS.unlink(BundleManager.CUSTOM_BUNDLE_PATH);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Also remove assets directory
|
|
78
|
+
const bundleDir = BundleManager.CUSTOM_BUNDLE_PATH.substring(
|
|
79
|
+
0,
|
|
80
|
+
BundleManager.CUSTOM_BUNDLE_PATH.lastIndexOf('/')
|
|
81
|
+
);
|
|
82
|
+
const assetsPath = `${bundleDir}/assets`;
|
|
83
|
+
|
|
84
|
+
if (await RNFS.exists(assetsPath)) {
|
|
85
|
+
await RNFS.unlink(assetsPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Failed to remove custom bundle:', error);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate that a bundle is properly formatted
|
|
96
|
+
*/
|
|
97
|
+
public static async validateBundle(bundlePath: string): Promise<boolean> {
|
|
98
|
+
try {
|
|
99
|
+
// Check if bundle file exists
|
|
100
|
+
if (!(await RNFS.exists(bundlePath))) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check file size (should be > 0)
|
|
105
|
+
const stats = await RNFS.stat(bundlePath);
|
|
106
|
+
if (stats.size === 0) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Additional validation could include:
|
|
111
|
+
// - Checking bundle format
|
|
112
|
+
// - Verifying bundle signature
|
|
113
|
+
// - Testing bundle loading
|
|
114
|
+
|
|
115
|
+
return true;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.error('Bundle validation failed:', error);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get bundle metadata
|
|
124
|
+
*/
|
|
125
|
+
public static async getBundleMetadata(bundlePath: string): Promise<any> {
|
|
126
|
+
try {
|
|
127
|
+
const metadataPath = `${bundlePath}/metadata.json`;
|
|
128
|
+
|
|
129
|
+
if (await RNFS.exists(metadataPath)) {
|
|
130
|
+
const metadataContent = await RNFS.readFile(metadataPath, 'utf8');
|
|
131
|
+
return JSON.parse(metadataContent);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error('Failed to read bundle metadata:', error);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface CodePushUpdate {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
description: string;
|
|
5
|
+
packageHash: string;
|
|
6
|
+
blobUrl: string;
|
|
7
|
+
downloadUrl: string;
|
|
8
|
+
packageSize: number;
|
|
9
|
+
deploymentKey: string;
|
|
10
|
+
isFirstRun: boolean;
|
|
11
|
+
failedInstall: boolean;
|
|
12
|
+
isMandatory: boolean;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
version: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CodePushDeployment {
|
|
18
|
+
name: string;
|
|
19
|
+
key: string;
|
|
20
|
+
package?: CodePushUpdate;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CodePushSyncStatus {
|
|
24
|
+
status: 'UP_TO_DATE' | 'UPDATE_INSTALLED' | 'UPDATE_IGNORED' | 'UNKNOWN_ERROR' | 'SYNC_IN_PROGRESS' | 'CHECKING_FOR_UPDATE' | 'AWAITING_USER_ACTION' | 'DOWNLOADING_PACKAGE' | 'INSTALLING_UPDATE';
|
|
25
|
+
progress?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CodePushConfiguration {
|
|
29
|
+
appName: string;
|
|
30
|
+
deploymentKey: string;
|
|
31
|
+
serverUrl: string;
|
|
32
|
+
checkFrequency: 'ON_APP_START' | 'ON_APP_RESUME' | 'MANUAL';
|
|
33
|
+
installMode: 'IMMEDIATE' | 'ON_NEXT_RESTART' | 'ON_NEXT_RESUME';
|
|
34
|
+
minimumBackgroundDuration: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UpdateHistory {
|
|
38
|
+
id: string;
|
|
39
|
+
version: string;
|
|
40
|
+
timestamp: number;
|
|
41
|
+
status: 'SUCCESS' | 'FAILED' | 'ROLLBACK';
|
|
42
|
+
description: string;
|
|
43
|
+
downloadSize: number;
|
|
44
|
+
}
|