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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const {
|
|
5
5
|
createIosBuild,
|
|
6
|
+
IOS_BUILD_STEPS,
|
|
6
7
|
pwd
|
|
7
8
|
} = require("./buildIos/index.js");
|
|
8
9
|
const {
|
|
@@ -23,30 +24,158 @@ const {
|
|
|
23
24
|
const {
|
|
24
25
|
loadAppConfig
|
|
25
26
|
} = require("../cli/appConfig.js");
|
|
27
|
+
const {
|
|
28
|
+
ensureDevServer,
|
|
29
|
+
migrateLanAddresses
|
|
30
|
+
} = require("./setupServer.js");
|
|
31
|
+
const {
|
|
32
|
+
parseNativeArgs
|
|
33
|
+
} = require("./utils.js");
|
|
34
|
+
const {
|
|
35
|
+
ensureXcodeReady,
|
|
36
|
+
prepareIosDebugTarget
|
|
37
|
+
} = require("./iosToolchain.js");
|
|
38
|
+
const {
|
|
39
|
+
isReleaseBuildType
|
|
40
|
+
} = require("./buildIos/signing.js");
|
|
41
|
+
const TerminalProgress = require("./terminalProgress.js").default;
|
|
26
42
|
const catalystCorePath = path.dirname(require.resolve("catalyst-core/package.json"));
|
|
27
43
|
const {
|
|
28
44
|
WEBVIEW_CONFIG,
|
|
29
45
|
BUILD_OUTPUT_PATH
|
|
30
46
|
} = loadAppConfig();
|
|
47
|
+
|
|
48
|
+
/** A coded error (IOS-002 and on, NATIVE-*) is our own; anything else is the toolchain's. */
|
|
49
|
+
const reportCode = error => /^(IOS|NATIVE)-\d{3}$/.test(error?.code) ? error.code : "IOS-000";
|
|
50
|
+
function reportVerbose(error) {
|
|
51
|
+
// --verbose/--debug add the registry's boxed report, printed with
|
|
52
|
+
// console.error rather than progress.log, which would prefix every
|
|
53
|
+
// border line and break the box.
|
|
54
|
+
if (resolveOutputMode() !== "default") {
|
|
55
|
+
console.error(formatBuildError({
|
|
56
|
+
code: reportCode(error),
|
|
57
|
+
category: reportCode(error).split("-")[0],
|
|
58
|
+
upstreamName: "Xcode/CocoaPods",
|
|
59
|
+
error
|
|
60
|
+
}));
|
|
61
|
+
} else if (process.env.CATALYST_DEBUG && error.stack) {
|
|
62
|
+
console.error(error.stack);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
31
65
|
async function main() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
66
|
+
// --release / --url / --device / --yes / --accept-licenses. Xcode is
|
|
67
|
+
// checked, the simulator or iPhone chosen and the config resolved (start
|
|
68
|
+
// URL and its guard, app id, version, signing files) before Xcode
|
|
69
|
+
// builds, so those failures surface first.
|
|
70
|
+
const cwd = process.cwd();
|
|
71
|
+
const progress = new TerminalProgress(IOS_BUILD_STEPS, "catalyst buildApp", {
|
|
72
|
+
subject: "ios"
|
|
35
73
|
});
|
|
74
|
+
const log = (message, type) => progress.log(message, type);
|
|
75
|
+
let flags;
|
|
76
|
+
let build;
|
|
77
|
+
let devHost = "localhost";
|
|
78
|
+
try {
|
|
79
|
+
flags = parseNativeArgs(process.argv.slice(2));
|
|
80
|
+
// Older setups saved this machine's LAN address in config.json. Put
|
|
81
|
+
// localhost back once (never for a release or in CI), then read the
|
|
82
|
+
// config again.
|
|
83
|
+
const migration = migrateLanAddresses({
|
|
84
|
+
cwd,
|
|
85
|
+
release: flags.release,
|
|
86
|
+
platform: "ios"
|
|
87
|
+
});
|
|
88
|
+
const appConfig = migration.changes.length ? loadAppConfig() : {
|
|
89
|
+
WEBVIEW_CONFIG,
|
|
90
|
+
BUILD_OUTPUT_PATH
|
|
91
|
+
};
|
|
92
|
+
for (const change of migration.changes) {
|
|
93
|
+
log(`config.json ${change.path}: ${change.from} -> ${change.to}`, "info");
|
|
94
|
+
}
|
|
95
|
+
const release = flags.release || isReleaseBuildType(appConfig.WEBVIEW_CONFIG?.ios?.buildType);
|
|
96
|
+
let target;
|
|
97
|
+
if (process.platform === "darwin") {
|
|
98
|
+
progress.start("toolchain");
|
|
99
|
+
const developerDir = await ensureXcodeReady({
|
|
100
|
+
flags,
|
|
101
|
+
log
|
|
102
|
+
});
|
|
103
|
+
progress.log(`Using ${developerDir}`, "info");
|
|
104
|
+
progress.complete("toolchain");
|
|
105
|
+
// A release archives for any iOS device: no simulator, no phone.
|
|
106
|
+
if (!release) {
|
|
107
|
+
progress.start("target");
|
|
108
|
+
({
|
|
109
|
+
target,
|
|
110
|
+
devHost
|
|
111
|
+
} = await prepareIosDebugTarget({
|
|
112
|
+
flags,
|
|
113
|
+
iosConfig: appConfig.WEBVIEW_CONFIG?.ios || {},
|
|
114
|
+
cwd,
|
|
115
|
+
log
|
|
116
|
+
}));
|
|
117
|
+
progress.log(target.target === "device" ? `iPhone ${target.name}` : `Simulator ${target.name}`, "info");
|
|
118
|
+
progress.complete("target");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
build = createIosBuild(appConfig, {
|
|
122
|
+
...flags,
|
|
123
|
+
devHost,
|
|
124
|
+
target,
|
|
125
|
+
progress,
|
|
126
|
+
appRoot: cwd
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (progress.currentStep) progress.fail(progress.currentStep.id);
|
|
130
|
+
process.stderr.write(buildFailure({
|
|
131
|
+
error,
|
|
132
|
+
stage: "Resolving the iOS build",
|
|
133
|
+
code: reportCode(error)
|
|
134
|
+
}));
|
|
135
|
+
reportVerbose(error);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// One-command dev: a debug build starts the dev server when nothing
|
|
140
|
+
// answers on its port, and waits until it does. Only on macOS, the one
|
|
141
|
+
// place an iOS build can run. An iPhone reaches it on devHost, so the
|
|
142
|
+
// server listens beyond localhost then.
|
|
143
|
+
if (build.usesDevServer && process.platform === "darwin") {
|
|
144
|
+
try {
|
|
145
|
+
progress.start("server");
|
|
146
|
+
await ensureDevServer({
|
|
147
|
+
host: devHost,
|
|
148
|
+
cwd,
|
|
149
|
+
log
|
|
150
|
+
});
|
|
151
|
+
progress.complete("server");
|
|
152
|
+
} catch (error) {
|
|
153
|
+
progress.fail("server");
|
|
154
|
+
process.stderr.write(buildFailure({
|
|
155
|
+
error,
|
|
156
|
+
stage: "Starting the dev server",
|
|
157
|
+
code: reportCode(error)
|
|
158
|
+
}));
|
|
159
|
+
build.cleanup();
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
36
163
|
const {
|
|
37
|
-
progress,
|
|
38
164
|
generateConfigConstants,
|
|
39
165
|
updateInfoPlist,
|
|
40
166
|
updateEntitlements,
|
|
41
167
|
syncPluginResources,
|
|
42
168
|
buildForIOS,
|
|
169
|
+
buildReleaseForIOS,
|
|
43
170
|
PROJECT_DIR
|
|
44
171
|
} = build;
|
|
172
|
+
// The merged release config when this is a release build.
|
|
173
|
+
const wvConfig = build.WEBVIEW_CONFIG;
|
|
45
174
|
let building = false;
|
|
46
175
|
try {
|
|
47
|
-
const aiPlugin = resolveAIPluginSource(
|
|
176
|
+
const aiPlugin = resolveAIPluginSource(wvConfig, process.cwd(), (message, status = "info") => progress.log(message, status));
|
|
48
177
|
const pluginConfig = {
|
|
49
|
-
...resolvePluginConfig(
|
|
178
|
+
...resolvePluginConfig(wvConfig),
|
|
50
179
|
...aiPlugin.toggles
|
|
51
180
|
};
|
|
52
181
|
const pluginComposition = composeIosPlugins({
|
|
@@ -61,7 +190,7 @@ async function main() {
|
|
|
61
190
|
await updateEntitlements(pluginComposition);
|
|
62
191
|
await syncPluginResources(pluginComposition);
|
|
63
192
|
building = true;
|
|
64
|
-
await buildForIOS(pluginComposition);
|
|
193
|
+
if (build.release) await buildReleaseForIOS(pluginComposition);else await buildForIOS(pluginComposition);
|
|
65
194
|
} catch (error) {
|
|
66
195
|
// buildForIOS already reported this through the progress tree, so the
|
|
67
196
|
// default mode does not repeat it. The steps before it report nothing
|
|
@@ -74,21 +203,11 @@ async function main() {
|
|
|
74
203
|
code: "IOS-000"
|
|
75
204
|
}));
|
|
76
205
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// border line and break the box.
|
|
80
|
-
if (resolveOutputMode() !== "default") {
|
|
81
|
-
console.error(formatBuildError({
|
|
82
|
-
code: "IOS-000",
|
|
83
|
-
category: "IOS",
|
|
84
|
-
upstreamName: "Xcode/CocoaPods",
|
|
85
|
-
error
|
|
86
|
-
}));
|
|
87
|
-
} else if (process.env.CATALYST_DEBUG && error.stack) {
|
|
88
|
-
console.error(error.stack);
|
|
89
|
-
}
|
|
206
|
+
reportVerbose(error);
|
|
207
|
+
build.cleanup();
|
|
90
208
|
process.exit(1);
|
|
91
209
|
}
|
|
210
|
+
build.cleanup();
|
|
92
211
|
process.exit(0);
|
|
93
212
|
}
|
|
94
213
|
if (require.main === module) {
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const crypto = require("crypto");
|
|
6
|
+
const {
|
|
7
|
+
checkAppIcon
|
|
8
|
+
} = require("../appIcon.js");
|
|
6
9
|
const GOOGLE_SERVICES_FILENAME = "GoogleService-Info.plist";
|
|
7
10
|
const NOTIFICATION_ICONS = [{
|
|
8
11
|
sourceName: "notification-icon",
|
|
@@ -468,12 +471,64 @@ module.exports = function createAssetsPhase(ctx) {
|
|
|
468
471
|
progress.log(`Warning: Error copying splash screen assets: ${error.message}`, "warning");
|
|
469
472
|
}
|
|
470
473
|
}
|
|
471
|
-
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The single public/icon.png as a one-entry universal 1024 AppIcon, which
|
|
477
|
+
* Xcode scales to every size. Leftover sized images are removed so the
|
|
478
|
+
* set holds only what Contents.json lists.
|
|
479
|
+
*/
|
|
480
|
+
function applySingleAppIcon(iconPath, iconSetDir, {
|
|
481
|
+
release
|
|
482
|
+
}) {
|
|
483
|
+
checkAppIcon(iconPath, {
|
|
484
|
+
platform: "ios",
|
|
485
|
+
release,
|
|
486
|
+
log: (message, type) => progress.log(message, type)
|
|
487
|
+
});
|
|
488
|
+
fs.mkdirSync(iconSetDir, {
|
|
489
|
+
recursive: true
|
|
490
|
+
});
|
|
491
|
+
for (const file of fs.readdirSync(iconSetDir)) {
|
|
492
|
+
if (file !== "Contents.json") fs.rmSync(path.join(iconSetDir, file), {
|
|
493
|
+
force: true
|
|
494
|
+
}); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - iconSetDir is the template's own asset folder; file comes from readdirSync on it.
|
|
495
|
+
}
|
|
496
|
+
fs.copyFileSync(iconPath, `${iconSetDir}/icon.png`);
|
|
497
|
+
const contents = {
|
|
498
|
+
images: [{
|
|
499
|
+
filename: "icon.png",
|
|
500
|
+
idiom: "universal",
|
|
501
|
+
platform: "ios",
|
|
502
|
+
size: "1024x1024"
|
|
503
|
+
}],
|
|
504
|
+
info: {
|
|
505
|
+
author: "xcode",
|
|
506
|
+
version: 1
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
fs.writeFileSync(`${iconSetDir}/Contents.json`, JSON.stringify(contents, null, 2));
|
|
510
|
+
progress.log("Applied the app icon from public/icon.png", "success");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* App icons, first match wins: the sized files in public/ios/appIcons,
|
|
515
|
+
* then the single public/icon.png. With neither, the template's icon stays.
|
|
516
|
+
*
|
|
517
|
+
* @param {object} [options]
|
|
518
|
+
* @param {boolean} [options.release] - an icon with alpha fails a release (ITMS-90717)
|
|
519
|
+
*/
|
|
520
|
+
async function copyAppIcon({
|
|
521
|
+
release = false
|
|
522
|
+
} = {}) {
|
|
523
|
+
const publicDir = `${process.cwd()}/public/ios/appIcons`;
|
|
524
|
+
const singleIconPath = `${process.cwd()}/public/icon.png`;
|
|
525
|
+
const assetsDir = `${PROJECT_DIR}/${PROJECT_NAME}/Assets.xcassets`;
|
|
526
|
+
const iconSetDir = `${assetsDir}/AppIcon.appiconset`;
|
|
472
527
|
try {
|
|
473
|
-
const publicDir = `${process.cwd()}/public/ios/appIcons`;
|
|
474
|
-
const assetsDir = `${PROJECT_DIR}/${PROJECT_NAME}/Assets.xcassets`;
|
|
475
|
-
const iconSetDir = `${assetsDir}/AppIcon.appiconset`;
|
|
476
528
|
if (!fs.existsSync(publicDir)) {
|
|
529
|
+
if (fs.existsSync(singleIconPath)) applySingleAppIcon(singleIconPath, iconSetDir, {
|
|
530
|
+
release
|
|
531
|
+
});
|
|
477
532
|
return;
|
|
478
533
|
}
|
|
479
534
|
const iconSizes = [{
|
|
@@ -529,6 +584,16 @@ module.exports = function createAssetsPhase(ctx) {
|
|
|
529
584
|
};
|
|
530
585
|
const allImages = findImagesRecursively(publicDir, imageExtensions);
|
|
531
586
|
const foundIcons = [];
|
|
587
|
+
const hasSizedIcon = iconSizes.some(({
|
|
588
|
+
size,
|
|
589
|
+
scale
|
|
590
|
+
}) => allImages.some(imgPath => path.parse(imgPath).name === `icon-${size}-${scale}`));
|
|
591
|
+
if (!hasSizedIcon && fs.existsSync(singleIconPath)) {
|
|
592
|
+
applySingleAppIcon(singleIconPath, iconSetDir, {
|
|
593
|
+
release
|
|
594
|
+
});
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
532
597
|
if (!fs.existsSync(iconSetDir)) fs.mkdirSync(iconSetDir, {
|
|
533
598
|
recursive: true
|
|
534
599
|
});
|
|
@@ -546,6 +611,11 @@ module.exports = function createAssetsPhase(ctx) {
|
|
|
546
611
|
version: 1
|
|
547
612
|
}
|
|
548
613
|
};
|
|
614
|
+
// Drop a single icon.png left by an earlier build; the sized set replaces it.
|
|
615
|
+
contents.images = contents.images.filter(img => img.filename !== "icon.png");
|
|
616
|
+
fs.rmSync(`${iconSetDir}/icon.png`, {
|
|
617
|
+
force: true
|
|
618
|
+
});
|
|
549
619
|
for (const {
|
|
550
620
|
size,
|
|
551
621
|
idiom,
|
|
@@ -592,6 +662,8 @@ module.exports = function createAssetsPhase(ctx) {
|
|
|
592
662
|
progress.log("Example: icon-20x20-2x.png, icon-60x60-3x.png, icon-1024x1024-1x.png", "info");
|
|
593
663
|
}
|
|
594
664
|
} catch (error) {
|
|
665
|
+
// An unusable public/icon.png is the user's to fix, not a warning to scroll past.
|
|
666
|
+
if (/^IOS-\d{3}$/.test(error?.code)) throw error;
|
|
595
667
|
progress.log(`Warning: Error copying app icons: ${error.message}`, "warning");
|
|
596
668
|
}
|
|
597
669
|
}
|
|
@@ -10,6 +10,9 @@ const {
|
|
|
10
10
|
const {
|
|
11
11
|
runInteractiveCommand
|
|
12
12
|
} = require("../utils.js");
|
|
13
|
+
const {
|
|
14
|
+
xcodeAuthArgs
|
|
15
|
+
} = require("./signing.js");
|
|
13
16
|
module.exports = function createBuildPhase(ctx) {
|
|
14
17
|
const {
|
|
15
18
|
WEBVIEW_CONFIG,
|
|
@@ -24,6 +27,10 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
24
27
|
|
|
25
28
|
// ─── Simulator helpers ────────────────────────────────────────────────────
|
|
26
29
|
|
|
30
|
+
// The simulator buildAppIos.js chose (its udid), else whichever is booted.
|
|
31
|
+
// The udid goes into shell strings, so only hex digits and hyphens pass.
|
|
32
|
+
const simulatorUdid = () => ctx.target?.target === "simulator" && /^[A-Fa-f0-9-]+$/.test(String(ctx.target.udid || "")) ? ctx.target.udid : null;
|
|
33
|
+
const simctlDevice = () => simulatorUdid() || "booted";
|
|
27
34
|
async function getBootedSimulatorUUID(modelName) {
|
|
28
35
|
const UUID_RE = /[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}/i;
|
|
29
36
|
try {
|
|
@@ -138,7 +145,9 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
138
145
|
${action}`;
|
|
139
146
|
}
|
|
140
147
|
async function buildProject(scheme, sdk, destination, bundleId, derivedDataPath, projectName) {
|
|
141
|
-
const bootedInfo =
|
|
148
|
+
const bootedInfo = simulatorUdid() ? {
|
|
149
|
+
udid: simulatorUdid()
|
|
150
|
+
} : await getBootedSimulatorInfo();
|
|
142
151
|
if (!bootedInfo) throw new Error("No booted simulator found");
|
|
143
152
|
progress.status(`simulator ${bootedInfo.udid.slice(0, 8)}`);
|
|
144
153
|
const isNotificationsEnabled = WEBVIEW_CONFIG.notifications?.enabled ?? false;
|
|
@@ -161,7 +170,10 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
161
170
|
const derivedDataPath = path.join(os.homedir(), "Library/Developer/Xcode/DerivedData");
|
|
162
171
|
try {
|
|
163
172
|
progress.status("building for specified simulator");
|
|
164
|
-
await buildProject(SCHEME_NAME, "iphonesimulator",
|
|
173
|
+
await buildProject(SCHEME_NAME, "iphonesimulator",
|
|
174
|
+
// Several runtimes can each have a simulator with this
|
|
175
|
+
// name; the udid buildAppIos.js chose is unambiguous.
|
|
176
|
+
simulatorUdid() ? `platform=iOS Simulator,id=${simulatorUdid()}` : `platform=iOS Simulator,name=${IPHONE_MODEL}`, APP_BUNDLE_ID, derivedDataPath, PROJECT_NAME);
|
|
165
177
|
} catch (buildError) {
|
|
166
178
|
progress.log("Initial build failed, attempting fallback...", "info");
|
|
167
179
|
const bootedUUID = await getBootedSimulatorUUID(IPHONE_MODEL);
|
|
@@ -208,52 +220,20 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
208
220
|
throw error;
|
|
209
221
|
}
|
|
210
222
|
}
|
|
211
|
-
async function moveAppToBuildOutput(APP_PATH) {
|
|
212
|
-
const {
|
|
213
|
-
BUILD_OUTPUT_PATH,
|
|
214
|
-
iosConfig
|
|
215
|
-
} = ctx;
|
|
216
|
-
try {
|
|
217
|
-
const buildType = iosConfig.buildType || "debug";
|
|
218
|
-
const appName = iosConfig.appName || "app";
|
|
219
|
-
const currentDate = new Date().toLocaleDateString("en-GB").replace(/\//g, "-");
|
|
220
|
-
// 24-hour and locale-free: "02-05-09 PM" put a space in every
|
|
221
|
-
// artifact name, which breaks unquoted shell use of the path.
|
|
222
|
-
const currentTime = new Date().toTimeString().slice(0, 8).replace(/:/g, "-");
|
|
223
|
-
// The app root, not process.cwd(): the iOS build chdirs into the
|
|
224
|
-
// Xcode project inside node_modules, so the artifact landed there
|
|
225
|
-
// and was wiped by the next npm install.
|
|
226
|
-
const destinationDir = path.join(
|
|
227
|
-
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - appRoot is the local project root from the CLI context.
|
|
228
|
-
ctx.appRoot || process.cwd(), BUILD_OUTPUT_PATH,
|
|
229
|
-
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - BUILD_OUTPUT_PATH is trusted application config.
|
|
230
|
-
"native", "ios", currentDate, buildType // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - buildType is "debug" or "release" from config.
|
|
231
|
-
);
|
|
232
|
-
const destinationPath = path.join(destinationDir, `${appName}-${currentTime}.app`); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
233
|
-
if (!fs.existsSync(destinationDir)) fs.mkdirSync(destinationDir, {
|
|
234
|
-
recursive: true
|
|
235
|
-
});
|
|
236
|
-
await runCommand(`cp -R "${APP_PATH}" "${destinationPath}"`);
|
|
237
|
-
return destinationPath;
|
|
238
|
-
} catch (error) {
|
|
239
|
-
console.error("Error moving app to build output:", error.message);
|
|
240
|
-
throw error;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
223
|
async function uninstallExistingApp() {
|
|
244
224
|
progress.status("uninstalling previous build");
|
|
245
|
-
await runCommand(`xcrun simctl uninstall
|
|
225
|
+
await runCommand(`xcrun simctl uninstall ${simctlDevice()} "${APP_BUNDLE_ID}"`);
|
|
246
226
|
}
|
|
247
227
|
async function installApp(APP_PATH) {
|
|
248
228
|
progress.status("installing");
|
|
249
|
-
await runCommand(`xcrun simctl install
|
|
229
|
+
await runCommand(`xcrun simctl install ${simctlDevice()} "${APP_PATH}"`);
|
|
250
230
|
}
|
|
251
231
|
async function waitForInstallation() {
|
|
252
232
|
progress.status("waiting for install");
|
|
253
233
|
for (let i = 0; i < 30; i++) {
|
|
254
234
|
try {
|
|
255
235
|
// No shell: the bundle id is passed as an argv entry, never interpolated.
|
|
256
|
-
execFileSync("xcrun", ["simctl", "get_app_container",
|
|
236
|
+
execFileSync("xcrun", ["simctl", "get_app_container", simctlDevice(), APP_BUNDLE_ID], {
|
|
257
237
|
stdio: ["ignore", "pipe", "ignore"]
|
|
258
238
|
});
|
|
259
239
|
break;
|
|
@@ -266,7 +246,7 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
266
246
|
async function handleLaunchError(error) {
|
|
267
247
|
progress.status("checking app container");
|
|
268
248
|
try {
|
|
269
|
-
await runCommand(`xcrun simctl get_app_container
|
|
249
|
+
await runCommand(`xcrun simctl get_app_container ${simctlDevice()} "${APP_BUNDLE_ID}"`);
|
|
270
250
|
} catch {
|
|
271
251
|
progress.log("App container not found", "warning");
|
|
272
252
|
}
|
|
@@ -276,7 +256,7 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
276
256
|
progress.status("waiting for launch");
|
|
277
257
|
for (let i = 0; i < 10; i++) {
|
|
278
258
|
try {
|
|
279
|
-
const launchResult = execFileSync("xcrun", ["simctl", "launch",
|
|
259
|
+
const launchResult = execFileSync("xcrun", ["simctl", "launch", simctlDevice(), APP_BUNDLE_ID], {
|
|
280
260
|
stdio: ["ignore", "pipe", "ignore"]
|
|
281
261
|
}).toString();
|
|
282
262
|
if (launchResult.includes("already launched")) {
|
|
@@ -291,7 +271,7 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
291
271
|
async function launchAndVerifyApp() {
|
|
292
272
|
progress.status("launching");
|
|
293
273
|
try {
|
|
294
|
-
await runCommand(`xcrun simctl launch
|
|
274
|
+
await runCommand(`xcrun simctl launch ${simctlDevice()} "${APP_BUNDLE_ID}"`);
|
|
295
275
|
await verifyAppLaunch();
|
|
296
276
|
} catch (error) {
|
|
297
277
|
await handleLaunchError(error);
|
|
@@ -326,150 +306,20 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
326
306
|
|
|
327
307
|
// ─── Physical device ──────────────────────────────────────────────────────
|
|
328
308
|
|
|
329
|
-
// `instruments -s devices` is deprecated and its plain-text output is fragile
|
|
330
|
-
// to parse. `xcrun devicectl list devices --json-output <path>` is the modern,
|
|
331
|
-
// structured replacement (Xcode 15+) and is already used elsewhere in this
|
|
332
|
-
// file for install/launch, so use it as the primary detection source too.
|
|
333
|
-
// Note: devicectl lists simulators as well as physical devices, so filter on
|
|
334
|
-
// hardwareProperties.reality === "physical".
|
|
335
|
-
//
|
|
336
|
-
// Write to a temp file rather than `--json-output -` (stdout): Apple's own
|
|
337
|
-
// scripting guidance is a file path, and stdout support for `-` isn't
|
|
338
|
-
// guaranteed across every Xcode version this needs to run on — a temp file
|
|
339
|
-
// works the same way on every devicectl version. Falls back to
|
|
340
|
-
// xcodebuild -showdestinations below on any failure (old devicectl, no
|
|
341
|
-
// physical device, malformed output), so this only needs to work when it can.
|
|
342
|
-
// Matches the identifier format Apple's own tooling uses (hex digits and
|
|
343
|
-
// hyphens only, e.g. a UUID or the shorter legacy 40-char UDID). device.udid
|
|
344
|
-
// is later interpolated unquoted into shell command strings (xcodebuild
|
|
345
|
-
// -destination, xcrun devicectl --device, see installAndLaunchOnPhysicalDevice
|
|
346
|
-
// and buildProjectForPhysicalDevice) — the old instruments-based parser
|
|
347
|
-
// structurally constrained what could reach that string via its own regex;
|
|
348
|
-
// this is the equivalent guard for the JSON-based devicectl path, so a
|
|
349
|
-
// malformed or hostile identifier in devicectl's output can't inject shell
|
|
350
|
-
// syntax rather than just being rejected as "not a device".
|
|
351
|
-
const VALID_UDID_RE = /^[A-Fa-f0-9-]+$/;
|
|
352
|
-
function tryDevicectl() {
|
|
353
|
-
const tmpPath = path.join(os.tmpdir(), `catalyst-devicectl-${process.pid}-${Date.now()}.json`);
|
|
354
|
-
try {
|
|
355
|
-
execSync(`xcrun devicectl list devices --json-output "${tmpPath}"`, {
|
|
356
|
-
stdio: ["ignore", "ignore", "ignore"],
|
|
357
|
-
timeout: 30000
|
|
358
|
-
}); // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - tmpPath is built from os.tmpdir()/process.pid/Date.now(), not user input.
|
|
359
|
-
const raw = fs.readFileSync(tmpPath, "utf8");
|
|
360
|
-
const parsed = JSON.parse(raw);
|
|
361
|
-
const devices = parsed?.result?.devices ?? [];
|
|
362
|
-
return devices.filter(d => d.hardwareProperties?.reality === "physical")
|
|
363
|
-
// devicectl lists every device it has ever paired with, reachable or
|
|
364
|
-
// not — a device that's unplugged or out of range still shows up with
|
|
365
|
-
// reality: "physical". Only tunnelState !== "unavailable" is actually
|
|
366
|
-
// installable/launchable right now; "disconnected" (paired, USB/Wi-Fi
|
|
367
|
-
// not currently active but developer-mode discoverable) still works for
|
|
368
|
-
// wired installs, so only "unavailable" is excluded here.
|
|
369
|
-
.filter(d => d.connectionProperties?.tunnelState !== "unavailable").map(d => ({
|
|
370
|
-
name: d.deviceProperties?.name ?? "Physical Device",
|
|
371
|
-
version: d.deviceProperties?.osVersionNumber ?? "Unknown",
|
|
372
|
-
udid: d.hardwareProperties?.udid ?? d.identifier,
|
|
373
|
-
type: "physical"
|
|
374
|
-
})).filter(d => typeof d.udid === "string" && VALID_UDID_RE.test(d.udid));
|
|
375
|
-
} catch (error) {
|
|
376
|
-
// A fallback is not a failure the user can act on; keep it for --verbose.
|
|
377
|
-
progress.log(`devicectl detection failed (${error.message}); falling back to xcodebuild destinations`, "info");
|
|
378
|
-
return [];
|
|
379
|
-
} finally {
|
|
380
|
-
try {
|
|
381
|
-
fs.unlinkSync(tmpPath);
|
|
382
|
-
} catch {
|
|
383
|
-
// Nothing to clean up if the command failed before writing the file.
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
async function detectPhysicalDevices() {
|
|
388
|
-
progress.start("deviceDetection");
|
|
389
|
-
const {
|
|
390
|
-
iosConfig
|
|
391
|
-
} = ctx;
|
|
392
|
-
try {
|
|
393
|
-
progress.status("scanning for connected physical devices");
|
|
394
|
-
let physicalDevices = [];
|
|
395
|
-
const configuredUDID = iosConfig.deviceUDID;
|
|
396
|
-
if (configuredUDID) {
|
|
397
|
-
progress.log(`Using configured device UDID: ${configuredUDID}`, "info");
|
|
398
|
-
const configuredDevice = tryDevicectl().find(d => d.udid === configuredUDID);
|
|
399
|
-
if (configuredDevice) {
|
|
400
|
-
progress.log(`Found configured physical device: ${configuredDevice.name} (${configuredDevice.version})`, "success");
|
|
401
|
-
progress.complete("deviceDetection");
|
|
402
|
-
return configuredDevice;
|
|
403
|
-
}
|
|
404
|
-
progress.log(`Configured device UDID not found in connected devices`, "warning");
|
|
405
|
-
progress.log("Falling back to auto-detection...", "info");
|
|
406
|
-
} else {
|
|
407
|
-
progress.status("no device udid configured, using auto-detection");
|
|
408
|
-
}
|
|
409
|
-
physicalDevices = tryDevicectl();
|
|
410
|
-
if (physicalDevices.length > 0) progress.log(`Found ${physicalDevices.length} physical device(s) via devicectl`, "success");
|
|
411
|
-
if (physicalDevices.length === 0) {
|
|
412
|
-
try {
|
|
413
|
-
const xcodebuildOutput = execSync(`xcodebuild -scheme "${SCHEME_NAME}" -showdestinations` // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - SCHEME_NAME is generated from trusted iOS project config.
|
|
414
|
-
).toString();
|
|
415
|
-
progress.status("scanning xcodebuild destinations for physical devices");
|
|
416
|
-
for (const line of xcodebuildOutput.split("\n")) {
|
|
417
|
-
const m = line.match(/\{\s*platform:iOS,\s*arch:(\w+),\s*id:([A-F0-9-]+),\s*name:(.+?)\s*\}/);
|
|
418
|
-
if (m) {
|
|
419
|
-
const [, arch, udid, name] = m;
|
|
420
|
-
progress.log(`Found device candidate: ${name.trim()} - ${udid}`, "info");
|
|
421
|
-
if (!udid.includes("placeholder") && udid.length > 20) {
|
|
422
|
-
progress.log(`Valid physical device: ${name.trim()}`, "success");
|
|
423
|
-
physicalDevices.push({
|
|
424
|
-
name: name.trim(),
|
|
425
|
-
version: "Unknown",
|
|
426
|
-
udid,
|
|
427
|
-
arch,
|
|
428
|
-
type: "physical"
|
|
429
|
-
});
|
|
430
|
-
} else {
|
|
431
|
-
progress.log(`Skipping placeholder: ${name.trim()}`, "info");
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
} catch (error) {
|
|
436
|
-
progress.log("xcodebuild destinations failed", "info");
|
|
437
|
-
progress.log(`Error: ${error.message}`, "error");
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
if (physicalDevices.length > 0) {
|
|
441
|
-
progress.log(`Found ${physicalDevices.length} physical device(s):`, "success");
|
|
442
|
-
physicalDevices.forEach(d => progress.log(` ${d.name} (${d.version || "Unknown iOS"}) - ${d.udid}`, "info"));
|
|
443
|
-
progress.complete("deviceDetection");
|
|
444
|
-
return physicalDevices[0];
|
|
445
|
-
} else {
|
|
446
|
-
// The normal case when building for the simulator, not a warning.
|
|
447
|
-
progress.log("No physical devices detected", "info");
|
|
448
|
-
progress.complete("deviceDetection");
|
|
449
|
-
return null;
|
|
450
|
-
}
|
|
451
|
-
} catch (error) {
|
|
452
|
-
progress.fail("deviceDetection", error.message);
|
|
453
|
-
return null;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
309
|
async function buildProjectForPhysicalDevice(scheme, bundleId, derivedDataPath, projectName, device) {
|
|
457
310
|
progress.log(`Building for physical device: ${device.name}`, "info");
|
|
458
311
|
const projectPath = `${process.cwd()}/${projectName}.xcodeproj`;
|
|
459
312
|
if (!fs.existsSync(projectPath)) throw new Error(`Xcode project not found at: ${projectPath}. Current directory: ${process.cwd()}`);
|
|
460
313
|
const isNotificationsEnabled = WEBVIEW_CONFIG.notifications?.enabled ?? false;
|
|
461
314
|
progress.log(isNotificationsEnabled ? "Building with notifications enabled" : "Building without notifications", "info");
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
// The full command is a dozen wrapped lines of build settings; the
|
|
471
|
-
// streamed output below is what actually tells you how it is going.
|
|
472
|
-
return runCommandStreaming(buildCommand);
|
|
315
|
+
// Automatic signing may create or fetch the development certificate
|
|
316
|
+
// and profile and register this device: with the App Store Connect
|
|
317
|
+
// key when one is configured, else with the Apple account signed in
|
|
318
|
+
// to Xcode. A manual profile (ios.provisioningProfile) needs neither.
|
|
319
|
+
const signingArgs = ctx.signingPlan?.style === "manual" ? [] : xcodeAuthArgs(ctx.apiKey);
|
|
320
|
+
// The streamed output is what tells you how it is going; argv, not a
|
|
321
|
+
// shell string, so a key path with spaces needs no quoting.
|
|
322
|
+
return runCommandStreaming("xcodebuild", ["-scheme", scheme, "-sdk", "iphoneos", "-configuration", "Debug", "-destination", `platform=iOS,id=${device.udid}`, `PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`, "ONLY_ACTIVE_ARCH=YES", ...signingArgs, "build"]);
|
|
473
323
|
}
|
|
474
324
|
async function findPhysicalDeviceAppPath() {
|
|
475
325
|
const DERIVED_DATA_DIR = path.join(os.homedir(), "Library/Developer/Xcode/DerivedData");
|
|
@@ -574,7 +424,9 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
574
424
|
isBooted = false;
|
|
575
425
|
Object.entries(simulatorsJson.devices).forEach(([, devices]) => {
|
|
576
426
|
devices.forEach(device => {
|
|
577
|
-
|
|
427
|
+
// The chosen udid when there is one: stock simulators share
|
|
428
|
+
// names across runtimes.
|
|
429
|
+
if (simulatorUdid() ? device.udid !== simulatorUdid() : device.name !== simulatorName) return;
|
|
578
430
|
const available = isDeviceAvailable(device);
|
|
579
431
|
const alreadyBooted = device.state === "Booted";
|
|
580
432
|
const isBetterMatch = !foundSimulator || available && !isDeviceAvailable(foundSimulator) || available === isDeviceAvailable(foundSimulator) && alreadyBooted && !isBooted;
|
|
@@ -663,9 +515,7 @@ module.exports = function createBuildPhase(ctx) {
|
|
|
663
515
|
buildXcodeProject,
|
|
664
516
|
buildProjectForTesting,
|
|
665
517
|
findAppPath,
|
|
666
|
-
moveAppToBuildOutput,
|
|
667
518
|
installAndLaunchApp,
|
|
668
|
-
detectPhysicalDevices,
|
|
669
519
|
buildProjectForPhysicalDevice,
|
|
670
520
|
findPhysicalDeviceAppPath,
|
|
671
521
|
installAndLaunchOnPhysicalDevice,
|