@bravemobile/react-native-code-push 13.1.0 → 13.3.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/README.md +18 -1
- package/android/app/proguard-rules.pro +0 -7
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +3 -0
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +25 -0
- package/cli/commands/createHistoryCommand/createReleaseHistory.test.ts +2 -2
- package/cli/commands/releaseCommand/addToReleaseHistory.ts +6 -0
- package/cli/commands/releaseCommand/index.test.ts +29 -0
- package/cli/commands/releaseCommand/index.ts +9 -0
- package/cli/commands/releaseCommand/release.test.ts +28 -0
- package/cli/commands/releaseCommand/release.ts +2 -0
- package/cli/commands/updateHistoryCommand/index.test.ts +129 -0
- package/cli/commands/updateHistoryCommand/index.ts +28 -2
- package/cli/commands/updateHistoryCommand/updateReleaseHistory.ts +3 -0
- package/cli/dist/commands/createHistoryCommand/createReleaseHistory.test.js +2 -2
- package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +5 -1
- package/cli/dist/commands/releaseCommand/index.js +7 -1
- package/cli/dist/commands/releaseCommand/index.test.js +22 -0
- package/cli/dist/commands/releaseCommand/release.js +2 -2
- package/cli/dist/commands/releaseCommand/release.test.js +18 -1
- package/cli/dist/commands/updateHistoryCommand/index.js +23 -2
- package/cli/dist/commands/updateHistoryCommand/index.test.js +102 -0
- package/cli/dist/commands/updateHistoryCommand/updateReleaseHistory.js +4 -1
- package/cli/dist/utils/fsUtils.js +35 -9
- package/cli/package.json +5 -1
- package/cli/utils/fsUtils.ts +43 -9
- package/ios/CodePush/CodePush.h +8 -3
- package/ios/CodePush/CodePush.mm +61 -1
- package/package.json +14 -6
- package/src/CodePush.js +16 -3
- package/src/CodePush.test.js +57 -0
- package/typings/react-native-code-push.d.ts +20 -0
|
@@ -95,7 +95,7 @@ async function runRelease(staged, overrides = {}) {
|
|
|
95
95
|
await release(recordingUploader(uploads, overrides.uploadFailsFor), history.getReleaseHistory, history.setReleaseHistory, BINARY_VERSION, APP_VERSION, undefined, overrides.platform ?? 'ios', undefined, staged.outputPath, 'index.ts',
|
|
96
96
|
// Left unset unless the case is about -j, the way commander leaves it.
|
|
97
97
|
overrides.jsBundleName, false, true, undefined, true, // skipBundle
|
|
98
|
-
overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch, overrides.bundleDownloader, overrides.diffBaseCount);
|
|
98
|
+
overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch, overrides.bundleDownloader, overrides.diffBaseCount, overrides.minimumBackgroundDuration);
|
|
99
99
|
return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
|
|
100
100
|
}
|
|
101
101
|
let logs;
|
|
@@ -563,3 +563,20 @@ describe("release with asset diff bases", () => {
|
|
|
563
563
|
expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
|
|
564
564
|
});
|
|
565
565
|
});
|
|
566
|
+
describe("release --minimum-background-duration", () => {
|
|
567
|
+
it("records the background wait on the release it publishes", async () => {
|
|
568
|
+
const staged = await stageBundleOutput("minimum-background-duration");
|
|
569
|
+
const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 600 });
|
|
570
|
+
expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(600);
|
|
571
|
+
});
|
|
572
|
+
it("releases a background wait of zero seconds as zero, not as an unset option", async () => {
|
|
573
|
+
const staged = await stageBundleOutput("zero-minimum-background-duration");
|
|
574
|
+
const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 0 });
|
|
575
|
+
expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
|
|
576
|
+
});
|
|
577
|
+
it("leaves the release saying nothing about the background wait when the option is not given", async () => {
|
|
578
|
+
const staged = await stageBundleOutput("no-minimum-background-duration");
|
|
579
|
+
const { releaseHistories } = await runRelease(staged);
|
|
580
|
+
expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
|
|
581
|
+
});
|
|
582
|
+
});
|
|
@@ -12,14 +12,35 @@ program.command('update-history')
|
|
|
12
12
|
.option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, undefined)
|
|
13
13
|
.option('-e, --enable <bool>', 'make the release to be enabled', parseBoolean, undefined)
|
|
14
14
|
.option('--rollout <number>', 'rollout percentage (0-100)', parseFloat, undefined)
|
|
15
|
+
.option('--minimum-background-duration <seconds>', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt, undefined)
|
|
15
16
|
.action(async (options) => {
|
|
16
17
|
const config = findAndReadConfigFile(process.cwd(), options.config);
|
|
17
|
-
if (typeof options.mandatory !== "boolean"
|
|
18
|
+
if (typeof options.mandatory !== "boolean"
|
|
19
|
+
&& typeof options.enable !== "boolean"
|
|
20
|
+
&& typeof options.rollout !== "number"
|
|
21
|
+
&& typeof options.minimumBackgroundDuration !== "number") {
|
|
18
22
|
console.error('No options specified. Exiting the program.');
|
|
19
23
|
process.exit(1);
|
|
20
24
|
}
|
|
21
|
-
|
|
25
|
+
// `Number.isFinite` is what rejects a non-numeric `--rollout`: `parseFloat` turns it into
|
|
26
|
+
// NaN, and both `NaN < 0` and `NaN > 100` are false.
|
|
27
|
+
if (options.rollout !== undefined
|
|
28
|
+
&& (!Number.isFinite(options.rollout) || options.rollout < 0 || options.rollout > 100)) {
|
|
29
|
+
console.error('Rollout percentage number must be between 0 and 100 (inclusive).');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
if (options.minimumBackgroundDuration !== undefined
|
|
33
|
+
&& (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
|
|
34
|
+
console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
await updateReleaseHistory(options.appVersion, options.binaryVersion, config.getReleaseHistory, config.setReleaseHistory, options.platform, options.identifier, options.mandatory, options.enable, options.rollout, options.minimumBackgroundDuration);
|
|
22
38
|
});
|
|
39
|
+
// Not `parseInt` itself: commander hands a coercion function the current value as its
|
|
40
|
+
// second argument, which `parseInt` reads as the radix.
|
|
41
|
+
function parseDecimalInt(value) {
|
|
42
|
+
return parseInt(value, 10);
|
|
43
|
+
}
|
|
23
44
|
function parseBoolean(value) {
|
|
24
45
|
if (value === 'true')
|
|
25
46
|
return true;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
|
4
|
+
/**
|
|
5
|
+
* Checks the command definition against the entry it saves. Everything this command does
|
|
6
|
+
* ends up in the release history the config is handed, so an option that never reaches it
|
|
7
|
+
* leaves the release exactly as it was - which the command still reports as a success.
|
|
8
|
+
*/
|
|
9
|
+
const BINARY_VERSION = '1.0.0';
|
|
10
|
+
const APP_VERSION = '1.0.1';
|
|
11
|
+
let mockConfig;
|
|
12
|
+
jest.mock("../../utils/fsUtils.js", () => ({
|
|
13
|
+
findAndReadConfigFile: () => mockConfig,
|
|
14
|
+
}));
|
|
15
|
+
/**
|
|
16
|
+
* Puts one released version in the config's history and records every history it is
|
|
17
|
+
* handed back, so a case can read the entry as the consumer would store it.
|
|
18
|
+
*/
|
|
19
|
+
function stageReleaseHistory() {
|
|
20
|
+
const releaseHistory = {
|
|
21
|
+
[APP_VERSION]: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
mandatory: false,
|
|
24
|
+
downloadUrl: 'https://cdn.example.com/bundle',
|
|
25
|
+
packageHash: 'a3f1c0',
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const saved = [];
|
|
29
|
+
mockConfig = {
|
|
30
|
+
bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
|
|
31
|
+
getReleaseHistory: async () => releaseHistory,
|
|
32
|
+
// The command edits the history in place, so what it saved is copied out here.
|
|
33
|
+
setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => {
|
|
34
|
+
saved.push(structuredClone(releaseInfo));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
return saved;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parses an `update-history` invocation against the real command definition. Commander is
|
|
41
|
+
* asked to throw instead of exiting, and to keep its diagnostics to itself, so a rejected
|
|
42
|
+
* option can be asserted on without ending the worker or the output.
|
|
43
|
+
*/
|
|
44
|
+
async function parseUpdateHistoryCommand(args) {
|
|
45
|
+
const { program } = await import("commander");
|
|
46
|
+
await import("./index.js");
|
|
47
|
+
const updateHistoryCommand = program.commands.find((command) => command.name() === 'update-history');
|
|
48
|
+
updateHistoryCommand?.exitOverride();
|
|
49
|
+
updateHistoryCommand?.configureOutput({ writeErr: () => { } });
|
|
50
|
+
await program.parseAsync(['update-history', ...args], { from: 'user' });
|
|
51
|
+
}
|
|
52
|
+
async function runUpdateHistoryCommand(args) {
|
|
53
|
+
await parseUpdateHistoryCommand(['-b', BINARY_VERSION, '-v', APP_VERSION, ...args]);
|
|
54
|
+
}
|
|
55
|
+
let saved;
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
jest.resetModules();
|
|
58
|
+
saved = stageReleaseHistory();
|
|
59
|
+
jest.spyOn(console, 'log').mockImplementation(() => { });
|
|
60
|
+
jest.spyOn(console, 'error').mockImplementation(() => { });
|
|
61
|
+
jest.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
62
|
+
throw new Error(`process.exit(${code})`);
|
|
63
|
+
}));
|
|
64
|
+
});
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
jest.restoreAllMocks();
|
|
67
|
+
// The command writes its JSON under the directory it was invoked in.
|
|
68
|
+
fs.rmSync(path.resolve(process.cwd(), "codepush-release-history"), { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
describe("update-history command options", () => {
|
|
71
|
+
it("lowers the background wait of a release that is already out to zero seconds", async () => {
|
|
72
|
+
await runUpdateHistoryCommand(['--minimum-background-duration', '0']);
|
|
73
|
+
expect(saved).toHaveLength(1);
|
|
74
|
+
expect(saved[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
it("leaves the entry saying nothing about the background wait when only --enable is given", async () => {
|
|
77
|
+
await runUpdateHistoryCommand(['--enable', 'false']);
|
|
78
|
+
expect(saved[0][APP_VERSION].enabled).toBe(false);
|
|
79
|
+
expect(saved[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
|
|
80
|
+
});
|
|
81
|
+
it("saves the rollout percentage when --rollout is the only option given", async () => {
|
|
82
|
+
await runUpdateHistoryCommand(['--rollout', '50']);
|
|
83
|
+
expect(saved[0][APP_VERSION].rollout).toBe(50);
|
|
84
|
+
});
|
|
85
|
+
it("exits without saving anything when no option says what to change", async () => {
|
|
86
|
+
await expect(runUpdateHistoryCommand([])).rejects.toThrow('process.exit(1)');
|
|
87
|
+
expect(saved).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
it("rejects a negative background wait and saves nothing", async () => {
|
|
90
|
+
await expect(runUpdateHistoryCommand(['--minimum-background-duration', '-1']))
|
|
91
|
+
.rejects.toThrow('process.exit(1)');
|
|
92
|
+
expect(saved).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
it.each([
|
|
95
|
+
['a percentage above 100', '150'],
|
|
96
|
+
['a percentage that is not a number', 'abc'],
|
|
97
|
+
])("rejects %s and saves nothing", async (_scenario, rollout) => {
|
|
98
|
+
await expect(runUpdateHistoryCommand(['--rollout', rollout]))
|
|
99
|
+
.rejects.toThrow('process.exit(1)');
|
|
100
|
+
expect(saved).toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js";
|
|
2
|
-
export async function updateReleaseHistory(appVersion, binaryVersion, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout) {
|
|
2
|
+
export async function updateReleaseHistory(appVersion, binaryVersion, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, minimumBackgroundDuration) {
|
|
3
3
|
const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
|
|
4
4
|
const updateInfo = releaseHistory[appVersion];
|
|
5
5
|
if (!updateInfo)
|
|
@@ -10,6 +10,9 @@ export async function updateReleaseHistory(appVersion, binaryVersion, getRelease
|
|
|
10
10
|
updateInfo.enabled = enable;
|
|
11
11
|
if (typeof rollout === "number")
|
|
12
12
|
updateInfo.rollout = rollout;
|
|
13
|
+
// 0 is what lowers the wait to "apply on the next resume", so truthiness cannot decide here.
|
|
14
|
+
if (typeof minimumBackgroundDuration === "number")
|
|
15
|
+
updateInfo.minimumBackgroundDuration = minimumBackgroundDuration;
|
|
13
16
|
try {
|
|
14
17
|
await stageReleaseHistoryFile(binaryVersion, releaseHistory, platform, (jsonFilePath) => setReleaseHistory(binaryVersion, jsonFilePath, releaseHistory, platform, identifier));
|
|
15
18
|
}
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { createRequire } from "module";
|
|
4
|
-
const nodeRequire = createRequire(import.meta.url);
|
|
5
4
|
/**
|
|
6
5
|
* allows to require a config file with .ts extension
|
|
7
6
|
*/
|
|
8
7
|
function requireConfig(filePath) {
|
|
9
8
|
const ext = path.extname(filePath);
|
|
9
|
+
// Resolve the loader from the project the config file lives in, not from the CLI install.
|
|
10
|
+
const projectRequire = createRequire(filePath);
|
|
10
11
|
if (ext === '.ts') {
|
|
11
|
-
|
|
12
|
-
nodeRequire('ts-node/register');
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
console.error('ts-node not found. Please install ts-node as a devDependency.');
|
|
16
|
-
process.exit(1);
|
|
17
|
-
}
|
|
12
|
+
return unwrapDefaultExport(requireTsConfig(projectRequire, filePath));
|
|
18
13
|
}
|
|
19
14
|
else if (ext === '.js') {
|
|
20
15
|
// do nothing
|
|
@@ -22,7 +17,38 @@ function requireConfig(filePath) {
|
|
|
22
17
|
else {
|
|
23
18
|
throw new Error(`Unsupported file extension: ${ext}`);
|
|
24
19
|
}
|
|
25
|
-
return
|
|
20
|
+
return unwrapDefaultExport(projectRequire(filePath));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* tsx is preferred: it needs no tsconfig setup and resolves tsconfig `paths` aliases on its own.
|
|
24
|
+
* ts-node keeps working for projects that already have it configured.
|
|
25
|
+
*/
|
|
26
|
+
function requireTsConfig(projectRequire, filePath) {
|
|
27
|
+
if (canResolve(projectRequire, 'tsx/cjs/api')) {
|
|
28
|
+
const { require: tsxRequire } = projectRequire('tsx/cjs/api');
|
|
29
|
+
return tsxRequire(filePath, filePath);
|
|
30
|
+
}
|
|
31
|
+
if (canResolve(projectRequire, 'ts-node/register')) {
|
|
32
|
+
console.warn('warn: Loading the config file with ts-node, which is no longer maintained. Support for it ' +
|
|
33
|
+
'will be removed in a future major version - please install tsx instead (`npm i -D tsx`).');
|
|
34
|
+
projectRequire('ts-node/register');
|
|
35
|
+
return projectRequire(filePath);
|
|
36
|
+
}
|
|
37
|
+
console.error('A TypeScript config file needs a loader. Please install tsx as a devDependency (`npm i -D tsx`).');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
function canResolve(projectRequire, id) {
|
|
41
|
+
try {
|
|
42
|
+
projectRequire.resolve(id);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// `export default` compiles to `exports.default`; `module.exports =` is returned as is.
|
|
50
|
+
function unwrapDefaultExport(loaded) {
|
|
51
|
+
return (loaded.default ?? loaded);
|
|
26
52
|
}
|
|
27
53
|
export function findAndReadConfigFile(startDir, configFileName) {
|
|
28
54
|
let dir = startDir;
|
package/cli/package.json
CHANGED
|
@@ -19,11 +19,15 @@
|
|
|
19
19
|
"yauzl": "^3.2.0"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
|
-
"ts-node": ">=10"
|
|
22
|
+
"ts-node": ">=10",
|
|
23
|
+
"tsx": ">=4"
|
|
23
24
|
},
|
|
24
25
|
"peerDependenciesMeta": {
|
|
25
26
|
"ts-node": {
|
|
26
27
|
"optional": true
|
|
28
|
+
},
|
|
29
|
+
"tsx": {
|
|
30
|
+
"optional": true
|
|
27
31
|
}
|
|
28
32
|
},
|
|
29
33
|
"engines": {
|
package/cli/utils/fsUtils.ts
CHANGED
|
@@ -3,28 +3,62 @@ import path from "path";
|
|
|
3
3
|
import { createRequire } from "module";
|
|
4
4
|
import type { CliConfigInterface } from "../../typings/react-native-code-push.d.ts";
|
|
5
5
|
|
|
6
|
-
const nodeRequire = createRequire(import.meta.url);
|
|
7
|
-
|
|
8
6
|
/**
|
|
9
7
|
* allows to require a config file with .ts extension
|
|
10
8
|
*/
|
|
11
9
|
function requireConfig(filePath: string): CliConfigInterface {
|
|
12
10
|
const ext = path.extname(filePath);
|
|
11
|
+
// Resolve the loader from the project the config file lives in, not from the CLI install.
|
|
12
|
+
const projectRequire = createRequire(filePath);
|
|
13
13
|
|
|
14
14
|
if (ext === '.ts') {
|
|
15
|
-
|
|
16
|
-
nodeRequire('ts-node/register');
|
|
17
|
-
} catch {
|
|
18
|
-
console.error('ts-node not found. Please install ts-node as a devDependency.');
|
|
19
|
-
process.exit(1);
|
|
20
|
-
}
|
|
15
|
+
return unwrapDefaultExport(requireTsConfig(projectRequire, filePath));
|
|
21
16
|
} else if (ext === '.js') {
|
|
22
17
|
// do nothing
|
|
23
18
|
} else {
|
|
24
19
|
throw new Error(`Unsupported file extension: ${ext}`);
|
|
25
20
|
}
|
|
26
21
|
|
|
27
|
-
return
|
|
22
|
+
return unwrapDefaultExport(projectRequire(filePath));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* tsx is preferred: it needs no tsconfig setup and resolves tsconfig `paths` aliases on its own.
|
|
27
|
+
* ts-node keeps working for projects that already have it configured.
|
|
28
|
+
*/
|
|
29
|
+
function requireTsConfig(projectRequire: NodeRequire, filePath: string): { default?: unknown } {
|
|
30
|
+
if (canResolve(projectRequire, 'tsx/cjs/api')) {
|
|
31
|
+
const { require: tsxRequire } = projectRequire('tsx/cjs/api') as {
|
|
32
|
+
require: (id: string, fromFile: string) => { default?: unknown };
|
|
33
|
+
};
|
|
34
|
+
return tsxRequire(filePath, filePath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (canResolve(projectRequire, 'ts-node/register')) {
|
|
38
|
+
console.warn(
|
|
39
|
+
'warn: Loading the config file with ts-node, which is no longer maintained. Support for it ' +
|
|
40
|
+
'will be removed in a future major version - please install tsx instead (`npm i -D tsx`).',
|
|
41
|
+
);
|
|
42
|
+
projectRequire('ts-node/register');
|
|
43
|
+
return projectRequire(filePath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.error('A TypeScript config file needs a loader. Please install tsx as a devDependency (`npm i -D tsx`).');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function canResolve(projectRequire: NodeRequire, id: string): boolean {
|
|
51
|
+
try {
|
|
52
|
+
projectRequire.resolve(id);
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// `export default` compiles to `exports.default`; `module.exports =` is returned as is.
|
|
60
|
+
function unwrapDefaultExport(loaded: { default?: unknown }): CliConfigInterface {
|
|
61
|
+
return (loaded.default ?? loaded) as CliConfigInterface;
|
|
28
62
|
}
|
|
29
63
|
|
|
30
64
|
export function findAndReadConfigFile(startDir: string, configFileName: string): CliConfigInterface {
|
package/ios/CodePush/CodePush.h
CHANGED
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
@interface CodePush : RCTEventEmitter
|
|
37
37
|
#endif
|
|
38
38
|
|
|
39
|
+
/*
|
|
40
|
+
* Returns the bundle embedded in the binary, without resolving an installed update.
|
|
41
|
+
* The update pipeline uses this as the base for binary patches and asset diffs.
|
|
42
|
+
*/
|
|
39
43
|
+ (NSURL *)binaryBundleURL;
|
|
40
44
|
/*
|
|
41
45
|
* This method is used to retrieve the URL for the most recent
|
|
@@ -64,8 +68,10 @@
|
|
|
64
68
|
subdirectory:(NSString *)resourceSubdirectory
|
|
65
69
|
bundle:(NSBundle *)resourceBundle;
|
|
66
70
|
|
|
71
|
+
/* Internal storage root used by CodePushPackage. */
|
|
67
72
|
+ (NSString *)getApplicationSupportDirectory;
|
|
68
73
|
|
|
74
|
+
/* Returns the embedded assets path used as the base for asset diffs. */
|
|
69
75
|
+ (NSString *)bundleAssetsPath;
|
|
70
76
|
|
|
71
77
|
/*
|
|
@@ -76,9 +82,8 @@
|
|
|
76
82
|
+ (void)overrideAppVersion:(NSString *)appVersion;
|
|
77
83
|
|
|
78
84
|
/*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* the Info.plist file's CodePushDeploymentKey setting.
|
|
85
|
+
* Retained for compatibility. The current implementation ignores the argument
|
|
86
|
+
* and uses the deprecated deployment key placeholder.
|
|
82
87
|
*/
|
|
83
88
|
+ (void)setDeploymentKey:(NSString *)deploymentKey;
|
|
84
89
|
|
package/ios/CodePush/CodePush.mm
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#import <React/RCTAssert.h>
|
|
2
2
|
#import <React/RCTBridgeModule.h>
|
|
3
3
|
#import <React/RCTConvert.h>
|
|
4
|
+
#import <React/RCTInvalidating.h>
|
|
4
5
|
#import <React/RCTEventDispatcher.h>
|
|
5
6
|
#import <React/RCTRootView.h>
|
|
6
7
|
#import <React/RCTUtils.h>
|
|
@@ -8,7 +9,9 @@
|
|
|
8
9
|
|
|
9
10
|
#import "CodePush.h"
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
#include <atomic>
|
|
13
|
+
|
|
14
|
+
@interface CodePush () <RCTFrameUpdateObserver, RCTInvalidating
|
|
12
15
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
13
16
|
, NativeCodePushSpec
|
|
14
17
|
#endif
|
|
@@ -32,6 +35,17 @@
|
|
|
32
35
|
BOOL _allowed;
|
|
33
36
|
BOOL _restartInProgress;
|
|
34
37
|
NSMutableArray *_restartQueue;
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* Whether the React Native instance this module was created for is still the one
|
|
41
|
+
* running.
|
|
42
|
+
*
|
|
43
|
+
* A reload starts the next instance before the one being replaced has finished being
|
|
44
|
+
* torn down, so work this instance started can still be running with nothing left to
|
|
45
|
+
* hand its result to. Read from whichever thread that work is on, written when the
|
|
46
|
+
* instance is invalidated, so it is atomic.
|
|
47
|
+
*/
|
|
48
|
+
std::atomic<bool> _generationAlive;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
RCT_EXPORT_MODULE()
|
|
@@ -307,7 +321,42 @@ static NSString *const LatestRollbackCountKey = @"count";
|
|
|
307
321
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
308
322
|
}
|
|
309
323
|
|
|
324
|
+
/*
|
|
325
|
+
* Called when the React Native instance this module belongs to is being replaced.
|
|
326
|
+
*
|
|
327
|
+
* A download in flight keeps this module alive past that point, and the suspend timer and
|
|
328
|
+
* the resume notifications keep it reachable too. None of them may act afterwards: the
|
|
329
|
+
* runtime they would report to is gone, and the restart they would trigger belongs to a
|
|
330
|
+
* generation that is no longer on screen.
|
|
331
|
+
*/
|
|
332
|
+
- (void)invalidate
|
|
333
|
+
{
|
|
334
|
+
_generationAlive.store(false);
|
|
335
|
+
|
|
336
|
+
[_appSuspendTimer invalidate];
|
|
337
|
+
_appSuspendTimer = nil;
|
|
338
|
+
_hasResumeListener = NO;
|
|
339
|
+
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
340
|
+
|
|
341
|
+
#ifndef RCT_NEW_ARCH_ENABLED
|
|
342
|
+
[super invalidate];
|
|
343
|
+
#endif
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* Whether this module still belongs to the React Native instance that is running. */
|
|
347
|
+
- (BOOL)isGenerationAlive
|
|
348
|
+
{
|
|
349
|
+
return _generationAlive.load();
|
|
350
|
+
}
|
|
351
|
+
|
|
310
352
|
- (void)dispatchDownloadProgressEvent {
|
|
353
|
+
// The download that is reporting here can be one this module started before the app was
|
|
354
|
+
// reloaded, in which case the event emitter it would reach belongs to a runtime that has
|
|
355
|
+
// been torn down.
|
|
356
|
+
if (![self isGenerationAlive]) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
311
360
|
// Notify the script-side about the progress
|
|
312
361
|
NSDictionary *progress = @{
|
|
313
362
|
@"totalBytes" : [NSNumber numberWithLongLong:_latestExpectedContentLength],
|
|
@@ -375,6 +424,7 @@ static NSString *const LatestRollbackCountKey = @"count";
|
|
|
375
424
|
_restartInProgress = NO;
|
|
376
425
|
_restartQueue = [NSMutableArray arrayWithCapacity:1];
|
|
377
426
|
_lastProgressEventTime = 0;
|
|
427
|
+
_generationAlive.store(true);
|
|
378
428
|
|
|
379
429
|
self = [super init];
|
|
380
430
|
if (self) {
|
|
@@ -533,6 +583,11 @@ static NSString *const LatestRollbackCountKey = @"count";
|
|
|
533
583
|
*/
|
|
534
584
|
- (void)loadBundle
|
|
535
585
|
{
|
|
586
|
+
if (![self isGenerationAlive]) {
|
|
587
|
+
CPLog(@"Ignoring a reload requested by a React Native instance that is no longer running.");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
536
591
|
@synchronized([CodePush class]) {
|
|
537
592
|
hasInitializedUpdateAfterRestartForCurrentLoad = NO;
|
|
538
593
|
}
|
|
@@ -802,6 +857,11 @@ RCT_EXPORT_METHOD(downloadUpdate:(NSDictionary*)updatePackage
|
|
|
802
857
|
|
|
803
858
|
- (void)restartAppInternal:(BOOL)onlyIfUpdateIsPending
|
|
804
859
|
{
|
|
860
|
+
if (![self isGenerationAlive]) {
|
|
861
|
+
CPLog(@"Ignoring a restart requested by a React Native instance that is no longer running.");
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
805
865
|
if (_restartInProgress) {
|
|
806
866
|
CPLog(@"Restart request queued until the current restart is completed.");
|
|
807
867
|
[_restartQueue addObject:@(onlyIfUpdateIsPending)];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bravemobile/react-native-code-push",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.3.0",
|
|
4
4
|
"description": "React Native plugin for the CodePush service",
|
|
5
5
|
"main": "src/CodePush.js",
|
|
6
6
|
"react-native": "src/CodePush.js",
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
],
|
|
72
72
|
"scripts": {
|
|
73
73
|
"setup": "npm install --quiet --no-progress",
|
|
74
|
-
"setup-example-app": "
|
|
75
|
-
"setup-expo-example-app": "
|
|
74
|
+
"setup-example-app": "tsx scripts/setupExampleApp/runSetupExampleApp.ts",
|
|
75
|
+
"setup-expo-example-app": "tsx scripts/setupExpoExampleApp/runSetupExpoExampleApp.ts",
|
|
76
76
|
"type:src": "tsc --project tsconfig.json --noEmit",
|
|
77
77
|
"type:cli": "npm run --workspace cli typecheck",
|
|
78
78
|
"type:scripts": "tsc --project scripts/tsconfig.json --noEmit",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"prepack": "npm run build:cli",
|
|
83
83
|
"eslint": "eslint --quiet .",
|
|
84
84
|
"jest": "jest src/CodePush.test.js src/versioning/* expo/* && npm run --workspace cli test",
|
|
85
|
-
"e2e": "
|
|
85
|
+
"e2e": "tsx e2e/run.ts"
|
|
86
86
|
},
|
|
87
87
|
"publishConfig": {
|
|
88
88
|
"access": "public"
|
|
@@ -102,11 +102,19 @@
|
|
|
102
102
|
},
|
|
103
103
|
"peerDependencies": {
|
|
104
104
|
"expo": ">=50.0.0",
|
|
105
|
-
"react-native": "*"
|
|
105
|
+
"react-native": "*",
|
|
106
|
+
"ts-node": ">=10",
|
|
107
|
+
"tsx": ">=4"
|
|
106
108
|
},
|
|
107
109
|
"peerDependenciesMeta": {
|
|
108
110
|
"expo": {
|
|
109
111
|
"optional": true
|
|
112
|
+
},
|
|
113
|
+
"ts-node": {
|
|
114
|
+
"optional": true
|
|
115
|
+
},
|
|
116
|
+
"tsx": {
|
|
117
|
+
"optional": true
|
|
110
118
|
}
|
|
111
119
|
},
|
|
112
120
|
"devDependencies": {
|
|
@@ -128,7 +136,7 @@
|
|
|
128
136
|
"jest": "^29.7.0",
|
|
129
137
|
"react": "19.2.3",
|
|
130
138
|
"react-native": "0.84.1",
|
|
131
|
-
"
|
|
139
|
+
"tsx": "^4.23.13",
|
|
132
140
|
"typescript": "5.0.4",
|
|
133
141
|
"typescript-eslint": "^8.11.0"
|
|
134
142
|
},
|
package/src/CodePush.js
CHANGED
|
@@ -200,6 +200,11 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
|
|
|
200
200
|
* back on when the diff fails on its asset side.
|
|
201
201
|
*/
|
|
202
202
|
asset_diff_download_url: diffPackageDownloadUrl,
|
|
203
|
+
/**
|
|
204
|
+
* Only present when the release asked for a background duration of its own, which
|
|
205
|
+
* then wins over the one the `sync` call passes.
|
|
206
|
+
*/
|
|
207
|
+
minimum_background_duration: latestReleaseInfo.minimumBackgroundDuration,
|
|
203
208
|
// (`enabled` will always be true in the release information obtained from the previous process.)
|
|
204
209
|
is_available: latestReleaseInfo.enabled,
|
|
205
210
|
package_hash: latestReleaseInfo.packageHash,
|
|
@@ -309,6 +314,11 @@ function mapToRemotePackageMetadata(updateInfo) {
|
|
|
309
314
|
...(updateInfo.asset_diff_download_url
|
|
310
315
|
? { assetDiffDownloadUrl: updateInfo.asset_diff_download_url }
|
|
311
316
|
: {}),
|
|
317
|
+
// Presence is read off the type, not off truthiness, so that a release asking for `0`
|
|
318
|
+
// seconds keeps its own value instead of looking like a release that asked for nothing.
|
|
319
|
+
...(typeof updateInfo.minimum_background_duration === 'number'
|
|
320
|
+
? { minimumBackgroundDuration: updateInfo.minimum_background_duration }
|
|
321
|
+
: {}),
|
|
312
322
|
};
|
|
313
323
|
}
|
|
314
324
|
|
|
@@ -552,6 +562,7 @@ const sync = (() => {
|
|
|
552
562
|
*/
|
|
553
563
|
async function syncInternal(options = {}, syncStatusChangeCallback, downloadProgressCallback, handleBinaryVersionMismatchCallback) {
|
|
554
564
|
let resolvedInstallMode;
|
|
565
|
+
let resolvedMinimumBackgroundDuration;
|
|
555
566
|
const syncOptions = {
|
|
556
567
|
deploymentKey: null,
|
|
557
568
|
ignoreFailedUpdates: true,
|
|
@@ -596,8 +607,8 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
|
|
|
596
607
|
if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
|
|
597
608
|
log("Update is installed and will be run on the next app restart.");
|
|
598
609
|
} else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
|
|
599
|
-
if (
|
|
600
|
-
log(`Update is installed and will be run after the app has been in the background for at least ${
|
|
610
|
+
if (resolvedMinimumBackgroundDuration > 0) {
|
|
611
|
+
log(`Update is installed and will be run after the app has been in the background for at least ${resolvedMinimumBackgroundDuration} seconds.`);
|
|
601
612
|
} else {
|
|
602
613
|
log("Update is installed and will be run when the app next resumes.");
|
|
603
614
|
}
|
|
@@ -630,9 +641,11 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
|
|
|
630
641
|
|
|
631
642
|
// Determine the correct install mode based on whether the update is mandatory or not.
|
|
632
643
|
resolvedInstallMode = localPackage.isMandatory ? syncOptions.mandatoryInstallMode : syncOptions.installMode;
|
|
644
|
+
// `??` rather than `||`, so a release asking for `0` seconds keeps its own value.
|
|
645
|
+
resolvedMinimumBackgroundDuration = remotePackage.minimumBackgroundDuration ?? syncOptions.minimumBackgroundDuration;
|
|
633
646
|
|
|
634
647
|
syncStatusChangeCallback(CodePush.SyncStatus.INSTALLING_UPDATE);
|
|
635
|
-
await localPackage.install(resolvedInstallMode,
|
|
648
|
+
await localPackage.install(resolvedInstallMode, resolvedMinimumBackgroundDuration, () => {
|
|
636
649
|
syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_INSTALLED);
|
|
637
650
|
});
|
|
638
651
|
|
package/src/CodePush.test.js
CHANGED
|
@@ -649,3 +649,60 @@ describe('telemetry callback errors', () => {
|
|
|
649
649
|
expect(nativeBridge.saveStatusReportForRetry).not.toHaveBeenCalled();
|
|
650
650
|
});
|
|
651
651
|
});
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* A release can ask for its own minimum background duration - how long the app has to have
|
|
655
|
+
* been in the background before an update installed with `ON_NEXT_RESUME` is applied. The
|
|
656
|
+
* app passes a duration to `sync` for the releases that ask for nothing, and whichever of
|
|
657
|
+
* the two wins is what the native module is given when the update is installed.
|
|
658
|
+
*/
|
|
659
|
+
describe('the minimum background duration an update is installed with', () => {
|
|
660
|
+
/** The release the CLI writes when the release carries its own background duration. */
|
|
661
|
+
function releaseAskingFor(minimumBackgroundDuration) {
|
|
662
|
+
const history = fullOnlyRelease();
|
|
663
|
+
history[LABEL].minimumBackgroundDuration = minimumBackgroundDuration;
|
|
664
|
+
return history;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** The background duration the native module is given, in seconds. */
|
|
668
|
+
function installedMinimumBackgroundDuration(nativeBridge) {
|
|
669
|
+
expect(nativeBridge.installUpdate).toHaveBeenCalledTimes(1);
|
|
670
|
+
return nativeBridge.installUpdate.mock.calls[0][2];
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
it('takes the one the release history entry asks for over the one the sync call passes', async () => {
|
|
674
|
+
const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: releaseAskingFor(600) });
|
|
675
|
+
|
|
676
|
+
const syncStatus = await CodePush.sync({
|
|
677
|
+
installMode: InstallMode.ON_NEXT_RESUME,
|
|
678
|
+
minimumBackgroundDuration: 3600,
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
|
|
682
|
+
expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(600);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('keeps the one the sync call passes when the release history entry asks for none', async () => {
|
|
686
|
+
const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: fullOnlyRelease() });
|
|
687
|
+
|
|
688
|
+
const syncStatus = await CodePush.sync({
|
|
689
|
+
installMode: InstallMode.ON_NEXT_RESUME,
|
|
690
|
+
minimumBackgroundDuration: 3600,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
|
|
694
|
+
expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(3600);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('runs a release that asks for zero seconds on the next resume, however long the sync call would have waited', async () => {
|
|
698
|
+
const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: releaseAskingFor(0) });
|
|
699
|
+
|
|
700
|
+
const syncStatus = await CodePush.sync({
|
|
701
|
+
installMode: InstallMode.ON_NEXT_RESUME,
|
|
702
|
+
minimumBackgroundDuration: 3600,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
|
|
706
|
+
expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(0);
|
|
707
|
+
});
|
|
708
|
+
});
|