@hot-updater/apple-helper 0.25.4

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/dist/index.js ADDED
@@ -0,0 +1,816 @@
1
+ import { BuildLogger, colors, getCwd, getReactNativeMetadatas, p } from "@hot-updater/cli-tools";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { generateMinBundleId } from "@hot-updater/plugin-core";
5
+ import { ExecaError, execa } from "execa";
6
+ import { mkdtemp } from "node:fs/promises";
7
+ import os, { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ //#region src/utils/cocoapods.ts
11
+ const installPodsIfNeeded = async (sourceDir) => {
12
+ const podfilePath = path.join(sourceDir, "Podfile");
13
+ if (!fs.existsSync(podfilePath)) {
14
+ p.log.info("No Podfile found, skipping CocoaPods installation");
15
+ return;
16
+ }
17
+ const gemfilePaths = [path.join(sourceDir, "Gemfile"), path.join(getCwd(), "Gemfile")];
18
+ const shouldUseBundler = (await Promise.all(gemfilePaths.map(checkShouldUseBundler))).some(Boolean);
19
+ try {
20
+ if (shouldUseBundler) {
21
+ p.log.info("Using bundler for CocoaPods installation");
22
+ const bundleSpinner = p.spinner();
23
+ bundleSpinner.start("Installing Ruby gems");
24
+ await execa("bundle", ["install"], { cwd: sourceDir });
25
+ bundleSpinner.stop("Ruby gems installed");
26
+ const podSpinner = p.spinner();
27
+ podSpinner.start("Installing CocoaPods dependencies");
28
+ await execa("bundle", [
29
+ "exec",
30
+ "pod",
31
+ "install"
32
+ ], {
33
+ cwd: sourceDir,
34
+ env: preparePodInstallEnvVars()
35
+ });
36
+ podSpinner.stop("CocoaPods dependencies installed");
37
+ } else {
38
+ const spinner = p.spinner();
39
+ spinner.start("Installing CocoaPods dependencies");
40
+ await execa("pod", ["install"], {
41
+ cwd: sourceDir,
42
+ env: preparePodInstallEnvVars()
43
+ });
44
+ spinner.stop("CocoaPods dependencies installed");
45
+ }
46
+ } catch (error) {
47
+ throw new Error(`pod install failed: ${error}`);
48
+ }
49
+ };
50
+ const checkShouldUseBundler = async (gemfilePath) => {
51
+ try {
52
+ if (!fs.existsSync(gemfilePath)) return false;
53
+ return (await fs.promises.readFile(gemfilePath, "utf-8")).includes("cocoapods");
54
+ } catch (_error) {
55
+ return false;
56
+ }
57
+ };
58
+ const preparePodInstallEnvVars = () => {
59
+ const { minor } = getReactNativeMetadatas().version;
60
+ const usePrebuiltReactNative = minor >= 81;
61
+ return {
62
+ RCT_IGNORE_PODS_DEPRECATION: "1",
63
+ RCT_USE_RN_DEP: process.env["RCT_USE_RN_DEP"] !== void 0 ? String(process.env["RCT_USE_RN_DEP"]) : usePrebuiltReactNative ? "1" : "0",
64
+ RCT_USE_PREBUILT_RNCORE: process.env["RCT_USE_PREBUILT_RNCORE"] !== void 0 ? String(process.env["RCT_USE_PREBUILT_RNCORE"]) : usePrebuiltReactNative ? "1" : "0"
65
+ };
66
+ };
67
+
68
+ //#endregion
69
+ //#region src/utils/createRandomTmpDir.ts
70
+ const createRandomTmpDir = async (prefix = "apple-helper-") => {
71
+ return await mkdtemp(join(tmpdir(), prefix));
72
+ };
73
+
74
+ //#endregion
75
+ //#region src/utils/createXcodebuildLogger.ts
76
+ const createXcodebuildLogger = () => new BuildLogger({
77
+ failurePatterns: ["BUILD FAILED", "ARCHIVE FAILED"],
78
+ importantLogPatterns: [
79
+ "error:",
80
+ "** BUILD FAILED **",
81
+ "** ARCHIVE FAILED **",
82
+ "The following build commands failed:"
83
+ ],
84
+ progressMapping: [
85
+ [["[CP-User] [RN]Check rncore"], 10],
86
+ [["[CP-User] [Hermes] Replace Hermes"], 35],
87
+ [["[CP-User] [RN]Check FBReactNativeSpec"], 53],
88
+ [["React-FabricComponents"], 66],
89
+ [["[CP] Check Pods Manifest.lock"], 90],
90
+ [["BUILD SUCCEEDED", "ARCHIVE SUCCEEDED"], 100]
91
+ ]
92
+ });
93
+
94
+ //#endregion
95
+ //#region src/utils/platform.ts
96
+ /**
97
+ * Platform configurations for different Apple platforms
98
+ */
99
+ const platformConfigs = { ios: {
100
+ name: "ios",
101
+ deviceSdk: "iphoneos",
102
+ simulatorSdk: "iphonesimulator",
103
+ supportsSimulator: true,
104
+ deviceDestination: "platform=iOS",
105
+ simulatorDestination: "platform=iOS Simulator"
106
+ } };
107
+
108
+ //#endregion
109
+ //#region src/utils/destination.ts
110
+ const buildDestinationString = ({ deviceType, platform, useGeneric }) => `${useGeneric ? "generic/" : ""}${deviceType === "device" ? platformConfigs[platform].deviceDestination : platformConfigs[platform].simulatorDestination}`;
111
+ /**
112
+ * Resolves IosBuildDestination to xcodebuild destination format
113
+ */
114
+ const resolveDestination = ({ destination, useGeneric }) => {
115
+ if (typeof destination === "object") {
116
+ if ("id" in destination) return `id=${destination.id}`;
117
+ if ("name" in destination) return `name=${destination.name}`;
118
+ }
119
+ switch (destination) {
120
+ case "ios-device": return buildDestinationString({
121
+ platform: "ios",
122
+ deviceType: "device",
123
+ useGeneric
124
+ });
125
+ case "ios-simulator": return buildDestinationString({
126
+ platform: "ios",
127
+ deviceType: "simulator",
128
+ useGeneric
129
+ });
130
+ }
131
+ };
132
+ const resolveDestinations = ({ destinations, useGeneric }) => {
133
+ return destinations.map((destination) => resolveDestination({
134
+ destination,
135
+ useGeneric
136
+ }));
137
+ };
138
+ const getDefaultDestination = ({ platform, useGeneric, deviceType }) => {
139
+ return buildDestinationString({
140
+ deviceType,
141
+ platform,
142
+ useGeneric
143
+ });
144
+ };
145
+
146
+ //#endregion
147
+ //#region src/utils/parseXcodeProjectInfo.ts
148
+ const parseXcodeProjectInfo = async (sourceDir) => {
149
+ const files = await fs.promises.readdir(sourceDir);
150
+ const workspace = files.find((file) => file.endsWith(".xcworkspace"));
151
+ if (workspace) return {
152
+ isWorkspace: true,
153
+ name: workspace,
154
+ path: path.join(sourceDir, workspace)
155
+ };
156
+ const project = files.find((file) => file.endsWith(".xcodeproj"));
157
+ if (project) return {
158
+ isWorkspace: false,
159
+ name: project,
160
+ path: path.join(sourceDir, project)
161
+ };
162
+ throw new Error(`No Xcode project or workspace found in ${sourceDir}`);
163
+ };
164
+
165
+ //#endregion
166
+ //#region src/utils/prettifyXcodebuildError.ts
167
+ const prettifyXcodebuildError = (error) => {
168
+ let errorString = "";
169
+ if (error instanceof ExecaError || error instanceof Error || "message" in error) errorString = error.message;
170
+ if (errorString.includes("error: No profiles for") || errorString.includes("error: Provisioning profile")) return /* @__PURE__ */ new Error("Xcodebuild failed: Not valid provisioning profile is set in the project. Check out Singing & Capabilities tab in the Xcode.");
171
+ return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
172
+ };
173
+
174
+ //#endregion
175
+ //#region src/builder/archiveXcodeProject.ts
176
+ const archiveXcodeProject = async ({ sourceDir, platform, schemeConfig }) => {
177
+ const xcodeProject = await parseXcodeProjectInfo(sourceDir);
178
+ if (schemeConfig.installPods ?? true) await installPodsIfNeeded(sourceDir);
179
+ const tmpDir = await createRandomTmpDir();
180
+ const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
181
+ const archivePath = path.join(tmpDir, archiveName);
182
+ const archiveArgs = prepareArchiveArgs({
183
+ archivePath,
184
+ platform,
185
+ schemeConfig,
186
+ sourceDir,
187
+ xcodeProject
188
+ });
189
+ p.log.info(`Xcode Archive Settings:
190
+ Project ${xcodeProject.name}
191
+ Scheme ${schemeConfig.scheme}
192
+ Platform ${platform}
193
+ Command xcodebuild ${archiveArgs.join(" ")}
194
+ `);
195
+ const logger = createXcodebuildLogger();
196
+ logger.start(`${xcodeProject.name} (Archive)`);
197
+ try {
198
+ const process$1 = execa("xcodebuild", archiveArgs, { cwd: sourceDir });
199
+ for await (const line of process$1) logger.processLine(line);
200
+ logger.stop("Archive completed successfully");
201
+ return { archivePath };
202
+ } catch (error) {
203
+ logger.stop("Archive failed", false);
204
+ throw prettifyXcodebuildError(error);
205
+ }
206
+ };
207
+ const prepareArchiveArgs = ({ archivePath, platform, schemeConfig, sourceDir, xcodeProject }) => {
208
+ const args = [
209
+ xcodeProject.isWorkspace ? "-workspace" : "-project",
210
+ path.join(sourceDir, xcodeProject.name),
211
+ "-scheme",
212
+ schemeConfig.scheme,
213
+ "-configuration",
214
+ schemeConfig.configuration || "Release",
215
+ "archive",
216
+ "-archivePath",
217
+ archivePath,
218
+ `HOT_UPDATER_MIN_BUNDLE_ID=${generateMinBundleId()}`
219
+ ];
220
+ if (schemeConfig.xcconfig) args.push("-xcconfig", schemeConfig.xcconfig);
221
+ if (schemeConfig.extraParams) args.push(...schemeConfig.extraParams);
222
+ const resolvedDestinations = resolveDestinations({
223
+ destinations: schemeConfig.destination || [],
224
+ useGeneric: true
225
+ });
226
+ if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
227
+ deviceType: "device",
228
+ platform,
229
+ useGeneric: true
230
+ }));
231
+ for (const destination of resolvedDestinations) args.push("-destination", destination);
232
+ return args;
233
+ };
234
+
235
+ //#endregion
236
+ //#region src/builder/exportXcodeArchive.ts
237
+ const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, exportOptionsPlist }) => {
238
+ const exportPath = await createRandomTmpDir();
239
+ const exportArgs = prepareExportArgs({
240
+ archivePath,
241
+ exportPath,
242
+ exportOptionsPlist,
243
+ exportExtraParams
244
+ });
245
+ const spinner = p.spinner();
246
+ spinner.start("Exporting archive to IPA");
247
+ try {
248
+ await execa("xcodebuild", exportArgs, { cwd: sourceDir });
249
+ spinner.stop("Archive exported successfully");
250
+ return { exportPath };
251
+ } catch (error) {
252
+ spinner.stop("Export failed");
253
+ throw prettifyXcodebuildError(error);
254
+ }
255
+ };
256
+ const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist }) => {
257
+ const args = [
258
+ "-exportArchive",
259
+ "-archivePath",
260
+ archivePath,
261
+ "-exportPath",
262
+ exportPath
263
+ ];
264
+ if (exportExtraParams) args.push(...exportExtraParams);
265
+ args.push("-exportOptionsPlist", exportOptionsPlist);
266
+ return args;
267
+ };
268
+
269
+ //#endregion
270
+ //#region src/utils/assertXcodebuildExist.ts
271
+ const assertXcodebuildExist = async () => {
272
+ try {
273
+ await execa("which", ["xcodebuild"]);
274
+ } catch {
275
+ p.log.error(`${colors.blueBright("xcodebuild")} resolve failed. Ensure that xcode is installed on your machine or xcodebuild is included in your path`);
276
+ process.exit(1);
277
+ }
278
+ };
279
+
280
+ //#endregion
281
+ //#region src/utils/enrichNativeBuildIosScheme.ts
282
+ const resolveExportOptionsPlist = (exportOptionsPlist) => {
283
+ const resolvedExportOptionsPlist = exportOptionsPlist ? path.isAbsolute(exportOptionsPlist) ? exportOptionsPlist : path.resolve(getCwd(), exportOptionsPlist) : void 0;
284
+ if (resolvedExportOptionsPlist && !fs.existsSync(resolvedExportOptionsPlist)) {
285
+ p.log.error(`exportOptionsPlist doesn't exist in ${resolvedExportOptionsPlist}`);
286
+ process.exit(1);
287
+ }
288
+ return resolvedExportOptionsPlist;
289
+ };
290
+ const enrichNativeBuildIosScheme = async (scheme) => {
291
+ return {
292
+ platform: "ios",
293
+ installPods: true,
294
+ configuration: "Release",
295
+ destination: [],
296
+ ...scheme,
297
+ exportOptionsPlist: resolveExportOptionsPlist(scheme.exportOptionsPlist)
298
+ };
299
+ };
300
+
301
+ //#endregion
302
+ //#region src/buildIos.ts
303
+ const buildIos = async ({ schemeConfig: _schemeConfig }) => {
304
+ await assertXcodebuildExist();
305
+ const iosProjectRoot = path.join(getCwd(), "ios");
306
+ const schemeConfig = await enrichNativeBuildIosScheme(_schemeConfig);
307
+ const buildDirectory = await createRandomTmpDir();
308
+ const archiveDir = path.join(buildDirectory, "archive");
309
+ const exportDir = path.join(buildDirectory, "export");
310
+ await fs.promises.mkdir(archiveDir, { recursive: true });
311
+ const { archivePath } = await archiveXcodeProject({
312
+ platform: schemeConfig.platform,
313
+ schemeConfig,
314
+ sourceDir: iosProjectRoot
315
+ });
316
+ const finalArchivePath = path.join(archiveDir, path.basename(archivePath));
317
+ await fs.promises.cp(archivePath, finalArchivePath, { recursive: true });
318
+ const appFromArchive = extractAppFromXcarchive(finalArchivePath);
319
+ if (appFromArchive) {
320
+ await fs.promises.cp(appFromArchive, path.join(archiveDir, path.basename(appFromArchive)), { recursive: true });
321
+ p.log.success(".app extracted from .xcarchive");
322
+ }
323
+ if (schemeConfig.exportOptionsPlist) {
324
+ await fs.promises.mkdir(exportDir, { recursive: true });
325
+ const { exportPath } = await exportXcodeArchive({
326
+ archivePath,
327
+ exportExtraParams: schemeConfig.exportExtraParams,
328
+ exportOptionsPlist: schemeConfig.exportOptionsPlist,
329
+ sourceDir: iosProjectRoot
330
+ });
331
+ await fs.promises.cp(exportPath, exportDir, { recursive: true });
332
+ const ipaFile = fs.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
333
+ if (ipaFile) return {
334
+ buildDirectory,
335
+ buildArtifactPath: path.join(exportDir, ipaFile)
336
+ };
337
+ throw new Error("IPA file not found after export");
338
+ }
339
+ return {
340
+ buildDirectory,
341
+ buildArtifactPath: finalArchivePath
342
+ };
343
+ };
344
+ const extractAppFromXcarchive = (archivePath) => {
345
+ const productsPath = path.join(archivePath, "Products", "Applications");
346
+ if (!fs.existsSync(productsPath)) return;
347
+ const appFile = fs.readdirSync(productsPath).find((file) => file.endsWith(".app"));
348
+ if (appFile) return path.join(productsPath, appFile);
349
+ p.log.warn("Failed to extract .app from xcarchive");
350
+ };
351
+
352
+ //#endregion
353
+ //#region src/builder/buildXcodeProject.ts
354
+ const buildXcodeProject = async ({ sourceDir, platform, scheme, configuration, deviceType, udid, installPods, extraParams }) => {
355
+ const xcodeProject = await parseXcodeProjectInfo(sourceDir);
356
+ if (installPods ?? true) await installPodsIfNeeded(sourceDir);
357
+ const derivedDataPath = await createRandomTmpDir();
358
+ const buildArgs = prepareBuildArgs({
359
+ configuration,
360
+ derivedDataPath,
361
+ deviceType,
362
+ extraParams,
363
+ platform,
364
+ scheme,
365
+ sourceDir,
366
+ udid,
367
+ xcodeProject
368
+ });
369
+ p.log.info(`Xcode Build Settings:
370
+ Project ${xcodeProject.name}
371
+ Scheme ${scheme}
372
+ Configuration ${configuration}
373
+ Platform ${platform}
374
+ Device Type ${deviceType}
375
+ Command xcodebuild ${buildArgs.join(" ")}
376
+ `);
377
+ const logger = createXcodebuildLogger();
378
+ logger.start(`${xcodeProject.name} (Build)`);
379
+ try {
380
+ const process$1 = execa("xcodebuild", buildArgs, { cwd: sourceDir });
381
+ for await (const line of process$1) logger.processLine(line);
382
+ logger.stop("Build completed successfully");
383
+ return await getBuildSettings({
384
+ configuration,
385
+ derivedDataPath,
386
+ deviceType,
387
+ platform,
388
+ scheme,
389
+ sourceDir,
390
+ udid,
391
+ xcodeProject
392
+ });
393
+ } catch (error) {
394
+ logger.stop("Build failed", false);
395
+ throw prettifyXcodebuildError(error);
396
+ }
397
+ };
398
+ const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, scheme, configuration, deviceType, udid, derivedDataPath, extraParams }) => {
399
+ const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
400
+ const destination = udid ? `id=${udid}` : deviceType === "simulator" ? platformConfigs[platform].simulatorDestination : platformConfigs[platform].deviceDestination;
401
+ const args = [
402
+ xcodeProject.isWorkspace ? "-workspace" : "-project",
403
+ path.join(sourceDir, xcodeProject.name),
404
+ "-scheme",
405
+ scheme,
406
+ "-configuration",
407
+ configuration,
408
+ "-sdk",
409
+ sdk,
410
+ "-destination",
411
+ destination,
412
+ "-derivedDataPath",
413
+ derivedDataPath,
414
+ `HOT_UPDATER_MIN_BUNDLE_ID=${generateMinBundleId()}`,
415
+ "build"
416
+ ];
417
+ if (extraParams) args.push(...extraParams);
418
+ return args;
419
+ };
420
+ const getBuildSettings = async ({ xcodeProject, sourceDir, platform, scheme, configuration, deviceType, udid, derivedDataPath }) => {
421
+ const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
422
+ const destination = udid ? `id=${udid}` : deviceType === "simulator" ? platformConfigs[platform].simulatorDestination : platformConfigs[platform].deviceDestination;
423
+ const { stdout: buildSettings } = await execa("xcodebuild", [
424
+ xcodeProject.isWorkspace ? "-workspace" : "-project",
425
+ xcodeProject.name,
426
+ "-scheme",
427
+ scheme,
428
+ "-configuration",
429
+ configuration,
430
+ "-sdk",
431
+ sdk,
432
+ "-destination",
433
+ destination,
434
+ "-derivedDataPath",
435
+ derivedDataPath,
436
+ "-showBuildSettings",
437
+ "-json"
438
+ ], { cwd: sourceDir });
439
+ const settings = JSON.parse(buildSettings).filter(({ target }) => target !== "React" && target !== "React-Core");
440
+ if (settings.length === 0) throw new Error("Failed to get build settings for your project");
441
+ const targetSettings = settings[0].buildSettings;
442
+ const wrapperExtension = targetSettings.WRAPPER_EXTENSION;
443
+ if (wrapperExtension !== "app" && wrapperExtension !== "framework") throw new Error(`Expected wrapper extension to be "app" or "framework" but found: ${wrapperExtension}`);
444
+ const targetBuildDir = targetSettings.TARGET_BUILD_DIR;
445
+ const executableFolderPath = targetSettings.EXECUTABLE_FOLDER_PATH;
446
+ const fullProductName = targetSettings.FULL_PRODUCT_NAME;
447
+ const infoPlistPath = targetSettings.INFOPLIST_PATH;
448
+ if (!targetBuildDir || !executableFolderPath || !fullProductName) throw new Error("Failed to get build paths from build settings");
449
+ return {
450
+ appPath: path.join(targetBuildDir, executableFolderPath),
451
+ infoPlistPath: path.join(targetBuildDir, infoPlistPath)
452
+ };
453
+ };
454
+
455
+ //#endregion
456
+ //#region src/runner/deviceRunner.ts
457
+ const installAndLaunchOnDevice = async ({ device, appPath, options }) => {
458
+ await installOnDevice({
459
+ device,
460
+ appPath,
461
+ options
462
+ });
463
+ if (options.launch !== false) await launchAppOnDevice({
464
+ device,
465
+ options
466
+ });
467
+ };
468
+ const installOnDevice = async ({ device, appPath, options }) => {
469
+ const deviceCtlArgs = [
470
+ "devicectl",
471
+ "device",
472
+ "install",
473
+ "app",
474
+ "--device",
475
+ device.udid,
476
+ appPath
477
+ ];
478
+ const spinner = p.spinner();
479
+ spinner.start(`Installing app on ${device.name}`);
480
+ try {
481
+ await execa("xcrun", deviceCtlArgs, { cwd: options.sourceDir });
482
+ spinner.stop(`Successfully installed app on ${device.name}`);
483
+ } catch (error) {
484
+ spinner.stop(`Failed to install app on ${device.name}`);
485
+ throw new Error(`Failed to install the app on ${device.name}: ${error}`);
486
+ }
487
+ };
488
+ const launchAppOnDevice = async ({ device, options }) => {
489
+ const deviceCtlArgs = [
490
+ "devicectl",
491
+ "device",
492
+ "process",
493
+ "launch",
494
+ "--device",
495
+ device.udid,
496
+ options.bundleIdentifier
497
+ ];
498
+ const spinner = p.spinner();
499
+ spinner.start(`Launching app on ${device.name}`);
500
+ try {
501
+ await execa("xcrun", deviceCtlArgs, { cwd: options.sourceDir });
502
+ spinner.stop(`Successfully launched app on ${device.name}`);
503
+ } catch (error) {
504
+ spinner.stop(`Failed to launch app on ${device.name}`);
505
+ throw new Error(`Failed to launch the app on ${device.name}: ${error}`);
506
+ }
507
+ };
508
+
509
+ //#endregion
510
+ //#region src/runner/simulatorRunner.ts
511
+ const installAndLaunchOnSimulator = async ({ device, appPath, options }) => {
512
+ await launchSimulator({ device });
513
+ await installOnSimulator({
514
+ device,
515
+ appPath,
516
+ options
517
+ });
518
+ if (options.launch !== false) await launchAppOnSimulator({
519
+ device,
520
+ options
521
+ });
522
+ };
523
+ const launchSimulator = async ({ device }) => {
524
+ if (device.type !== "simulator") return;
525
+ const { stdout: activeDeveloperDir } = await execa("xcode-select", ["-p"]);
526
+ await execa("open", [
527
+ `${activeDeveloperDir}/Applications/Simulator.app`,
528
+ "--args",
529
+ "-CurrentDeviceUDID",
530
+ device.udid
531
+ ]);
532
+ if (device.state !== "Booted") await bootSimulator({ device });
533
+ };
534
+ const installOnSimulator = async ({ device, appPath, options }) => {
535
+ const spinner = p.spinner();
536
+ spinner.start(`Installing app on "${device.name}"`);
537
+ try {
538
+ await execa("xcrun", [
539
+ "simctl",
540
+ "install",
541
+ device.udid,
542
+ appPath
543
+ ], { cwd: options.sourceDir });
544
+ spinner.stop(`Successfully installed app on "${device.name}"`);
545
+ } catch (error) {
546
+ spinner.stop(`Failed to install app on "${device.name}"`);
547
+ throw new Error(`Failed to install the app on simulator: ${error}`);
548
+ }
549
+ };
550
+ const launchAppOnSimulator = async ({ device, options }) => {
551
+ const spinner = p.spinner();
552
+ spinner.start(`Launching app on "${device.name}"`);
553
+ try {
554
+ await execa("xcrun", [
555
+ "simctl",
556
+ "launch",
557
+ device.udid,
558
+ options.bundleIdentifier
559
+ ], { cwd: options.sourceDir });
560
+ spinner.stop(`Successfully launched app on "${device.name}"`);
561
+ } catch (error) {
562
+ spinner.stop(`Failed to launch app on "${device.name}"`);
563
+ throw new Error(`Failed to launch the app on simulator: ${error}`);
564
+ }
565
+ };
566
+ const bootSimulator = async ({ device }) => {
567
+ try {
568
+ await execa("xcrun", [
569
+ "simctl",
570
+ "boot",
571
+ device.udid
572
+ ]);
573
+ } catch (error) {
574
+ if ((error instanceof Error ? error.message : String(error)).includes("Unable to boot device in current state: Booted")) {
575
+ p.log.info(`Simulator ${device.udid} already booted. Skipping.`);
576
+ return;
577
+ }
578
+ throw new Error(`Failed to boot simulator: ${error}`);
579
+ }
580
+ };
581
+
582
+ //#endregion
583
+ //#region src/utils/device.ts
584
+ /**
585
+ * Parses devicectl output to Device array
586
+ * @param devicectlOutput - Raw devicectl JSON output
587
+ * @returns Array of parsed devices
588
+ */
589
+ const parseDevicectlList = (devicectlOutput) => {
590
+ return devicectlOutput.map((device) => ({
591
+ name: device.deviceProperties.name,
592
+ udid: device.hardwareProperties.udid,
593
+ version: `${device.hardwareProperties.platform} ${device.deviceProperties.osVersionNumber}`,
594
+ platform: getPlatformFromOsVersion(device.hardwareProperties.platform),
595
+ state: device.deviceProperties.bootState === "booted" ? "Booted" : "Shutdown",
596
+ type: "device"
597
+ }));
598
+ };
599
+ /**
600
+ * Gets physical iOS devices using devicectl
601
+ */
602
+ const getDevices = async () => {
603
+ const tmpPath = path.resolve(os.tmpdir(), "iosPhysicalDevices.json");
604
+ try {
605
+ await execa("xcrun", [
606
+ "devicectl",
607
+ "list",
608
+ "devices",
609
+ "-j",
610
+ tmpPath
611
+ ]);
612
+ return parseDevicectlList(JSON.parse(fs.readFileSync(tmpPath, "utf8")).result.devices);
613
+ } catch (error) {
614
+ throw new Error(`Failed to get devices: ${error}`);
615
+ }
616
+ };
617
+ /**
618
+ * Gets iOS simulators using simctl with JSON output
619
+ */
620
+ const getSimulators = async () => {
621
+ try {
622
+ const { stdout } = await execa("xcrun", [
623
+ "simctl",
624
+ "list",
625
+ "devices",
626
+ "available",
627
+ "-j"
628
+ ]);
629
+ return parseSimctlJson(JSON.parse(stdout));
630
+ } catch (error) {
631
+ throw new Error(`Failed to get simulators: ${error}`);
632
+ }
633
+ };
634
+ /**
635
+ * Parses simctl JSON output to Device array
636
+ */
637
+ const parseSimctlJson = (simctlOutput) => {
638
+ const devices = [];
639
+ for (const [runtime, runtimeDevices] of Object.entries(simctlOutput.devices)) {
640
+ const osVersion = runtime.replace("com.apple.CoreSimulator.SimRuntime.", "").replace(/-/g, " ");
641
+ const platform = getPlatformFromOsVersion(osVersion.split(" ")[0]);
642
+ if (!platform) continue;
643
+ for (const device of runtimeDevices) if (device.isAvailable) devices.push({
644
+ name: device.name,
645
+ udid: device.udid,
646
+ version: osVersion,
647
+ platform,
648
+ state: device.state,
649
+ type: "simulator"
650
+ });
651
+ }
652
+ return devices;
653
+ };
654
+ /**
655
+ * Maps OS version string to Apple platform
656
+ * @param osVersion - OS version string from devicectl/simctl
657
+ * @returns Apple platform or undefined if unknown
658
+ */
659
+ const getPlatformFromOsVersion = (osVersion) => {
660
+ switch (osVersion) {
661
+ case "iOS": return "ios";
662
+ default: return "ios";
663
+ }
664
+ };
665
+ /**
666
+ * Lists devices and simulators for a specific platform
667
+ * @param platform - Apple platform to filter by
668
+ * @param options - Filtering options
669
+ * @returns Array of devices filtered by options
670
+ */
671
+ const listDevices = async (platform, options = {}) => {
672
+ const spinner = p.spinner();
673
+ spinner.start(`Discovering ${platform} devices`);
674
+ try {
675
+ const [simulators, devices] = await Promise.all([getSimulators(), getDevices().catch(() => [])]);
676
+ let allDevices = [...devices, ...simulators].filter((device) => device.platform === platform);
677
+ if (options.deviceType) allDevices = allDevices.filter((d) => d.type === options.deviceType);
678
+ if (options.state && options.state !== "all") allDevices = allDevices.filter((d) => d.state === options.state);
679
+ spinner.stop(`Found ${allDevices.length} ${platform} devices`);
680
+ return allDevices;
681
+ } catch (error) {
682
+ spinner.stop(`Failed to discover ${platform} devices`);
683
+ throw error;
684
+ }
685
+ };
686
+ const matchingDevice = (devices, deviceArg, options = {}) => {
687
+ let filtered = [...devices];
688
+ if (options.deviceType) filtered = filtered.filter((d) => d.type === options.deviceType);
689
+ if (options.bootedOnly) filtered = filtered.filter((d) => d.state === "Booted");
690
+ const byName = filtered.find((d) => d.name === deviceArg);
691
+ if (byName) return byName;
692
+ const byFormattedName = filtered.find((d) => formatDeviceName(d) === deviceArg);
693
+ if (byFormattedName) return byFormattedName;
694
+ const byUdid = filtered.find((d) => d.udid === deviceArg);
695
+ if (byUdid) return byUdid;
696
+ };
697
+ const formatDeviceName = (device) => {
698
+ return device.version ? `${device.name} (${device.version})` : device.name;
699
+ };
700
+ const selectTargetDevice = async ({ platform, interactive, deviceOption }) => {
701
+ const availableDevices = await listDevices(platform);
702
+ if (deviceOption === true && !interactive) {
703
+ p.log.error("you can't select device manually with non-interactive cli mode(without -i option).");
704
+ process.exit(1);
705
+ }
706
+ if (typeof deviceOption === "string") {
707
+ const matchedDevice = matchingDevice(availableDevices, deviceOption);
708
+ if (!matchedDevice) {
709
+ p.log.error(`device '${deviceOption}' isn't included in the attached devices.`);
710
+ process.exit(1);
711
+ }
712
+ return matchedDevice;
713
+ }
714
+ if (interactive) {
715
+ if (!availableDevices.length) {
716
+ p.log.error("you passed -d option but there is no attached devices.");
717
+ process.exit(1);
718
+ }
719
+ const device = await p.select({
720
+ message: "Target Device",
721
+ options: availableDevices.map((d) => ({
722
+ value: d,
723
+ label: `${d.name} (${d.type}) - ${d.state}`,
724
+ hint: d.udid
725
+ }))
726
+ });
727
+ if (p.isCancel(device)) process.exit(1);
728
+ return device;
729
+ }
730
+ const bootedDevices = await listDevices(platform, { state: "Booted" });
731
+ if (bootedDevices.length > 0) return bootedDevices[0];
732
+ };
733
+ const Device = {
734
+ listDevices,
735
+ selectTargetDevice
736
+ };
737
+
738
+ //#endregion
739
+ //#region src/runIos.ts
740
+ const runIos = async ({ schemeConfig: _schemeConfig, runOption }) => {
741
+ const { interactive, device: deviceOption } = runOption;
742
+ const iosProjectPath = path.join(getCwd(), "ios");
743
+ const schemeConfig = await enrichNativeBuildIosScheme(_schemeConfig);
744
+ const device = await Device.selectTargetDevice({
745
+ platform: "ios",
746
+ deviceOption,
747
+ interactive
748
+ });
749
+ if (!device) {
750
+ const simulators = await Device.listDevices("ios", { deviceType: "simulator" });
751
+ if (simulators.length === 0) {
752
+ p.log.error("No simulators found. Please create a simulator in Xcode.");
753
+ process.exit(1);
754
+ }
755
+ p.log.info(`Using first available simulator: ${simulators[0].name}`);
756
+ const selectedDevice = simulators[0];
757
+ p.log.info(`Building for ${selectedDevice.name}...`);
758
+ const result$1 = await buildXcodeProject({
759
+ sourceDir: iosProjectPath,
760
+ platform: schemeConfig.platform,
761
+ scheme: schemeConfig.scheme,
762
+ configuration: schemeConfig.configuration,
763
+ deviceType: selectedDevice.type,
764
+ udid: selectedDevice.udid,
765
+ installPods: schemeConfig.installPods,
766
+ extraParams: schemeConfig.extraParams
767
+ });
768
+ const runnerOptions$1 = {
769
+ bundleIdentifier: schemeConfig.bundleIdentifier,
770
+ sourceDir: iosProjectPath,
771
+ launch: true,
772
+ infoPlistPath: result$1.infoPlistPath
773
+ };
774
+ await installAndLaunchOnSimulator({
775
+ device: selectedDevice,
776
+ appPath: result$1.appPath,
777
+ options: runnerOptions$1
778
+ });
779
+ p.outro("Success 🎉");
780
+ return result$1;
781
+ }
782
+ p.log.info(`Building for ${device.name}...`);
783
+ const result = await buildXcodeProject({
784
+ sourceDir: iosProjectPath,
785
+ platform: schemeConfig.platform,
786
+ scheme: schemeConfig.scheme,
787
+ configuration: schemeConfig.configuration,
788
+ deviceType: device.type,
789
+ udid: device.udid,
790
+ installPods: schemeConfig.installPods,
791
+ extraParams: schemeConfig.extraParams
792
+ });
793
+ const runnerOptions = {
794
+ bundleIdentifier: schemeConfig.bundleIdentifier,
795
+ sourceDir: iosProjectPath,
796
+ launch: true
797
+ };
798
+ if (device.type === "simulator") await installAndLaunchOnSimulator({
799
+ device,
800
+ appPath: result.appPath,
801
+ options: {
802
+ ...runnerOptions,
803
+ infoPlistPath: result.infoPlistPath
804
+ }
805
+ });
806
+ else await installAndLaunchOnDevice({
807
+ device,
808
+ appPath: result.appPath,
809
+ options: runnerOptions
810
+ });
811
+ p.outro("Success 🎉");
812
+ return result;
813
+ };
814
+
815
+ //#endregion
816
+ export { buildIos, runIos };