@hot-updater/android-helper 1.1.4 → 1.2.0-rc.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/dist/index.cjs +1375 -379
- package/dist/index.d.cts +237 -17
- package/dist/index.d.mts +238 -17
- package/dist/index.mjs +1357 -379
- package/package.json +5 -4
package/dist/index.cjs
CHANGED
|
@@ -24,12 +24,21 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
let path = require("path");
|
|
25
25
|
path = __toESM(path, 1);
|
|
26
26
|
let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
|
|
27
|
-
let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
|
|
28
27
|
let fs = require("fs");
|
|
29
28
|
fs = __toESM(fs, 1);
|
|
30
29
|
let execa = require("execa");
|
|
31
|
-
let
|
|
32
|
-
|
|
30
|
+
let os = require("os");
|
|
31
|
+
os = __toESM(os, 1);
|
|
32
|
+
let jszip = require("jszip");
|
|
33
|
+
jszip = __toESM(jszip, 1);
|
|
34
|
+
let crypto = require("crypto");
|
|
35
|
+
crypto = __toESM(crypto, 1);
|
|
36
|
+
let stream_promises = require("stream/promises");
|
|
37
|
+
let zlib = require("zlib");
|
|
38
|
+
let node_crypto = require("node:crypto");
|
|
39
|
+
node_crypto = __toESM(node_crypto, 1);
|
|
40
|
+
let node_http = require("node:http");
|
|
41
|
+
node_http = __toESM(node_http, 1);
|
|
33
42
|
//#region src/utils/createGradleLogger.ts
|
|
34
43
|
const createGradleLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLogger({
|
|
35
44
|
logPrefix,
|
|
@@ -140,25 +149,29 @@ const createGradleLogger = ({ logPrefix }) => new _hot_updater_cli_tools.BuildLo
|
|
|
140
149
|
],
|
|
141
150
|
["bundleRelease", "bundleDebug"],
|
|
142
151
|
["BUILD SUCCESSFUL", /\d+ actionable tasks:/]
|
|
143
|
-
]
|
|
152
|
+
],
|
|
153
|
+
failureSummary: {
|
|
154
|
+
lines: [
|
|
155
|
+
/^e: /,
|
|
156
|
+
/(^|:\s)error: /,
|
|
157
|
+
/^BUILD FAILED/
|
|
158
|
+
],
|
|
159
|
+
blocks: [{
|
|
160
|
+
start: "* What went wrong:",
|
|
161
|
+
end: "* Try:",
|
|
162
|
+
includeEnd: false
|
|
163
|
+
}]
|
|
164
|
+
}
|
|
144
165
|
});
|
|
145
166
|
//#endregion
|
|
146
167
|
//#region src/builder/runGradle.ts
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
"> Run with --stacktrace option to get the stack trace.",
|
|
155
|
-
"> Run with --info or --debug option to get more log output.",
|
|
156
|
-
"> Run with --scan to get full insights.",
|
|
157
|
-
"> Get more help at [undefined](https://help.gradle.org).",
|
|
158
|
-
"> Get more help at https://help.gradle.org.",
|
|
159
|
-
"BUILD FAILED"
|
|
160
|
-
];
|
|
161
|
-
return error.message.split("\n").filter((line) => !gradleLinesToRemove.some((l) => line.includes(l))).join("\n").trim();
|
|
168
|
+
/** Why Gradle failed, without its command line. */
|
|
169
|
+
const describeFailure = (error) => {
|
|
170
|
+
if (error instanceof execa.ExecaError) {
|
|
171
|
+
if (error.signal) return `Gradle was stopped by ${error.signal}.`;
|
|
172
|
+
if (error.exitCode !== void 0) return `Gradle exited with code ${error.exitCode}.`;
|
|
173
|
+
}
|
|
174
|
+
return error instanceof Error ? error.message : String(error);
|
|
162
175
|
};
|
|
163
176
|
/**
|
|
164
177
|
* Generate gradle task names with module prefix
|
|
@@ -173,13 +186,13 @@ const getGradleWrapper = () => process.platform.startsWith("win") ? "gradlew.bat
|
|
|
173
186
|
/**
|
|
174
187
|
* Run gradle build with specified tasks and arguments
|
|
175
188
|
*/
|
|
176
|
-
async function runGradle({ tasks, logPrefix, args, androidProjectPath, appModuleName }) {
|
|
189
|
+
async function runGradle({ tasks, logPrefix, args, env, androidProjectPath, appModuleName }) {
|
|
177
190
|
const gradleArgs = getTaskNames(appModuleName, tasks);
|
|
178
191
|
gradleArgs.push("-x", "lint");
|
|
179
192
|
if (args.extraParams) gradleArgs.push(...args.extraParams);
|
|
180
193
|
_hot_updater_cli_tools.p.log.info(`Run Gradle Settings:
|
|
181
194
|
Project ${androidProjectPath}
|
|
182
|
-
App
|
|
195
|
+
App Module ${appModuleName}
|
|
183
196
|
Tasks ${tasks.join(", ")}
|
|
184
197
|
Args ${gradleArgs.join(" ")}
|
|
185
198
|
`);
|
|
@@ -187,20 +200,25 @@ Args ${gradleArgs.join(" ")}
|
|
|
187
200
|
const logger = createGradleLogger({ logPrefix });
|
|
188
201
|
await logger.start();
|
|
189
202
|
try {
|
|
190
|
-
const
|
|
203
|
+
const gradle = (0, execa.execa)(getGradleWrapper(), gradleArgs, {
|
|
191
204
|
cwd: androidProjectPath,
|
|
192
|
-
|
|
205
|
+
env,
|
|
206
|
+
all: true,
|
|
207
|
+
buffer: false
|
|
193
208
|
});
|
|
194
|
-
const outputStream =
|
|
195
|
-
|
|
196
|
-
|
|
209
|
+
const outputStream = gradle.all ?? gradle.stdout;
|
|
210
|
+
const [gradleResult, loggingResult] = await Promise.allSettled([gradle, outputStream ? logger.processStream(outputStream) : void 0]);
|
|
211
|
+
if (gradleResult.status === "rejected") throw gradleResult.reason;
|
|
212
|
+
if (loggingResult.status === "rejected") throw loggingResult.reason;
|
|
197
213
|
logger.stop();
|
|
198
214
|
} catch (e) {
|
|
199
215
|
logger.writeError(e);
|
|
200
216
|
logger.stop("Build failed", false);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
217
|
+
throw new _hot_updater_cli_tools.BuildFailedError(`Build failed: ${describeFailure(e)}`, {
|
|
218
|
+
logPath: logger.logPath,
|
|
219
|
+
summary: logger.failureSummary,
|
|
220
|
+
cause: e
|
|
221
|
+
});
|
|
204
222
|
} finally {
|
|
205
223
|
await logger.close();
|
|
206
224
|
}
|
|
@@ -243,429 +261,1407 @@ async function getBuildOutputFilePath({ aab, appModuleName, buildDirectory, vari
|
|
|
243
261
|
process.exit(1);
|
|
244
262
|
}
|
|
245
263
|
//#endregion
|
|
246
|
-
//#region src/
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
264
|
+
//#region src/utils/gradleSigning.ts
|
|
265
|
+
const isDebugVariant = (variant) => /debug$/i.test(variant);
|
|
266
|
+
/**
|
|
267
|
+
* The environment that signs a release variant with the scheme's keystore,
|
|
268
|
+
* through the properties Android Studio's signed builds inject. Gradle reads
|
|
269
|
+
* `ORG_GRADLE_PROJECT_*` variables as project properties, so the passwords
|
|
270
|
+
* never appear in the Gradle command line. The keystore is resolved only
|
|
271
|
+
* here: a debug variant, or a scheme without `signing`, resolves nothing.
|
|
272
|
+
* Without `credentials`, it resolves from the environment or the config's
|
|
273
|
+
* `secrets` adapter, and the temporary files are deleted when the process
|
|
274
|
+
* exits.
|
|
275
|
+
*/
|
|
276
|
+
const resolveGradleSigningEnv = async ({ schemeConfig, credentials }) => {
|
|
277
|
+
const reference = schemeConfig.signing?.keystore;
|
|
278
|
+
if (!reference) return;
|
|
279
|
+
if (isDebugVariant(schemeConfig.variant)) {
|
|
280
|
+
_hot_updater_cli_tools.p.log.info(`${schemeConfig.variant} signs with the debug keystore, so signing.keystore is not used.`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const keystore = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
|
|
284
|
+
kind: "android-keystore",
|
|
285
|
+
name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.keystore")
|
|
255
286
|
});
|
|
287
|
+
return (0, _hot_updater_cli_tools.assertUnreservedEnv)({
|
|
288
|
+
"ORG_GRADLE_PROJECT_android.injected.signing.store.file": keystore.storeFile,
|
|
289
|
+
"ORG_GRADLE_PROJECT_android.injected.signing.store.password": keystore.storePassword,
|
|
290
|
+
"ORG_GRADLE_PROJECT_android.injected.signing.key.alias": keystore.keyAlias,
|
|
291
|
+
"ORG_GRADLE_PROJECT_android.injected.signing.key.password": keystore.keyPassword
|
|
292
|
+
}, "The Gradle signing environment");
|
|
256
293
|
};
|
|
257
294
|
//#endregion
|
|
258
|
-
//#region src/utils/
|
|
295
|
+
//#region src/utils/readBuiltApp.ts
|
|
296
|
+
const CHANNEL_META_DATA_KEY = "com.hotupdater.CHANNEL";
|
|
297
|
+
/** The SDK Gradle builds with: the environment's, or local.properties'. */
|
|
298
|
+
const findAndroidSdk = (androidProjectPath) => {
|
|
299
|
+
const fromEnv = process.env["ANDROID_HOME"] || process.env["ANDROID_SDK_ROOT"];
|
|
300
|
+
if (fromEnv) return fromEnv;
|
|
301
|
+
const localProperties = path.default.join(androidProjectPath, "local.properties");
|
|
302
|
+
if (!fs.default.existsSync(localProperties)) return;
|
|
303
|
+
return (fs.default.readFileSync(localProperties, "utf-8").match(/^sdk\.dir=(.*)$/m)?.[1])?.trim().replace(/\\(.)/g, "$1");
|
|
304
|
+
};
|
|
305
|
+
const compareVersions = (a, b) => {
|
|
306
|
+
const left = a.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
307
|
+
const right = b.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
308
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
309
|
+
const difference = (left[i] ?? 0) - (right[i] ?? 0);
|
|
310
|
+
if (difference !== 0) return difference;
|
|
311
|
+
}
|
|
312
|
+
return 0;
|
|
313
|
+
};
|
|
314
|
+
/** The newest build-tools' aapt2, which reads the artifact Gradle wrote. */
|
|
315
|
+
const findAapt2 = (androidProjectPath) => {
|
|
316
|
+
const sdk = findAndroidSdk(androidProjectPath);
|
|
317
|
+
const buildTools = sdk && path.default.join(sdk, "build-tools");
|
|
318
|
+
if (!buildTools || !fs.default.existsSync(buildTools)) return;
|
|
319
|
+
const executable = process.platform.startsWith("win") ? "aapt2.exe" : "aapt2";
|
|
320
|
+
return fs.default.readdirSync(buildTools).sort(compareVersions).reverse().map((version) => path.default.join(buildTools, version, executable)).find((aapt2) => fs.default.existsSync(aapt2));
|
|
321
|
+
};
|
|
322
|
+
const RAW_MARKER = "\" (Raw: \"";
|
|
323
|
+
/**
|
|
324
|
+
* An attribute aapt2 prints as `="<value>" (Raw: "<value>")`, given the text
|
|
325
|
+
* after `="`. aapt2 does not escape the value, so it is the prefix the rest
|
|
326
|
+
* of the line repeats; a value with a line break, which aapt2 indents, reads
|
|
327
|
+
* as undefined.
|
|
328
|
+
*/
|
|
329
|
+
const readQuotedValue = (rest) => {
|
|
330
|
+
for (let end = rest.indexOf(RAW_MARKER); end !== -1; end = rest.indexOf(RAW_MARKER, end + 1)) {
|
|
331
|
+
const value = rest.slice(0, end);
|
|
332
|
+
if (rest === `${value}${RAW_MARKER}${value}")`) return value;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
259
335
|
/**
|
|
260
|
-
*
|
|
336
|
+
* The value of the attribute `marker` (such as `:value(0x01010024)=`) on
|
|
337
|
+
* `line`: null without one, and undefined when it cannot be read exactly.
|
|
261
338
|
*/
|
|
262
|
-
const
|
|
263
|
-
|
|
339
|
+
const readAttribute = (line, marker) => {
|
|
340
|
+
const start = line.indexOf(marker);
|
|
341
|
+
if (start === -1) return null;
|
|
342
|
+
const rest = line.slice(start + marker.length);
|
|
343
|
+
if (rest.startsWith("\"")) return readQuotedValue(rest.slice(1));
|
|
344
|
+
return rest.match(/^@[\w./:]+/)?.[0];
|
|
264
345
|
};
|
|
265
346
|
/**
|
|
266
|
-
*
|
|
347
|
+
* A `<meta-data>` value from `aapt2 dump xmltree` of the manifest: the
|
|
348
|
+
* literal, or a resource reference such as `@string/channel`. null when the
|
|
349
|
+
* manifest has none, and undefined when aapt2 printed one it cannot read
|
|
350
|
+
* exactly.
|
|
267
351
|
*/
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
352
|
+
const parseManifestMetaData = (xmltree, name) => {
|
|
353
|
+
const lines = xmltree.split("\n");
|
|
354
|
+
for (let i = 0; i < lines.length; i++) {
|
|
355
|
+
if (!/^\s*E: meta-data\b/.test(lines[i])) continue;
|
|
356
|
+
let metaDataName = null;
|
|
357
|
+
let value = null;
|
|
358
|
+
for (let j = i + 1; j < lines.length && /^\s*A: /.test(lines[j]); j++) {
|
|
359
|
+
metaDataName ||= readAttribute(lines[j], ":name(0x01010003)=");
|
|
360
|
+
if (value === null) value = readAttribute(lines[j], ":value(0x01010024)=");
|
|
361
|
+
}
|
|
362
|
+
if (metaDataName === name) return value ?? void 0;
|
|
275
363
|
}
|
|
276
|
-
return
|
|
364
|
+
return null;
|
|
277
365
|
};
|
|
278
366
|
/**
|
|
279
|
-
*
|
|
367
|
+
* The `android:versionName` of the `<manifest>` element in `aapt2 dump
|
|
368
|
+
* xmltree`, which Gradle merges from the variant, with its suffixes: null
|
|
369
|
+
* without one, and undefined when it cannot be read exactly.
|
|
280
370
|
*/
|
|
281
|
-
const
|
|
282
|
-
const
|
|
371
|
+
const parseManifestVersionName = (xmltree) => {
|
|
372
|
+
for (const line of xmltree.split("\n")) {
|
|
373
|
+
const value = readAttribute(line, ":versionName(0x0101021c)=");
|
|
374
|
+
if (value !== null) return value;
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
};
|
|
378
|
+
/**
|
|
379
|
+
* The `android:versionCode` of the `<manifest>` element in `aapt2 dump
|
|
380
|
+
* xmltree`, an integer aapt2 prints bare or quoted: null without one.
|
|
381
|
+
*/
|
|
382
|
+
const parseManifestVersionCode = (xmltree) => {
|
|
383
|
+
const match = /:versionCode\(0x0101021b\)="?(\d+)/.exec(xmltree);
|
|
384
|
+
return match ? Number(match[1]) : null;
|
|
385
|
+
};
|
|
386
|
+
/**
|
|
387
|
+
* The string a resource reference resolves to, from `aapt2 dump resources`:
|
|
388
|
+
* its default configuration's value, or else its first. undefined when the
|
|
389
|
+
* table has none aapt2 printed on one line.
|
|
390
|
+
*/
|
|
391
|
+
const parseResourceString = (resources, reference) => {
|
|
392
|
+
const lines = resources.split("\n");
|
|
393
|
+
const start = lines.findIndex((line) => {
|
|
394
|
+
const [, id, name] = line.match(/^\s*resource (0x[0-9a-f]+) (\S+)/) ?? [];
|
|
395
|
+
return id !== void 0 && (reference === `@${id}` || reference === `@${name}`);
|
|
396
|
+
});
|
|
397
|
+
if (start === -1) return;
|
|
398
|
+
const values = [];
|
|
399
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
400
|
+
if (/^\s*resource 0x/.test(lines[i])) break;
|
|
401
|
+
const match = lines[i].match(/^\s*\(([^)]*)\) "(.*)"$/);
|
|
402
|
+
if (match) values.push({
|
|
403
|
+
config: match[1],
|
|
404
|
+
value: match[2]
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return (values.find(({ config }) => config === "") ?? values[0])?.value;
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* An `.aab` module is a proto-format APK under `base/`, which aapt2 reads
|
|
411
|
+
* once its manifest and resource table sit at the root of a zip.
|
|
412
|
+
*/
|
|
413
|
+
const writeBaseModuleApk = async (aabPath, directory) => {
|
|
414
|
+
const bundle = await jszip.default.loadAsync(await fs.default.promises.readFile(aabPath));
|
|
415
|
+
const module = new jszip.default();
|
|
416
|
+
for (const [entry, name] of [["base/manifest/AndroidManifest.xml", "AndroidManifest.xml"], ["base/resources.pb", "resources.pb"]]) {
|
|
417
|
+
const file = bundle.file(entry);
|
|
418
|
+
if (file) module.file(name, await file.async("uint8array"));
|
|
419
|
+
}
|
|
420
|
+
const apkPath = path.default.join(directory, "base.apk");
|
|
421
|
+
await fs.default.promises.writeFile(apkPath, await module.generateAsync({ type: "uint8array" }));
|
|
422
|
+
return apkPath;
|
|
423
|
+
};
|
|
424
|
+
/**
|
|
425
|
+
* What the built `.apk` or `.aab` reports at runtime: the `versionName` and
|
|
426
|
+
* the `com.hotupdater.CHANNEL` meta-data Gradle merged into its manifest,
|
|
427
|
+
* from flavors, build types and `versionNameSuffix`. Without aapt2 it
|
|
428
|
+
* returns undefined, and the build reports the project's values.
|
|
429
|
+
*/
|
|
430
|
+
const readBuiltApp = async ({ artifactPath, androidProjectPath }) => {
|
|
431
|
+
const aapt2 = findAapt2(androidProjectPath);
|
|
432
|
+
if (!aapt2) {
|
|
433
|
+
_hot_updater_cli_tools.p.log.warn("aapt2 was not found in the Android SDK's build-tools, so the build reports the app version and channel of the project files.");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "android-helper-"));
|
|
283
437
|
try {
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
438
|
+
const apkPath = artifactPath.endsWith(".aab") ? await writeBaseModuleApk(artifactPath, directory) : artifactPath;
|
|
439
|
+
const { stdout: xmltree } = await (0, execa.execa)(aapt2, [
|
|
440
|
+
"dump",
|
|
441
|
+
"xmltree",
|
|
442
|
+
"--file",
|
|
443
|
+
"AndroidManifest.xml",
|
|
444
|
+
apkPath
|
|
445
|
+
]);
|
|
446
|
+
let resources;
|
|
447
|
+
const resolve = async (value) => {
|
|
448
|
+
if (!value?.startsWith("@")) return value;
|
|
449
|
+
resources ??= (0, execa.execa)(aapt2, [
|
|
450
|
+
"dump",
|
|
451
|
+
"resources",
|
|
452
|
+
apkPath
|
|
453
|
+
]).then(({ stdout }) => stdout);
|
|
454
|
+
return parseResourceString(await resources, value);
|
|
455
|
+
};
|
|
456
|
+
const appVersion = await resolve(parseManifestVersionName(xmltree));
|
|
457
|
+
const channel = await resolve(parseManifestMetaData(xmltree, CHANNEL_META_DATA_KEY));
|
|
458
|
+
if (appVersion === void 0 || channel === void 0) _hot_updater_cli_tools.p.log.warn("aapt2 printed the artifact's versionName or com.hotupdater.CHANNEL in a form that cannot be read exactly, so the build reports the project files' value for it.");
|
|
459
|
+
const versionCode = parseManifestVersionCode(xmltree);
|
|
460
|
+
return {
|
|
461
|
+
appVersion: appVersion ?? null,
|
|
462
|
+
channel,
|
|
463
|
+
buildNumber: versionCode === null ? null : String(versionCode)
|
|
464
|
+
};
|
|
465
|
+
} finally {
|
|
466
|
+
await fs.default.promises.rm(directory, {
|
|
467
|
+
recursive: true,
|
|
468
|
+
force: true
|
|
469
|
+
});
|
|
288
470
|
}
|
|
289
471
|
};
|
|
290
472
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
473
|
+
* The application ID of the built `.apk` or `.aab`, the package name Google
|
|
474
|
+
* Play files it under, with the suffixes flavors and build types add.
|
|
475
|
+
* undefined without aapt2.
|
|
293
476
|
*/
|
|
294
|
-
async
|
|
477
|
+
const readBuiltPackageName = async ({ artifactPath, androidProjectPath }) => {
|
|
478
|
+
const aapt2 = findAapt2(androidProjectPath);
|
|
479
|
+
if (!aapt2) return;
|
|
480
|
+
const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "android-helper-"));
|
|
295
481
|
try {
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
"
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
482
|
+
const apkPath = artifactPath.endsWith(".aab") ? await writeBaseModuleApk(artifactPath, directory) : artifactPath;
|
|
483
|
+
const { stdout } = await (0, execa.execa)(aapt2, [
|
|
484
|
+
"dump",
|
|
485
|
+
"packagename",
|
|
486
|
+
apkPath
|
|
487
|
+
]);
|
|
488
|
+
return stdout.trim() || void 0;
|
|
489
|
+
} finally {
|
|
490
|
+
await fs.default.promises.rm(directory, {
|
|
491
|
+
recursive: true,
|
|
492
|
+
force: true
|
|
493
|
+
});
|
|
308
494
|
}
|
|
309
|
-
}
|
|
495
|
+
};
|
|
310
496
|
/**
|
|
311
|
-
*
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
"emu",
|
|
319
|
-
"avd",
|
|
320
|
-
"name"
|
|
321
|
-
], { stdio: "pipe" });
|
|
322
|
-
if (!stdout) return "";
|
|
323
|
-
return stdout.split(node_os.default.EOL)[0].replace(/(\r\n|\n|\r)/gm, "").trim();
|
|
324
|
-
};
|
|
325
|
-
/**
|
|
326
|
-
* Get Android device name in readable format
|
|
327
|
-
*/
|
|
328
|
-
const getPhoneName = async (deviceId) => {
|
|
329
|
-
const adbPath = getAdbPath();
|
|
330
|
-
const { stdout } = await (0, execa.execa)(adbPath, [
|
|
331
|
-
"-s",
|
|
332
|
-
deviceId,
|
|
333
|
-
"shell",
|
|
334
|
-
"getprop",
|
|
335
|
-
"ro.product.model"
|
|
336
|
-
], { stdio: "pipe" });
|
|
337
|
-
return stdout.replace(/\[ro\.product\.model\]:\s*\[(.*)\]/, "$1").trim();
|
|
338
|
-
};
|
|
339
|
-
/**
|
|
340
|
-
* Check if emulator has finished booting
|
|
341
|
-
*/
|
|
342
|
-
const isEmulatorBooted = async (device) => {
|
|
343
|
-
const adbPath = getAdbPath();
|
|
344
|
-
const adbArgs = [
|
|
345
|
-
"-s",
|
|
346
|
-
device,
|
|
347
|
-
"shell",
|
|
348
|
-
"getprop",
|
|
349
|
-
"sys.boot_completed"
|
|
350
|
-
];
|
|
497
|
+
* The version code of the built `.apk` or `.aab`, which Google Play files
|
|
498
|
+
* its releases by. undefined without aapt2, or when the manifest has none.
|
|
499
|
+
*/
|
|
500
|
+
const readBuiltVersionCode = async ({ artifactPath, androidProjectPath }) => {
|
|
501
|
+
const aapt2 = findAapt2(androidProjectPath);
|
|
502
|
+
if (!aapt2) return;
|
|
503
|
+
const directory = await fs.default.promises.mkdtemp(path.default.join(os.default.tmpdir(), "android-helper-"));
|
|
351
504
|
try {
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
505
|
+
const apkPath = artifactPath.endsWith(".aab") ? await writeBaseModuleApk(artifactPath, directory) : artifactPath;
|
|
506
|
+
const { stdout } = await (0, execa.execa)(aapt2, [
|
|
507
|
+
"dump",
|
|
508
|
+
"xmltree",
|
|
509
|
+
"--file",
|
|
510
|
+
"AndroidManifest.xml",
|
|
511
|
+
apkPath
|
|
512
|
+
]);
|
|
513
|
+
return parseManifestVersionCode(stdout) ?? void 0;
|
|
514
|
+
} finally {
|
|
515
|
+
await fs.default.promises.rm(directory, {
|
|
516
|
+
recursive: true,
|
|
517
|
+
force: true
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
//#endregion
|
|
522
|
+
//#region src/buildAndroid.ts
|
|
523
|
+
const buildAndroid = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
|
|
524
|
+
const androidProjectPath = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "android");
|
|
525
|
+
const result = await runGradle({
|
|
526
|
+
args: { extraParams: [`-PMIN_BUNDLE_ID=${minBundleId}`] },
|
|
527
|
+
env: await resolveGradleSigningEnv({
|
|
528
|
+
schemeConfig,
|
|
529
|
+
credentials
|
|
530
|
+
}),
|
|
531
|
+
appModuleName: schemeConfig.appModuleName,
|
|
532
|
+
tasks: schemeConfig.aab ? [`bundle${schemeConfig.variant}`] : [`assemble${schemeConfig.variant}`],
|
|
533
|
+
logPrefix: `android-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
534
|
+
androidProjectPath
|
|
535
|
+
});
|
|
536
|
+
return {
|
|
537
|
+
...result,
|
|
538
|
+
builtApp: await readBuiltApp({
|
|
539
|
+
artifactPath: result.buildArtifactPath,
|
|
540
|
+
androidProjectPath
|
|
541
|
+
})
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region src/uploadToPlay.ts
|
|
546
|
+
const API_URL = "https://androidpublisher.googleapis.com";
|
|
547
|
+
/** Where Google serves resumable upload sessions, as its docs show both. */
|
|
548
|
+
const UPLOAD_ORIGINS = /* @__PURE__ */ new Set([API_URL, "https://www.googleapis.com"]);
|
|
549
|
+
const DEFAULT_TOKEN_URI = "https://oauth2.googleapis.com/token";
|
|
550
|
+
const SCOPE = "https://www.googleapis.com/auth/androidpublisher";
|
|
551
|
+
/** Google accepts assertions that expire within an hour. */
|
|
552
|
+
const ASSERTION_LIFETIME_SECONDS = 3600;
|
|
553
|
+
/** An access token is replaced well before it expires. */
|
|
554
|
+
const TOKEN_REUSE_MS = 27e5;
|
|
555
|
+
/** Google asks for multiples of 256 KiB; only one chunk is held in memory. */
|
|
556
|
+
const CHUNK_SIZE = 8388608;
|
|
557
|
+
/** Attempts per chunk, each resuming at the offset Google confirms. */
|
|
558
|
+
const CHUNK_ATTEMPTS = 3;
|
|
559
|
+
/** Commits while Google is still processing the uploaded bundle. */
|
|
560
|
+
const COMMIT_ATTEMPTS = 6;
|
|
561
|
+
/** The track `--submit play-internal` releases to. */
|
|
562
|
+
const INTERNAL_TRACK = "internal";
|
|
563
|
+
const BUNDLE_STILL_PROCESSING = "Some of the Android App Bundle uploads are not completed yet";
|
|
564
|
+
const NEEDS_CHANGES_NOT_SENT_FOR_REVIEW = "Please set the query parameter changesNotSentForReview to true";
|
|
565
|
+
const DRAFT_APP_ERRORS = ["Only releases with status draft may be created on draft app"];
|
|
566
|
+
/** A Google Play Developer API request that failed, with Google's message. */
|
|
567
|
+
var GooglePlayError = class extends Error {
|
|
568
|
+
status;
|
|
569
|
+
apiMessage;
|
|
570
|
+
constructor(action, status, apiMessage) {
|
|
571
|
+
super(`${action} failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : "."}`);
|
|
572
|
+
this.name = "GooglePlayError";
|
|
573
|
+
this.status = status;
|
|
574
|
+
this.apiMessage = apiMessage;
|
|
356
575
|
}
|
|
357
576
|
};
|
|
358
|
-
const emulatorCommand = process.env["ANDROID_HOME"] ? `${process.env["ANDROID_HOME"]}/emulator/emulator` : "emulator";
|
|
359
577
|
/**
|
|
360
|
-
*
|
|
578
|
+
* Reads the service account JSON in memory: its private key never touches
|
|
579
|
+
* the disk, and its contents are never echoed.
|
|
361
580
|
*/
|
|
362
|
-
const
|
|
581
|
+
const readServiceAccount = (json) => {
|
|
582
|
+
let parsed;
|
|
583
|
+
try {
|
|
584
|
+
parsed = JSON.parse(json);
|
|
585
|
+
} catch {
|
|
586
|
+
throw new Error("The Play service account is not a JSON key file.");
|
|
587
|
+
}
|
|
588
|
+
const { client_email, private_key, token_uri } = parsed;
|
|
589
|
+
if (typeof client_email !== "string" || typeof private_key !== "string") throw new Error("The Play service account has no client_email or private_key: use the JSON key file Google Cloud creates for the service account.");
|
|
590
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(private_key);
|
|
591
|
+
let privateKey;
|
|
363
592
|
try {
|
|
364
|
-
|
|
365
|
-
return stdout.split(node_os.default.EOL).filter((name) => name !== "" && !name.includes(" "));
|
|
593
|
+
privateKey = crypto.default.createPrivateKey(private_key);
|
|
366
594
|
} catch {
|
|
367
|
-
|
|
595
|
+
throw new Error("The Play service account's private_key is no private key.");
|
|
368
596
|
}
|
|
597
|
+
const tokenUri = typeof token_uri === "string" ? token_uri : DEFAULT_TOKEN_URI;
|
|
598
|
+
if (new URL(tokenUri).protocol !== "https:") throw new Error("The Play service account's token_uri is not an https URL.");
|
|
599
|
+
return {
|
|
600
|
+
clientEmail: client_email,
|
|
601
|
+
privateKey,
|
|
602
|
+
tokenUri
|
|
603
|
+
};
|
|
604
|
+
};
|
|
605
|
+
/** The RS256 JWT a service account exchanges for an access token. */
|
|
606
|
+
const createServiceAccountAssertion = ({ clientEmail, privateKey, tokenUri, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
|
|
607
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
608
|
+
const signingInput = `${encode({
|
|
609
|
+
alg: "RS256",
|
|
610
|
+
typ: "JWT"
|
|
611
|
+
})}.${encode({
|
|
612
|
+
iss: clientEmail,
|
|
613
|
+
scope: SCOPE,
|
|
614
|
+
aud: tokenUri,
|
|
615
|
+
iat: issuedAtSeconds,
|
|
616
|
+
exp: issuedAtSeconds + ASSERTION_LIFETIME_SECONDS
|
|
617
|
+
})}`;
|
|
618
|
+
return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), privateKey).toString("base64url")}`;
|
|
369
619
|
};
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
if (
|
|
373
|
-
|
|
374
|
-
|
|
620
|
+
const readApiMessage = async (response) => {
|
|
621
|
+
const body = await response.json().catch(() => void 0);
|
|
622
|
+
if (typeof body?.error === "object" && typeof body.error.message === "string") return body.error.message;
|
|
623
|
+
if (typeof body?.error_description === "string") return body.error_description;
|
|
624
|
+
return typeof body?.error === "string" ? body.error : "";
|
|
625
|
+
};
|
|
626
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
627
|
+
/**
|
|
628
|
+
* Google Play refused the upload: it already has a bundle or APK with the
|
|
629
|
+
* `.aab`'s version code, from this or another upload, and takes each version
|
|
630
|
+
* code once.
|
|
631
|
+
*/
|
|
632
|
+
var PlayVersionCodeUsedError = class extends Error {
|
|
633
|
+
name = "PlayVersionCodeUsedError";
|
|
634
|
+
constructor(options) {
|
|
635
|
+
super("Google Play already has this .aab's version code. Raise versionCode for every build you upload.", options);
|
|
375
636
|
}
|
|
376
637
|
};
|
|
377
638
|
/**
|
|
378
|
-
*
|
|
639
|
+
* What Google's message means for the user, for the failures a release to
|
|
640
|
+
* a track runs into.
|
|
379
641
|
*/
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
loader,
|
|
409
|
-
onBooted: (deviceId) => {
|
|
410
|
-
cleanup();
|
|
411
|
-
resolve(deviceId);
|
|
412
|
-
}
|
|
642
|
+
const explain = (error, packageName) => {
|
|
643
|
+
if (!(error instanceof GooglePlayError)) return error;
|
|
644
|
+
const { status, apiMessage } = error;
|
|
645
|
+
if (status === 401 || /does not have permission/i.test(apiMessage)) return new Error(`The Play service account cannot release ${packageName}. Invite its email in Play Console under Users and permissions, with permission to release to testing tracks, and enable the Google Play Android Developer API in its Google Cloud project.`, { cause: error });
|
|
646
|
+
if (/package not found/i.test(apiMessage)) return new Error(`Google Play has no app ${packageName}. Create the app in Play Console and upload its first .aab there; the API releases the builds after it.`, { cause: error });
|
|
647
|
+
if (/version code .*(already been used|already used)/i.test(apiMessage)) return new PlayVersionCodeUsedError({ cause: error });
|
|
648
|
+
return error;
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* Requests to the Google Play Developer API for `packageName`, as the
|
|
652
|
+
* service account: each carries an access token, exchanged for a signed
|
|
653
|
+
* assertion and replaced well before it expires.
|
|
654
|
+
*/
|
|
655
|
+
const createPlayClient = ({ json, packageName, fetch }) => {
|
|
656
|
+
const account = readServiceAccount(json);
|
|
657
|
+
let token;
|
|
658
|
+
const authorization = async () => {
|
|
659
|
+
if (!token || Date.now() - token.issuedAtMs >= TOKEN_REUSE_MS) {
|
|
660
|
+
const issuedAtMs = Date.now();
|
|
661
|
+
const assertion = createServiceAccountAssertion(account);
|
|
662
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(assertion);
|
|
663
|
+
const response = await fetch(account.tokenUri, {
|
|
664
|
+
method: "POST",
|
|
665
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
666
|
+
body: new URLSearchParams({
|
|
667
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
668
|
+
assertion
|
|
669
|
+
}).toString()
|
|
413
670
|
});
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
671
|
+
if (!response.ok) throw new Error(`Google refused the Play service account (HTTP ${response.status}): ${await readApiMessage(response) || "check that its key is current and the service account is enabled."}`);
|
|
672
|
+
const { access_token } = await response.json();
|
|
673
|
+
if (typeof access_token !== "string" || !access_token) throw new Error("Google answered the Play service account with no access token.");
|
|
674
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(access_token);
|
|
675
|
+
token = {
|
|
676
|
+
value: access_token,
|
|
677
|
+
issuedAtMs
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
return `Bearer ${token.value}`;
|
|
681
|
+
};
|
|
682
|
+
const app = `/androidpublisher/v3/applications/${encodeURIComponent(packageName)}`;
|
|
683
|
+
const request = async (action, method, url, body) => {
|
|
684
|
+
const response = await fetch(url, {
|
|
685
|
+
method,
|
|
686
|
+
headers: {
|
|
687
|
+
Authorization: await authorization(),
|
|
688
|
+
...body ? { "Content-Type": "application/json" } : {}
|
|
689
|
+
},
|
|
690
|
+
...body ? { body: JSON.stringify(body) } : {}
|
|
420
691
|
});
|
|
421
|
-
|
|
692
|
+
if (!response.ok) throw new GooglePlayError(action, response.status, await readApiMessage(response));
|
|
693
|
+
return response.status === 204 ? void 0 : await response.json();
|
|
694
|
+
};
|
|
695
|
+
return {
|
|
696
|
+
app,
|
|
697
|
+
authorization,
|
|
698
|
+
request
|
|
699
|
+
};
|
|
422
700
|
};
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
*
|
|
427
|
-
*/
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
for (let port = startPort; port <= maxPort; port += 2) if (!devices.some((d) => d.includes(port.toString()))) return port;
|
|
438
|
-
throw new Error("Failed to launch emulator: no available ports");
|
|
439
|
-
};
|
|
440
|
-
/**
|
|
441
|
-
* Try to launch an emulator (or find the first available one)
|
|
442
|
-
*/
|
|
443
|
-
const tryLaunchEmulator = async (name) => {
|
|
444
|
-
const port = await getAvailableDevicePort();
|
|
445
|
-
const spinner = _hot_updater_cli_tools.p.spinner();
|
|
446
|
-
spinner.start("Looking for available emulators");
|
|
447
|
-
const emulators = await getEmulatorNames();
|
|
448
|
-
const emulatorName = name ?? emulators[0];
|
|
449
|
-
let deviceId;
|
|
450
|
-
if (emulators.length > 0) try {
|
|
451
|
-
spinner.message(`Launching emulator "${emulatorName}"`);
|
|
452
|
-
deviceId = await launchEmulator(emulatorName, port, spinner);
|
|
453
|
-
spinner.stop(`Launched ${emulatorName} emulator.`);
|
|
701
|
+
/**
|
|
702
|
+
* Whether Google Play's internal testing track already releases the
|
|
703
|
+
* `.aab`'s version code, as when a reused native build was uploaded
|
|
704
|
+
* before. It reads the track in an edit it then deletes, changing nothing.
|
|
705
|
+
*/
|
|
706
|
+
const findPlayInternalRelease = async ({ versionCode, packageName, serviceAccount: { json }, fetch = globalThis.fetch }) => {
|
|
707
|
+
const { app, request } = createPlayClient({
|
|
708
|
+
json,
|
|
709
|
+
packageName,
|
|
710
|
+
fetch
|
|
711
|
+
});
|
|
712
|
+
let editId;
|
|
713
|
+
try {
|
|
714
|
+
({id: editId} = await request("Opening a Google Play edit", "POST", `${API_URL}${app}/edits`, {}));
|
|
454
715
|
} catch (error) {
|
|
455
|
-
|
|
716
|
+
throw explain(error, packageName);
|
|
717
|
+
}
|
|
718
|
+
const edit = `${API_URL}${app}/edits/${encodeURIComponent(editId)}`;
|
|
719
|
+
try {
|
|
720
|
+
const { releases = [] } = await request("Reading the internal track", "GET", `${edit}/tracks/${INTERNAL_TRACK}`);
|
|
721
|
+
return releases.some(({ versionCodes = [] }) => versionCodes.includes(String(versionCode)));
|
|
722
|
+
} catch (error) {
|
|
723
|
+
throw explain(error, packageName);
|
|
724
|
+
} finally {
|
|
725
|
+
await request("Deleting the Google Play edit", "DELETE", edit).catch(() => void 0);
|
|
456
726
|
}
|
|
457
|
-
else spinner.error("No emulators found as an output of `emulator -list-avds`. Please launch an emulator manually or connect a device");
|
|
458
|
-
return deviceId;
|
|
459
727
|
};
|
|
460
728
|
/**
|
|
461
|
-
*
|
|
729
|
+
* The common name of every Android debug certificate: Android Studio's and
|
|
730
|
+
* the Android Gradle plugin's (`CN=Android Debug, O=Android, C=US`), and React
|
|
731
|
+
* Native's template `debug.keystore` (`CN=Android Debug, OU=Android,
|
|
732
|
+
* O=Unknown, …`), as `keytool` prints the owner, whatever the label's
|
|
733
|
+
* language.
|
|
462
734
|
*/
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
735
|
+
const ANDROID_DEBUG_SIGNER = /:\s*CN=Android Debug\s*(?:,|$)/mu;
|
|
736
|
+
/**
|
|
737
|
+
* Throws when the `.aab` is signed with the Android debug keystore, which
|
|
738
|
+
* Google Play refuses: a release build type left on `signingConfigs.debug`,
|
|
739
|
+
* as React Native's template ships it. It reads the signer with the JDK's
|
|
740
|
+
* `keytool`, which Gradle needs too; without one it checks nothing.
|
|
741
|
+
*/
|
|
742
|
+
const assertPlaySigned = async (aabPath) => {
|
|
743
|
+
const { stdout, exitCode } = await (0, execa.execa)("keytool", [
|
|
744
|
+
"-J-Duser.language=en",
|
|
745
|
+
"-printcert",
|
|
746
|
+
"-jarfile",
|
|
747
|
+
aabPath
|
|
748
|
+
], { reject: false });
|
|
749
|
+
if (exitCode === 0 && ANDROID_DEBUG_SIGNER.test(String(stdout))) throw new Error(`Google Play refuses an .aab signed with the Android debug keystore, and ${aabPath} is. Set the scheme's signing.keystore, or a release signingConfig in android/app/build.gradle, then build again.`);
|
|
750
|
+
};
|
|
751
|
+
/**
|
|
752
|
+
* Uploads an `.aab` to Google Play's internal testing track with the Google
|
|
753
|
+
* Play Developer API: it opens an edit, uploads the bundle, releases its
|
|
754
|
+
* version code to the internal track, and commits the edit, which it
|
|
755
|
+
* deletes again when any step fails. The service account stays in memory.
|
|
756
|
+
*/
|
|
757
|
+
const uploadToPlay = async ({ aabPath, packageName, serviceAccount: { json }, fetch = globalThis.fetch, commitRetryDelayMs = 2e3 }) => {
|
|
758
|
+
const { app, authorization, request } = createPlayClient({
|
|
759
|
+
json,
|
|
760
|
+
packageName,
|
|
761
|
+
fetch
|
|
762
|
+
});
|
|
763
|
+
let editId;
|
|
764
|
+
try {
|
|
765
|
+
({id: editId} = await request("Opening a Google Play edit", "POST", `${API_URL}${app}/edits`, {}));
|
|
766
|
+
} catch (error) {
|
|
767
|
+
throw explain(error, packageName);
|
|
768
|
+
}
|
|
769
|
+
const edit = `${API_URL}${app}/edits/${encodeURIComponent(editId)}`;
|
|
770
|
+
try {
|
|
771
|
+
_hot_updater_cli_tools.p.log.info(`Uploading ${packageName}'s .aab to Google Play`);
|
|
772
|
+
const versionCode = await uploadBundle({
|
|
773
|
+
aabPath,
|
|
774
|
+
sessionUrl: `${API_URL}/upload${app}/edits/${encodeURIComponent(editId)}/bundles?uploadType=resumable`,
|
|
775
|
+
authorization,
|
|
776
|
+
fetch
|
|
777
|
+
});
|
|
778
|
+
const release = async (status) => {
|
|
779
|
+
await request("Releasing to the internal track", "PUT", `${edit}/tracks/${INTERNAL_TRACK}`, {
|
|
780
|
+
track: INTERNAL_TRACK,
|
|
781
|
+
releases: [{
|
|
782
|
+
versionCodes: [String(versionCode)],
|
|
783
|
+
status
|
|
784
|
+
}]
|
|
785
|
+
});
|
|
786
|
+
await commit({
|
|
787
|
+
request,
|
|
788
|
+
edit,
|
|
789
|
+
retryDelayMs: commitRetryDelayMs
|
|
790
|
+
});
|
|
491
791
|
};
|
|
492
|
-
|
|
792
|
+
try {
|
|
793
|
+
await release("completed");
|
|
794
|
+
return {
|
|
795
|
+
versionCode,
|
|
796
|
+
status: "completed"
|
|
797
|
+
};
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (!(error instanceof GooglePlayError) || !DRAFT_APP_ERRORS.some((message) => error.apiMessage.includes(message))) throw error;
|
|
800
|
+
_hot_updater_cli_tools.p.log.warn(`Play Console has not published ${packageName} yet, which takes only draft releases. The internal release of version code ${versionCode} is a draft: roll it out in Play Console.`);
|
|
801
|
+
await release("draft");
|
|
802
|
+
return {
|
|
803
|
+
versionCode,
|
|
804
|
+
status: "draft"
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
} catch (error) {
|
|
808
|
+
await request("Deleting the Google Play edit", "DELETE", edit).catch(() => void 0);
|
|
809
|
+
throw explain(error, packageName);
|
|
493
810
|
}
|
|
494
|
-
return allDevices.sort((a, b) => {
|
|
495
|
-
if (a.type === "phone" && b.type === "emulator") return -1;
|
|
496
|
-
if (a.type === "emulator" && b.type === "phone") return 1;
|
|
497
|
-
return 0;
|
|
498
|
-
});
|
|
499
811
|
};
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (!
|
|
514
|
-
|
|
515
|
-
|
|
812
|
+
/**
|
|
813
|
+
* Commits the edit, again while Google is still processing the bundle, and
|
|
814
|
+
* with `changesNotSentForReview` when the app's state asks for it. An
|
|
815
|
+
* unanswered commit is reported as unconfirmed, since Google may have
|
|
816
|
+
* applied it.
|
|
817
|
+
*/
|
|
818
|
+
const commit = async ({ request, edit, retryDelayMs }) => {
|
|
819
|
+
let changesNotSentForReview = false;
|
|
820
|
+
for (let attempt = 1;; attempt++) try {
|
|
821
|
+
await request("Committing the Google Play edit", "POST", `${edit}:commit${changesNotSentForReview ? "?changesNotSentForReview=true" : ""}`);
|
|
822
|
+
if (changesNotSentForReview) _hot_updater_cli_tools.p.log.warn("Google Play saved the release without sending it for review, as the app's state requires. Send the changes for review in Play Console.");
|
|
823
|
+
return;
|
|
824
|
+
} catch (error) {
|
|
825
|
+
if (!(error instanceof GooglePlayError) || error.status >= 500 || error.status === 429) throw new Error(`Google Play did not confirm the release: ${error instanceof Error ? error.message : String(error)} Check internal testing in Play Console before you upload this version code again.`, { cause: error });
|
|
826
|
+
if (error.status !== 400) throw error;
|
|
827
|
+
if (!changesNotSentForReview && error.apiMessage.includes(NEEDS_CHANGES_NOT_SENT_FOR_REVIEW)) {
|
|
828
|
+
changesNotSentForReview = true;
|
|
829
|
+
continue;
|
|
516
830
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (!availableDevices.length) {
|
|
521
|
-
_hot_updater_cli_tools.p.log.error("you passed -d option but there is no attached adb devices.");
|
|
522
|
-
process.exit(1);
|
|
831
|
+
if (error.apiMessage.includes(BUNDLE_STILL_PROCESSING) && attempt < COMMIT_ATTEMPTS) {
|
|
832
|
+
await sleep(retryDelayMs * 2 ** (attempt - 1));
|
|
833
|
+
continue;
|
|
523
834
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
835
|
+
throw error;
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
/**
|
|
839
|
+
* Uploads the `.aab` in a resumable upload session, one chunk in memory at a
|
|
840
|
+
* time. A chunk that fails without an answer resumes at the offset Google
|
|
841
|
+
* confirms. Returns the bundle's version code.
|
|
842
|
+
*/
|
|
843
|
+
const uploadBundle = async ({ aabPath, sessionUrl, authorization, fetch }) => {
|
|
844
|
+
const file = await fs.default.promises.open(aabPath, "r");
|
|
845
|
+
try {
|
|
846
|
+
const { size } = await file.stat();
|
|
847
|
+
if (size === 0) throw new Error("The .aab is empty.");
|
|
848
|
+
const started = await fetch(sessionUrl, {
|
|
849
|
+
method: "POST",
|
|
850
|
+
headers: {
|
|
851
|
+
Authorization: await authorization(),
|
|
852
|
+
"X-Upload-Content-Type": "application/octet-stream",
|
|
853
|
+
"X-Upload-Content-Length": String(size)
|
|
854
|
+
}
|
|
530
855
|
});
|
|
531
|
-
if (
|
|
532
|
-
|
|
856
|
+
if (!started.ok) throw new GooglePlayError("Starting the .aab upload", started.status, await readApiMessage(started));
|
|
857
|
+
const location = started.headers.get("location");
|
|
858
|
+
if (!location || !UPLOAD_ORIGINS.has(new URL(location).origin)) throw new Error("Google returned no upload session for the .aab.");
|
|
859
|
+
let offset = 0;
|
|
860
|
+
let failures = 0;
|
|
861
|
+
/**
|
|
862
|
+
* Resumes at the offset Google confirms. Progress starts a new retry
|
|
863
|
+
* budget; an attempt that keeps nothing counts against it.
|
|
864
|
+
*/
|
|
865
|
+
const resumeAt = (confirmed, reason) => {
|
|
866
|
+
if (confirmed > offset) {
|
|
867
|
+
offset = confirmed;
|
|
868
|
+
failures = 0;
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
offset = confirmed;
|
|
872
|
+
if (++failures === CHUNK_ATTEMPTS) throw new Error(`Uploading the .aab failed: ${reason}.`);
|
|
873
|
+
};
|
|
874
|
+
for (;;) {
|
|
875
|
+
const end = Math.min(offset + CHUNK_SIZE, size);
|
|
876
|
+
const body = Buffer.alloc(end - offset);
|
|
877
|
+
const { bytesRead } = await file.read(body, 0, body.length, offset);
|
|
878
|
+
if (bytesRead !== body.length) throw new Error("The .aab changed while it was uploading.");
|
|
879
|
+
const response = await fetch(location, {
|
|
880
|
+
method: "PUT",
|
|
881
|
+
headers: {
|
|
882
|
+
"Content-Range": `bytes ${offset}-${end - 1}/${size}`,
|
|
883
|
+
"Content-Type": "application/octet-stream"
|
|
884
|
+
},
|
|
885
|
+
body
|
|
886
|
+
}).catch((error) => error);
|
|
887
|
+
if (!(response instanceof Error) && response.ok) return readVersionCode(response);
|
|
888
|
+
if (!(response instanceof Error) && response.status === 308) {
|
|
889
|
+
resumeAt(confirmedOffset(response), "Google kept none of a chunk");
|
|
890
|
+
if (failures > 0) await sleep(1e3 * failures);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
if (!(response instanceof Error) && response.status < 500 && response.status !== 429) throw new GooglePlayError("Uploading the .aab", response.status, await readApiMessage(response));
|
|
894
|
+
await sleep(1e3 * (failures + 1));
|
|
895
|
+
const status = await fetch(location, {
|
|
896
|
+
method: "PUT",
|
|
897
|
+
headers: { "Content-Range": `bytes */${size}` }
|
|
898
|
+
}).catch(() => void 0);
|
|
899
|
+
if (status?.ok) return readVersionCode(status);
|
|
900
|
+
resumeAt(status?.status === 308 ? confirmedOffset(status) : offset, response instanceof Error ? response.message : `HTTP ${response.status}`);
|
|
901
|
+
}
|
|
902
|
+
} finally {
|
|
903
|
+
await file.close();
|
|
533
904
|
}
|
|
534
905
|
};
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
906
|
+
/** The version code of the bundle a finished upload answers with. */
|
|
907
|
+
const readVersionCode = async (response) => {
|
|
908
|
+
const { versionCode } = await response.json();
|
|
909
|
+
if (typeof versionCode !== "number") throw new Error("Google Play read no version code from the .aab.");
|
|
910
|
+
return versionCode;
|
|
911
|
+
};
|
|
912
|
+
/**
|
|
913
|
+
* The next offset of a `308 Resume Incomplete`: past its `Range`, which
|
|
914
|
+
* Google sends as `bytes=0-<last>` or `0-<last>`, and 0 without one.
|
|
915
|
+
*/
|
|
916
|
+
const confirmedOffset = (response) => {
|
|
917
|
+
const range = response.headers.get("range")?.match(/^(?:bytes=)?0-(\d+)$/);
|
|
918
|
+
return range ? Number(range[1]) + 1 : 0;
|
|
919
|
+
};
|
|
920
|
+
/**
|
|
921
|
+
* The Play service account a scheme's `.aab` uploads with: the
|
|
922
|
+
* `play-service-account` item. It reads only the config, before the native
|
|
923
|
+
* projects are written, and throws for a scheme whose build Google Play
|
|
924
|
+
* would not take as a release: an `.apk`, which Play takes only as an `.aab`
|
|
925
|
+
* from new apps, or a debug variant, which Play refuses.
|
|
926
|
+
*/
|
|
927
|
+
const resolvePlayUploadServiceAccount = ({ scheme, androidScheme }) => {
|
|
928
|
+
if (androidScheme.aab === false) throw new Error(`Google Play takes an .aab, and the scheme ${scheme} builds an .apk (aab: false).`);
|
|
929
|
+
const variant = androidScheme.variant ?? "Release";
|
|
930
|
+
if (/debug$/i.test(variant)) throw new Error(`Google Play takes a release build, and the scheme ${scheme} builds the ${variant} variant, which is debuggable and loads its JavaScript from Metro.`);
|
|
931
|
+
return {
|
|
932
|
+
kind: "play-service-account",
|
|
933
|
+
name: "play-service-account"
|
|
934
|
+
};
|
|
542
935
|
};
|
|
543
936
|
//#endregion
|
|
544
|
-
//#region src/
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
937
|
+
//#region src/utils/enrichNativeBuildAndroidScheme.ts
|
|
938
|
+
const enrichNativeBuildAndroidScheme = async ({ schemeConfig, hotUpdaterSchemeName }) => {
|
|
939
|
+
return {
|
|
940
|
+
aab: true,
|
|
941
|
+
variant: "Release",
|
|
942
|
+
appModuleName: "app",
|
|
943
|
+
applicationId: schemeConfig.packageName,
|
|
944
|
+
...schemeConfig,
|
|
945
|
+
hotUpdaterSchemeName
|
|
946
|
+
};
|
|
947
|
+
};
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region src/utils/extractBuiltInBundle.ts
|
|
950
|
+
const EOCD_SIGNATURE = 101010256;
|
|
951
|
+
const ZIP64_EOCD_LOCATOR_SIGNATURE = 117853008;
|
|
952
|
+
const ZIP64_EOCD_SIGNATURE = 101075792;
|
|
953
|
+
const CENTRAL_HEADER_SIGNATURE = 33639248;
|
|
954
|
+
const LOCAL_HEADER_SIGNATURE = 67324752;
|
|
955
|
+
const ZIP64_EXTRA_ID = 1;
|
|
956
|
+
const UINT32_MAX = 4294967295;
|
|
957
|
+
/** The end-of-central-directory record and the longest comment after it. */
|
|
958
|
+
const EOCD_SEARCH_LENGTH = 65557;
|
|
959
|
+
const read = async (file, offset, length) => {
|
|
960
|
+
const buffer = Buffer.alloc(length);
|
|
961
|
+
const { bytesRead } = await file.read(buffer, 0, length, offset);
|
|
962
|
+
return buffer.subarray(0, bytesRead);
|
|
963
|
+
};
|
|
964
|
+
/** The central directory's offset and size, from its end record (ZIP64 too). */
|
|
965
|
+
const centralDirectoryOf = async (file) => {
|
|
966
|
+
const { size } = await file.stat();
|
|
967
|
+
const start = Math.max(0, size - EOCD_SEARCH_LENGTH);
|
|
968
|
+
const tail = await read(file, start, size - start);
|
|
969
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
970
|
+
if (tail.readUInt32LE(i) !== EOCD_SIGNATURE) continue;
|
|
971
|
+
const entries = tail.readUInt16LE(i + 10);
|
|
972
|
+
let directorySize = tail.readUInt32LE(i + 12);
|
|
973
|
+
let directoryOffset = tail.readUInt32LE(i + 16);
|
|
974
|
+
if (directoryOffset === UINT32_MAX || directorySize === UINT32_MAX) {
|
|
975
|
+
const locator = i - 20;
|
|
976
|
+
if (locator < 0 || tail.readUInt32LE(locator) !== ZIP64_EOCD_LOCATOR_SIGNATURE) throw new Error("The archive's ZIP64 end record is missing.");
|
|
977
|
+
const record = await read(file, Number(tail.readBigUInt64LE(locator + 8)), 56);
|
|
978
|
+
if (record.readUInt32LE(0) !== ZIP64_EOCD_SIGNATURE) throw new Error("The archive's ZIP64 end record is invalid.");
|
|
979
|
+
directorySize = Number(record.readBigUInt64LE(40));
|
|
980
|
+
directoryOffset = Number(record.readBigUInt64LE(48));
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
entries,
|
|
984
|
+
directoryOffset,
|
|
985
|
+
directorySize
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
throw new Error("The artifact is not a zip archive.");
|
|
989
|
+
};
|
|
990
|
+
/** One entry's location, from the central directory, without reading the rest of the archive. */
|
|
991
|
+
const findEntry = async (file, name) => {
|
|
992
|
+
const { directoryOffset, directorySize } = await centralDirectoryOf(file);
|
|
993
|
+
const directory = await read(file, directoryOffset, directorySize);
|
|
994
|
+
for (let i = 0; i + 46 <= directory.length;) {
|
|
995
|
+
if (directory.readUInt32LE(i) !== CENTRAL_HEADER_SIGNATURE) break;
|
|
996
|
+
const method = directory.readUInt16LE(i + 10);
|
|
997
|
+
let compressedSize = directory.readUInt32LE(i + 20);
|
|
998
|
+
const uncompressedSize = directory.readUInt32LE(i + 24);
|
|
999
|
+
const nameLength = directory.readUInt16LE(i + 28);
|
|
1000
|
+
const extraLength = directory.readUInt16LE(i + 30);
|
|
1001
|
+
const commentLength = directory.readUInt16LE(i + 32);
|
|
1002
|
+
let localHeaderOffset = directory.readUInt32LE(i + 42);
|
|
1003
|
+
if (directory.toString("utf8", i + 46, i + 46 + nameLength) === name) {
|
|
1004
|
+
const extra = directory.subarray(i + 46 + nameLength, i + 46 + nameLength + extraLength);
|
|
1005
|
+
for (let j = 0; j + 4 <= extra.length;) {
|
|
1006
|
+
const id = extra.readUInt16LE(j);
|
|
1007
|
+
const length = extra.readUInt16LE(j + 2);
|
|
1008
|
+
if (id === ZIP64_EXTRA_ID) {
|
|
1009
|
+
let k = j + 4;
|
|
1010
|
+
if (uncompressedSize === UINT32_MAX) k += 8;
|
|
1011
|
+
if (compressedSize === UINT32_MAX) {
|
|
1012
|
+
compressedSize = Number(extra.readBigUInt64LE(k));
|
|
1013
|
+
k += 8;
|
|
1014
|
+
}
|
|
1015
|
+
if (localHeaderOffset === UINT32_MAX) localHeaderOffset = Number(extra.readBigUInt64LE(k));
|
|
1016
|
+
}
|
|
1017
|
+
j += 4 + length;
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
method,
|
|
1021
|
+
compressedSize,
|
|
1022
|
+
localHeaderOffset
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
i += 46 + nameLength + extraLength + commentLength;
|
|
1026
|
+
}
|
|
1027
|
+
return null;
|
|
1028
|
+
};
|
|
1029
|
+
/**
|
|
1030
|
+
* Copies the JavaScript bundle React Native's Gradle build packed into an
|
|
1031
|
+
* `.apk` (`assets/index.android.bundle`) or an `.aab`
|
|
1032
|
+
* (`base/assets/index.android.bundle`) to `outputPath`: the built-in bundle,
|
|
1033
|
+
* byte for byte, which no rebuild reproduces. It reads only the archive's
|
|
1034
|
+
* directory and that entry, streamed, so a large artifact stays on disk.
|
|
1035
|
+
*/
|
|
1036
|
+
const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
|
|
1037
|
+
const name = path.default.extname(artifactPath) === ".aab" ? "base/assets/index.android.bundle" : "assets/index.android.bundle";
|
|
1038
|
+
const file = await fs.default.promises.open(artifactPath, "r");
|
|
1039
|
+
let entry;
|
|
1040
|
+
let dataOffset;
|
|
1041
|
+
try {
|
|
1042
|
+
entry = await findEntry(file, name);
|
|
1043
|
+
if (entry === null) throw new Error(`${path.default.basename(artifactPath)} has no ${name}. A release build packs its JavaScript bundle there unless the app's react { bundleAssetName } names another file.`);
|
|
1044
|
+
const local = await read(file, entry.localHeaderOffset, 30);
|
|
1045
|
+
if (local.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) throw new Error(`${path.default.basename(artifactPath)} has a corrupt ${name}.`);
|
|
1046
|
+
dataOffset = entry.localHeaderOffset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
|
|
1047
|
+
} finally {
|
|
1048
|
+
await file.close();
|
|
1049
|
+
}
|
|
1050
|
+
if (entry.method !== 0 && entry.method !== 8) throw new Error(`${path.default.basename(artifactPath)} stores ${name} with an unsupported compression method (${entry.method}).`);
|
|
1051
|
+
await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
|
|
1052
|
+
if (entry.compressedSize === 0) {
|
|
1053
|
+
await fs.default.promises.writeFile(outputPath, "");
|
|
548
1054
|
return;
|
|
549
1055
|
}
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
1056
|
+
const source = fs.default.createReadStream(artifactPath, {
|
|
1057
|
+
start: dataOffset,
|
|
1058
|
+
end: dataOffset + entry.compressedSize - 1
|
|
1059
|
+
});
|
|
1060
|
+
await (entry.method === 8 ? (0, stream_promises.pipeline)(source, (0, zlib.createInflateRaw)(), fs.default.createWriteStream(outputPath)) : (0, stream_promises.pipeline)(source, fs.default.createWriteStream(outputPath)));
|
|
1061
|
+
};
|
|
1062
|
+
//#endregion
|
|
1063
|
+
//#region src/google/googleApi.ts
|
|
1064
|
+
/** A Google Cloud or Google Play API request that failed, with Google's error. */
|
|
1065
|
+
var GoogleApiError = class extends Error {
|
|
1066
|
+
status;
|
|
1067
|
+
/** Google's canonical status, such as `PERMISSION_DENIED`. */
|
|
1068
|
+
apiStatus;
|
|
1069
|
+
apiMessage;
|
|
1070
|
+
/** The `ErrorInfo` reasons, such as `SERVICE_DISABLED`. */
|
|
1071
|
+
reasons;
|
|
1072
|
+
/** The `PreconditionFailure` violation types, such as an org policy constraint. */
|
|
1073
|
+
violations;
|
|
1074
|
+
/** The `ErrorInfo` consumer of a disabled service, such as `projects/123`. */
|
|
1075
|
+
consumer;
|
|
1076
|
+
constructor(action, status, body = {}) {
|
|
1077
|
+
const apiMessage = body.apiMessage ?? "";
|
|
1078
|
+
super(`${action} failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : "."}`);
|
|
1079
|
+
this.name = "GoogleApiError";
|
|
1080
|
+
this.status = status;
|
|
1081
|
+
this.apiStatus = body.apiStatus;
|
|
1082
|
+
this.apiMessage = apiMessage;
|
|
1083
|
+
this.reasons = body.reasons ?? [];
|
|
1084
|
+
this.violations = body.violations ?? [];
|
|
1085
|
+
this.consumer = body.consumer;
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
/** Google's error body, `{ error: { code, status, message, details } }`. */
|
|
1089
|
+
const readError = async (action, response) => {
|
|
1090
|
+
let error;
|
|
562
1091
|
try {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1092
|
+
error = (await response.json()).error;
|
|
1093
|
+
} catch {}
|
|
1094
|
+
const details = Array.isArray(error?.details) ? error.details : [];
|
|
1095
|
+
const info = details.filter((detail) => typeof detail.reason === "string");
|
|
1096
|
+
return new GoogleApiError(action, response.status, {
|
|
1097
|
+
...typeof error?.status === "string" ? { apiStatus: error.status } : {},
|
|
1098
|
+
...typeof error?.message === "string" ? { apiMessage: error.message } : {},
|
|
1099
|
+
reasons: info.map((detail) => detail.reason),
|
|
1100
|
+
violations: details.flatMap((detail) => (detail.violations ?? []).flatMap((violation) => typeof violation.type === "string" ? [violation.type] : [])),
|
|
1101
|
+
...info.find((detail) => detail.metadata?.["consumer"]) ? { consumer: info.find((detail) => detail.metadata?.["consumer"]).metadata["consumer"] } : {}
|
|
1102
|
+
});
|
|
1103
|
+
};
|
|
1104
|
+
/** Whether Google answered that a service is disabled in a project. */
|
|
1105
|
+
const isServiceDisabled = (error) => error instanceof GoogleApiError && (error.reasons.includes("SERVICE_DISABLED") || /has not been used in project .* or it is disabled/i.test(error.apiMessage));
|
|
1106
|
+
/** Requests with the signed-in user's access token, throwing `GoogleApiError`. */
|
|
1107
|
+
const createGoogleRequest = ({ accessToken, fetch }) => async (action, method, url, body) => {
|
|
1108
|
+
const response = await fetch(url, {
|
|
1109
|
+
method,
|
|
1110
|
+
headers: {
|
|
1111
|
+
authorization: `Bearer ${accessToken}`,
|
|
1112
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
1113
|
+
},
|
|
1114
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
1115
|
+
});
|
|
1116
|
+
if (!response.ok) throw await readError(action, response);
|
|
1117
|
+
const text = await response.text();
|
|
1118
|
+
return text ? JSON.parse(text) : {};
|
|
1119
|
+
};
|
|
1120
|
+
/**
|
|
1121
|
+
* Polls `operation` at `origin` until it is done, returning its response or
|
|
1122
|
+
* throwing its error as a `GoogleApiError` with the operation's code.
|
|
1123
|
+
*/
|
|
1124
|
+
const waitForOperation = async ({ request, origin, operation, action, sleep, intervalMs = 2e3, attempts = 60 }) => {
|
|
1125
|
+
let current = operation;
|
|
1126
|
+
for (let attempt = 0; !current.done; attempt++) {
|
|
1127
|
+
if (attempt >= attempts || !current.name) throw new Error(`${action} did not finish in time.`);
|
|
1128
|
+
await sleep(intervalMs);
|
|
1129
|
+
current = await request(action, "GET", `${origin}/${current.name}`);
|
|
570
1130
|
}
|
|
1131
|
+
if (current.error) throw new GoogleApiError(action, 400, {
|
|
1132
|
+
apiMessage: current.error.message ?? "",
|
|
1133
|
+
...current.error.code === 7 ? { apiStatus: "PERMISSION_DENIED" } : {},
|
|
1134
|
+
...current.error.code === 8 ? { apiStatus: "RESOURCE_EXHAUSTED" } : {}
|
|
1135
|
+
});
|
|
1136
|
+
return current.response;
|
|
571
1137
|
};
|
|
572
1138
|
//#endregion
|
|
573
|
-
//#region src/
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
1139
|
+
//#region src/google/types.ts
|
|
1140
|
+
/** A named failure of `issuePlayServiceAccount`, with what to do in its message. */
|
|
1141
|
+
var PlayServiceAccountError = class extends Error {
|
|
1142
|
+
code;
|
|
1143
|
+
constructor(code, message, options) {
|
|
1144
|
+
super(message, options);
|
|
1145
|
+
this.name = "PlayServiceAccountError";
|
|
1146
|
+
this.code = code;
|
|
578
1147
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1148
|
+
};
|
|
1149
|
+
/** The environment variables that replace Hot Updater's Google OAuth client. */
|
|
1150
|
+
const GOOGLE_OAUTH_CLIENT_ENV_NAMES = {
|
|
1151
|
+
clientId: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID",
|
|
1152
|
+
clientSecret: "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_SECRET"
|
|
1153
|
+
};
|
|
1154
|
+
/** The service account Hot Updater creates, `hot-updater-play@<project>.iam.gserviceaccount.com`. */
|
|
1155
|
+
const PLAY_SERVICE_ACCOUNT_ID = "hot-updater-play";
|
|
1156
|
+
/** Where a Play Console admin finds the developer account ID. */
|
|
1157
|
+
const PLAY_CONSOLE_URL = "https://play.google.com/console";
|
|
1158
|
+
//#endregion
|
|
1159
|
+
//#region src/google/googleSignIn.ts
|
|
1160
|
+
const AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
1161
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
1162
|
+
const REVOKE_URL = "https://oauth2.googleapis.com/revoke";
|
|
1163
|
+
/** How long the browser has to come back with the sign-in. */
|
|
1164
|
+
const SIGN_IN_TIMEOUT_MS = 3e5;
|
|
1165
|
+
const base64url = (bytes) => bytes.toString("base64url");
|
|
1166
|
+
const page = (message) => `<!doctype html><meta charset="utf-8"><title>Hot Updater</title><body style="font-family:system-ui;margin:3rem"><p>${message}</p></body>`;
|
|
1167
|
+
/**
|
|
1168
|
+
* Signs in with Google as an installed app does (RFC 8252): the browser
|
|
1169
|
+
* opens Google's own consent screen, so the password and the second factor
|
|
1170
|
+
* stay with Google, and the authorization code comes back to a loopback
|
|
1171
|
+
* server on 127.0.0.1, bound to a random port for this sign-in only. The
|
|
1172
|
+
* code is exchanged with PKCE (S256), and `state` ties the answer to this
|
|
1173
|
+
* request.
|
|
1174
|
+
*
|
|
1175
|
+
* @param onUrl Sees the sign-in URL, to print and open in a browser.
|
|
1176
|
+
*/
|
|
1177
|
+
const signInWithGoogle = async ({ client, scopes, onUrl, fetch = globalThis.fetch, timeoutMs = SIGN_IN_TIMEOUT_MS }) => {
|
|
1178
|
+
const verifier = base64url(node_crypto.default.randomBytes(32));
|
|
1179
|
+
const challenge = base64url(node_crypto.default.createHash("sha256").update(verifier).digest());
|
|
1180
|
+
const state = base64url(node_crypto.default.randomBytes(16));
|
|
1181
|
+
const server = node_http.default.createServer();
|
|
1182
|
+
await new Promise((resolve, reject) => {
|
|
1183
|
+
server.once("error", reject);
|
|
1184
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
583
1185
|
});
|
|
584
|
-
const activity = mainActivity || ".MainActivity";
|
|
585
|
-
const adbArgs = [
|
|
586
|
-
"shell",
|
|
587
|
-
"am",
|
|
588
|
-
"start",
|
|
589
|
-
"-n",
|
|
590
|
-
`${applicationId}/${activity.startsWith(packageName) || !activity.startsWith(".") && activity.includes(".") ? activity : activity.startsWith(".") ? [packageName, activity].join("") : [packageName, activity].filter(Boolean).join(".")}`,
|
|
591
|
-
"-a",
|
|
592
|
-
"android.intent.action.MAIN",
|
|
593
|
-
"-c",
|
|
594
|
-
"android.intent.category.LAUNCHER"
|
|
595
|
-
];
|
|
596
|
-
adbArgs.unshift("-s", deviceId);
|
|
597
|
-
const adbPath = Device.getAdbPath();
|
|
598
|
-
console.debug(`Running ${adbPath} ${adbArgs.join(" ")}.`);
|
|
599
|
-
const spinner = _hot_updater_cli_tools.p.spinner();
|
|
600
|
-
spinner.start(`Launching the app on ${device.readableName} (id: ${deviceId})`);
|
|
601
1186
|
try {
|
|
602
|
-
|
|
603
|
-
|
|
1187
|
+
const { port } = server.address();
|
|
1188
|
+
const redirectUri = `http://127.0.0.1:${port}`;
|
|
1189
|
+
const code = new Promise((resolve, reject) => {
|
|
1190
|
+
const timer = setTimeout(() => reject(new PlayServiceAccountError("sign-in-cancelled", "The Google sign-in did not finish in time.")), timeoutMs);
|
|
1191
|
+
timer.unref();
|
|
1192
|
+
server.on("request", (req, res) => {
|
|
1193
|
+
const query = new URL(req.url ?? "/", redirectUri).searchParams;
|
|
1194
|
+
if (query.get("state") !== state) {
|
|
1195
|
+
res.writeHead(404).end();
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const error = query.get("error");
|
|
1199
|
+
const answer = query.get("code");
|
|
1200
|
+
res.writeHead(error || !answer ? 400 : 200, { "content-type": "text/html; charset=utf-8" }).end(page(error || !answer ? "The sign-in did not finish. Return to the terminal." : "Signed in. Close this tab and return to the terminal."));
|
|
1201
|
+
clearTimeout(timer);
|
|
1202
|
+
if (error || !answer) reject(new PlayServiceAccountError("sign-in-cancelled", error === "access_denied" ? "The Google sign-in was cancelled." : `The Google sign-in failed: ${error ?? "no authorization code"}.`));
|
|
1203
|
+
else resolve(answer);
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
code.catch(() => {});
|
|
1207
|
+
const url = new URL(AUTHORIZATION_URL);
|
|
1208
|
+
url.search = new URLSearchParams({
|
|
1209
|
+
client_id: client.clientId,
|
|
1210
|
+
redirect_uri: redirectUri,
|
|
1211
|
+
response_type: "code",
|
|
1212
|
+
scope: scopes.join(" "),
|
|
1213
|
+
code_challenge: challenge,
|
|
1214
|
+
code_challenge_method: "S256",
|
|
1215
|
+
state,
|
|
1216
|
+
prompt: "select_account"
|
|
1217
|
+
}).toString();
|
|
1218
|
+
await onUrl(url.toString());
|
|
1219
|
+
const authorizationCode = await code;
|
|
1220
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(authorizationCode);
|
|
1221
|
+
const response = await fetch(TOKEN_URL, {
|
|
1222
|
+
method: "POST",
|
|
1223
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1224
|
+
body: new URLSearchParams({
|
|
1225
|
+
code: authorizationCode,
|
|
1226
|
+
client_id: client.clientId,
|
|
1227
|
+
...client.clientSecret ? { client_secret: client.clientSecret } : {},
|
|
1228
|
+
redirect_uri: redirectUri,
|
|
1229
|
+
grant_type: "authorization_code",
|
|
1230
|
+
code_verifier: verifier
|
|
1231
|
+
}).toString()
|
|
1232
|
+
});
|
|
1233
|
+
const token = await response.json().catch(() => ({}));
|
|
1234
|
+
if (!response.ok || typeof token.access_token !== "string") throw new PlayServiceAccountError(token.error === "invalid_client" ? "sign-in-not-configured" : "sign-in-cancelled", `Google refused the sign-in (HTTP ${response.status})${typeof token.error_description === "string" ? `: ${token.error_description}` : typeof token.error === "string" ? `: ${token.error}` : "."}`);
|
|
1235
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(token.access_token);
|
|
1236
|
+
return {
|
|
1237
|
+
accessToken: token.access_token,
|
|
1238
|
+
scopes: typeof token.scope === "string" ? token.scope.split(" ") : []
|
|
1239
|
+
};
|
|
1240
|
+
} finally {
|
|
1241
|
+
server.closeAllConnections();
|
|
1242
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
/** Ends a sign-in: Google revokes its access token. Whether Google did. */
|
|
1246
|
+
const revokeGoogleToken = async (accessToken, fetch = globalThis.fetch) => {
|
|
1247
|
+
try {
|
|
1248
|
+
return (await fetch(REVOKE_URL, {
|
|
1249
|
+
method: "POST",
|
|
1250
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1251
|
+
body: new URLSearchParams({ token: accessToken }).toString()
|
|
1252
|
+
})).ok;
|
|
604
1253
|
} catch {
|
|
605
|
-
|
|
606
|
-
throw new Error(`Failed to launch the app on ${device.readableName}`);
|
|
1254
|
+
return false;
|
|
607
1255
|
}
|
|
608
|
-
}
|
|
1256
|
+
};
|
|
609
1257
|
//#endregion
|
|
610
|
-
//#region src/
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
1258
|
+
//#region src/google/googleKeyIssuer.ts
|
|
1259
|
+
const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
1260
|
+
const ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
|
|
1261
|
+
const SCOPES = [CLOUD_PLATFORM_SCOPE, ANDROID_PUBLISHER_SCOPE];
|
|
1262
|
+
const PUBLISHER_API = "https://androidpublisher.googleapis.com/androidpublisher/v3";
|
|
1263
|
+
const RESOURCE_MANAGER_API = "https://cloudresourcemanager.googleapis.com";
|
|
1264
|
+
const SERVICE_USAGE_API = "https://serviceusage.googleapis.com";
|
|
1265
|
+
const IAM_API = "https://iam.googleapis.com/v1";
|
|
1266
|
+
/** IAM creates the service account; the Play Developer API is what it calls. */
|
|
1267
|
+
const SERVICES = ["iam.googleapis.com", "androidpublisher.googleapis.com"];
|
|
1268
|
+
/**
|
|
1269
|
+
* What the service account may do in Play Console, for the app only: read
|
|
1270
|
+
* it, and release to testing tracks, which `--submit play-internal` does.
|
|
1271
|
+
*/
|
|
1272
|
+
const APP_PERMISSIONS = ["CAN_VIEW_NON_FINANCIAL_DATA", "CAN_MANAGE_TRACK_APKS"];
|
|
1273
|
+
const KEY_CREATION_CONSTRAINT = "constraints/iam.disableServiceAccountKeyCreation";
|
|
1274
|
+
/** Retries while Google enables an API or creates a service account. */
|
|
1275
|
+
const PROPAGATION_ATTEMPTS = 12;
|
|
1276
|
+
const PROPAGATION_DELAY_MS = 1e4;
|
|
1277
|
+
const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1278
|
+
/** The developer account ID in `input`, a number or a Play Console URL. */
|
|
1279
|
+
const parseDeveloperId = (input) => {
|
|
1280
|
+
const trimmed = input.trim();
|
|
1281
|
+
if (/^\d+$/u.test(trimmed)) return trimmed;
|
|
1282
|
+
return trimmed.match(/\/developers\/(\d+)(?:[/?#]|$)/u)?.[1];
|
|
1283
|
+
};
|
|
1284
|
+
/** A project ID nobody has: Google Cloud's are global. */
|
|
1285
|
+
const newProjectId = () => `${PLAY_SERVICE_ACCOUNT_ID}-${node_crypto.default.randomBytes(3).toString("hex")}`;
|
|
1286
|
+
const isNotFound = (error) => error instanceof GoogleApiError && error.status === 404;
|
|
1287
|
+
const isConflict = (error) => error instanceof GoogleApiError && error.status === 409;
|
|
1288
|
+
/** The API is disabled in the OAuth client's own project: waiting does not help. */
|
|
1289
|
+
const clientApiDisabled = (error) => new PlayServiceAccountError("api-not-enabled", `${error.apiMessage} The Google OAuth client Hot Updater signs in with belongs to that project, which must enable the API (see ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId}).`, { cause: error });
|
|
1290
|
+
/** Google's own words, after a message that names the likely cause. */
|
|
1291
|
+
const googleSays = (error) => error instanceof GoogleApiError && error.apiMessage ? ` (Google: ${error.apiMessage})` : "";
|
|
1292
|
+
/** A Play Console refusal, as `not-play-admin`. */
|
|
1293
|
+
const notPlayAdmin = (developerId, error) => new PlayServiceAccountError("not-play-admin", `The Google Account you signed in with is not an admin of the Play Console developer account ${developerId}. Sign in with an account that has the Admin permission there (Users and permissions in ${PLAY_CONSOLE_URL})${googleSays(error)}.`, { cause: error });
|
|
1294
|
+
const isDenied = (error) => error instanceof GoogleApiError && (error.status === 403 || error.apiStatus === "PERMISSION_DENIED");
|
|
1295
|
+
/**
|
|
1296
|
+
* `IssuePlayServiceAccount` with Google's own APIs, after a sign-in on
|
|
1297
|
+
* Google's consent screen (OAuth for installed apps, with PKCE and a
|
|
1298
|
+
* loopback redirect):
|
|
1299
|
+
*
|
|
1300
|
+
* 1. the Play Console side is checked first, with no change anywhere: the
|
|
1301
|
+
* developer account answers to the account as its admin, and has the app;
|
|
1302
|
+
* 2. the chosen Google Cloud project, or a new one, gets the IAM and Play
|
|
1303
|
+
* Developer APIs enabled, and the service account `hot-updater-play`;
|
|
1304
|
+
* 3. the service account becomes a Play Console user that may release the
|
|
1305
|
+
* app to testing tracks;
|
|
1306
|
+
* 4. only then is its JSON key created, the last step, so a failure before
|
|
1307
|
+
* it leaves no key behind.
|
|
1308
|
+
*
|
|
1309
|
+
* Each step reuses what an earlier run created: the project, the service
|
|
1310
|
+
* account, and its Play Console access.
|
|
1311
|
+
*/
|
|
1312
|
+
const createGoogleKeyIssuer = ({ fetch = globalThis.fetch, sleep = sleepFor } = {}) => async (request) => {
|
|
1313
|
+
const { packageName, oauthClient, onStep = () => {} } = request;
|
|
1314
|
+
if (!oauthClient?.clientId) throw new PlayServiceAccountError("sign-in-not-configured", `This build of Hot Updater has no Google OAuth client to sign in with. Set ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientId} and ${GOOGLE_OAUTH_CLIENT_ENV_NAMES.clientSecret} to a "Desktop app" OAuth client.`);
|
|
1315
|
+
const signIn = await signInWithGoogle({
|
|
1316
|
+
client: oauthClient,
|
|
1317
|
+
scopes: SCOPES,
|
|
1318
|
+
onUrl: request.onSignInUrl,
|
|
1319
|
+
fetch
|
|
621
1320
|
});
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
1321
|
+
const dispose = async () => {
|
|
1322
|
+
await revokeGoogleToken(signIn.accessToken, fetch);
|
|
1323
|
+
};
|
|
1324
|
+
try {
|
|
1325
|
+
const missing = SCOPES.filter((scope) => !signIn.scopes.includes(scope));
|
|
1326
|
+
if (missing.length > 0) throw new PlayServiceAccountError("scope-not-granted", `The sign-in did not allow ${missing.map((scope) => scope === ANDROID_PUBLISHER_SCOPE ? "managing Google Play" : "managing Google Cloud").join(" or ")}. Run this again and keep every box on Google's consent screen ticked.`);
|
|
1327
|
+
const google = createGoogleRequest({
|
|
1328
|
+
accessToken: signIn.accessToken,
|
|
1329
|
+
fetch
|
|
1330
|
+
});
|
|
1331
|
+
const developerInput = await request.askDeveloperId();
|
|
1332
|
+
const developerId = parseDeveloperId(developerInput);
|
|
1333
|
+
if (developerId === void 0) throw new PlayServiceAccountError("developer-not-found", `"${developerInput.trim()}" is no developer account ID. Open ${PLAY_CONSOLE_URL}: it is the number after /developers/ in the address.`);
|
|
1334
|
+
onStep("Checking the Play Console developer account");
|
|
1335
|
+
await checkPlayConsole({
|
|
1336
|
+
google,
|
|
1337
|
+
developerId,
|
|
1338
|
+
packageName
|
|
1339
|
+
});
|
|
1340
|
+
const project = await chooseProject({
|
|
1341
|
+
google,
|
|
1342
|
+
request,
|
|
1343
|
+
sleep,
|
|
1344
|
+
onStep
|
|
1345
|
+
});
|
|
1346
|
+
const { projectId } = project;
|
|
1347
|
+
onStep(`Enabling the Google Play Developer API in ${projectId}`);
|
|
1348
|
+
await enableServices({
|
|
1349
|
+
google,
|
|
1350
|
+
projectId,
|
|
1351
|
+
sleep
|
|
1352
|
+
});
|
|
1353
|
+
onStep(`Creating the service account in ${projectId}`);
|
|
1354
|
+
const email = await ensureServiceAccount({
|
|
1355
|
+
google,
|
|
1356
|
+
project,
|
|
1357
|
+
sleep
|
|
1358
|
+
});
|
|
1359
|
+
onStep(`Giving ${email} release access to ${packageName}`);
|
|
1360
|
+
await grantPlayAccess({
|
|
1361
|
+
google,
|
|
1362
|
+
developerId,
|
|
1363
|
+
email,
|
|
1364
|
+
packageName
|
|
1365
|
+
});
|
|
1366
|
+
onStep("Creating the service account's JSON key");
|
|
1367
|
+
const key = await createKey({
|
|
1368
|
+
google,
|
|
1369
|
+
project,
|
|
1370
|
+
email,
|
|
1371
|
+
sleep
|
|
1372
|
+
});
|
|
1373
|
+
return {
|
|
1374
|
+
json: key.json,
|
|
1375
|
+
keyId: key.keyId,
|
|
1376
|
+
email,
|
|
1377
|
+
projectId,
|
|
1378
|
+
developerId,
|
|
1379
|
+
revoke: async () => {
|
|
1380
|
+
try {
|
|
1381
|
+
await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`);
|
|
1382
|
+
return true;
|
|
1383
|
+
} catch (error) {
|
|
1384
|
+
return isNotFound(error);
|
|
1385
|
+
}
|
|
1386
|
+
},
|
|
1387
|
+
dispose
|
|
1388
|
+
};
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
await dispose();
|
|
1391
|
+
throw error;
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
/**
|
|
1395
|
+
* Whether the signed-in account administers `developerId`, and Google Play
|
|
1396
|
+
* has `packageName` there, asked without changing anything: listing the
|
|
1397
|
+
* account's users takes the admin permission, and an edit opened on the app
|
|
1398
|
+
* is deleted again.
|
|
1399
|
+
*/
|
|
1400
|
+
const checkPlayConsole = async ({ google, developerId, packageName }) => {
|
|
1401
|
+
try {
|
|
1402
|
+
await google("Listing the Play Console users", "GET", `${PUBLISHER_API}/developers/${developerId}/users?pageSize=1`);
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
if (isServiceDisabled(error)) throw clientApiDisabled(error);
|
|
1405
|
+
if (isNotFound(error)) throw new PlayServiceAccountError("developer-not-found", `Play Console has no developer account ${developerId}. Open ${PLAY_CONSOLE_URL}: the developer account ID is the number after /developers/ in the address${googleSays(error)}.`, { cause: error });
|
|
1406
|
+
if (isDenied(error)) throw notPlayAdmin(developerId, error);
|
|
1407
|
+
throw error;
|
|
1408
|
+
}
|
|
1409
|
+
let edit;
|
|
1410
|
+
try {
|
|
1411
|
+
edit = await google(`Opening an edit of ${packageName}`, "POST", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits`);
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
if (isServiceDisabled(error)) throw clientApiDisabled(error);
|
|
1414
|
+
if (isNotFound(error) || isDenied(error)) throw appNotFound(packageName, developerId, error);
|
|
1415
|
+
throw error;
|
|
1416
|
+
}
|
|
1417
|
+
if (edit.id) await google("Deleting the edit", "DELETE", `${PUBLISHER_API}/applications/${encodeURIComponent(packageName)}/edits/${edit.id}`).catch(() => {});
|
|
1418
|
+
};
|
|
1419
|
+
const appNotFound = (packageName, developerId, cause) => new PlayServiceAccountError("app-not-found", `Google Play has no app ${packageName} in the developer account ${developerId}. Google takes the first .aab of a new app only in Play Console: create the app there, upload one .aab by hand (for example to internal testing), then run this again${googleSays(cause)}.`, { cause });
|
|
1420
|
+
/** The account's active projects, every page of them. */
|
|
1421
|
+
const listProjects = async (google) => {
|
|
1422
|
+
const projects = [];
|
|
1423
|
+
let pageToken;
|
|
1424
|
+
do {
|
|
1425
|
+
const page = await google("Listing the Google Cloud projects", "GET", `${RESOURCE_MANAGER_API}/v3/projects:search?${new URLSearchParams({
|
|
1426
|
+
query: "state:ACTIVE",
|
|
1427
|
+
...pageToken ? { pageToken } : {}
|
|
1428
|
+
})}`);
|
|
1429
|
+
for (const project of page.projects ?? []) if (project.projectId) projects.push({
|
|
1430
|
+
projectId: project.projectId,
|
|
1431
|
+
...project.displayName ? { displayName: project.displayName } : {},
|
|
1432
|
+
...projectNumberOf(project.name)
|
|
1433
|
+
});
|
|
1434
|
+
pageToken = page.nextPageToken || void 0;
|
|
1435
|
+
} while (pageToken);
|
|
1436
|
+
return projects;
|
|
1437
|
+
};
|
|
1438
|
+
/** The project the developer picks, or a new one when there is none or they ask. */
|
|
1439
|
+
const chooseProject = async ({ google, request, sleep, onStep }) => {
|
|
1440
|
+
const projects = await listProjects(google);
|
|
1441
|
+
const chosen = projects.length > 0 ? await request.chooseProject(projects.map(({ projectId, displayName }) => ({
|
|
1442
|
+
projectId,
|
|
1443
|
+
...displayName ? { displayName } : {}
|
|
1444
|
+
}))) : void 0;
|
|
1445
|
+
if (chosen !== void 0) return projects.find((project) => project.projectId === chosen) ?? { projectId: chosen };
|
|
1446
|
+
const projectId = newProjectId();
|
|
1447
|
+
onStep(`Creating the Google Cloud project ${projectId}`);
|
|
1448
|
+
try {
|
|
1449
|
+
const operation = await google("Creating the Google Cloud project", "POST", `${RESOURCE_MANAGER_API}/v3/projects`, {
|
|
1450
|
+
projectId,
|
|
1451
|
+
displayName: "Hot Updater Play"
|
|
1452
|
+
});
|
|
1453
|
+
const created = await waitForOperation({
|
|
1454
|
+
request: google,
|
|
1455
|
+
origin: `${RESOURCE_MANAGER_API}/v3`,
|
|
1456
|
+
operation,
|
|
1457
|
+
action: "Creating the Google Cloud project",
|
|
1458
|
+
sleep
|
|
1459
|
+
});
|
|
1460
|
+
return {
|
|
1461
|
+
projectId,
|
|
1462
|
+
...projectNumberOf(created?.name)
|
|
1463
|
+
};
|
|
1464
|
+
} catch (error) {
|
|
1465
|
+
if (!(error instanceof GoogleApiError)) throw error;
|
|
1466
|
+
if (error.status === 429 || error.apiStatus === "RESOURCE_EXHAUSTED" || /quota/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-quota", `Google Cloud has no project quota left for this account: ${error.apiMessage || "quota exceeded"}. Delete a project you no longer use (https://console.cloud.google.com/cloud-resource-manager), or run this again and pick an existing project.`, { cause: error });
|
|
1467
|
+
if (/terms of service/iu.test(error.apiMessage)) throw new PlayServiceAccountError("project-create-denied", "This Google Account has not accepted the Google Cloud Terms of Service. Open https://console.cloud.google.com once to accept them, then run this again.", { cause: error });
|
|
1468
|
+
if (isDenied(error) || error.apiStatus === "FAILED_PRECONDITION") throw new PlayServiceAccountError("project-create-denied", `This Google Account may not create Google Cloud projects: ${error.apiMessage || "permission denied"}. Ask your organization's admin for the Project Creator role, or run this again and pick an existing project.`, { cause: error });
|
|
1469
|
+
throw error;
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
/** `projectNumber` from a resource name `projects/<number>`. */
|
|
1473
|
+
const projectNumberOf = (name) => {
|
|
1474
|
+
const number = name?.match(/^projects\/(\d+)$/u)?.[1];
|
|
1475
|
+
return number === void 0 ? {} : { projectNumber: number };
|
|
1476
|
+
};
|
|
1477
|
+
/**
|
|
1478
|
+
* Runs `run` again while Google is still enabling an API in `project`,
|
|
1479
|
+
* or, with `notFound`, still creating what it reads.
|
|
1480
|
+
*/
|
|
1481
|
+
const whilePropagating = async ({ run, project, sleep, notFound = false }) => {
|
|
1482
|
+
for (let attempt = 1;; attempt++) try {
|
|
1483
|
+
return await run();
|
|
1484
|
+
} catch (error) {
|
|
1485
|
+
const disabled = isServiceDisabled(error);
|
|
1486
|
+
if (disabled && !consumerIs(error, project)) throw clientApiDisabled(error);
|
|
1487
|
+
if (!disabled && !(notFound && isNotFound(error)) || attempt >= PROPAGATION_ATTEMPTS) {
|
|
1488
|
+
if (disabled) throw new PlayServiceAccountError("api-not-enabled", `Google is still enabling the APIs in ${project.projectId}: ${error.apiMessage} Wait a few minutes, then run this again.`, { cause: error });
|
|
1489
|
+
throw error;
|
|
625
1490
|
}
|
|
626
|
-
|
|
627
|
-
} else if ((await Device.getConnectedDevices()).length === 0) {
|
|
628
|
-
_hot_updater_cli_tools.p.log.info("No devices found. Launching first available emulator.");
|
|
629
|
-
await Device.tryLaunchEmulator();
|
|
1491
|
+
await sleep(PROPAGATION_DELAY_MS);
|
|
630
1492
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
await
|
|
642
|
-
|
|
643
|
-
|
|
1493
|
+
};
|
|
1494
|
+
/**
|
|
1495
|
+
* Whether a disabled-service error is about `project` itself, which waiting
|
|
1496
|
+
* fixes: Google names the consumer by number, and an error naming none
|
|
1497
|
+
* counts as the project's own.
|
|
1498
|
+
*/
|
|
1499
|
+
const consumerIs = (error, project) => error.consumer === void 0 || error.consumer === `projects/${project.projectId}` || (project.projectNumber === void 0 ? true : error.consumer === `projects/${project.projectNumber}`);
|
|
1500
|
+
const enableServices = async ({ google, projectId, sleep }) => {
|
|
1501
|
+
try {
|
|
1502
|
+
const operation = await google("Enabling the APIs", "POST", `${SERVICE_USAGE_API}/v1/projects/${projectId}/services:batchEnable`, { serviceIds: SERVICES });
|
|
1503
|
+
await waitForOperation({
|
|
1504
|
+
request: google,
|
|
1505
|
+
origin: `${SERVICE_USAGE_API}/v1`,
|
|
1506
|
+
operation,
|
|
1507
|
+
action: "Enabling the APIs",
|
|
1508
|
+
sleep
|
|
1509
|
+
});
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", `This Google Account may not enable APIs in ${projectId}: ${error.apiMessage || "permission denied"}. Pick a project you own, or ask its owner for the Editor role.`, { cause: error });
|
|
1512
|
+
throw error;
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
/** The `hot-updater-play` service account's email, created unless it exists. */
|
|
1516
|
+
const ensureServiceAccount = async ({ google, project, sleep }) => {
|
|
1517
|
+
const { projectId } = project;
|
|
1518
|
+
const email = `${PLAY_SERVICE_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
|
|
1519
|
+
try {
|
|
1520
|
+
return (await whilePropagating({
|
|
1521
|
+
project,
|
|
1522
|
+
sleep,
|
|
1523
|
+
run: () => google("Creating the service account", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts`, {
|
|
1524
|
+
accountId: "hot-updater-play",
|
|
1525
|
+
serviceAccount: {
|
|
1526
|
+
displayName: "Hot Updater Play uploads",
|
|
1527
|
+
description: "Uploads Android App Bundles to Google Play for hot-updater native-build android --submit play-internal."
|
|
1528
|
+
}
|
|
1529
|
+
})
|
|
1530
|
+
})).email ?? email;
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
if (isConflict(error)) return email;
|
|
1533
|
+
if (isDenied(error) && !isServiceDisabled(error)) throw new PlayServiceAccountError("project-access-denied", `This Google Account may not create service accounts in ${projectId}: ${error.apiMessage || "permission denied"}. Pick a project you own, or ask its owner for the Service Account Admin role.`, { cause: error });
|
|
1534
|
+
throw error;
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
/**
|
|
1538
|
+
* Makes `email` a Play Console user that may release `packageName`, keeping
|
|
1539
|
+
* any permission it has: the user and the app's grant are created, or the
|
|
1540
|
+
* grant gains the release permissions.
|
|
1541
|
+
*/
|
|
1542
|
+
const grantPlayAccess = async ({ google, developerId, email, packageName }) => {
|
|
1543
|
+
const developer = `${PUBLISHER_API}/developers/${developerId}`;
|
|
1544
|
+
const user = `${developer}/users/${encodeURIComponent(email)}`;
|
|
1545
|
+
try {
|
|
1546
|
+
await google("Adding the Play Console user", "POST", `${developer}/users`, {
|
|
1547
|
+
name: `developers/${developerId}/users/${email}`,
|
|
1548
|
+
email
|
|
644
1549
|
});
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
if (isDenied(error)) throw notPlayAdmin(developerId, error);
|
|
1552
|
+
if (!isConflict(error)) throw error;
|
|
1553
|
+
}
|
|
1554
|
+
try {
|
|
1555
|
+
await google("Granting access to the app", "POST", `${user}/grants`, {
|
|
1556
|
+
name: `developers/${developerId}/users/${email}/grants/${packageName}`,
|
|
1557
|
+
packageName,
|
|
1558
|
+
appLevelPermissions: APP_PERMISSIONS
|
|
651
1559
|
});
|
|
1560
|
+
return;
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
if (isNotFound(error)) throw appNotFound(packageName, developerId, error);
|
|
1563
|
+
if (isDenied(error)) throw notPlayAdmin(developerId, error);
|
|
1564
|
+
if (!isConflict(error)) throw error;
|
|
652
1565
|
}
|
|
653
|
-
|
|
654
|
-
|
|
1566
|
+
const granted = await findGrant({
|
|
1567
|
+
google,
|
|
1568
|
+
developer,
|
|
1569
|
+
email,
|
|
1570
|
+
packageName
|
|
1571
|
+
});
|
|
1572
|
+
const missing = APP_PERMISSIONS.filter((permission) => !granted.includes(permission));
|
|
1573
|
+
if (missing.length === 0) return;
|
|
1574
|
+
await google("Granting access to the app", "PATCH", `${user}/grants/${encodeURIComponent(packageName)}?updateMask=appLevelPermissions`, { appLevelPermissions: [...granted, ...missing] });
|
|
655
1575
|
};
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1576
|
+
/** The app permissions `email` holds on `packageName`, from the user list. */
|
|
1577
|
+
const findGrant = async ({ google, developer, email, packageName }) => {
|
|
1578
|
+
let pageToken;
|
|
1579
|
+
do {
|
|
1580
|
+
const page = await google("Listing the Play Console users", "GET", `${developer}/users?${new URLSearchParams({
|
|
1581
|
+
pageSize: "100",
|
|
1582
|
+
...pageToken ? { pageToken } : {}
|
|
1583
|
+
})}`);
|
|
1584
|
+
const found = page.users?.find((candidate) => candidate.email === email);
|
|
1585
|
+
if (found) return found.grants?.find((grant) => grant.packageName === packageName)?.appLevelPermissions ?? [];
|
|
1586
|
+
pageToken = page.nextPageToken || void 0;
|
|
1587
|
+
} while (pageToken);
|
|
1588
|
+
return [];
|
|
1589
|
+
};
|
|
1590
|
+
/** A new JSON key of the service account, checked and masked in the CLI's output. */
|
|
1591
|
+
const createKey = async ({ google, project, email, sleep }) => {
|
|
1592
|
+
const { projectId } = project;
|
|
1593
|
+
let key;
|
|
1594
|
+
try {
|
|
1595
|
+
key = await whilePropagating({
|
|
1596
|
+
project,
|
|
1597
|
+
sleep,
|
|
1598
|
+
notFound: true,
|
|
1599
|
+
run: () => google("Creating the key", "POST", `${IAM_API}/projects/${projectId}/serviceAccounts/${email}/keys`, {
|
|
1600
|
+
privateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
|
|
1601
|
+
keyAlgorithm: "KEY_ALG_RSA_2048"
|
|
1602
|
+
})
|
|
1603
|
+
});
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
if (error instanceof GoogleApiError && (error.violations.includes(KEY_CREATION_CONSTRAINT) || /disableServiceAccountKeyCreation/u.test(error.apiMessage))) throw new PlayServiceAccountError("key-creation-disabled", `An organization policy (iam.disableServiceAccountKeyCreation) forbids service account keys in ${projectId}; organizations created since 2024 enforce it by default. Ask an organization policy admin to allow keys for this project, or run this again with a project outside the organization. The service account ${email} and its Play Console access remain for the next run.`, { cause: error });
|
|
1606
|
+
throw error;
|
|
1607
|
+
}
|
|
1608
|
+
if (!key.name || !key.privateKeyData) throw new Error("Google Cloud created the key without its JSON file.");
|
|
1609
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(key.privateKeyData);
|
|
1610
|
+
const json = Buffer.from(key.privateKeyData, "base64").toString("utf-8");
|
|
1611
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(json);
|
|
1612
|
+
let parsed;
|
|
1613
|
+
try {
|
|
1614
|
+
parsed = JSON.parse(json);
|
|
1615
|
+
} catch {
|
|
1616
|
+
parsed = {};
|
|
1617
|
+
}
|
|
1618
|
+
if (typeof parsed.private_key !== "string") {
|
|
1619
|
+
await google("Deleting the key", "DELETE", `${IAM_API}/${key.name}`).catch(() => {});
|
|
1620
|
+
throw new Error("Google Cloud created a key that is no JSON key file.");
|
|
1621
|
+
}
|
|
1622
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(parsed.private_key);
|
|
659
1623
|
return {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
applicationId: schemeConfig.packageName,
|
|
664
|
-
...schemeConfig,
|
|
665
|
-
hotUpdaterSchemeName
|
|
1624
|
+
json,
|
|
1625
|
+
name: key.name,
|
|
1626
|
+
keyId: key.name.split("/").pop()
|
|
666
1627
|
};
|
|
667
1628
|
};
|
|
668
1629
|
//#endregion
|
|
1630
|
+
//#region src/playServiceAccount.ts
|
|
1631
|
+
/**
|
|
1632
|
+
* Hot Updater's Google OAuth client, a "Desktop app" client the sign-in
|
|
1633
|
+
* opens Google's consent screen for. Empty in a build that has none, where
|
|
1634
|
+
* `HOT_UPDATER_GOOGLE_OAUTH_CLIENT_ID` and `_SECRET` name one.
|
|
1635
|
+
*/
|
|
1636
|
+
const HOT_UPDATER_GOOGLE_OAUTH_CLIENT = {
|
|
1637
|
+
clientId: "",
|
|
1638
|
+
clientSecret: ""
|
|
1639
|
+
};
|
|
1640
|
+
/**
|
|
1641
|
+
* Hot Updater's issuer, the one place that picks how it is done: Google's
|
|
1642
|
+
* OAuth for installed apps, then the Cloud Resource Manager, Service Usage,
|
|
1643
|
+
* IAM, and Play Developer APIs. Another implementation of
|
|
1644
|
+
* `IssuePlayServiceAccount` replaces it here without touching its callers.
|
|
1645
|
+
*/
|
|
1646
|
+
const issuePlayServiceAccount = (request) => createGoogleKeyIssuer()({
|
|
1647
|
+
...request,
|
|
1648
|
+
oauthClient: request.oauthClient ?? HOT_UPDATER_GOOGLE_OAUTH_CLIENT
|
|
1649
|
+
});
|
|
1650
|
+
//#endregion
|
|
1651
|
+
exports.GOOGLE_OAUTH_CLIENT_ENV_NAMES = GOOGLE_OAUTH_CLIENT_ENV_NAMES;
|
|
1652
|
+
exports.HOT_UPDATER_GOOGLE_OAUTH_CLIENT = HOT_UPDATER_GOOGLE_OAUTH_CLIENT;
|
|
1653
|
+
exports.PLAY_CONSOLE_URL = PLAY_CONSOLE_URL;
|
|
1654
|
+
exports.PLAY_SERVICE_ACCOUNT_ID = PLAY_SERVICE_ACCOUNT_ID;
|
|
1655
|
+
exports.PlayServiceAccountError = PlayServiceAccountError;
|
|
1656
|
+
exports.PlayVersionCodeUsedError = PlayVersionCodeUsedError;
|
|
1657
|
+
exports.assertPlaySigned = assertPlaySigned;
|
|
669
1658
|
exports.buildAndroid = buildAndroid;
|
|
670
1659
|
exports.enrichNativeBuildAndroidScheme = enrichNativeBuildAndroidScheme;
|
|
671
|
-
exports.
|
|
1660
|
+
exports.extractBuiltInBundle = extractBuiltInBundle;
|
|
1661
|
+
exports.findPlayInternalRelease = findPlayInternalRelease;
|
|
1662
|
+
exports.issuePlayServiceAccount = issuePlayServiceAccount;
|
|
1663
|
+
exports.readBuiltApp = readBuiltApp;
|
|
1664
|
+
exports.readBuiltPackageName = readBuiltPackageName;
|
|
1665
|
+
exports.readBuiltVersionCode = readBuiltVersionCode;
|
|
1666
|
+
exports.resolvePlayUploadServiceAccount = resolvePlayUploadServiceAccount;
|
|
1667
|
+
exports.uploadToPlay = uploadToPlay;
|