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.
- package/bin/catalyst.js +54 -6
- package/changelog.md +27 -1
- package/dist/errors/registry.js +306 -18
- package/dist/errors-index.json +273 -70
- package/dist/native/androidProject/app/build.gradle.kts +53 -79
- package/dist/native/androidProject/app/src/main/AndroidManifest.xml +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CatalystConstants.kt +3 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +32 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +5 -1
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ConfigMappingTest.kt +25 -0
- package/dist/native/androidSetup.js +76 -170
- package/dist/native/androidToolchain.js +642 -0
- package/dist/native/appIcon.js +100 -0
- package/dist/native/buildAndroid/assets.js +29 -5
- package/dist/native/buildAndroid/build.js +110 -223
- package/dist/native/buildAndroid/config.js +57 -65
- package/dist/native/buildAndroid/devices.js +176 -0
- package/dist/native/buildAndroid/index.js +271 -46
- package/dist/native/buildAndroid/output.js +43 -0
- package/dist/native/buildAndroid/signing.js +456 -0
- package/dist/native/buildAppAndroid.js +27 -7
- package/dist/native/buildAppIos.js +139 -20
- package/dist/native/buildIos/assets.js +76 -4
- package/dist/native/buildIos/build.js +31 -181
- package/dist/native/buildIos/config.js +126 -11
- package/dist/native/buildIos/index.js +354 -34
- package/dist/native/buildIos/localhostCert.js +87 -0
- package/dist/native/buildIos/output.js +28 -0
- package/dist/native/buildIos/signing.js +596 -0
- package/dist/native/credentials.js +309 -0
- package/dist/native/exportCredentials.js +51 -0
- package/dist/native/iosToolchain.js +583 -0
- package/dist/native/iosnativeWebView/Configurations/Shared.xcconfig +1 -3
- package/dist/native/iosnativeWebView/iosnativeWebView/Info-Release.plist +2 -0
- package/dist/native/iosnativeWebView/iosnativeWebView/PrivacyInfo.xcprivacy +40 -0
- package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements +0 -2
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -4
- package/dist/native/localState.js +148 -0
- package/dist/native/nativeConfig.js +621 -0
- package/dist/native/releaseOutput.js +53 -0
- package/dist/native/renameAndroidProject.js +154 -369
- package/dist/native/setupEmulatorIos.js +58 -275
- package/dist/native/setupServer.js +520 -124
- package/dist/native/utils.js +418 -115
- package/dist/vite/resolveDevServerConfig.js +21 -0
- package/mcp_v2/knowledge-base.json +13 -11
- package/mcp_v2/test/config.test.ts +40 -0
- package/mcp_v2/tools/build.js +80 -104
- package/mcp_v2/tools/config.js +181 -174
- package/package.json +1 -1
- package/test/cli/nativeFlags.test.ts +97 -0
- package/test/native/androidDevices.test.ts +230 -0
- package/test/native/androidElfAlignment.test.ts +148 -0
- package/test/native/androidSigning.test.ts +646 -0
- package/test/native/androidToolchain.test.ts +766 -0
- package/test/native/appIcon.test.ts +124 -0
- package/test/native/credentials.test.ts +241 -0
- package/test/native/interaction.test.ts +179 -0
- package/test/native/iosConfigPhase.test.ts +611 -0
- package/test/native/iosSigning.test.ts +1060 -0
- package/test/native/iosToolchain.test.ts +821 -0
- package/test/native/localState.test.ts +80 -0
- package/test/native/nativeConfig.test.ts +602 -0
- package/test/native/setupServer.test.ts +529 -0
- package/test/native/utils.test.ts +102 -127
- package/test/vite/resolveDevServerConfig.test.js +25 -0
- package/dist/native/androidProject/keystore/Untitled +0 -0
- package/dist/native/androidProject/keystore/release-key.jks +0 -0
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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(`
|
|
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
|
|
328
|
+
newProjectName: aabNames(androidConfig).newProjectName,
|
|
397
329
|
packageName: androidConfig.packageName || null,
|
|
398
330
|
createSignedAAB: true,
|
|
399
|
-
outputPath: androidConfig.outputPath
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
}
|