@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/LICENSE +33 -0
- package/dist/index.cjs +848 -0
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +816 -0
- package/package.json +39 -0
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 };
|