@bravemobile/react-native-code-push 13.1.0-beta.1 → 13.1.0-beta.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/README.md +43 -1
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +22 -8
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +5 -2
- package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +37 -0
- package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +189 -2
- package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
- package/cli/commands/releaseCommand/index.test.ts +32 -0
- package/cli/commands/releaseCommand/index.ts +16 -1
- package/cli/commands/releaseCommand/release.test.ts +212 -5
- package/cli/commands/releaseCommand/release.ts +128 -6
- package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
- package/cli/dist/commands/releaseCommand/index.js +12 -2
- package/cli/dist/commands/releaseCommand/index.test.js +25 -0
- package/cli/dist/commands/releaseCommand/release.js +90 -7
- package/cli/dist/commands/releaseCommand/release.test.js +175 -5
- package/cli/dist/functions/makeAssetDiffBundle.js +114 -0
- package/cli/dist/functions/makeAssetDiffBundle.test.js +215 -0
- package/cli/dist/functions/resolveAssetDiffBases.js +71 -0
- package/cli/dist/functions/resolveAssetDiffBases.test.js +174 -0
- package/cli/dist/functions/runExpoBundleCommand.js +4 -0
- package/cli/dist/functions/runReactNativeBundleCommand.js +10 -0
- package/cli/dist/utils/zip.js +12 -2
- package/cli/functions/makeAssetDiffBundle.test.ts +283 -0
- package/cli/functions/makeAssetDiffBundle.ts +166 -0
- package/cli/functions/resolveAssetDiffBases.test.ts +220 -0
- package/cli/functions/resolveAssetDiffBases.ts +101 -0
- package/cli/functions/runExpoBundleCommand.ts +4 -0
- package/cli/functions/runReactNativeBundleCommand.ts +10 -0
- package/cli/package.json +2 -0
- package/cli/utils/zip.ts +14 -3
- package/ios/CodePush/CodePushBinaryPatch.m +21 -8
- package/ios/CodePush/CodePushPackage.m +29 -2
- package/package.json +1 -1
- package/src/CodePush.js +7 -1
- package/src/CodePush.test.js +75 -5
- package/typings/react-native-code-push.d.ts +19 -0
package/README.md
CHANGED
|
@@ -332,6 +332,38 @@ CodePush.sync({
|
|
|
332
332
|
```
|
|
333
333
|
|
|
334
334
|
|
|
335
|
+
#### 4-2. Asset Diff Archives
|
|
336
|
+
|
|
337
|
+
A release published with a binary patch can carry **asset diff archives** as well - one per
|
|
338
|
+
recently released version. A diff archive holds the patch of the JS bundle, only the assets
|
|
339
|
+
that version does not already have, and a manifest of the files it has to drop. The client
|
|
340
|
+
copies the update it already has installed, applies those, and ends up holding exactly the
|
|
341
|
+
contents of the full archive.
|
|
342
|
+
|
|
343
|
+
So one release has a full archive, a patch archive and up to `--diff-base-count` diff
|
|
344
|
+
archives, and a client downloads one of them per attempt: the diff archive built against
|
|
345
|
+
the update it is running, when the release has one, and the patch archive when it is
|
|
346
|
+
running the bundle in the binary or an update this release was not diffed against.
|
|
347
|
+
|
|
348
|
+
Diff archives are published only when all three of these hold:
|
|
349
|
+
- the release is a binary patch release (`release --binary-bundle-path`),
|
|
350
|
+
- `code-push.config.ts` implements `bundleDownloader`, so the CLI can fetch the earlier releases to diff against,
|
|
351
|
+
- `--diff-base-count` is greater than `0` (it defaults to `3`).
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
bundleDownloader: async (downloadUrl) => {
|
|
355
|
+
const downloadedFilePath = path.join(os.tmpdir(), path.basename(new URL(downloadUrl).pathname));
|
|
356
|
+
// fetch the archive from your storage (S3, GCS, ...) to downloadedFilePath
|
|
357
|
+
return { downloadedFilePath };
|
|
358
|
+
},
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Rebuilding the update from a diff archive falls back the way a patch does: the update is
|
|
362
|
+
downloaded in full instead, and `onBinaryPatchResult` reports it as
|
|
363
|
+
`{ status: "fallback", fallbackReason: "package_verification_failed" }`. See
|
|
364
|
+
[Asset diff archives](cli/README.md#asset-diff-archives) for what the release publishes.
|
|
365
|
+
|
|
366
|
+
|
|
335
367
|
### 5. Configure the CLI Tool
|
|
336
368
|
|
|
337
369
|
> [!TIP]
|
|
@@ -339,7 +371,8 @@ CodePush.sync({
|
|
|
339
371
|
|
|
340
372
|
**(1) Create a `code-push.config.ts` file in the root directory of your project.**
|
|
341
373
|
|
|
342
|
-
Then, implement three functions to upload the bundle file and create/update the release history
|
|
374
|
+
Then, implement three functions to upload the bundle file and create/update the release history,
|
|
375
|
+
plus an optional fourth one - `bundleDownloader` - if you publish [asset diff archives](#4-2-asset-diff-archives).
|
|
343
376
|
The CLI tool uses these functions to release CodePush updates and manage releases.
|
|
344
377
|
(These functions are not used at runtime by the library.)
|
|
345
378
|
|
|
@@ -377,6 +410,15 @@ const Config: CliConfigInterface = {
|
|
|
377
410
|
): Promise<void> => {
|
|
378
411
|
// ...
|
|
379
412
|
},
|
|
413
|
+
|
|
414
|
+
// Only needed to publish asset diff archives (see "4-2. Asset Diff Archives").
|
|
415
|
+
// bundleDownloader: async (
|
|
416
|
+
// downloadUrl: string,
|
|
417
|
+
// platform: "ios" | "android",
|
|
418
|
+
// identifier,
|
|
419
|
+
// ): Promise<{downloadedFilePath: string}> => {
|
|
420
|
+
// // ...
|
|
421
|
+
// },
|
|
380
422
|
};
|
|
381
423
|
|
|
382
424
|
module.exports = Config;
|
|
@@ -181,25 +181,39 @@ public class CodePushBinaryPatch {
|
|
|
181
181
|
* An archive wraps its files in a single directory, and the manifest's paths are
|
|
182
182
|
* relative to that directory rather than to the archive. An archive whose files are at
|
|
183
183
|
* the top level is its own contents root, which is how the tooling that unpacks one
|
|
184
|
-
* reads it too.
|
|
184
|
+
* reads it too. A diff archive carries the manifest of the assets to delete beside that
|
|
185
|
+
* wrapper directory, at the root, so that file does not count against the shape.
|
|
185
186
|
*/
|
|
186
187
|
private static File resolveContentsFolder(File unzippedFolder) {
|
|
187
188
|
File[] entries = unzippedFolder.listFiles();
|
|
188
|
-
if (entries
|
|
189
|
-
return
|
|
189
|
+
if (entries == null) {
|
|
190
|
+
return unzippedFolder;
|
|
190
191
|
}
|
|
191
192
|
|
|
192
|
-
|
|
193
|
+
File wrapper = null;
|
|
194
|
+
for (File entry : entries) {
|
|
195
|
+
if (entry.isFile() && entry.getName().equals(CodePushConstants.DIFF_MANIFEST_FILE_NAME)) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (wrapper == null && entry.isDirectory()) {
|
|
199
|
+
wrapper = entry;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return unzippedFolder;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return wrapper != null ? wrapper : unzippedFolder;
|
|
193
207
|
}
|
|
194
208
|
|
|
195
209
|
/**
|
|
196
|
-
* Resolves a path
|
|
197
|
-
*
|
|
198
|
-
* than its entries.
|
|
210
|
+
* Resolves a path a manifest points at, refusing anything that would reach outside the
|
|
211
|
+
* folder it is relative to - an archive is untrusted input, and its manifest is no more
|
|
212
|
+
* trusted than its entries.
|
|
199
213
|
*
|
|
200
214
|
* @return the resolved file, or null when the path is unusable
|
|
201
215
|
*/
|
|
202
|
-
|
|
216
|
+
static File resolveInsideFolder(File folder, String relativePath) {
|
|
203
217
|
if (isNullOrEmpty(relativePath) || new File(relativePath).isAbsolute()) {
|
|
204
218
|
return null;
|
|
205
219
|
}
|
|
@@ -110,8 +110,11 @@ public class CodePushUpdateUtils {
|
|
|
110
110
|
JSONArray deletedFiles = diffManifest.getJSONArray("deletedFiles");
|
|
111
111
|
for (int i = 0; i < deletedFiles.length(); i++) {
|
|
112
112
|
String fileNameToDelete = deletedFiles.getString(i);
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
// The manifest is untrusted input, so an entry that reaches outside the
|
|
114
|
+
// package folder is ignored rather than followed. Whether skipping it left
|
|
115
|
+
// the update whole is the folder hash check's answer to give.
|
|
116
|
+
File fileToDelete = CodePushBinaryPatch.resolveInsideFolder(new File(newPackageFolderPath), fileNameToDelete);
|
|
117
|
+
if (fileToDelete != null && fileToDelete.exists()) {
|
|
115
118
|
fileToDelete.delete();
|
|
116
119
|
}
|
|
117
120
|
}
|
|
@@ -87,6 +87,43 @@ public class CodePushBinaryPatchTest {
|
|
|
87
87
|
assertNoTemporaryFilesLeft();
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
@Test
|
|
91
|
+
public void restoresTheBundleWhenTheDiffManifestSitsBesideTheContentsDirectory() throws IOException {
|
|
92
|
+
// A diff archive carries the manifest of the assets to delete at its root, beside
|
|
93
|
+
// the one directory its contents are wrapped in.
|
|
94
|
+
File unzippedFolder = mTemporaryFolder.newFolder("diff-archive");
|
|
95
|
+
File contentsFolder = new File(unzippedFolder, "CodePush");
|
|
96
|
+
assertTrue(contentsFolder.mkdirs());
|
|
97
|
+
File diffManifestFile = new File(unzippedFolder, CodePushConstants.DIFF_MANIFEST_FILE_NAME);
|
|
98
|
+
writeFile(diffManifestFile, bytes("{\"deletedFiles\":[]}"));
|
|
99
|
+
assertTrue(mManifestFile.renameTo(new File(contentsFolder, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME)));
|
|
100
|
+
assertTrue(mPatchFile.renameTo(new File(contentsFolder, PATCH_FILE_NAME)));
|
|
101
|
+
File assetFile = new File(contentsFolder, "logo.png");
|
|
102
|
+
writeFile(assetFile, bytes("an asset the diff brought along"));
|
|
103
|
+
|
|
104
|
+
BinaryPatchResult result = new CodePushBinaryPatch(providerOf(BASE_BUNDLE), succeedingApplier())
|
|
105
|
+
.restoreBundle(unzippedFolder.getAbsolutePath(), mWorkingFolder.getAbsolutePath(), BUNDLE_FILE_NAME);
|
|
106
|
+
|
|
107
|
+
assertTrue(result.succeeded());
|
|
108
|
+
assertArrayEquals(TARGET_BUNDLE, readFile(new File(contentsFolder, BUNDLE_FILE_NAME)));
|
|
109
|
+
assertFalse(new File(contentsFolder, PATCH_FILE_NAME).exists());
|
|
110
|
+
assertFalse(new File(contentsFolder, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME).exists());
|
|
111
|
+
assertTrue("the diff manifest is what tells the install which assets to delete", diffManifestFile.exists());
|
|
112
|
+
assertTrue("the rest of the contents is left as the archive delivered it", assetFile.exists());
|
|
113
|
+
assertNoTemporaryFilesLeft();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@Test
|
|
117
|
+
public void keepsARootHoldingLooseFilesBesidesTheDiffManifestAsItsOwnContentsRoot() throws IOException {
|
|
118
|
+
writeFile(new File(mContentsFolder, CodePushConstants.DIFF_MANIFEST_FILE_NAME), bytes("{\"deletedFiles\":[]}"));
|
|
119
|
+
|
|
120
|
+
BinaryPatchResult result = restoreBundle(BASE_BUNDLE, succeedingApplier());
|
|
121
|
+
|
|
122
|
+
assertTrue(result.succeeded());
|
|
123
|
+
assertArrayEquals(TARGET_BUNDLE, readFile(mBundleFile));
|
|
124
|
+
assertNoTemporaryFilesLeft();
|
|
125
|
+
}
|
|
126
|
+
|
|
90
127
|
@Test
|
|
91
128
|
public void removesWhatAnInterruptedAttemptLeftInTheWorkingDirectory() throws IOException {
|
|
92
129
|
assertTrue(mWorkingFolder.mkdirs());
|
|
@@ -34,6 +34,8 @@ import java.util.HashMap;
|
|
|
34
34
|
import java.util.LinkedHashMap;
|
|
35
35
|
import java.util.List;
|
|
36
36
|
import java.util.Map;
|
|
37
|
+
import java.util.TreeMap;
|
|
38
|
+
import java.util.TreeSet;
|
|
37
39
|
import java.util.zip.ZipEntry;
|
|
38
40
|
import java.util.zip.ZipOutputStream;
|
|
39
41
|
|
|
@@ -50,11 +52,18 @@ public class CodePushUpdateManagerDownloadTest {
|
|
|
50
52
|
/** Every archive wraps its files in one directory, which the manifest paths are relative to. */
|
|
51
53
|
private static final String CONTENTS_DIR_NAME = "CodePush";
|
|
52
54
|
private static final String ASSET_PATH = "assets/logo.png";
|
|
55
|
+
/** An asset the update adds, so the diff archive is the only place it comes from. */
|
|
56
|
+
private static final String ADDED_ASSET_PATH = "assets/badge.png";
|
|
57
|
+
/** An asset the installed package holds and the update does without. */
|
|
58
|
+
private static final String DROPPED_ASSET_PATH = "assets/legacy.png";
|
|
53
59
|
|
|
54
60
|
private static final byte[] BASE_BUNDLE = bytes("the bundle inside the app binary");
|
|
55
61
|
private static final byte[] TARGET_BUNDLE = bytes("the bundle the update wants to run");
|
|
62
|
+
private static final byte[] INSTALLED_BUNDLE = bytes("the bundle of the update already installed");
|
|
56
63
|
private static final byte[] PATCH = bytes("the difference between the two");
|
|
57
64
|
private static final byte[] ASSET = bytes("an image the update ships with");
|
|
65
|
+
private static final byte[] ADDED_ASSET = bytes("an image only the newer update ships");
|
|
66
|
+
private static final byte[] DROPPED_ASSET = bytes("an image the newer update leaves behind");
|
|
58
67
|
private static final byte[] ERROR_PAGE = bytes("<html><body>404 Not Found</body></html>");
|
|
59
68
|
|
|
60
69
|
@Rule
|
|
@@ -161,6 +170,83 @@ public class CodePushUpdateManagerDownloadTest {
|
|
|
161
170
|
assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
|
|
162
171
|
}
|
|
163
172
|
|
|
173
|
+
@Test
|
|
174
|
+
public void installsAnAssetDiffUpdateByMergingWithTheInstalledPackage() throws IOException {
|
|
175
|
+
installBaseUpdate();
|
|
176
|
+
Map<String, byte[]> updateContents = assetDiffTargetContents();
|
|
177
|
+
String updateHash = packageHashOf(updateContents);
|
|
178
|
+
String diffUrl = serve("/diff.zip", zipOf(assetDiffArchiveContents(DROPPED_ASSET_PATH)));
|
|
179
|
+
String fullUrl = serve("/full.zip", zipOf(updateContents));
|
|
180
|
+
|
|
181
|
+
JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
|
|
182
|
+
.downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
|
|
183
|
+
|
|
184
|
+
assertEquals("the update is downloaded as its diff alone, the base being already installed",
|
|
185
|
+
Arrays.asList("/installed.zip", "/diff.zip"), mServer.requestedPaths());
|
|
186
|
+
assertEquals("applied", patchResult.optString("status", null));
|
|
187
|
+
assertFalse("an applied diff has nothing to report a reason for", patchResult.has("fallbackReason"));
|
|
188
|
+
// The asset the diff leaves out is carried over from the installed package, and the
|
|
189
|
+
// one its manifest names is not.
|
|
190
|
+
assertInstalledContents(updateHash, updateContents);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@Test
|
|
194
|
+
public void fallsBackToTheFullArchiveWhenTheAssetDiffMergeYieldsTheWrongContents() throws IOException {
|
|
195
|
+
// A manifest that deletes an asset the update keeps: the merge ends up missing a file
|
|
196
|
+
// the release was published with, and the update is not the one the hash is for.
|
|
197
|
+
installBaseUpdate();
|
|
198
|
+
Map<String, byte[]> updateContents = assetDiffTargetContents();
|
|
199
|
+
String updateHash = packageHashOf(updateContents);
|
|
200
|
+
String diffUrl = serve("/diff.zip", zipOf(assetDiffArchiveContents(ASSET_PATH)));
|
|
201
|
+
String fullUrl = serve("/full.zip", zipOf(updateContents));
|
|
202
|
+
|
|
203
|
+
JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
|
|
204
|
+
.downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
|
|
205
|
+
|
|
206
|
+
assertEquals(Arrays.asList("/installed.zip", "/diff.zip", "/full.zip"), mServer.requestedPaths());
|
|
207
|
+
assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
|
|
208
|
+
assertInstalledContents(updateHash, updateContents);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@Test
|
|
212
|
+
public void fallsBackToTheFullArchiveWhenAnAssetDiffArrivesWithNoInstalledPackage() throws IOException {
|
|
213
|
+
// Nothing is installed, so the assets the diff counts on being there already have
|
|
214
|
+
// nowhere to be copied from and the merge cannot make the update whole.
|
|
215
|
+
Map<String, byte[]> updateContents = assetDiffTargetContents();
|
|
216
|
+
String updateHash = packageHashOf(updateContents);
|
|
217
|
+
String diffUrl = serve("/diff.zip", zipOf(assetDiffArchiveContents(DROPPED_ASSET_PATH)));
|
|
218
|
+
String fullUrl = serve("/full.zip", zipOf(updateContents));
|
|
219
|
+
|
|
220
|
+
JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
|
|
221
|
+
.downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
|
|
222
|
+
|
|
223
|
+
assertEquals(Arrays.asList("/diff.zip", "/full.zip"), mServer.requestedPaths());
|
|
224
|
+
assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
|
|
225
|
+
assertInstalledContents(updateHash, updateContents);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@Test
|
|
229
|
+
public void leavesAFileOutsideThePackageFolderAloneWhenTheAssetDiffManifestNamesIt() throws IOException {
|
|
230
|
+
// A manifest entry that climbs out of the package folder: the client is not the one
|
|
231
|
+
// that wrote it, so it must not act on it.
|
|
232
|
+
installBaseUpdate();
|
|
233
|
+
File fileOutsideThePackage = mTemporaryFolder.newFile("keep-me.txt");
|
|
234
|
+
Map<String, byte[]> updateContents = assetDiffTargetContents();
|
|
235
|
+
String updateHash = packageHashOf(updateContents);
|
|
236
|
+
String diffUrl = serve("/diff.zip",
|
|
237
|
+
zipOf(assetDiffArchiveContents(DROPPED_ASSET_PATH, CONTENTS_DIR_NAME + "/../../../keep-me.txt")));
|
|
238
|
+
String fullUrl = serve("/full.zip", zipOf(updateContents));
|
|
239
|
+
|
|
240
|
+
JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE))
|
|
241
|
+
.downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
|
|
242
|
+
|
|
243
|
+
assertTrue("a manifest entry reaching outside the package folder deleted " + fileOutsideThePackage,
|
|
244
|
+
fileOutsideThePackage.exists());
|
|
245
|
+
// Skipping it costs the update nothing, so the diff still installs.
|
|
246
|
+
assertEquals("applied", patchResult.optString("status", null));
|
|
247
|
+
assertInstalledContents(updateHash, updateContents);
|
|
248
|
+
}
|
|
249
|
+
|
|
164
250
|
@Test
|
|
165
251
|
public void announcesEachDownloadOfAFallbackAsItsOwnProgressStream() throws IOException {
|
|
166
252
|
byte[] fullArchive = zipOf(fullArchiveContents());
|
|
@@ -229,6 +315,58 @@ public class CodePushUpdateManagerDownloadTest {
|
|
|
229
315
|
assertFalse(binaryPatchFolder.exists());
|
|
230
316
|
}
|
|
231
317
|
|
|
318
|
+
/**
|
|
319
|
+
* The update installed under a hash is these contents and nothing else, which is the
|
|
320
|
+
* whole question for an update that was rebuilt from parts of two archives.
|
|
321
|
+
*/
|
|
322
|
+
private void assertInstalledContents(String packageHash, Map<String, byte[]> expectedContents) throws IOException {
|
|
323
|
+
File packageFolder = new File(new File(mDocumentsDirectory, CodePushConstants.CODE_PUSH_FOLDER_PREFIX), packageHash);
|
|
324
|
+
Map<String, byte[]> installedFiles = filesUnder(packageFolder, "");
|
|
325
|
+
// Written last, so its presence also says the folder hash check passed.
|
|
326
|
+
assertTrue("the update is installed without its metadata",
|
|
327
|
+
installedFiles.remove(CodePushConstants.PACKAGE_FILE_NAME) != null);
|
|
328
|
+
|
|
329
|
+
assertEquals("the installed files", new TreeSet<>(expectedContents.keySet()), installedFiles.keySet());
|
|
330
|
+
for (Map.Entry<String, byte[]> expectedFile : expectedContents.entrySet()) {
|
|
331
|
+
assertArrayEquals("the contents of " + expectedFile.getKey(),
|
|
332
|
+
expectedFile.getValue(), installedFiles.get(expectedFile.getKey()));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Every file in a folder, keyed by its path relative to that folder. */
|
|
337
|
+
private static Map<String, byte[]> filesUnder(File folder, String pathPrefix) throws IOException {
|
|
338
|
+
Map<String, byte[]> files = new TreeMap<>();
|
|
339
|
+
File[] entries = folder.listFiles();
|
|
340
|
+
if (entries == null) {
|
|
341
|
+
return files;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (File entry : entries) {
|
|
345
|
+
String relativePath = pathPrefix.isEmpty() ? entry.getName() : pathPrefix + "/" + entry.getName();
|
|
346
|
+
if (entry.isDirectory()) {
|
|
347
|
+
files.putAll(filesUnder(entry, relativePath));
|
|
348
|
+
} else {
|
|
349
|
+
files.put(relativePath, readFile(entry));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return files;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Downloads an update in full and makes it the current package, which is what the asset
|
|
358
|
+
* diff of a later release is merged into.
|
|
359
|
+
*/
|
|
360
|
+
private void installBaseUpdate() throws IOException {
|
|
361
|
+
Map<String, byte[]> contents = installedArchiveContents();
|
|
362
|
+
JSONObject installedPackage = fullUpdatePackage(
|
|
363
|
+
packageHashOf(contents), serve("/installed.zip", zipOf(contents)));
|
|
364
|
+
|
|
365
|
+
CodePushUpdateManager updateManager = updateManager(applierWriting(TARGET_BUNDLE));
|
|
366
|
+
updateManager.downloadPackage(installedPackage, BUNDLE_FILE_NAME, ignoreProgress());
|
|
367
|
+
updateManager.installPackage(installedPackage, false);
|
|
368
|
+
}
|
|
369
|
+
|
|
232
370
|
private CodePushUpdateManager updateManager(CodePushBinaryPatch.PatchApplier applier) {
|
|
233
371
|
CodePushBinaryPatch binaryPatch = new CodePushBinaryPatch(new CodePushBinaryPatch.BaseBundleProvider() {
|
|
234
372
|
@Override
|
|
@@ -283,11 +421,60 @@ public class CodePushUpdateManagerDownloadTest {
|
|
|
283
421
|
return contents;
|
|
284
422
|
}
|
|
285
423
|
|
|
424
|
+
/** The full archive of the release that is installed when the asset diff arrives. */
|
|
425
|
+
private Map<String, byte[]> installedArchiveContents() {
|
|
426
|
+
Map<String, byte[]> contents = new LinkedHashMap<>();
|
|
427
|
+
contents.put(CONTENTS_DIR_NAME + "/" + BUNDLE_FILE_NAME, INSTALLED_BUNDLE);
|
|
428
|
+
contents.put(CONTENTS_DIR_NAME + "/" + ASSET_PATH, ASSET);
|
|
429
|
+
contents.put(CONTENTS_DIR_NAME + "/" + DROPPED_ASSET_PATH, DROPPED_ASSET);
|
|
430
|
+
return contents;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* An asset diff archive: the patch archive carrying only the assets the update changes,
|
|
435
|
+
* plus the manifest of the files to delete at the archive root, beside the contents
|
|
436
|
+
* directory the manifest's paths are relative to. An asset the installed package already
|
|
437
|
+
* holds unchanged is not shipped at all - the client copies it over.
|
|
438
|
+
*/
|
|
439
|
+
private Map<String, byte[]> assetDiffArchiveContents(String deletedAssetPath) {
|
|
440
|
+
Map<String, byte[]> contents = patchArchiveContents();
|
|
441
|
+
contents.remove(CONTENTS_DIR_NAME + "/" + ASSET_PATH);
|
|
442
|
+
contents.put(CONTENTS_DIR_NAME + "/" + ADDED_ASSET_PATH, ADDED_ASSET);
|
|
443
|
+
contents.put(CodePushConstants.DIFF_MANIFEST_FILE_NAME,
|
|
444
|
+
bytes("{\"deletedFiles\":[\"" + CONTENTS_DIR_NAME + "/" + deletedAssetPath + "\"]}"));
|
|
445
|
+
return contents;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The same archive, with one more path in its manifest of the files to delete. */
|
|
449
|
+
private Map<String, byte[]> assetDiffArchiveContents(String deletedAssetPath, String otherDeletedPath) {
|
|
450
|
+
Map<String, byte[]> contents = assetDiffArchiveContents(deletedAssetPath);
|
|
451
|
+
contents.put(CodePushConstants.DIFF_MANIFEST_FILE_NAME,
|
|
452
|
+
bytes("{\"deletedFiles\":[\"" + CONTENTS_DIR_NAME + "/" + deletedAssetPath + "\",\"" + otherDeletedPath + "\"]}"));
|
|
453
|
+
return contents;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** What an asset diff has to add up to: the contents of the update's full archive. */
|
|
457
|
+
private Map<String, byte[]> assetDiffTargetContents() {
|
|
458
|
+
Map<String, byte[]> contents = fullArchiveContents();
|
|
459
|
+
contents.put(CONTENTS_DIR_NAME + "/" + ADDED_ASSET_PATH, ADDED_ASSET);
|
|
460
|
+
return contents;
|
|
461
|
+
}
|
|
462
|
+
|
|
286
463
|
private JSONObject updatePackage(String downloadUrl, String binaryPatchDownloadUrl) {
|
|
464
|
+
return updatePackage(mPackageHash, downloadUrl, binaryPatchDownloadUrl);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private JSONObject updatePackage(String packageHash, String downloadUrl, String binaryPatchDownloadUrl) {
|
|
468
|
+
JSONObject updatePackage = fullUpdatePackage(packageHash, downloadUrl);
|
|
469
|
+
CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.BINARY_PATCH_DOWNLOAD_URL_KEY, binaryPatchDownloadUrl);
|
|
470
|
+
return updatePackage;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A release with no second archive to try, so it is downloaded in full. */
|
|
474
|
+
private JSONObject fullUpdatePackage(String packageHash, String downloadUrl) {
|
|
287
475
|
JSONObject updatePackage = new JSONObject();
|
|
288
|
-
CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.PACKAGE_HASH_KEY,
|
|
476
|
+
CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.PACKAGE_HASH_KEY, packageHash);
|
|
289
477
|
CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.DOWNLOAD_URL_KEY, downloadUrl);
|
|
290
|
-
CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.BINARY_PATCH_DOWNLOAD_URL_KEY, binaryPatchDownloadUrl);
|
|
291
478
|
return updatePackage;
|
|
292
479
|
}
|
|
293
480
|
|
|
@@ -15,6 +15,7 @@ export async function addToReleaseHistory(
|
|
|
15
15
|
mandatory: boolean,
|
|
16
16
|
enable: boolean,
|
|
17
17
|
rollout: number | undefined,
|
|
18
|
+
diffPackages: Record<string, string> | undefined,
|
|
18
19
|
): Promise<void> {
|
|
19
20
|
const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
|
|
20
21
|
|
|
@@ -39,6 +40,12 @@ export async function addToReleaseHistory(
|
|
|
39
40
|
newReleaseHistory[appVersion].binaryPatchDownloadUrl = binaryPatchDownloadUrl;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// Same for the asset diffs: an entry only names the bases it actually published a
|
|
44
|
+
// diff archive for.
|
|
45
|
+
if (diffPackages && Object.keys(diffPackages).length > 0) {
|
|
46
|
+
newReleaseHistory[appVersion].diffPackages = diffPackages;
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
if (typeof rollout === 'number') {
|
|
43
50
|
newReleaseHistory[appVersion].rollout = rollout;
|
|
44
51
|
}
|
|
@@ -12,6 +12,7 @@ jest.mock("./release.js");
|
|
|
12
12
|
jest.mock("../../utils/fsUtils.js", () => ({
|
|
13
13
|
findAndReadConfigFile: () => ({
|
|
14
14
|
bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
|
|
15
|
+
bundleDownloader: async () => ({ downloadedFilePath: '/tmp/base.zip' }),
|
|
15
16
|
getReleaseHistory: async () => ({}),
|
|
16
17
|
setReleaseHistory: async () => undefined,
|
|
17
18
|
}),
|
|
@@ -29,6 +30,8 @@ const ARG_INDEX = {
|
|
|
29
30
|
bundleDirectory: 16,
|
|
30
31
|
baseBundlePath: 19,
|
|
31
32
|
onOversizedPatch: 20,
|
|
33
|
+
bundleDownloader: 21,
|
|
34
|
+
diffBaseCount: 22,
|
|
32
35
|
} as const;
|
|
33
36
|
|
|
34
37
|
/**
|
|
@@ -115,4 +118,33 @@ describe("release command options", () => {
|
|
|
115
118
|
const { release } = await import("./release.js");
|
|
116
119
|
expect(jest.mocked(release)).not.toHaveBeenCalled();
|
|
117
120
|
});
|
|
121
|
+
|
|
122
|
+
it("hands the release the bundle downloader from the config and three diff bases by default", async () => {
|
|
123
|
+
const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);
|
|
124
|
+
|
|
125
|
+
expect(typeof args[ARG_INDEX.bundleDownloader]).toBe('function');
|
|
126
|
+
expect(args[ARG_INDEX.diffBaseCount]).toBe(3);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("passes the chosen asset diff base count through to the release", async () => {
|
|
130
|
+
const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--diff-base-count', '5']);
|
|
131
|
+
|
|
132
|
+
expect(args[ARG_INDEX.diffBaseCount]).toBe(5);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it.each([
|
|
136
|
+
['is negative', '-1'],
|
|
137
|
+
['is not a number at all', 'many'],
|
|
138
|
+
])("rejects an asset diff base count that %s", async (_caseName, value) => {
|
|
139
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
140
|
+
jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
141
|
+
throw new Error(`process.exit(${code})`);
|
|
142
|
+
}) as never);
|
|
143
|
+
|
|
144
|
+
await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--diff-base-count', value]))
|
|
145
|
+
.rejects.toThrow('process.exit(1)');
|
|
146
|
+
|
|
147
|
+
const { release } = await import("./release.js");
|
|
148
|
+
expect(jest.mocked(release)).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
118
150
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { program, Option } from "commander";
|
|
2
2
|
import { findAndReadConfigFile } from "../../utils/fsUtils.js";
|
|
3
|
-
import { release } from "./release.js";
|
|
3
|
+
import { DEFAULT_DIFF_BASE_COUNT, release } from "./release.js";
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_OVERSIZED_PATCH_POLICY,
|
|
6
6
|
OVERSIZED_PATCH_POLICIES,
|
|
@@ -30,6 +30,7 @@ type Options = {
|
|
|
30
30
|
hashCalc?: boolean;
|
|
31
31
|
binaryBundlePath?: string;
|
|
32
32
|
onOversizedPatch: OversizedPatchPolicy;
|
|
33
|
+
diffBaseCount: number;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
program.command('release')
|
|
@@ -55,6 +56,7 @@ program.command('release')
|
|
|
55
56
|
.addOption(new Option('--on-oversized-patch <policy>', 'what to do when the binary patch bundle is not smaller than the full bundle: "skip" releases the full bundle only, "fail" stops the release before any upload')
|
|
56
57
|
.choices(OVERSIZED_PATCH_POLICIES)
|
|
57
58
|
.default(DEFAULT_OVERSIZED_PATCH_POLICY))
|
|
59
|
+
.option('--diff-base-count <number>', 'how many recent releases to build asset diff archives against (0 disables). Requires `bundleDownloader` in the config file.', parseDecimalInt, DEFAULT_DIFF_BASE_COUNT)
|
|
58
60
|
.action(async (options: Options) => {
|
|
59
61
|
const config = findAndReadConfigFile(process.cwd(), options.config);
|
|
60
62
|
|
|
@@ -63,6 +65,11 @@ program.command('release')
|
|
|
63
65
|
process.exit(1);
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
if (!Number.isInteger(options.diffBaseCount) || options.diffBaseCount < 0) {
|
|
69
|
+
console.error('--diff-base-count must be a whole number of releases, 0 or greater.');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
if (options.hashCalc && !options.skipBundle) {
|
|
67
74
|
console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
|
|
68
75
|
process.exit(1);
|
|
@@ -93,11 +100,19 @@ program.command('release')
|
|
|
93
100
|
options.hashCalc,
|
|
94
101
|
baseBundlePath,
|
|
95
102
|
options.onOversizedPatch,
|
|
103
|
+
config.bundleDownloader,
|
|
104
|
+
options.diffBaseCount,
|
|
96
105
|
)
|
|
97
106
|
|
|
98
107
|
console.log('🚀 Release completed.')
|
|
99
108
|
});
|
|
100
109
|
|
|
110
|
+
// Not `parseInt` itself: commander hands a coercion function the current value as its
|
|
111
|
+
// second argument, which `parseInt` reads as the radix.
|
|
112
|
+
function parseDecimalInt(value: string): number {
|
|
113
|
+
return parseInt(value, 10);
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
function parseBoolean(value: string): boolean | undefined {
|
|
102
117
|
if (value === 'true') return true;
|
|
103
118
|
if (value === 'false') return false;
|