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
@@ -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
- const build = createIosBuild({
33
- WEBVIEW_CONFIG,
34
- BUILD_OUTPUT_PATH
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(WEBVIEW_CONFIG, process.cwd(), (message, status = "info") => progress.log(message, status));
176
+ const aiPlugin = resolveAIPluginSource(wvConfig, process.cwd(), (message, status = "info") => progress.log(message, status));
48
177
  const pluginConfig = {
49
- ...resolvePluginConfig(WEBVIEW_CONFIG),
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
- // --verbose/--debug add the registry's boxed report, printed with
78
- // console.error rather than progress.log, which would prefix every
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
- async function copyAppIcon() {
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 = await getBootedSimulatorInfo();
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", `platform=iOS Simulator,name=${IPHONE_MODEL}`, APP_BUNDLE_ID, derivedDataPath, PROJECT_NAME);
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 booted "${APP_BUNDLE_ID}"`);
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 booted "${APP_PATH}"`);
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", "booted", APP_BUNDLE_ID], {
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 booted "${APP_BUNDLE_ID}"`);
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", "booted", APP_BUNDLE_ID], {
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 booted "${APP_BUNDLE_ID}"`);
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
- const buildCommand = `xcodebuild \
463
- -scheme ${scheme} \
464
- -sdk iphoneos \
465
- -configuration Debug \
466
- -destination platform=iOS,id=${device.udid} \
467
- PRODUCT_BUNDLE_IDENTIFIER=${bundleId} \
468
- ONLY_ACTIVE_ARCH=YES \
469
- build`;
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
- if (device.name !== simulatorName) return;
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,