catalyst-core 1.2.0-canary.2 → 1.2.0-canary.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/errors/registry.js +2 -2
  2. package/dist/errors-index.json +2 -2
  3. package/dist/native/buildAndroid/assets.js +18 -1
  4. package/dist/native/buildAndroid/build.js +45 -3
  5. package/dist/native/buildAndroid/index.js +51 -18
  6. package/dist/native/buildAndroid/output.js +166 -4
  7. package/dist/native/buildAppAndroid.js +26 -1
  8. package/dist/native/buildAppIos.js +132 -4
  9. package/dist/native/buildIos/config.js +9 -2
  10. package/dist/native/buildIos/index.js +41 -12
  11. package/dist/native/buildIos/signing.js +536 -12
  12. package/dist/native/nativeConfig.js +64 -45
  13. package/dist/native/releaseOutput.js +34 -1
  14. package/dist/native/renameAndroidProject.js +23 -8
  15. package/dist/native/setupServer.js +266 -14
  16. package/dist/native/splashConfig.js +64 -0
  17. package/dist/native/utils.js +1 -1
  18. package/dist/scripts/preServerInit.js +14 -1
  19. package/dist/server/utils/validator.d.ts +8 -1
  20. package/dist/server/utils/validator.js +20 -1
  21. package/dist/web-router/components/MetaTag.jsx +17 -2
  22. package/dist/web-router/components/MetaTag.test.jsx +95 -0
  23. package/mcp_v2/knowledge-base.json +7 -7
  24. package/mcp_v2/test/build.test.ts +104 -0
  25. package/mcp_v2/test/config.test.ts +113 -0
  26. package/mcp_v2/tools/build.js +54 -17
  27. package/mcp_v2/tools/config.js +89 -37
  28. package/package.json +1 -1
  29. package/test/native/androidOutput.test.ts +228 -0
  30. package/test/native/androidSigning.test.ts +9 -5
  31. package/test/native/iosConfigPhase.test.ts +17 -4
  32. package/test/native/iosInstalledSigning.test.ts +607 -0
  33. package/test/native/iosSigning.test.ts +92 -0
  34. package/test/native/localState.test.ts +4 -5
  35. package/test/native/nativeConfig.test.ts +104 -11
  36. package/test/native/setupServer.test.ts +258 -7
  37. package/test/native/splashConfig.test.ts +177 -0
  38. package/test/server/validator.test.ts +37 -0
@@ -330,9 +330,9 @@ export const ERROR_DEFINITIONS = {
330
330
  category: "IOS",
331
331
  defaultMessage: "iOS code signing is not set up",
332
332
  defaultDetails:
333
- "A device or release build needs a signing identity and provisioning profile. Automatic signing needs WEBVIEW_CONFIG.ios.developmentTeam plus either an App Store Connect API key in credentials/ios/ (cloud signing) or an Apple account signed in to Xcode; manual signing needs a .p12 and its provisioning profile in credentials/ios/, or an installed ios.provisioningProfile. xcodebuild reported a signing failure, or a release build has no team to sign for.",
333
+ "A device or release build needs a signing identity and provisioning profile. A release signs with the first of: a .p12 and its profile in credentials/ios/, an installed ios.provisioningProfile, an App Store Connect API key in credentials/ios/, an App Store profile installed on this Mac for the app id (with its certificate in the keychain), or the Apple account signed in to Xcode for WEBVIEW_CONFIG.ios.developmentTeam. xcodebuild reported a signing failure, or a release build has no team to sign for.",
334
334
  suggestedAction:
335
- "For cloud signing, put an App Store Connect API key (its AuthKey_*.p8 file, with an ascIssuerId line in signing.properties) in credentials/ios/ and set WEBVIEW_CONFIG.ios.developmentTeam; for local signing, put a distribution .p12 and its .mobileprovision there instead. In CI, set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios.",
335
+ "Sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your team, or install that team's App Store profile for your appId. In CI, put the signing files in credentials/ios/ and set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios.",
336
336
  },
