catalyst-core 1.1.0 → 1.2.0-canary.2

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 (69) hide show
  1. package/bin/catalyst.js +54 -6
  2. package/changelog.md +27 -1
  3. package/dist/errors/registry.js +306 -18
  4. package/dist/errors-index.json +273 -70
  5. package/dist/native/androidProject/app/build.gradle.kts +53 -79
  6. package/dist/native/androidProject/app/src/main/AndroidManifest.xml +1 -1
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CatalystConstants.kt +3 -1
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +32 -4
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +5 -1
  10. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ConfigMappingTest.kt +25 -0
  11. package/dist/native/androidSetup.js +76 -170
  12. package/dist/native/androidToolchain.js +642 -0
  13. package/dist/native/appIcon.js +100 -0
  14. package/dist/native/buildAndroid/assets.js +29 -5
  15. package/dist/native/buildAndroid/build.js +110 -223
  16. package/dist/native/buildAndroid/config.js +57 -65
  17. package/dist/native/buildAndroid/devices.js +176 -0
  18. package/dist/native/buildAndroid/index.js +271 -46
  19. package/dist/native/buildAndroid/output.js +43 -0
  20. package/dist/native/buildAndroid/signing.js +456 -0
  21. package/dist/native/buildAppAndroid.js +27 -7
  22. package/dist/native/buildAppIos.js +139 -20
  23. package/dist/native/buildIos/assets.js +76 -4
  24. package/dist/native/buildIos/build.js +31 -181
  25. package/dist/native/buildIos/config.js +126 -11
  26. package/dist/native/buildIos/index.js +354 -34
  27. package/dist/native/buildIos/localhostCert.js +87 -0
  28. package/dist/native/buildIos/output.js +28 -0
  29. package/dist/native/buildIos/signing.js +596 -0
  30. package/dist/native/credentials.js +309 -0
  31. package/dist/native/exportCredentials.js +51 -0
  32. package/dist/native/iosToolchain.js +583 -0
  33. package/dist/native/iosnativeWebView/Configurations/Shared.xcconfig +1 -3
  34. package/dist/native/iosnativeWebView/iosnativeWebView/Info-Release.plist +2 -0
  35. package/dist/native/iosnativeWebView/iosnativeWebView/PrivacyInfo.xcprivacy +40 -0
  36. package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements +0 -2
  37. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -4
  38. package/dist/native/localState.js +148 -0
  39. package/dist/native/nativeConfig.js +621 -0
  40. package/dist/native/releaseOutput.js +53 -0
  41. package/dist/native/renameAndroidProject.js +154 -369
  42. package/dist/native/setupEmulatorIos.js +58 -275
  43. package/dist/native/setupServer.js +520 -124
  44. package/dist/native/utils.js +418 -115
  45. package/dist/vite/resolveDevServerConfig.js +21 -0
  46. package/mcp_v2/knowledge-base.json +13 -11
  47. package/mcp_v2/test/config.test.ts +40 -0
  48. package/mcp_v2/tools/build.js +80 -104
  49. package/mcp_v2/tools/config.js +181 -174
  50. package/package.json +1 -1
  51. package/test/cli/nativeFlags.test.ts +97 -0
  52. package/test/native/androidDevices.test.ts +230 -0
  53. package/test/native/androidElfAlignment.test.ts +148 -0
  54. package/test/native/androidSigning.test.ts +646 -0
  55. package/test/native/androidToolchain.test.ts +766 -0
  56. package/test/native/appIcon.test.ts +124 -0
  57. package/test/native/credentials.test.ts +241 -0
  58. package/test/native/interaction.test.ts +179 -0
  59. package/test/native/iosConfigPhase.test.ts +611 -0
  60. package/test/native/iosSigning.test.ts +1060 -0
  61. package/test/native/iosToolchain.test.ts +821 -0
  62. package/test/native/localState.test.ts +80 -0
  63. package/test/native/nativeConfig.test.ts +602 -0
  64. package/test/native/setupServer.test.ts +529 -0
  65. package/test/native/utils.test.ts +102 -127
  66. package/test/vite/resolveDevServerConfig.test.js +25 -0
  67. package/dist/native/androidProject/keystore/Untitled +0 -0
  68. package/dist/native/androidProject/keystore/release-key.jks +0 -0
  69. package/dist/native/iosnativeWebView/iosnativeWebView/localhost.p12 +0 -0
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.MIN_ICON_SIZE = void 0;
7
+ exports.checkAppIcon = checkAppIcon;
8
+ exports.readPngInfo = readPngInfo;
9
+ var _fs = _interopRequireDefault(require("fs"));
10
+ var _path = _interopRequireDefault(require("path"));
11
+ var _utils = require("./utils.js");
12
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
13
+ // The single app icon both platforms build from: public/icon.png. App Store
14
+ // Connect wants a 1024x1024 marketing icon, so that is the floor.
15
+ const MIN_ICON_SIZE = exports.MIN_ICON_SIZE = 1024;
16
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
17
+ const ICON_ERROR_CODES = {
18
+ android: {
19
+ invalid: "ANDROID-010"
20
+ },
21
+ ios: {
22
+ invalid: "IOS-005",
23
+ alpha: "IOS-006"
24
+ }
25
+ };
26
+
27
+ /**
28
+ * Width, height and colour type from a PNG's IHDR, and whether it carries
29
+ * alpha: colour type 4 (grey + alpha) or 6 (RGBA), or a tRNS chunk (which
30
+ * must come before the image data). Null when the bytes are not a PNG.
31
+ *
32
+ * @param {Buffer} buffer
33
+ * @returns {{ width: number, height: number, bitDepth: number, colorType: number, hasAlpha: boolean } | null}
34
+ */
35
+ function readPngInfo(buffer) {
36
+ if (!Buffer.isBuffer(buffer) || buffer.length < 33) return null;
37
+ if (!buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
38
+ if (buffer.toString("ascii", 12, 16) !== "IHDR") return null;
39
+ const width = buffer.readUInt32BE(16);
40
+ const height = buffer.readUInt32BE(20);
41
+ const bitDepth = buffer[24];
42
+ const colorType = buffer[25];
43
+ let hasTransparencyChunk = false;
44
+ let offset = 8;
45
+ while (offset + 8 <= buffer.length) {
46
+ const length = buffer.readUInt32BE(offset);
47
+ const type = buffer.toString("ascii", offset + 4, offset + 8);
48
+ if (type === "tRNS") {
49
+ hasTransparencyChunk = true;
50
+ break;
51
+ }
52
+ if (type === "IDAT" || type === "IEND") break;
53
+ offset += 12 + length;
54
+ }
55
+ return {
56
+ width,
57
+ height,
58
+ bitDepth,
59
+ colorType,
60
+ hasAlpha: colorType === 4 || colorType === 6 || hasTransparencyChunk
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Check public/icon.png before it becomes the app icon. Not a PNG, not square
66
+ * or under 1024px is a coded error. Alpha is a warning, except for an iOS
67
+ * release, where App Store Connect would reject it (ITMS-90717).
68
+ *
69
+ * @param {string} file
70
+ * @param {object} options
71
+ * @param {"android"|"ios"} options.platform
72
+ * @param {boolean} [options.release]
73
+ * @param {(message: string, type?: string) => void} [options.log]
74
+ */
75
+ function checkAppIcon(file, {
76
+ platform,
77
+ release = false,
78
+ log = () => {}
79
+ }) {
80
+ const codes = ICON_ERROR_CODES[platform];
81
+ if (!codes) throw new Error(`No app icon error codes for platform "${platform}"`);
82
+ const info = readPngInfo(_fs.default.readFileSync(file));
83
+ // public/icon.png, not the absolute path, in what the user reads.
84
+ const relative = _path.default.relative(process.cwd(), file);
85
+ const name = relative && !relative.startsWith("..") ? relative : file;
86
+ if (!info) throw (0, _utils.nativeError)(codes.invalid, `${name} is not a PNG file`);
87
+ if (info.width !== info.height) {
88
+ throw (0, _utils.nativeError)(codes.invalid, `${name} is ${info.width}x${info.height}; it must be square`);
89
+ }
90
+ if (info.width < MIN_ICON_SIZE) {
91
+ throw (0, _utils.nativeError)(codes.invalid, `${name} is ${info.width}x${info.height}; it must be at least ${MIN_ICON_SIZE}x${MIN_ICON_SIZE}`);
92
+ }
93
+ if (info.hasAlpha) {
94
+ if (platform === "ios" && release) {
95
+ throw (0, _utils.nativeError)(codes.alpha, `${name} has an alpha channel`);
96
+ }
97
+ log(`${name} has an alpha channel. The App Store rejects icons with transparency; flatten it before an iOS release.`, "warning");
98
+ }
99
+ return info;
100
+ }
@@ -2,6 +2,9 @@
2
2
 
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
+ const {
6
+ checkAppIcon
7
+ } = require("../appIcon.js");
5
8
  function createAssetsPhase(ctx) {
6
9
  const {
7
10
  configPath,
@@ -123,7 +126,14 @@ function createAssetsPhase(ctx) {
123
126
  async function copySplashHtml() {
124
127
  await copyPublicHtml("splashscreen.html", "splash", true);
125
128
  }
126
- async function copyIconAssets() {
129
+
130
+ /**
131
+ * Launcher icons, first match wins: per-density files in
132
+ * public/android/appIcons, then the single public/icon.png, then the
133
+ * Catalyst placeholder. The single icon is checked (square, >= 1024px)
134
+ * and goes where the placeholder does, mipmap-xxxhdpi.
135
+ */
136
+ async function copyIconAssets(androidConfig = {}) {
127
137
  try {
128
138
  const destPath = `${pwd}/androidProject/app/src/main/res`;
129
139
  const manifestPath = `${pwd}/androidProject/app/src/main/AndroidManifest.xml`;
@@ -186,15 +196,25 @@ function createAssetsPhase(ctx) {
186
196
  hasCustomIcons = true;
187
197
  }
188
198
  }
189
- if (!hasCustomIcons && fallbackExists) {
199
+ const singleIconPath = path.join(publicPath, "icon.png"); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - publicPath is process.cwd()/public; "icon.png" is a fixed literal.
200
+ let usedSingleIcon = false;
201
+ if (!hasCustomIcons && fs.existsSync(singleIconPath)) {
202
+ checkAppIcon(singleIconPath, {
203
+ platform: "android",
204
+ release: androidConfig.buildType === "release",
205
+ log: (message, type) => progress.log(message, type)
206
+ });
207
+ usedSingleIcon = true;
208
+ }
209
+ if (!hasCustomIcons && (usedSingleIcon || fallbackExists)) {
190
210
  const targetDir = path.join(destPath, "mipmap-xxxhdpi"); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - destPath is derived from pwd (internal); "mipmap-xxxhdpi" is a fixed literal.
191
211
  fs.mkdirSync(targetDir, {
192
212
  recursive: true
193
213
  });
194
214
  cleanDensityDir(targetDir);
195
215
  const destination = path.join(targetDir, "icon.png"); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - targetDir is derived from pwd (internal); "icon.png" is a fixed literal.
196
- fs.copyFileSync(fallbackIconPath, destination);
197
- usedFallback = true;
216
+ fs.copyFileSync(usedSingleIcon ? singleIconPath : fallbackIconPath, destination);
217
+ usedFallback = !usedSingleIcon;
198
218
  }
199
219
  const setManifestIcons = (iconValue, roundIconValue) => {
200
220
  let current;
@@ -207,7 +227,7 @@ function createAssetsPhase(ctx) {
207
227
  const updated = current.replace(/android:icon="[^"]*"/g, `android:icon="${iconValue}"`).replace(/android:roundIcon="[^"]*"/g, `android:roundIcon="${roundIconValue}"`);
208
228
  if (updated !== current) fs.writeFileSync(manifestPath, updated);
209
229
  };
210
- if (!hasCustomIcons && !usedFallback) {
230
+ if (!hasCustomIcons && !usedFallback && !usedSingleIcon) {
211
231
  progress.log("No custom Android icons found; using default template icons.", "info");
212
232
  setManifestIcons("@mipmap/ic_launcher", "@mipmap/ic_launcher_round");
213
233
  return;
@@ -215,8 +235,12 @@ function createAssetsPhase(ctx) {
215
235
  setManifestIcons("@mipmap/icon", "@mipmap/icon");
216
236
  if (hasCustomIcons) {
217
237
  progress.log("Applied Android launcher icons from public/android/appIcons.", "success");
238
+ } else if (usedSingleIcon) {
239
+ progress.log("Applied the Android launcher icon from public/icon.png.", "success");
218
240
  }
219
241
  } catch (error) {
242
+ // An unusable public/icon.png is the user's to fix, not a warning to scroll past.
243
+ if (/^ANDROID-\d{3}$/.test(error?.code)) throw error;
220
244
  progress.log(`Warning: Error copying app icon assets: ${error.message}`, "warning");
221
245
  }
222
246
  }
@@ -4,14 +4,21 @@ const {
4
4
  spawn,
5
5
  execFileSync
6
6
  } = require("child_process");
7
- const fs = require("fs");
8
- const path = require("path");
9
7
  const {
10
8
  buildAndroidAAB
11
9
  } = require("../renameAndroidProject.js");
12
10
  const {
13
11
  ensureLocalProperties
14
12
  } = require("../utils.js");
13
+ const {
14
+ signingGradleEnv
15
+ } = require("./signing.js");
16
+ const {
17
+ launchArgs
18
+ } = require("./devices.js");
19
+ const {
20
+ DEFAULT_VERSION_NAME
21
+ } = require("../releaseOutput.js");
15
22
 
16
23
  // adb answers in milliseconds when healthy. A wedged adb server accepts the
17
24
  // connection and never replies, so without a bound the build sat on
@@ -23,106 +30,29 @@ const isToolTimeout = error => error?.code === "ETOOLTIMEOUT";
23
30
  const DEFAULT_DEPLOYMENT_PATH = "./deployment";
24
31
  const DEFAULT_OLD_PROJECT_NAME = "androidProject";
25
32
  const DEFAULT_OVERWRITE_EXISTING = true;
33
+
34
+ /**
35
+ * Version and application id as Gradle project properties, passed through
36
+ * the environment (ORG_GRADLE_PROJECT_*) like the signing values. Without a
37
+ * package.json version the app ships 1.0.0, as on iOS, which is also what
38
+ * the release folder is named after.
39
+ */
40
+ function versionGradleEnv({
41
+ applicationId,
42
+ version
43
+ } = {}) {
44
+ const env = {};
45
+ if (applicationId) env.ORG_GRADLE_PROJECT_catalystApplicationId = applicationId;
46
+ if (version?.buildNumber) env.ORG_GRADLE_PROJECT_catalystVersionCode = String(version.buildNumber);
47
+ if (version) env.ORG_GRADLE_PROJECT_catalystVersionName = version.versionName || DEFAULT_VERSION_NAME;
48
+ return env;
49
+ }
26
50
  function createBuildPhase(ctx) {
27
51
  const {
28
- configPath,
52
+ configPath: defaultConfigPath,
29
53
  pwd,
30
- progress,
31
- ANDROID_PACKAGE
54
+ progress
32
55
  } = ctx;
33
- async function detectPhysicalDevice(ADB_PATH) {
34
- try {
35
- progress.status("detecting physical devices");
36
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - ADB_PATH is derived from androidConfig.sdkPath, a trusted internal config value.
37
- const devices = ctx.runCommand(`${ADB_PATH} devices -l`, {
38
- timeout: ADB_TIMEOUT_MS
39
- });
40
- const lines = devices.split("\n").filter(line => line.trim() && !line.includes("List of devices"));
41
- for (const line of lines) {
42
- const parts = line.trim().split(/\s+/);
43
- if (parts.length >= 2 && parts[1] === "device") {
44
- const deviceId = parts[0];
45
- if (!deviceId.startsWith("emulator-")) {
46
- try {
47
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - deviceId comes from adb devices output, not user input.
48
- const qemuCheck = ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell getprop ro.kernel.qemu`, {
49
- timeout: ADB_TIMEOUT_MS
50
- });
51
- if (!qemuCheck.trim()) {
52
- progress.log(`Physical device detected: ${deviceId}`, "success");
53
- let deviceModel = "Unknown Device";
54
- try {
55
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - deviceId comes from adb devices output, not user input.
56
- const model = ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell getprop ro.product.model`, {
57
- timeout: ADB_TIMEOUT_MS
58
- });
59
- deviceModel = model.trim() || deviceModel;
60
- } catch (e) {
61
- // Ignore model detection errors
62
- }
63
- return {
64
- id: deviceId,
65
- model: deviceModel
66
- };
67
- }
68
- } catch (error) {
69
- continue;
70
- }
71
- }
72
- }
73
- }
74
- progress.log("No physical devices detected", "info");
75
- return null;
76
- } catch (error) {
77
- if (isToolTimeout(error)) throw error;
78
- progress.log("Error detecting physical devices: " + error.message, "error");
79
- return null;
80
- }
81
- }
82
- async function testPhysicalDeviceInstallation(ADB_PATH, deviceId) {
83
- try {
84
- progress.log(`Testing installation capability on device ${deviceId}...`, "info");
85
-
86
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - deviceId comes from adb devices output, not user input.
87
- const connectionTest = ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell echo "test"`);
88
- if (!connectionTest.includes("test")) {
89
- progress.log("Device connection test failed", "error");
90
- return false;
91
- }
92
- try {
93
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - deviceId comes from adb devices output, not user input.
94
- const developerOptions = ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell settings get global development_settings_enabled`);
95
- if (developerOptions.trim() !== "1") {
96
- progress.log("Developer options not enabled on device", "warning");
97
- return false;
98
- }
99
- } catch (error) {
100
- progress.log("Cannot verify developer options status", "warning");
101
- }
102
- try {
103
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - deviceId comes from adb devices output, not user input.
104
- const usbDebugging = ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell settings get global adb_enabled`);
105
- if (usbDebugging.trim() !== "1") {
106
- progress.log("USB debugging not enabled on device", "warning");
107
- return false;
108
- }
109
- } catch (error) {
110
- progress.log("Cannot verify USB debugging status", "warning");
111
- }
112
- try {
113
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - deviceId comes from adb devices output, not user input.
114
- ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell pm list packages -3 | head -1`);
115
- } catch (error) {
116
- progress.log("Cannot access package manager on device", "error");
117
- return false;
118
- }
119
- progress.log(`Device ${deviceId} is ready for installation`, "success");
120
- return true;
121
- } catch (error) {
122
- progress.log(`Installation capability test failed: ${error.message}`, "error");
123
- return false;
124
- }
125
- }
126
56
 
127
57
  // Returns the serial of an online emulator (adb state "device"), or null if
128
58
  // none is online. `devices.includes("emulator")` alone would also match a
@@ -132,7 +62,7 @@ function createBuildPhase(ctx) {
132
62
  async function checkEmulator(ADB_PATH) {
133
63
  try {
134
64
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - ADB_PATH is derived from androidConfig.sdkPath, a trusted internal config value.
135
- const devices = ctx.runCommand(`${ADB_PATH} devices`, {
65
+ const devices = ctx.runFile(ADB_PATH, ["devices"], {
136
66
  timeout: ADB_TIMEOUT_MS
137
67
  });
138
68
  const onlineEmulator = devices.split("\n").map(line => line.trim().split(/\s+/)).find(([id, state]) => id?.startsWith("emulator-") && state === "device");
@@ -153,23 +83,6 @@ function createBuildPhase(ctx) {
153
83
  return match ? match[1] : null;
154
84
  }
155
85
 
156
- /**
157
- * Run something that writes directly to the terminal, with the progress
158
- * tree stood down for the duration.
159
- *
160
- * The resume must happen on the failure path too -- a build that throws
161
- * while paused would otherwise leave the tree silent for the rest of the
162
- * run, hiding exactly the output someone needs when a build fails.
163
- */
164
- async function withStreamedOutput(fn) {
165
- progress.pause();
166
- try {
167
- return await fn();
168
- } finally {
169
- progress.resume();
170
- }
171
- }
172
-
173
86
  // The Android emulator binary is a long-running server process — it does not
174
87
  // exit after boot, it keeps running until the emulator window is closed. Using
175
88
  // runInteractiveCommand (which only resolves on the child's `close` event, see
@@ -226,7 +139,7 @@ function createBuildPhase(ctx) {
226
139
  function listEmulatorSerials(ADB_PATH) {
227
140
  try {
228
141
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - ADB_PATH is derived from androidConfig.sdkPath, a trusted internal config value.
229
- const devices = ctx.runCommand(`${ADB_PATH} devices`, {
142
+ const devices = ctx.runFile(ADB_PATH, ["devices"], {
230
143
  timeout: ADB_TIMEOUT_MS
231
144
  });
232
145
  return devices.split("\n").map(line => line.trim().split(/\s+/)[0]).filter(id => id && id.startsWith("emulator-"));
@@ -330,10 +243,21 @@ function createBuildPhase(ctx) {
330
243
  progress.log("Emulator booted successfully", "success");
331
244
  return {
332
245
  type: "emulator",
333
- name: androidConfig.emulatorName
246
+ name: androidConfig.emulatorName,
247
+ serial
334
248
  };
335
249
  }
336
- async function buildApp(ADB_PATH, androidConfig, buildOptimisation, targetDevice = null) {
250
+
251
+ /**
252
+ * Build the debug APK and install it on `serial` only. Gradle's
253
+ * installDebug installs on every connected device unless ANDROID_SERIAL
254
+ * names one.
255
+ */
256
+ async function buildApp(androidConfig, buildOptimisation, serial, {
257
+ configPath = defaultConfigPath,
258
+ applicationId,
259
+ version
260
+ } = {}) {
337
261
  progress.status("building and installing app");
338
262
  try {
339
263
  // Gradle needs the SDK location in local.properties. Only
@@ -345,10 +269,7 @@ function createBuildPhase(ctx) {
345
269
  // error output entirely. --console=rich emits ANSI redraws, which
346
270
  // are meaningless on a pipe; plain gives one line per task.
347
271
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - pwd and configPath are internal resolved paths, not user input.
348
- let buildCommand = `cd ${pwd}/androidProject && ./gradlew generateWebViewConfig -PconfigPath=${configPath} -PbuildOptimisation=${buildOptimisation} && ./gradlew clean installDebug -PconfigPath=${configPath} --console=plain`;
349
- if (targetDevice && targetDevice.type === "physical") {
350
- buildCommand = buildCommand.replace("installDebug", `installDebug -Pandroid.injected.target.device=${targetDevice.id}`);
351
- }
272
+ const buildCommand = `cd ${pwd}/androidProject && ./gradlew generateWebViewConfig -PconfigPath=${configPath} -PcatalystBuildType=debug -PbuildOptimisation=${buildOptimisation} && ./gradlew clean installDebug -PconfigPath=${configPath} -PcatalystBuildType=debug --console=plain`;
352
273
 
353
274
  // Gradle emits hundreds of lines that nobody reads on success and
354
275
  // that the error path re-derives from error.output anyway. Show the
@@ -356,7 +277,17 @@ function createBuildPhase(ctx) {
356
277
  await ctx.runInteractiveCommand("sh", ["-c", buildCommand], {
357
278
  "BUILD SUCCESSFUL": ""
358
279
  }, {
359
- onLine: line => progress.status(gradleTask(line))
280
+ onLine: line => progress.status(gradleTask(line)),
281
+ env: {
282
+ ...process.env,
283
+ ...versionGradleEnv({
284
+ applicationId,
285
+ version
286
+ }),
287
+ ...(serial ? {
288
+ ANDROID_SERIAL: serial
289
+ } : {})
290
+ }
360
291
  });
361
292
  } catch (error) {
362
293
  // Re-wrapping with a new Error dropped `output`, and with it the
@@ -365,27 +296,28 @@ function createBuildPhase(ctx) {
365
296
  throw error;
366
297
  }
367
298
  }
368
- async function launchApp(ADB_PATH, buildType = "debug", targetDevice = null) {
369
- if (!targetDevice) {
370
- progress.log("No target device specified, skipping launch", "warning");
371
- return;
372
- }
373
- if (targetDevice.type === "physical") {
374
- progress.log(`App installed on physical device: ${targetDevice.model}`, "success");
375
- progress.log("Open the app on your device to launch it", "note");
376
- return;
377
- }
299
+
300
+ /** Open the installed app on `serial`. A failure is a warning: the app is installed. */
301
+ function launchApp(ADB_PATH, serial, packageName) {
378
302
  try {
379
- progress.status("launching app on emulator");
380
- const packageName = `${ANDROID_PACKAGE}${buildType === "debug" ? ".debug" : ""}`;
381
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - ADB_PATH is derived from androidConfig.sdkPath, a trusted internal config value.
382
- const launchCommand = `${ADB_PATH} shell monkey -p ${packageName} 1`;
383
- await ctx.runInteractiveCommand("sh", ["-c", launchCommand], {});
303
+ progress.status("launching app");
304
+ execFileSync(ADB_PATH, launchArgs(serial, packageName), {
305
+ encoding: "utf8",
306
+ timeout: ADB_TIMEOUT_MS,
307
+ stdio: ["ignore", "pipe", "pipe"]
308
+ });
384
309
  } catch (error) {
385
- progress.log(`Warning: Could not auto-launch app: ${error.message}`, "warning");
386
- progress.log("App was installed successfully, but auto-launch failed", "info");
310
+ progress.log(`Could not open the app: ${error.message}. It is installed; open it on the device.`, "warning");
387
311
  }
388
312
  }
313
+ function aabNames(androidConfig) {
314
+ const newProjectName = androidConfig.newProjectName || androidConfig.appName || androidConfig.packageName?.split(".").pop() || "catalystapp";
315
+ const outputPath = androidConfig.outputPath || `${process.cwd()}/build-output`;
316
+ return {
317
+ newProjectName,
318
+ outputPath
319
+ };
320
+ }
389
321
  async function createAABConfig(androidConfig) {
390
322
  const DEFAULT_PROJECT_PATH = `${pwd}/androidProject`;
391
323
  const aabConfig = {
@@ -393,27 +325,11 @@ function createBuildPhase(ctx) {
393
325
  deploymentPath: DEFAULT_DEPLOYMENT_PATH,
394
326
  oldProjectName: DEFAULT_OLD_PROJECT_NAME,
395
327
  overwriteExisting: androidConfig.overwriteExisting !== undefined ? androidConfig.overwriteExisting : DEFAULT_OVERWRITE_EXISTING,
396
- newProjectName: androidConfig.newProjectName || androidConfig.appName || androidConfig.packageName?.split(".").pop() || "catalystapp",
328
+ newProjectName: aabNames(androidConfig).newProjectName,
397
329
  packageName: androidConfig.packageName || null,
398
330
  createSignedAAB: true,
399
- outputPath: androidConfig.outputPath || `${process.cwd()}/build-output`
331
+ outputPath: aabNames(androidConfig).outputPath
400
332
  };
401
- if (androidConfig.keystoreConfig) {
402
- aabConfig.keystoreConfig = androidConfig.keystoreConfig;
403
- } else if (androidConfig.keystore) {
404
- aabConfig.keystoreConfig = {
405
- keyAlias: androidConfig.keystore.alias || "release",
406
- storePassword: androidConfig.keystore.storePassword,
407
- keyPassword: androidConfig.keystore.keyPassword,
408
- validityYears: 25,
409
- organizationInfo: {
410
- companyName: androidConfig.keystore.organizationName || "YourCompany",
411
- city: androidConfig.keystore.city || "YourCity",
412
- state: androidConfig.keystore.state || "YourState",
413
- countryCode: androidConfig.keystore.countryCode || "US"
414
- }
415
- };
416
- }
417
333
  progress.log(`AAB Configuration:`, "info");
418
334
  progress.log(` Project Path: ${aabConfig.projectPath} ${aabConfig.projectPath === DEFAULT_PROJECT_PATH ? "(default)" : "(configured)"}`, "info");
419
335
  progress.log(` Deployment Path: ${aabConfig.deploymentPath} ${aabConfig.deploymentPath === DEFAULT_DEPLOYMENT_PATH ? "(default)" : "(configured)"}`, "info");
@@ -423,82 +339,54 @@ function createBuildPhase(ctx) {
423
339
  progress.log(` Output Path: ${aabConfig.outputPath}`, "info");
424
340
  return aabConfig;
425
341
  }
426
- async function moveApkToOutputPath(buildType, BUILD_OUTPUT_PATH, appName) {
427
- try {
428
- if (!BUILD_OUTPUT_PATH) {
429
- progress.log("BUILD_OUTPUT_PATH not set, skipping APK move", "warning");
430
- return null;
431
- }
432
- const currentDate = new Date().toLocaleDateString("en-GB").replace(/\//g, "-");
433
- // 24-hour and locale-free: "02-05-09 PM" put a space in every
434
- // artifact name, which breaks unquoted shell use of the path.
435
- const currentTime = new Date().toTimeString().slice(0, 8).replace(/:/g, "-");
436
- let destinationApkFileName = "";
437
- if (appName) {
438
- destinationApkFileName = buildType === "release" ? `${appName}-${currentTime}.release.apk` : `${appName}-${currentTime}.debug.apk`;
439
- } else {
440
- destinationApkFileName = buildType === "release" ? `app-${currentTime}.release.apk` : `app-${currentTime}.debug.apk`;
441
- }
442
- const sourceApkFileName = buildType === "release" ? `app.apk` : `app-debug.apk`;
443
- const sourceApkPath = path.join(pwd,
444
- // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - pwd is an internal resolved path.
445
- "androidProject", "app", "build", "outputs", "apk", buildType,
446
- // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - buildType is "debug" or "release" from config.
447
- sourceApkFileName);
448
- const destinationDir = path.join(process.cwd(), BUILD_OUTPUT_PATH,
449
- // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - BUILD_OUTPUT_PATH is trusted application config.
450
- "native", "android", currentDate, buildType // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - buildType is "debug" or "release" from config.
451
- );
452
- const destinationApkPath = path.join(destinationDir, destinationApkFileName); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - destinationDir and destinationApkFileName are derived from internal config values, not user input.
453
342
 
454
- if (!fs.existsSync(sourceApkPath)) {
455
- progress.log(`APK not found at source path: ${sourceApkPath}`, "warning");
456
- return null;
457
- }
458
- if (!fs.existsSync(destinationDir)) {
459
- fs.mkdirSync(destinationDir, {
460
- recursive: true
461
- });
462
- }
463
- fs.copyFileSync(sourceApkPath, destinationApkPath);
464
- return destinationApkPath;
465
- } catch (error) {
466
- progress.log(`Error moving APK: ${error.message}`, "error");
467
- return null;
468
- }
469
- }
470
- async function buildSignedAAB(androidConfig) {
343
+ /**
344
+ * Build the signed release bundle. The webview config is generated in
345
+ * the temp_build_<pkg> copy that Gradle then bundles, from the resolved
346
+ * release config and with -PcatalystBuildType=release, so the release
347
+ * branch of generateWebViewConfig actually runs. Signing and version
348
+ * values reach Gradle through the environment, never argv.
349
+ */
350
+ async function buildSignedAAB(androidConfig, {
351
+ configPath = defaultConfigPath,
352
+ signing,
353
+ version,
354
+ applicationId,
355
+ buildOptimisation = false,
356
+ finalAabPath
357
+ } = {}) {
471
358
  progress.log("Building signed AAB for release...", "info");
472
359
  try {
360
+ if (!signing) throw new Error("No upload key resolved for the release build");
473
361
  // Same as buildApp: gradle needs local.properties on a fresh machine.
474
362
  if (androidConfig.sdkPath) ensureLocalProperties(`${pwd}/androidProject`, androidConfig.sdkPath);
475
- progress.log("Generating webview configuration for release...", "info");
476
- try {
477
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - pwd and configPath are internal resolved paths, not user input.
478
- const generateConfigCommand = `cd ${pwd}/androidProject && ./gradlew generateWebViewConfig -PconfigPath=${configPath}`;
479
- await ctx.runInteractiveCommand("sh", ["-c", generateConfigCommand], {
480
- "BUILD SUCCESSFUL": ""
481
- }, {
482
- onLine: line => progress.status(gradleTask(line))
483
- });
484
- progress.log("Webview config generated successfully", "success");
485
- } catch (configError) {
486
- progress.log(`Warning: Webview config generation failed: ${configError.message}`, "warning");
487
- throw new Error("Webview config generation is required for release builds");
488
- }
489
363
  const aabConfig = await createAABConfig(androidConfig);
490
- // buildAndroidAAB shells out to `./gradlew bundleRelease` and prints
491
- // its own progress lines, so it owns the screen for its duration.
492
- await withStreamedOutput(() => buildAndroidAAB(aabConfig));
364
+ if (finalAabPath) aabConfig.finalAabPath = finalAabPath;
365
+ aabConfig.gradle = {
366
+ configPath,
367
+ buildOptimisation,
368
+ env: {
369
+ ...process.env,
370
+ ...versionGradleEnv({
371
+ applicationId,
372
+ version
373
+ }),
374
+ ...signingGradleEnv(signing)
375
+ }
376
+ };
377
+ // Its steps and details go through this progress: spinner detail
378
+ // by default, lines under --verbose; warnings always print.
379
+ const result = await buildAndroidAAB(aabConfig, {
380
+ progress
381
+ });
493
382
  progress.log("Signed AAB build completed successfully!", "success");
383
+ return result;
494
384
  } catch (error) {
495
385
  error.stage = "Building the signed AAB";
496
386
  throw error;
497
387
  }
498
388
  }
499
389
  return {
500
- detectPhysicalDevice,
501
- testPhysicalDeviceInstallation,
502
390
  checkEmulator,
503
391
  handleEmulatorSetup,
504
392
  startEmulator,
@@ -506,7 +394,6 @@ function createBuildPhase(ctx) {
506
394
  buildApp,
507
395
  launchApp,
508
396
  createAABConfig,
509
- moveApkToOutputPath,
510
397
  buildSignedAAB
511
398
  };
512
399
  }