337
337
  [ERROR_CODES.IOS_ASC_API_KEY_INVALID]: {
338
338
  category: "IOS",
@@ -226,8 +226,8 @@
226
226
  "IOS-010": {
227
227
  "category": "IOS",
228
228
  "message": "iOS code signing is not set up",
229
- "details": "A device or release build needs a signing identity and provisioning profile. Automatic signing needs WEBVIEW_CONFIG.ios.developmentTeam plus either an App Store Connect API key in credentials/ios/ (cloud signing) or an Apple account signed in to Xcode; manual signing needs a .p12 and its provisioning profile in credentials/ios/, or an installed ios.provisioningProfile. xcodebuild reported a signing failure, or a release build has no team to sign for.",
230
- "suggestedAction": "For cloud signing, put an App Store Connect API key (its AuthKey_*.p8 file, with an ascIssuerId line in signing.properties) in credentials/ios/ and set WEBVIEW_CONFIG.ios.developmentTeam; for local signing, put a distribution .p12 and its .mobileprovision there instead. In CI, set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios.",
229
+ "details": "A device or release build needs a signing identity and provisioning profile. A release signs with the first of: a .p12 and its profile in credentials/ios/, an installed ios.provisioningProfile, an App Store Connect API key in credentials/ios/, an App Store profile installed on this Mac for the app id (with its certificate in the keychain), or the Apple account signed in to Xcode for WEBVIEW_CONFIG.ios.developmentTeam. xcodebuild reported a signing failure, or a release build has no team to sign for.",
230
+ "suggestedAction": "Sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your team, or install that team's App Store profile for your appId. In CI, put the signing files in credentials/ios/ and set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios.",
231
231
  "docUrl": "https://github.com/tata1mg/catalyst-core/blob/main/errors/IOS/IOS-010.md"
232
232
  },
233
233
  "IOS-011": {
@@ -5,6 +5,7 @@ const path = require("path");
5
5
  const {
6
6
  checkAppIcon
7
7
  } = require("../appIcon.js");
8
+ const splashConfig = require("../splashConfig.js");
8
9
  function createAssetsPhase(ctx) {
9
10
  const {
10
11
  configPath,
@@ -123,8 +124,24 @@ function createAssetsPhase(ctx) {
123
124
  async function copyOfflinePage() {
124
125
  await copyPublicHtml("offline.html", "offline");
125
126
  }
127
+
128
+ // The page paints the native splash colour once it takes over, so it gets the
129
+ // colour the native side resolves (SplashActivity and themes.xml default to white).
126
130
  async function copySplashHtml() {
127
- await copyPublicHtml("splashscreen.html", "splash", true);
131
+ let backgroundColor = "#ffffff";
132
+ try {
133
+ const {
134
+ WEBVIEW_CONFIG = {}
135
+ } = JSON.parse(fs.readFileSync(configPath, "utf8"));
136
+ backgroundColor = WEBVIEW_CONFIG.splashScreen?.backgroundColor || backgroundColor;
137
+ } catch {
138
+ // An unreadable config fails the config phase; the copy itself still goes ahead.
139
+ }
140
+ try {
141
+ splashConfig.copySplashHtml(`${process.cwd()}/public/splashscreen.html`, `${pwd}/androidProject/app/src/main/assets/splash/splashscreen.html`, backgroundColor);
142
+ } catch (error) {
143
+ progress.log(`Warning: Error copying splashscreen.html: ${error.message}`, "warning");
144
+ }
128
145
  }
129
146
 
130
147
  /**
@@ -19,6 +19,12 @@ const {
19
19
  const {
20
20
  DEFAULT_VERSION_NAME
21
21
  } = require("../releaseOutput.js");
22
+ const {
23
+ saveDebugApk
24
+ } = require("./output.js");
25
+ const {
26
+ displayPath
27
+ } = require("../../cli/theme.js");
22
28
 
23
29
  // adb answers in milliseconds when healthy. A wedged adb server accepts the
24
30
  // connection and never replies, so without a bound the build sat on
@@ -252,13 +258,39 @@ function createBuildPhase(ctx) {
252
258
  * Build the debug APK and install it on `serial` only. Gradle's
253
259
  * installDebug installs on every connected device unless ANDROID_SERIAL
254
260
  * names one.
261
+ *
262
+ * The APK is copied to build-output/android/debug/ (see saveDebugApk),
263
+ * also when the install fails after Gradle packaged it: installDebug
264
+ * writes app-debug.apk before it installs, so one Gradle run is enough.
265
+ *
266
+ * @returns {Promise<{ apkPath: string | null }>}
255
267
  */
256
268
  async function buildApp(androidConfig, buildOptimisation, serial, {
257
269
  configPath = defaultConfigPath,
258
270
  applicationId,
259
- version
271
+ version,
272
+ appRoot = process.cwd(),
273
+ name,
274
+ usesDevServer = false
260
275
  } = {}) {
261
276
  progress.status("building and installing app");
277
+ const builtAfter = Date.now();
278
+ // Best effort: a copy that fails must not fail a build that
279
+ // installed, or hide the error of one that did not.
280
+ const keepApk = () => {
281
+ try {
282
+ return saveDebugApk({
283
+ projectPath: `${pwd}/androidProject`,
284
+ appRoot,
285
+ name,
286
+ usesDevServer,
287
+ builtAfter
288
+ });
289
+ } catch (error) {
290
+ progress.log(`Could not save the APK to build-output: ${error.message}`, "warning");
291
+ return null;
292
+ }
293
+ };
262
294
  try {
263
295
  // Gradle needs the SDK location in local.properties. Only
264
296
  // setupEmulator:android wrote it, so building without a prior
@@ -290,11 +322,19 @@ function createBuildPhase(ctx) {
290
322
  }
291
323
  });
292
324
  } catch (error) {
325
+ const apkPath = keepApk();
326
+ if (apkPath) {
327
+ error.apkPath = apkPath;
328
+ progress.log(`The APK was built and saved to ${displayPath(apkPath, appRoot)}`, "warning");
329
+ }
293
330
  // Re-wrapping with a new Error dropped `output`, and with it the
294
331
  // compiler errors. Keep the original and let the reporter mine it.
295
332
  error.stage = "Building and installing the app";
296
333
  throw error;
297
334
  }
335
+ return {
336
+ apkPath: keepApk()
337
+ };
298
338
  }
299
339
 
300
340
  /** Open the installed app on `serial`. A failure is a warning: the app is installed. */
@@ -341,7 +381,7 @@ function createBuildPhase(ctx) {
341
381
  }
342
382
 
343
383
  /**
344
- * Build the signed release bundle. The webview config is generated in
384
+ * Build the signed release bundle and APK. The webview config is generated in
345
385
  * the temp_build_<pkg> copy that Gradle then bundles, from the resolved
346
386
  * release config and with -PcatalystBuildType=release, so the release
347
387
  * branch of generateWebViewConfig actually runs. Signing and version
@@ -353,7 +393,8 @@ function createBuildPhase(ctx) {
353
393
  version,
354
394
  applicationId,
355
395
  buildOptimisation = false,
356
- finalAabPath
396
+ finalAabPath,
397
+ finalApkPath
357
398
  } = {}) {
358
399
  progress.log("Building signed AAB for release...", "info");
359
400
  try {
@@ -362,6 +403,7 @@ function createBuildPhase(ctx) {
362
403
  if (androidConfig.sdkPath) ensureLocalProperties(`${pwd}/androidProject`, androidConfig.sdkPath);
363
404
  const aabConfig = await createAABConfig(androidConfig);
364
405
  if (finalAabPath) aabConfig.finalAabPath = finalAabPath;
406
+ if (finalApkPath) aabConfig.finalApkPath = finalApkPath;
365
407
  aabConfig.gradle = {
366
408
  configPath,
367
409
  buildOptimisation,
@@ -40,12 +40,16 @@ const {
40
40
  reachDevServer
41
41
  } = require("./devices.js");
42
42
  const {
43
+ RELEASE_APK_NOTE,
44
+ artifactName,
45
+ debugApkNotes,
43
46
  releaseOutputDir,
44
47
  releaseArtifactPaths
45
48
  } = require("./output.js");
46
49
  const {
47
50
  DEFAULT_VERSION_NAME,
48
- assertReleaseFolderFree
51
+ assertReleaseFolderFree,
52
+ ensureBuildOutputIgnore
49
53
  } = require("../releaseOutput.js");
50
54
  const {
51
55
  applyJavaHome,
@@ -66,6 +70,10 @@ const {
66
70
  const catalystCorePath = path.dirname(require.resolve("catalyst-core/package.json"));
67
71
  const pwd = path.join(catalystCorePath, "dist/native");
68
72
  const ANDROID_PACKAGE = "io.yourname.androidproject";
73
+
74
+ // The summary rows are "<key padded to 11> <value>"; a note under a value
75
+ // starts in the value column.
76
+ const SUMMARY_NOTE_INDENT = " ".repeat(11);
69
77
  const steps = {
70
78
  server: "Start dev server",
71
79
  config: "Initialize configuration",
@@ -190,6 +198,7 @@ function createAndroidBuild(config, {
190
198
  let resolved = null;
191
199
  let signing = null;
192
200
  let artifactPath = null;
201
+ let apkPath = null;
193
202
  let certificatePath = null;
194
203
  try {
195
204
  const log = (message, type) => progress.log(message, type);
@@ -223,7 +232,7 @@ function createAndroidBuild(config, {
223
232
  appRoot,
224
233
  androidConfig,
225
234
  version
226
- }), [".aab"]);
235
+ }), [".aab", ".apk"]);
227
236
  progress.complete("config");
228
237
 
229
238
  // One-command dev: a debug build starts the dev server when
@@ -362,32 +371,45 @@ function createAndroidBuild(config, {
362
371
  androidConfig,
363
372
  version
364
373
  });
365
- const names = releaseArtifactPaths(outputDir, androidConfig.newProjectName || androidConfig.appName || "app");
374
+ const names = releaseArtifactPaths(outputDir, artifactName(androidConfig));
375
+ ensureBuildOutputIgnore(outputDir, appRoot);
366
376
  const aab = await buildSignedAAB(androidConfig, {
367
377
  ...gradleOptions,
368
378
  signing,
369
379
  buildOptimisation,
370
- finalAabPath: names.aab
380
+ finalAabPath: names.aab,
381
+ finalApkPath: names.apk
371
382
  });
372
383
  artifactPath = aab?.finalAabPath || names.aab;
384
+ apkPath = aab?.finalApkPath || names.apk;
373
385
  // The upload certificate beside the bundle (#508): Play asks
374
386
  // for it when an upload key is registered or reset.
375
387
  certificatePath = exportUploadCertificate(signing, names.certificate);
376
388
  progress.complete("aab");
377
389
  } else {
378
390
  progress.start("build");
379
- await buildApp(androidConfig, buildOptimisation, targetDevice.id, gradleOptions);
380
- // Debug builds load http://localhost:<port>; on the device that
381
- // is the device itself. Forward every port the app uses to this
382
- // machine, after the install and before the launch, every run.
383
- const packageName = `${applicationId}.debug`;
384
- const ports = devServerPorts(resolved.config, {
385
- hmrPort: await readHmrPort({
386
- cwd: appRoot
387
- })
391
+ const debug = await buildApp(androidConfig, buildOptimisation, targetDevice.id, {
392
+ ...gradleOptions,
393
+ appRoot,
394
+ name: artifactName(androidConfig),
395
+ usesDevServer: resolved.usesDevServer
388
396
  });
389
- reachDevServer(adbRunner(ADB_PATH), targetDevice.id, packageName, ports);
390
- progress.log(`Forwarded ports ${ports.join(", ")} to this machine (adb reverse)`, "info");
397
+ apkPath = debug?.apkPath || null;
398
+ const packageName = `${applicationId}.debug`;
399
+ // A dev server build loads http://localhost:<port>; on the
400
+ // device that is the device itself. Forward every port the app
401
+ // uses to this machine, after the install and before the
402
+ // launch, every run. A build that loads a real host from
403
+ // LOCAL_IP reaches it directly and needs none of this.
404
+ if (resolved.usesDevServer) {
405
+ const ports = devServerPorts(resolved.config, {
406
+ hmrPort: await readHmrPort({
407
+ cwd: appRoot
408
+ })
409
+ });
410
+ reachDevServer(adbRunner(ADB_PATH), targetDevice.id, packageName, ports);
411
+ progress.log(`Forwarded ports ${ports.join(", ")} to this machine (adb reverse)`, "info");
412
+ }
391
413
  launchApp(ADB_PATH, targetDevice.id, packageName);
392
414
  progress.complete("build");
393
415
  }
@@ -401,7 +423,18 @@ function createAndroidBuild(config, {
401
423
  }];
402
424
  if (artifactPath) {
403
425
  summaryItems.push({
404
- text: `artifact ${displayPath(artifactPath, appRoot)}`,
426
+ text: `aab ${displayPath(artifactPath, appRoot)}`,
427
+ color: "gray"
428
+ });
429
+ }
430
+ if (apkPath) {
431
+ summaryItems.push({
432
+ text: `apk ${displayPath(apkPath, appRoot)}`,
433
+ color: "gray"
434
+ });
435
+ const notes = release ? [RELEASE_APK_NOTE] : debugApkNotes(resolved.usesDevServer);
436
+ for (const note of notes) summaryItems.push({
437
+ text: `${SUMMARY_NOTE_INDENT}${note}`,
405
438
  color: "gray"
406
439
  });
407
440
  }
@@ -421,9 +454,9 @@ function createAndroidBuild(config, {
421
454
  });
422
455
  progress.printTreeContent("Build", summaryItems);
423
456
  if (release) {
424
- progress.summary("Built the signed AAB", "Upload it to Google Play");
457
+ progress.summary("Built the signed AAB and APK", "Upload the AAB to Google Play");
425
458
  } else {
426
- progress.summary(`Running on ${deviceName}`, "Edit your app; the dev server reloads it");
459
+ progress.summary(`Running on ${deviceName}`, resolved.usesDevServer ? "Edit your app; the dev server reloads it" : `The app loads ${resolved.startUrl || wvConfig.LOCAL_IP}`);
427
460
  }
428
461
  } catch (error) {
429
462
  // Mark the step, then show what actually broke. The old handler
@@ -3,11 +3,58 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
+ exports.RELEASE_GRADLE_TASKS = exports.RELEASE_APK_NOTE = void 0;
7
+ exports.apkBeside = apkBeside;
8
+ exports.artifactName = artifactName;
9
+ exports.builtReleaseApk = builtReleaseApk;
10
+ exports.debugApkNotes = debugApkNotes;
11
+ exports.debugApkPaths = debugApkPaths;
12
+ exports.gradleOutputs = gradleOutputs;
6
13
  exports.releaseArtifactPaths = releaseArtifactPaths;
7
14
  exports.releaseOutputDir = releaseOutputDir;
15
+ exports.saveDebugApk = saveDebugApk;
16
+ var _fs = _interopRequireDefault(require("fs"));
8
17
  var _path = _interopRequireDefault(require("path"));
9
18
  var _releaseOutput = require("../releaseOutput.js");
10
19
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
20
+ /**
21
+ * The Gradle tasks a release runs, in one Gradle run: the bundle for Play
22
+ * and a universal APK for test installs. The release buildType's
23
+ * signingConfig signs both, and the app sets no ABI splits, so the APK is
24
+ * a single app-release.apk.
25
+ */
26
+ const RELEASE_GRADLE_TASKS = exports.RELEASE_GRADLE_TASKS = ["clean", "bundleRelease", "assembleRelease"];
27
+
28
+ /** The note printed under a release APK. */
29
+ const RELEASE_APK_NOTE = exports.RELEASE_APK_NOTE = "The APK is for test installs; uninstall a Play-installed copy first.";
30
+
31
+ /**
32
+ * A debug APK copied after a failed run must come from this run: a step
33
+ * that fails before Gradle's clean leaves the previous run's APK in place.
34
+ * The slack covers file systems that store mtimes in whole seconds.
35
+ */
36
+ const MTIME_SLACK_MS = 2000;
37
+
38
+ /** The name the AAB and APK files are given, before artifactBaseName makes it file-name safe. */
39
+ function artifactName(androidConfig = {}) {
40
+ return androidConfig.newProjectName || androidConfig.appName || "app";
41
+ }
42
+
43
+ /**
44
+ * Where Gradle writes its outputs in an Android project (androidProject or
45
+ * the temp_build copy a release builds in).
46
+ *
47
+ * @param {string} projectPath
48
+ */
49
+ function gradleOutputs(projectPath) {
50
+ const outputs = _path.default.join(projectPath, "app", "build", "outputs");
51
+ return {
52
+ debugApk: _path.default.join(outputs, "apk", "debug", "app-debug.apk"),
53
+ releaseAab: _path.default.join(outputs, "bundle", "release", "app-release.aab"),
54
+ releaseApk: _path.default.join(outputs, "apk", "release", "app-release.apk")
55
+ };
56
+ }
57
+
11
58
  /**
12
59
  * Where a release puts its files: build-output/android/<version>-<build>/
13
60
  * in the app, or <android.outputPath>/<version>-<build>/ when the config
@@ -24,20 +71,135 @@ function releaseOutputDir({
24
71
  androidConfig = {},
25
72
  version
26
73
  }) {
27
- const root = androidConfig.outputPath ? _path.default.resolve(appRoot, androidConfig.outputPath) : _path.default.join(appRoot, "build-output", "android");
74
+ const root = androidConfig.outputPath ? _path.default.resolve(appRoot, androidConfig.outputPath) : _path.default.join(appRoot, _releaseOutput.BUILD_OUTPUT_DIR, "android");
28
75
  return _path.default.join(root, (0, _releaseOutput.releaseFolderName)(version));
29
76
  }
30
77
 
31
78
  /**
32
- * The release files in that folder: the bundle for Play and the upload
33
- * certificate (PEM) that identifies the key that signed it.
79
+ * The release files in that folder: the bundle for Play, the APK for test
80
+ * installs, and the upload certificate (PEM) that identifies the key that
81
+ * signed both.
34
82
  *
35
83
  * @param {string} dir - releaseOutputDir()
36
84
  * @param {string} name - the app's name; made file-name safe here
37
85
  */
38
86
  function releaseArtifactPaths(dir, name) {
87
+ const base = (0, _releaseOutput.artifactBaseName)(name);
39
88
  return {
40
- aab: _path.default.join(dir, `${(0, _releaseOutput.artifactBaseName)(name)}.aab`),
89
+ aab: _path.default.join(dir, `${base}.aab`),
90
+ apk: _path.default.join(dir, `${base}.apk`),
41
91
  certificate: _path.default.join(dir, "upload_certificate.pem")
42
92
  };
93
+ }
94
+
95
+ /** The APK beside an AAB: the same name with .apk. */
96
+ function apkBeside(aabPath) {
97
+ return `${aabPath.replace(/\.aab$/i, "")}.apk`;
98
+ }
99
+
100
+ /**
101
+ * The signed release APK Gradle built, or a clear error. Without a signing
102
+ * config Gradle names it app-release-unsigned.apk, which no phone installs.
103
+ *
104
+ * @param {string} projectPath
105
+ * @returns {string}
106
+ */
107
+ function builtReleaseApk(projectPath) {
108
+ const {
109
+ releaseApk
110
+ } = gradleOutputs(projectPath);
111
+ if (_fs.default.existsSync(releaseApk)) return releaseApk;
112
+ const unsigned = _path.default.join(_path.default.dirname(releaseApk), "app-release-unsigned.apk");
113
+ if (_fs.default.existsSync(unsigned)) {
114
+ throw new Error(`The release APK was built without the upload key (${unsigned}), so it cannot be installed`);
115
+ }
116
+ throw new Error(`Signed APK file was not generated at ${releaseApk}`);
117
+ }
118
+
119
+ /**
120
+ * Where a debug build keeps its APK: build-output/android/debug/, under a
121
+ * fixed name that each run overwrites. A build that loads this computer's
122
+ * dev server gets its own name, since it only works here.
123
+ *
124
+ * @param {object} options
125
+ * @param {string} [options.appRoot]
126
+ * @param {string} [options.name] - the app's name; made file-name safe here
127
+ * @param {boolean} [options.usesDevServer] - resolveNativeConfig().usesDevServer
128
+ * @returns {{ dir: string, apk: string, stale: string }} `stale` is the other variant's file
129
+ */
130
+ function debugApkPaths({
131
+ appRoot = process.cwd(),
132
+ name,
133
+ usesDevServer = false
134
+ } = {}) {
135
+ const dir = _path.default.join(appRoot, _releaseOutput.BUILD_OUTPUT_DIR, "android", "debug");
136
+ const base = (0, _releaseOutput.artifactBaseName)(name);
137
+ const realHost = _path.default.join(dir, `${base}-debug.apk`);
138
+ const devServer = _path.default.join(dir, `${base}-debug-devserver.apk`);
139
+ return usesDevServer ? {
140
+ dir,
141
+ apk: devServer,
142
+ stale: realHost
143
+ } : {
144
+ dir,
145
+ apk: realHost,
146
+ stale: devServer
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Copy the debug APK Gradle built into build-output/android/debug/ and
152
+ * remove the other variant's file there, so only the current one exists.
153
+ * Returns null, and changes nothing, when there is no APK from this run.
154
+ *
155
+ * @param {object} options
156
+ * @param {string} options.projectPath - the androidProject Gradle built in
157
+ * @param {string} [options.appRoot]
158
+ * @param {string} [options.name]
159
+ * @param {boolean} [options.usesDevServer]
160
+ * @param {number} [options.builtAfter] - ms timestamp; an older APK is from an earlier run
161
+ * @returns {string | null} the saved APK's path
162
+ */
163
+ function saveDebugApk({
164
+ projectPath,
165
+ appRoot = process.cwd(),
166
+ name,
167
+ usesDevServer = false,
168
+ builtAfter = 0
169
+ }) {
170
+ const source = gradleOutputs(projectPath).debugApk;
171
+ let stat;
172
+ try {
173
+ stat = _fs.default.statSync(source);
174
+ } catch (error) {
175
+ return null;
176
+ }
177
+ if (builtAfter && stat.mtimeMs < builtAfter - MTIME_SLACK_MS) return null;
178
+ const {
179
+ dir,
180
+ apk,
181
+ stale
182
+ } = debugApkPaths({
183
+ appRoot,
184
+ name,
185
+ usesDevServer
186
+ });
187
+ (0, _releaseOutput.ensureBuildOutputIgnore)(dir, appRoot);
188
+ _fs.default.mkdirSync(dir, {
189
+ recursive: true
190
+ });
191
+ _fs.default.copyFileSync(source, apk);
192
+ _fs.default.rmSync(stale, {
193
+ force: true
194
+ });
195
+ return apk;
196
+ }
197
+
198
+ /** The lines printed under a debug APK in the build summary. */
199
+ function debugApkNotes(usesDevServer = false) {
200
+ const notes = ["Test build: debuggable, for testing only."];
201
+ if (usesDevServer) {
202
+ notes.push("Only works with this computer's dev server. To share, set WEBVIEW_CONFIG.LOCAL_IP to your site, or run android:release.");
203
+ }
204
+ return notes;
43
205
  }
@@ -15,6 +15,10 @@ const {
15
15
  buildFailure
16
16
  } = require("../cli/diagnostic.js");
17
17
  const {
18
+ askReleaseConfig
19
+ } = require("./setupServer.js");
20
+ const {
21
+ isInteractive,
18
22
  parseNativeArgs
19
23
  } = require("./utils.js");
20
24
  const {
@@ -34,12 +38,33 @@ async function main() {
34
38
  }));
35
39
  process.exit(1);
36
40
  }
41
+ // The build reads config.json from disk when it starts (initializeConfig),
42
+ // so answers saved below reach it; the config passed here is not read again.
37
43
  const {
38
- buildAndroidApp
44
+ buildAndroidApp,
45
+ progress
39
46
  } = createAndroidBuild({
40
47
  WEBVIEW_CONFIG,
41
48
  BUILD_OUTPUT_PATH
42
49
  }, flags);
50
+
51
+ // A store build in a terminal asks once for a missing app id or website
52
+ // and saves the answers. Its errors are left for the build, which
53
+ // resolves the config again and reports them the usual way.
54
+ if (isInteractive()) {
55
+ const log = (message, type) => progress.log(message, type);
56
+ try {
57
+ await askReleaseConfig("android", {
58
+ release: flags.release,
59
+ url: flags.url,
60
+ log
61
+ });
62
+ } catch (error) {
63
+ if (!/^NATIVE-\d{3}$/.test(error?.code)) {
64
+ log(`Could not read or update config/config.json: ${error.message}`, "warning");
65
+ }
66
+ }
67
+ }
43
68
  try {
44
69
  await buildAndroidApp();
45
70
  } catch (error) {