@hot-updater/apple-helper 1.2.0-rc.12 → 1.2.0-rc.13
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.cjs +2140 -1185
- package/dist/index.d.cts +291 -35
- package/dist/index.d.mts +291 -35
- package/dist/index.mjs +2125 -1186
- package/package.json +4 -3
package/dist/index.cjs
CHANGED
|
@@ -25,6 +25,9 @@ let node_crypto = require("node:crypto");
|
|
|
25
25
|
node_crypto = __toESM(node_crypto, 1);
|
|
26
26
|
let node_timers_promises = require("node:timers/promises");
|
|
27
27
|
let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
|
|
28
|
+
let _hot_updater_protocol_native_artifact = require("@hot-updater/protocol/native-artifact");
|
|
29
|
+
let crypto = require("crypto");
|
|
30
|
+
crypto = __toESM(crypto, 1);
|
|
28
31
|
let fs = require("fs");
|
|
29
32
|
fs = __toESM(fs, 1);
|
|
30
33
|
let path = require("path");
|
|
@@ -35,8 +38,6 @@ let node_fs_promises = require("node:fs/promises");
|
|
|
35
38
|
let node_os = require("node:os");
|
|
36
39
|
let node_path = require("node:path");
|
|
37
40
|
let child_process = require("child_process");
|
|
38
|
-
let crypto = require("crypto");
|
|
39
|
-
crypto = __toESM(crypto, 1);
|
|
40
41
|
let os = require("os");
|
|
41
42
|
os = __toESM(os, 1);
|
|
42
43
|
//#region src/asc/appleSrp.ts
|
|
@@ -782,220 +783,6 @@ const ASC_INTEGRATIONS_URL = "https://appstoreconnect.apple.com/access/integrati
|
|
|
782
783
|
*/
|
|
783
784
|
const issueAscApiKey = (request) => createAscKeyIssuer()(request);
|
|
784
785
|
//#endregion
|
|
785
|
-
//#region src/utils/formatCommand.ts
|
|
786
|
-
/**
|
|
787
|
-
* A command to print, broken at spaces with ` \` once a line would pass
|
|
788
|
-
* `width`, each continuation indented two spaces, so a terminal never wraps
|
|
789
|
-
* it mid-word and it still pastes as one command.
|
|
790
|
-
*/
|
|
791
|
-
const formatCommand = (words, width = 74) => {
|
|
792
|
-
const lines = [];
|
|
793
|
-
let line = "";
|
|
794
|
-
for (const word of words) {
|
|
795
|
-
const next = line ? `${line} ${word}` : word;
|
|
796
|
-
if (line && next.length > width - 2) {
|
|
797
|
-
lines.push(`${line} \\`);
|
|
798
|
-
line = ` ${word}`;
|
|
799
|
-
} else line = next;
|
|
800
|
-
}
|
|
801
|
-
lines.push(line);
|
|
802
|
-
return lines.join("\n");
|
|
803
|
-
};
|
|
804
|
-
//#endregion
|
|
805
|
-
//#region src/utils/cocoapods.ts
|
|
806
|
-
/**
|
|
807
|
-
* A failed command's error: the command, then the last lines of its
|
|
808
|
-
* output, stdout and stderr as printed, without execa's full message.
|
|
809
|
-
*/
|
|
810
|
-
const commandFailure = (command, error) => {
|
|
811
|
-
const { all, stderr, stdout } = error ?? {};
|
|
812
|
-
const detail = String(all || [stdout, stderr].filter(Boolean).join("\n") || "").trimEnd().split("\n").filter((line) => line.trim()).slice(-12).join("\n") || (error instanceof Error ? error.message : String(error));
|
|
813
|
-
return new Error(`${command} failed.\n${detail}`, { cause: error });
|
|
814
|
-
};
|
|
815
|
-
/**
|
|
816
|
-
* A line of the progress meter curl prints on stderr for each pod it
|
|
817
|
-
* downloads, such as React Native's prebuilt core.
|
|
818
|
-
*/
|
|
819
|
-
const DOWNLOAD_PROGRESS = [
|
|
820
|
-
/^% Total +% Received/,
|
|
821
|
-
/^Dload +Upload/,
|
|
822
|
-
/^\d{1,3} +[\d.]+[kMGT]? +\d{1,3} +[\d.]+[kMGT]? /
|
|
823
|
-
];
|
|
824
|
-
/**
|
|
825
|
-
* `pod install`'s output: its live line shows the pod it installs, its log
|
|
826
|
-
* keeps the whole output, and a failure prints CocoaPods' `[!]` errors and a
|
|
827
|
-
* crash's `SomeError - …` line with its last lines, without the download
|
|
828
|
-
* progress CocoaPods' curl prints on stderr.
|
|
829
|
-
*/
|
|
830
|
-
const createPodInstallLogger = () => new _hot_updater_cli_tools.BuildLogger({
|
|
831
|
-
logPrefix: "pod-install",
|
|
832
|
-
createPhaseReader: () => (line) => {
|
|
833
|
-
const installing = /^Installing (\S+)/.exec(line);
|
|
834
|
-
if (installing) return `Installing ${installing[1]}`;
|
|
835
|
-
return /^(Analyzing dependencies|Downloading dependencies|Generating Pods project|Integrating client project)$/.test(line) ? line : void 0;
|
|
836
|
-
},
|
|
837
|
-
failureSummary: {
|
|
838
|
-
lines: [/^[A-Z]\w*(::\w+)*Error - /],
|
|
839
|
-
blocks: [{
|
|
840
|
-
start: /^\[!\] /,
|
|
841
|
-
end: /^$/,
|
|
842
|
-
includeEnd: false
|
|
843
|
-
}],
|
|
844
|
-
ignore: DOWNLOAD_PROGRESS,
|
|
845
|
-
tail: 4,
|
|
846
|
-
stopAfter: /^\[!\] Oh no, an error occurred\.$/,
|
|
847
|
-
maxWidth: 76
|
|
848
|
-
}
|
|
849
|
-
});
|
|
850
|
-
/**
|
|
851
|
-
* Runs `pod install` as a step whose failure prints CocoaPods' errors and
|
|
852
|
-
* the log's path, then throws a `BuildFailedError`.
|
|
853
|
-
*/
|
|
854
|
-
const runPodInstall = async ({ command, env, sourceDir }) => {
|
|
855
|
-
const logger = createPodInstallLogger();
|
|
856
|
-
const failureMessage = `${command.join(" ")} failed`;
|
|
857
|
-
await logger.start({
|
|
858
|
-
title: "Installing CocoaPods dependencies",
|
|
859
|
-
header: [`$ ${formatCommand([...Object.entries(env).map(([name, value]) => `${name}=${value}`), ...command])}`, ""]
|
|
860
|
-
});
|
|
861
|
-
try {
|
|
862
|
-
const podProcess = (0, execa.execa)(command[0], command.slice(1), {
|
|
863
|
-
cwd: sourceDir,
|
|
864
|
-
env,
|
|
865
|
-
all: true,
|
|
866
|
-
buffer: false
|
|
867
|
-
});
|
|
868
|
-
const logging = podProcess.all ? logger.processStream(podProcess.all) : Promise.resolve();
|
|
869
|
-
const [podResult, loggingResult] = await Promise.allSettled([podProcess, logging]);
|
|
870
|
-
if (podResult.status === "rejected") throw podResult.reason;
|
|
871
|
-
if (loggingResult.status === "rejected") throw loggingResult.reason;
|
|
872
|
-
logger.stop("CocoaPods dependencies installed");
|
|
873
|
-
} catch (error) {
|
|
874
|
-
logger.writeError(error);
|
|
875
|
-
const reason = error instanceof execa.ExecaError && error.exitCode !== void 0 ? `exited with code ${error.exitCode}` : error instanceof Error ? error.message : String(error);
|
|
876
|
-
logger.stop(`${failureMessage} · ${reason}`, false);
|
|
877
|
-
throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
|
|
878
|
-
logPath: logger.logPath,
|
|
879
|
-
summary: logger.failureSummary,
|
|
880
|
-
cause: error
|
|
881
|
-
});
|
|
882
|
-
} finally {
|
|
883
|
-
await logger.close();
|
|
884
|
-
}
|
|
885
|
-
};
|
|
886
|
-
const installPodsIfNeeded = async (sourceDir) => {
|
|
887
|
-
const podfilePath = path.default.join(sourceDir, "Podfile");
|
|
888
|
-
if (!fs.default.existsSync(podfilePath)) {
|
|
889
|
-
_hot_updater_cli_tools.p.log.info("No Podfile found, skipping CocoaPods installation");
|
|
890
|
-
return {
|
|
891
|
-
codegen: false,
|
|
892
|
-
pods: false
|
|
893
|
-
};
|
|
894
|
-
}
|
|
895
|
-
const gemfilePaths = [path.default.join(sourceDir, "Gemfile"), path.default.join((0, _hot_updater_cli_tools.getCwd)(), "Gemfile")];
|
|
896
|
-
const shouldUseBundler = (await Promise.all(gemfilePaths.map(checkShouldUseBundler))).some(Boolean);
|
|
897
|
-
const reactNative = (0, _hot_updater_cli_tools.getReactNativeMetadatas)();
|
|
898
|
-
const plan = resolvePodInstallPlan({
|
|
899
|
-
version: reactNative.version,
|
|
900
|
-
env: process.env
|
|
901
|
-
});
|
|
902
|
-
if (plan.runCodegen) await runReactNativeCodegen({
|
|
903
|
-
projectRoot: (0, _hot_updater_cli_tools.getCwd)(),
|
|
904
|
-
reactNativePath: reactNative.packagePath,
|
|
905
|
-
sourceDir
|
|
906
|
-
});
|
|
907
|
-
if (shouldUseBundler) {
|
|
908
|
-
const bundleSpinner = _hot_updater_cli_tools.p.spinner();
|
|
909
|
-
bundleSpinner.start("Installing Ruby gems");
|
|
910
|
-
try {
|
|
911
|
-
await (0, execa.execa)("bundle", ["install"], {
|
|
912
|
-
cwd: sourceDir,
|
|
913
|
-
all: true
|
|
914
|
-
});
|
|
915
|
-
} catch (error) {
|
|
916
|
-
bundleSpinner.error("bundle install failed");
|
|
917
|
-
throw commandFailure("bundle install", error);
|
|
918
|
-
}
|
|
919
|
-
bundleSpinner.stop("Ruby gems installed");
|
|
920
|
-
}
|
|
921
|
-
await runPodInstall({
|
|
922
|
-
command: shouldUseBundler ? [
|
|
923
|
-
"bundle",
|
|
924
|
-
"exec",
|
|
925
|
-
"pod",
|
|
926
|
-
"install"
|
|
927
|
-
] : ["pod", "install"],
|
|
928
|
-
env: plan.env,
|
|
929
|
-
sourceDir
|
|
930
|
-
});
|
|
931
|
-
return {
|
|
932
|
-
codegen: plan.runCodegen,
|
|
933
|
-
pods: true
|
|
934
|
-
};
|
|
935
|
-
};
|
|
936
|
-
const checkShouldUseBundler = async (gemfilePath) => {
|
|
937
|
-
try {
|
|
938
|
-
if (!fs.default.existsSync(gemfilePath)) return false;
|
|
939
|
-
return (await fs.default.promises.readFile(gemfilePath, "utf-8")).includes("cocoapods");
|
|
940
|
-
} catch {
|
|
941
|
-
return false;
|
|
942
|
-
}
|
|
943
|
-
};
|
|
944
|
-
/**
|
|
945
|
-
* Runs React Native codegen into `<sourceDir>/build/generated/ios`, as the
|
|
946
|
-
* React Native CLI does before `pod install`. Clearing the old output first
|
|
947
|
-
* drops the modules of removed libraries.
|
|
948
|
-
*/
|
|
949
|
-
const runReactNativeCodegen = async ({ projectRoot, reactNativePath, sourceDir }) => {
|
|
950
|
-
await fs.default.promises.rm(path.default.join(sourceDir, "build", "generated", "ios"), {
|
|
951
|
-
recursive: true,
|
|
952
|
-
force: true
|
|
953
|
-
});
|
|
954
|
-
const spinner = _hot_updater_cli_tools.p.spinner();
|
|
955
|
-
spinner.start("Running React Native codegen");
|
|
956
|
-
try {
|
|
957
|
-
await (0, execa.execa)("node", [
|
|
958
|
-
path.default.join(reactNativePath, "scripts", "generate-codegen-artifacts.js"),
|
|
959
|
-
"-p",
|
|
960
|
-
projectRoot,
|
|
961
|
-
"-o",
|
|
962
|
-
sourceDir,
|
|
963
|
-
"-t",
|
|
964
|
-
"ios"
|
|
965
|
-
], {
|
|
966
|
-
cwd: projectRoot,
|
|
967
|
-
all: true
|
|
968
|
-
});
|
|
969
|
-
} catch (error) {
|
|
970
|
-
spinner.error("React Native codegen failed");
|
|
971
|
-
throw commandFailure("React Native codegen", error);
|
|
972
|
-
}
|
|
973
|
-
spinner.stop("React Native codegen done");
|
|
974
|
-
};
|
|
975
|
-
/**
|
|
976
|
-
* The environment `pod install` runs with, and whether codegen runs first.
|
|
977
|
-
*
|
|
978
|
-
* From React Native 0.79, `RCT_IGNORE_PODS_DEPRECATION=1` silences the
|
|
979
|
-
* deprecation notice for calling `pod install` directly and also sets
|
|
980
|
-
* `RCT_SKIP_CODEGEN=1`, because the React Native CLI runs codegen itself
|
|
981
|
-
* first. Hot Updater sets it the same way and runs codegen too, unless the
|
|
982
|
-
* user's `RCT_SKIP_CODEGEN=1` takes codegen over. Before 0.79, and when the
|
|
983
|
-
* version is unknown, CocoaPods runs codegen. A user's value of a flag wins.
|
|
984
|
-
*/
|
|
985
|
-
const resolvePodInstallPlan = ({ version, env }) => {
|
|
986
|
-
const podsSkipCodegen = version.major > 0 || version.minor >= 79;
|
|
987
|
-
const usePrebuiltReactNative = version.major > 0 || version.minor >= 81;
|
|
988
|
-
const ignorePodsDeprecation = env["RCT_IGNORE_PODS_DEPRECATION"] ?? (podsSkipCodegen ? "1" : void 0);
|
|
989
|
-
return {
|
|
990
|
-
runCodegen: podsSkipCodegen && ignorePodsDeprecation === "1" && env["RCT_SKIP_CODEGEN"] !== "1",
|
|
991
|
-
env: {
|
|
992
|
-
...ignorePodsDeprecation !== void 0 && { RCT_IGNORE_PODS_DEPRECATION: ignorePodsDeprecation },
|
|
993
|
-
RCT_USE_RN_DEP: env["RCT_USE_RN_DEP"] ?? (usePrebuiltReactNative ? "1" : "0"),
|
|
994
|
-
RCT_USE_PREBUILT_RNCORE: env["RCT_USE_PREBUILT_RNCORE"] ?? (usePrebuiltReactNative ? "1" : "0")
|
|
995
|
-
}
|
|
996
|
-
};
|
|
997
|
-
};
|
|
998
|
-
//#endregion
|
|
999
786
|
//#region src/utils/createRandomTmpDir.ts
|
|
1000
787
|
const TERMINATION_SIGNALS$2 = [
|
|
1001
788
|
"SIGHUP",
|
|
@@ -1048,844 +835,325 @@ const removeTmpDir = async (directory) => {
|
|
|
1048
835
|
if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
|
|
1049
836
|
};
|
|
1050
837
|
//#endregion
|
|
1051
|
-
//#region src/utils/
|
|
838
|
+
//#region src/utils/escapeUnzipPattern.ts
|
|
1052
839
|
/**
|
|
1053
|
-
*
|
|
840
|
+
* A zip entry name as `unzip` reads a literal member. It takes `*`, `?` and
|
|
841
|
+
* `[...]` in a member name as wildcards, which a backslash escapes, so an
|
|
842
|
+
* app named `My[Dev].app` would otherwise match nothing.
|
|
1054
843
|
*/
|
|
1055
|
-
const
|
|
1056
|
-
name: "ios",
|
|
1057
|
-
deviceSdk: "iphoneos",
|
|
1058
|
-
simulatorSdk: "iphonesimulator",
|
|
1059
|
-
supportsSimulator: true,
|
|
1060
|
-
deviceDestination: "platform=iOS",
|
|
1061
|
-
simulatorDestination: "platform=iOS Simulator"
|
|
1062
|
-
} };
|
|
844
|
+
const escapeUnzipPattern = (name) => name.replace(/[[\]*?\\]/gu, "\\$&");
|
|
1063
845
|
//#endregion
|
|
1064
|
-
//#region src/utils/
|
|
1065
|
-
const
|
|
846
|
+
//#region src/utils/readIpaInfo.ts
|
|
847
|
+
const INFO_PLIST_KEYS = {
|
|
848
|
+
bundleId: "CFBundleIdentifier",
|
|
849
|
+
shortVersion: "CFBundleShortVersionString",
|
|
850
|
+
bundleVersion: "CFBundleVersion"
|
|
851
|
+
};
|
|
1066
852
|
/**
|
|
1067
|
-
*
|
|
853
|
+
* Runs `read` on a copy of the app's `Payload/<App>.app/Info.plist` out of an
|
|
854
|
+
* `.ipa`, never a framework's or an extension's, which nest deeper.
|
|
1068
855
|
*/
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
856
|
+
const withIpaInfoPlist = async (ipaPath, read) => {
|
|
857
|
+
const { stdout } = await (0, execa.execa)("unzip", ["-Z1", ipaPath]);
|
|
858
|
+
const entry = stdout.split("\n").map((name) => name.trim()).find((name) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(name));
|
|
859
|
+
if (entry === void 0) throw new Error(`${path.default.basename(ipaPath)} has no Payload/<App>.app/Info.plist.`);
|
|
860
|
+
const directory = await createRandomTmpDir();
|
|
861
|
+
try {
|
|
862
|
+
const infoPlist = path.default.join(directory, "Info.plist");
|
|
863
|
+
await (0, execa.execa)("unzip", [
|
|
864
|
+
"-p",
|
|
865
|
+
ipaPath,
|
|
866
|
+
escapeUnzipPattern(entry)
|
|
867
|
+
], { stdout: { file: infoPlist } });
|
|
868
|
+
return await read(infoPlist);
|
|
869
|
+
} finally {
|
|
870
|
+
await fs.default.promises.rm(directory, {
|
|
871
|
+
recursive: true,
|
|
872
|
+
force: true
|
|
1084
873
|
});
|
|
1085
874
|
}
|
|
1086
875
|
};
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
};
|
|
1100
|
-
|
|
1101
|
-
//#region src/utils/parseXcodeProjectInfo.ts
|
|
1102
|
-
const parseXcodeProjectInfo = async (sourceDir) => {
|
|
1103
|
-
const files = await fs.default.promises.readdir(sourceDir);
|
|
1104
|
-
const workspace = files.find((file) => file.endsWith(".xcworkspace"));
|
|
1105
|
-
if (workspace) return {
|
|
1106
|
-
isWorkspace: true,
|
|
1107
|
-
name: workspace,
|
|
1108
|
-
path: path.default.join(sourceDir, workspace)
|
|
1109
|
-
};
|
|
1110
|
-
const project = files.find((file) => file.endsWith(".xcodeproj"));
|
|
1111
|
-
if (project) return {
|
|
1112
|
-
isWorkspace: false,
|
|
1113
|
-
name: project,
|
|
1114
|
-
path: path.default.join(sourceDir, project)
|
|
1115
|
-
};
|
|
1116
|
-
throw new Error(`No Xcode project or workspace found in ${sourceDir}`);
|
|
1117
|
-
};
|
|
1118
|
-
//#endregion
|
|
1119
|
-
//#region src/utils/prettifyXcodebuildError.ts
|
|
1120
|
-
const prettifyXcodebuildError = (error) => {
|
|
1121
|
-
if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
|
|
1122
|
-
const errorLines = error.summary.join("\n");
|
|
1123
|
-
const hint = mentionsProgramLicenseAgreement(errorLines) ? PROGRAM_LICENSE_AGREEMENT_HINT : errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "No valid provisioning profile is set in the project.\nCheck the Signing & Capabilities tab in Xcode." : null;
|
|
1124
|
-
return new _hot_updater_cli_tools.BuildFailedError(hint ?? error.message, {
|
|
1125
|
-
logPath: error.logPath,
|
|
1126
|
-
summary: error.summary,
|
|
1127
|
-
hint,
|
|
1128
|
-
cause: error.cause
|
|
1129
|
-
});
|
|
876
|
+
/** What App Store Connect files an `.ipa` under. */
|
|
877
|
+
const readIpaInfo = (ipaPath) => withIpaInfoPlist(ipaPath, async (infoPlist) => {
|
|
878
|
+
const info = {};
|
|
879
|
+
for (const [field, key] of Object.entries(INFO_PLIST_KEYS)) {
|
|
880
|
+
const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
|
|
881
|
+
"-extract",
|
|
882
|
+
key,
|
|
883
|
+
"raw",
|
|
884
|
+
"-o",
|
|
885
|
+
"-",
|
|
886
|
+
infoPlist
|
|
887
|
+
], { reject: false });
|
|
888
|
+
if (exitCode !== 0 || !value.trim()) throw new Error(`${path.default.basename(ipaPath)}'s Info.plist has no ${key}.`);
|
|
889
|
+
info[field] = value.trim();
|
|
1130
890
|
}
|
|
1131
|
-
return
|
|
1132
|
-
};
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
CompileAssetCatalogVariant: "Compiling assets",
|
|
1146
|
-
LinkAssetCatalog: "Compiling assets",
|
|
1147
|
-
CompileStoryboard: "Compiling storyboards",
|
|
1148
|
-
CompileXIB: "Compiling storyboards",
|
|
1149
|
-
LinkStoryboards: "Compiling storyboards",
|
|
1150
|
-
Ld: "Linking",
|
|
1151
|
-
Libtool: "Linking",
|
|
1152
|
-
CodeSign: "Signing",
|
|
1153
|
-
ProcessInfoPlistFile: "Processing",
|
|
1154
|
-
ProcessProductPackaging: "Processing",
|
|
1155
|
-
ProcessProductPackagingDER: "Processing",
|
|
1156
|
-
CopySwiftLibs: "Copying",
|
|
1157
|
-
CpResource: "Copying",
|
|
1158
|
-
GenerateDSYMFile: "Generating dSYM",
|
|
1159
|
-
Strip: "Stripping",
|
|
1160
|
-
Validate: "Validating"
|
|
1161
|
-
};
|
|
1162
|
-
const COMPILES = /* @__PURE__ */ new Set([
|
|
1163
|
-
"CompileC",
|
|
1164
|
-
"SwiftCompile",
|
|
1165
|
-
"CompileSwift"
|
|
1166
|
-
]);
|
|
1167
|
-
const PRECOMPILES = /* @__PURE__ */ new Set([
|
|
1168
|
-
"PrecompileModule",
|
|
1169
|
-
"ScanDependencies",
|
|
1170
|
-
"SwiftExplicitDependencyGeneratePcm",
|
|
1171
|
-
"SwiftExplicitDependencyCompileModuleFromInterface"
|
|
1172
|
-
]);
|
|
1173
|
-
const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
891
|
+
return info;
|
|
892
|
+
});
|
|
893
|
+
/** Whether Xcode marked an `.ipa`'s app for TestFlight internal testing only. */
|
|
894
|
+
const readIpaInternalTestingOnly = (ipaPath) => withIpaInfoPlist(ipaPath, async (infoPlist) => {
|
|
895
|
+
const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
|
|
896
|
+
"-extract",
|
|
897
|
+
"TFInternalTestingOnly",
|
|
898
|
+
"raw",
|
|
899
|
+
"-o",
|
|
900
|
+
"-",
|
|
901
|
+
infoPlist
|
|
902
|
+
], { reject: false });
|
|
903
|
+
return exitCode === 0 && value.trim() === "true";
|
|
904
|
+
});
|
|
1174
905
|
/**
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
* `Signing CodexRelay`, `Bundling JavaScript`, with the target each
|
|
1178
|
-
* action's line names.
|
|
906
|
+
* Whether an `.ipa`'s app answers export compliance in its Info.plist. Either
|
|
907
|
+
* value is an answer, so this asks only whether the key is there.
|
|
1179
908
|
*/
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
compiled += batch ? unescape(batch).split(",").length : 1;
|
|
1191
|
-
return `${withTarget("Compiling")} · ${plural(compiled, "file")}`;
|
|
1192
|
-
}
|
|
1193
|
-
if (PRECOMPILES.has(action)) {
|
|
1194
|
-
precompiled += 1;
|
|
1195
|
-
return `Precompiling modules · ${precompiled}`;
|
|
1196
|
-
}
|
|
1197
|
-
if (action === "PhaseScriptExecution") {
|
|
1198
|
-
const script = unescape(SCRIPT.exec(line)?.[1] ?? "");
|
|
1199
|
-
if (script === "Bundle React Native code and images") return "Bundling JavaScript";
|
|
1200
|
-
return script ? `Running ${script}` : withTarget("Running a script");
|
|
1201
|
-
}
|
|
1202
|
-
const phase = ACTIONS[action];
|
|
1203
|
-
if (phase) return withTarget(phase);
|
|
1204
|
-
if (line === "Prepare packages" || line.startsWith("Resolve Package")) return "Resolving packages";
|
|
1205
|
-
if (line === "ComputeTargetDependencyGraph") return "Planning the build";
|
|
1206
|
-
};
|
|
1207
|
-
};
|
|
1208
|
-
//#endregion
|
|
1209
|
-
//#region src/utils/createXcodebuildLogger.ts
|
|
1210
|
-
const createXcodebuildLogger = ({ logPrefix, logFilePath }) => new _hot_updater_cli_tools.BuildLogger({
|
|
1211
|
-
logPrefix,
|
|
1212
|
-
logFilePath,
|
|
1213
|
-
createPhaseReader: createXcodebuildPhaseReader,
|
|
1214
|
-
failureSummary: {
|
|
1215
|
-
lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
|
|
1216
|
-
blocks: [{
|
|
1217
|
-
start: "The following build commands failed:",
|
|
1218
|
-
end: /^\(\d+ failures?\)$/,
|
|
1219
|
-
includeEnd: true
|
|
1220
|
-
}, {
|
|
1221
|
-
start: /^error: exportArchive /,
|
|
1222
|
-
end: /^$/,
|
|
1223
|
-
includeEnd: false
|
|
1224
|
-
}]
|
|
1225
|
-
}
|
|
909
|
+
const readIpaHasExportComplianceKey = (ipaPath) => withIpaInfoPlist(ipaPath, async (infoPlist) => {
|
|
910
|
+
const { exitCode } = await (0, execa.execa)("plutil", [
|
|
911
|
+
"-extract",
|
|
912
|
+
"ITSAppUsesNonExemptEncryption",
|
|
913
|
+
"raw",
|
|
914
|
+
"-o",
|
|
915
|
+
"-",
|
|
916
|
+
infoPlist
|
|
917
|
+
], { reject: false });
|
|
918
|
+
return exitCode === 0;
|
|
1226
919
|
});
|
|
1227
920
|
//#endregion
|
|
1228
|
-
//#region src/utils/
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
921
|
+
//#region src/utils/xcodeSigning.ts
|
|
922
|
+
/**
|
|
923
|
+
* How a device build signs with automatic signing, authenticated by the
|
|
924
|
+
* scheme's App Store Connect API key: the xcodebuild arguments, and the key.
|
|
925
|
+
* The key is resolved only here, for a device build of a scheme with
|
|
926
|
+
* `signing`. Without `credentials`, it resolves from the environment or the
|
|
927
|
+
* config's `secrets` adapter, and the temporary files are deleted when the
|
|
928
|
+
* process exits.
|
|
929
|
+
*/
|
|
930
|
+
const resolveXcodeSigning = async ({ schemeConfig, credentials }) => {
|
|
931
|
+
const reference = schemeConfig.signing?.ascApiKey;
|
|
932
|
+
if (!reference) return;
|
|
933
|
+
const apiKey = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
|
|
934
|
+
kind: "asc-api-key",
|
|
935
|
+
name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.ascApiKey")
|
|
1240
936
|
});
|
|
937
|
+
return {
|
|
938
|
+
authenticationArgs: [
|
|
939
|
+
"-allowProvisioningUpdates",
|
|
940
|
+
"-authenticationKeyPath",
|
|
941
|
+
apiKey.keyPath,
|
|
942
|
+
"-authenticationKeyID",
|
|
943
|
+
apiKey.keyId,
|
|
944
|
+
"-authenticationKeyIssuerID",
|
|
945
|
+
apiKey.issuerId
|
|
946
|
+
],
|
|
947
|
+
apiKey
|
|
948
|
+
};
|
|
1241
949
|
};
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
950
|
+
const APP_STORE_CONNECT_EXPORT_OPTIONS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
951
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
952
|
+
<plist version="1.0">
|
|
953
|
+
<dict>
|
|
954
|
+
<key>method</key>
|
|
955
|
+
<string>app-store-connect</string>
|
|
956
|
+
<key>signingStyle</key>
|
|
957
|
+
<string>automatic</string>
|
|
958
|
+
</dict>
|
|
959
|
+
</plist>
|
|
960
|
+
`;
|
|
961
|
+
/** The export option that keeps a build from App Store review. */
|
|
962
|
+
const INTERNAL_TESTING_ONLY = "testFlightInternalTestingOnly";
|
|
963
|
+
/**
|
|
964
|
+
* Writes the export options of a scheme that signs with an App Store Connect
|
|
965
|
+
* API key and sets no `exportOptionsPlist`: an App Store Connect export with
|
|
966
|
+
* automatic signing, internal testing only for a sandbox build.
|
|
967
|
+
*/
|
|
968
|
+
const writeAppStoreConnectExportOptions = async (directory, { internalTestingOnly = false } = {}) => {
|
|
969
|
+
const exportOptionsPlist = path.default.join(directory, "ExportOptions.plist");
|
|
970
|
+
await fs.default.promises.writeFile(exportOptionsPlist, internalTestingOnly ? APP_STORE_CONNECT_EXPORT_OPTIONS.replace("</dict>", ` <key>${INTERNAL_TESTING_ONLY}</key>\n <true/>\n</dict>`) : APP_STORE_CONNECT_EXPORT_OPTIONS);
|
|
971
|
+
return exportOptionsPlist;
|
|
972
|
+
};
|
|
973
|
+
/**
|
|
974
|
+
* `testFlightInternalTestingOnly` in export options: undefined without it.
|
|
975
|
+
*/
|
|
976
|
+
const readInternalTestingOnly = async (exportOptionsPlist) => {
|
|
977
|
+
const uncommented = (await fs.default.promises.readFile(exportOptionsPlist, "utf-8").catch(() => void 0))?.replace(/<!--[\s\S]*?-->/gu, "");
|
|
978
|
+
if (uncommented?.includes("<plist")) {
|
|
979
|
+
const match = new RegExp(`<key>\\s*${INTERNAL_TESTING_ONLY}\\s*</key>\\s*<(true|false)\\s*/>`, "u").exec(uncommented);
|
|
980
|
+
return match ? match[1] === "true" : void 0;
|
|
1247
981
|
}
|
|
1248
|
-
|
|
982
|
+
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
983
|
+
"-extract",
|
|
984
|
+
INTERNAL_TESTING_ONLY,
|
|
985
|
+
"raw",
|
|
986
|
+
"-o",
|
|
987
|
+
"-",
|
|
988
|
+
exportOptionsPlist
|
|
989
|
+
], { reject: false });
|
|
990
|
+
return exitCode === 0 ? stdout.trim() === "true" : void 0;
|
|
1249
991
|
};
|
|
1250
992
|
/**
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
993
|
+
* Refuses a sandbox scheme's App Store Connect export that turns internal
|
|
994
|
+
* testing off: Apple keeps a build out of review only with it on. Another
|
|
995
|
+
* method's `.ipa` never reaches App Store Connect.
|
|
1254
996
|
*/
|
|
1255
|
-
const
|
|
1256
|
-
|
|
1257
|
-
logPrefix,
|
|
1258
|
-
logFilePath
|
|
1259
|
-
});
|
|
1260
|
-
await logger.start({
|
|
1261
|
-
title,
|
|
1262
|
-
header: [
|
|
1263
|
-
...header,
|
|
1264
|
-
`$ ${formatCommand(["xcodebuild", ...args])}`,
|
|
1265
|
-
""
|
|
1266
|
-
]
|
|
1267
|
-
});
|
|
1268
|
-
const activeProcesses = [];
|
|
1269
|
-
const terminateActiveProcesses = () => {
|
|
1270
|
-
for (const activeProcess of activeProcesses) activeProcess.kill();
|
|
1271
|
-
};
|
|
1272
|
-
for (const signal of TERMINATION_SIGNALS$1) process.once(signal, terminateActiveProcesses);
|
|
1273
|
-
try {
|
|
1274
|
-
const xcodebuildProcess = createRawXcodebuildProcess({
|
|
1275
|
-
args,
|
|
1276
|
-
env,
|
|
1277
|
-
sourceDir
|
|
1278
|
-
});
|
|
1279
|
-
activeProcesses.push(xcodebuildProcess);
|
|
1280
|
-
const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
|
|
1281
|
-
const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput) : Promise.resolve();
|
|
1282
|
-
const [xcodebuildResult, loggingResult] = await Promise.allSettled([xcodebuildProcess, logging]);
|
|
1283
|
-
if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
|
|
1284
|
-
if (loggingResult.status === "rejected") throw loggingResult.reason;
|
|
1285
|
-
logger.stop(successMessage);
|
|
1286
|
-
return { logFilePath: logger.logFilePath };
|
|
1287
|
-
} catch (error) {
|
|
1288
|
-
logger.writeError(error);
|
|
1289
|
-
const reason = describeFailure(error);
|
|
1290
|
-
logger.stop(`${failureMessage} · ${reason.replace(/\.$/, "")}`, false);
|
|
1291
|
-
throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
|
|
1292
|
-
logPath: logger.logPath,
|
|
1293
|
-
summary: logger.failureSummary,
|
|
1294
|
-
cause: error
|
|
1295
|
-
});
|
|
1296
|
-
} finally {
|
|
1297
|
-
for (const signal of TERMINATION_SIGNALS$1) process.off(signal, terminateActiveProcesses);
|
|
1298
|
-
await logger.close();
|
|
1299
|
-
}
|
|
997
|
+
const assertSandboxExportOptions = async (schemeName, exportOptionsPlist) => {
|
|
998
|
+
if (APP_STORE_EXPORT_METHODS.has(await readExportMethod(exportOptionsPlist)) && await readInternalTestingOnly(exportOptionsPlist) === false) throw new Error(`The sandbox scheme ${schemeName} turns ${INTERNAL_TESTING_ONLY} off in exportOptionsPlist.\nRemove the key, or set it to true.`);
|
|
1300
999
|
};
|
|
1301
|
-
//#endregion
|
|
1302
|
-
//#region src/utils/writeBuildXcconfig.ts
|
|
1303
|
-
const SDK_CONDITIONS = [
|
|
1304
|
-
"",
|
|
1305
|
-
"[sdk=iphoneos*]",
|
|
1306
|
-
"[sdk=iphonesimulator*]"
|
|
1307
|
-
];
|
|
1308
1000
|
/**
|
|
1309
|
-
*
|
|
1310
|
-
* the
|
|
1311
|
-
*
|
|
1312
|
-
* relative to the including file.
|
|
1001
|
+
* The export options a sandbox scheme's `exportOptionsPlist` exports with:
|
|
1002
|
+
* the file itself, or a copy in `directory` that turns internal testing on
|
|
1003
|
+
* when an App Store Connect export leaves it out.
|
|
1313
1004
|
*/
|
|
1314
|
-
const
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
if (key && keys.includes(key) && !SDK_CONDITIONS.includes(conditions)) return `${file}:${index + 1} sets ${key}${conditions}`;
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1005
|
+
const resolveSandboxExportOptions = async (schemeName, exportOptionsPlist, directory) => {
|
|
1006
|
+
await assertSandboxExportOptions(schemeName, exportOptionsPlist);
|
|
1007
|
+
if (!APP_STORE_EXPORT_METHODS.has(await readExportMethod(exportOptionsPlist)) || await readInternalTestingOnly(exportOptionsPlist) === true) return exportOptionsPlist;
|
|
1008
|
+
const copy = path.default.join(directory, "ExportOptions.plist");
|
|
1009
|
+
await fs.default.promises.copyFile(exportOptionsPlist, copy);
|
|
1010
|
+
await (0, execa.execa)("plutil", [
|
|
1011
|
+
"-insert",
|
|
1012
|
+
INTERNAL_TESTING_ONLY,
|
|
1013
|
+
"-bool",
|
|
1014
|
+
"true",
|
|
1015
|
+
copy
|
|
1016
|
+
]);
|
|
1017
|
+
return copy;
|
|
1330
1018
|
};
|
|
1331
|
-
/**
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
const buildXcconfig = path.default.join(directory, "HotUpdater.xcconfig");
|
|
1347
|
-
await fs.default.promises.writeFile(buildXcconfig, [
|
|
1348
|
-
...includedFiles.map((file) => `#include "${file}"`),
|
|
1349
|
-
...Object.entries(settings).flatMap(([key, value]) => SDK_CONDITIONS.map((condition) => `${key}${condition} = ${value}`)),
|
|
1350
|
-
""
|
|
1351
|
-
].join("\n"));
|
|
1352
|
-
return {
|
|
1353
|
-
xcconfig: buildXcconfig,
|
|
1354
|
-
extraParams: otherParams
|
|
1355
|
-
};
|
|
1019
|
+
/** The export methods whose `.ipa` App Store Connect takes. */
|
|
1020
|
+
const APP_STORE_EXPORT_METHODS = /* @__PURE__ */ new Set(["app-store", "app-store-connect"]);
|
|
1021
|
+
/** The `method` of export options, or "" when it has none or can't be read. */
|
|
1022
|
+
const readExportMethod = async (exportOptionsPlist) => {
|
|
1023
|
+
const xml = (await fs.default.promises.readFile(exportOptionsPlist, "utf-8").catch(() => void 0))?.replace(/<!--[\s\S]*?-->/gu, "").match(/<key>\s*method\s*<\/key>\s*<string>\s*([^<]*?)\s*<\/string>/u);
|
|
1024
|
+
if (xml) return xml[1];
|
|
1025
|
+
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
1026
|
+
"-extract",
|
|
1027
|
+
"method",
|
|
1028
|
+
"raw",
|
|
1029
|
+
"-o",
|
|
1030
|
+
"-",
|
|
1031
|
+
exportOptionsPlist
|
|
1032
|
+
], { reject: false });
|
|
1033
|
+
return exitCode === 0 ? stdout.trim() : "";
|
|
1356
1034
|
};
|
|
1357
1035
|
/**
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1036
|
+
* Whether a device build exports for the devices its profile lists: with
|
|
1037
|
+
* `release-testing`, or `ad-hoc`, Xcode's older name for it. On an empty
|
|
1038
|
+
* keychain with the API key, Apple's cloud-managed distribution certificate
|
|
1039
|
+
* signs it, as it does an App Store Connect export.
|
|
1360
1040
|
*/
|
|
1361
|
-
const
|
|
1362
|
-
CODE_SIGN_STYLE: "Automatic",
|
|
1363
|
-
PROVISIONING_PROFILE: "",
|
|
1364
|
-
PROVISIONING_PROFILE_SPECIFIER: "",
|
|
1365
|
-
CODE_SIGN_IDENTITY: "Apple Development"
|
|
1366
|
-
};
|
|
1041
|
+
const exportsForRegisteredDevices = async ({ exportOptionsPlist }) => exportOptionsPlist !== void 0 && ["release-testing", "ad-hoc"].includes(await readExportMethod(exportOptionsPlist));
|
|
1367
1042
|
/**
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1043
|
+
* Whether a device build exports for App Store Connect: with its
|
|
1044
|
+
* `exportOptionsPlist`'s method, or, without one, as a scheme signing with
|
|
1045
|
+
* an App Store Connect API key does. Such an `.ipa` is signed with Apple's
|
|
1046
|
+
* cloud-managed distribution certificate, never with a development
|
|
1047
|
+
* certificate the archive was signed with.
|
|
1371
1048
|
*/
|
|
1372
|
-
const
|
|
1373
|
-
HOT_UPDATER_MIN_BUNDLE_ID: minBundleId,
|
|
1374
|
-
...authenticationArgs ? AUTOMATIC_SIGNING : {}
|
|
1375
|
-
});
|
|
1049
|
+
const exportsForAppStoreConnect = async (schemeConfig) => !schemeConfig.exportOptionsPlist || APP_STORE_EXPORT_METHODS.has(await readExportMethod(schemeConfig.exportOptionsPlist));
|
|
1376
1050
|
//#endregion
|
|
1377
|
-
//#region src/
|
|
1051
|
+
//#region src/uploadToTestFlight.ts
|
|
1052
|
+
const API_URL$2 = "https://api.appstoreconnect.apple.com";
|
|
1053
|
+
/** Apple accepts tokens that expire within 20 minutes. */
|
|
1054
|
+
const TOKEN_LIFETIME_SECONDS = 1200;
|
|
1055
|
+
/** A token is replaced well before it expires, so no request carries a stale one. */
|
|
1056
|
+
const TOKEN_REUSE_SECONDS = 600;
|
|
1057
|
+
/** The parts of an `.ipa` sent at once. */
|
|
1058
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
1059
|
+
/** Attempts to create the build upload and to commit the uploaded `.ipa`. */
|
|
1060
|
+
const COMMIT_ATTEMPTS = 3;
|
|
1061
|
+
/** Attempts per part: a part is a plain PUT, safe to repeat. */
|
|
1062
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
1063
|
+
const IN_PROGRESS_STATES = /* @__PURE__ */ new Set(["AWAITING_UPLOAD", "PROCESSING"]);
|
|
1064
|
+
/** The error App Store Connect answers with when a build number is taken. */
|
|
1065
|
+
const DUPLICATE_ERROR_CODE = "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE";
|
|
1378
1066
|
/**
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
1067
|
+
* App Store Connect refused the upload: it already has a build with the
|
|
1068
|
+
* `.ipa`'s version and build number, from this or another upload, and takes
|
|
1069
|
+
* each build number once per version.
|
|
1382
1070
|
*/
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
directory: tmpDir,
|
|
1392
|
-
sourceDir,
|
|
1393
|
-
xcconfig,
|
|
1394
|
-
extraParams,
|
|
1395
|
-
settings: buildSettingsOverScheme({
|
|
1396
|
-
minBundleId,
|
|
1397
|
-
authenticationArgs
|
|
1398
|
-
})
|
|
1399
|
-
});
|
|
1400
|
-
const archiveArgs = prepareArchiveArgs({
|
|
1401
|
-
authenticationArgs,
|
|
1402
|
-
minBundleId,
|
|
1403
|
-
archivePath,
|
|
1404
|
-
platform,
|
|
1405
|
-
sourceDir,
|
|
1406
|
-
xcodeProject,
|
|
1407
|
-
configuration,
|
|
1408
|
-
extraParams: build.extraParams,
|
|
1409
|
-
destination,
|
|
1410
|
-
xcconfig: build.xcconfig,
|
|
1411
|
-
xcodeScheme
|
|
1412
|
-
});
|
|
1413
|
-
try {
|
|
1414
|
-
const { logFilePath } = await runXcodebuildWithLogging({
|
|
1415
|
-
args: archiveArgs,
|
|
1416
|
-
env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
|
|
1417
|
-
title: `Archiving ${xcodeScheme}`,
|
|
1418
|
-
successMessage: `Archived ${xcodeScheme}`,
|
|
1419
|
-
failureMessage: "Archive failed",
|
|
1420
|
-
logPrefix,
|
|
1421
|
-
header: [
|
|
1422
|
-
...header,
|
|
1423
|
-
`Project ${xcodeProject.name}`,
|
|
1424
|
-
`Scheme ${xcodeScheme}`,
|
|
1425
|
-
`Platform ${platform}`
|
|
1426
|
-
],
|
|
1427
|
-
sourceDir
|
|
1428
|
-
});
|
|
1429
|
-
return {
|
|
1430
|
-
archivePath,
|
|
1431
|
-
logFilePath
|
|
1432
|
-
};
|
|
1433
|
-
} catch (error) {
|
|
1434
|
-
throw prettifyXcodebuildError(error);
|
|
1435
|
-
}
|
|
1436
|
-
} finally {
|
|
1437
|
-
await removeTmpDir(tmpDir);
|
|
1071
|
+
var TestFlightBuildExistsError = class extends Error {
|
|
1072
|
+
shortVersion;
|
|
1073
|
+
bundleVersion;
|
|
1074
|
+
name = "TestFlightBuildExistsError";
|
|
1075
|
+
constructor(shortVersion, bundleVersion, options) {
|
|
1076
|
+
super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}.\nUse a new build number (CFBundleVersion).`, options);
|
|
1077
|
+
this.shortVersion = shortVersion;
|
|
1078
|
+
this.bundleVersion = bundleVersion;
|
|
1438
1079
|
}
|
|
1439
1080
|
};
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
"
|
|
1449
|
-
"
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
if (xcconfig) args.push("-xcconfig", xcconfig);
|
|
1455
|
-
if (extraParams) args.push(...extraParams);
|
|
1456
|
-
const resolvedDestinations = resolveDestinations({
|
|
1457
|
-
destinations: destination,
|
|
1458
|
-
useGeneric: true
|
|
1459
|
-
});
|
|
1460
|
-
if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
|
|
1461
|
-
deviceType: "device",
|
|
1462
|
-
platform,
|
|
1463
|
-
useGeneric: true
|
|
1464
|
-
}));
|
|
1465
|
-
for (const destination of resolvedDestinations) args.push("-destination", destination);
|
|
1466
|
-
return args;
|
|
1081
|
+
/** An App Store Connect API request that failed, with the errors it answered. */
|
|
1082
|
+
var AppStoreConnectError = class extends Error {
|
|
1083
|
+
status;
|
|
1084
|
+
errors;
|
|
1085
|
+
/** The answer's `Retry-After` header, for a rate limit. */
|
|
1086
|
+
retryAfter;
|
|
1087
|
+
constructor(action, status, errors, retryAfter = null) {
|
|
1088
|
+
const details = errors.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean);
|
|
1089
|
+
super(`${action} failed (HTTP ${status})${details.length > 0 ? `: ${details.join(" ")}` : "."}`);
|
|
1090
|
+
this.name = "AppStoreConnectError";
|
|
1091
|
+
this.status = status;
|
|
1092
|
+
this.errors = errors;
|
|
1093
|
+
this.retryAfter = retryAfter;
|
|
1094
|
+
}
|
|
1467
1095
|
};
|
|
1468
|
-
//#endregion
|
|
1469
|
-
//#region src/builder/buildXcodeProject.ts
|
|
1470
1096
|
/**
|
|
1471
|
-
*
|
|
1472
|
-
*
|
|
1473
|
-
* more, whether the build succeeds or not.
|
|
1097
|
+
* An App Store Connect API token: an ES256 JWT, whose signature JWS encodes
|
|
1098
|
+
* as the raw 64-byte r || s, not the DER Node signs by default.
|
|
1474
1099
|
*/
|
|
1475
|
-
const
|
|
1476
|
-
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
directory: tmpDir,
|
|
1492
|
-
sourceDir,
|
|
1493
|
-
extraParams,
|
|
1494
|
-
settings: buildSettingsOverScheme({
|
|
1495
|
-
minBundleId,
|
|
1496
|
-
authenticationArgs
|
|
1497
|
-
})
|
|
1498
|
-
});
|
|
1499
|
-
const buildArgs = prepareBuildArgs({
|
|
1500
|
-
authenticationArgs,
|
|
1501
|
-
xcconfig: build.xcconfig,
|
|
1502
|
-
minBundleId,
|
|
1503
|
-
configuration,
|
|
1504
|
-
derivedDataPath,
|
|
1505
|
-
deviceType,
|
|
1506
|
-
resolvedDestinations,
|
|
1507
|
-
extraParams: build.extraParams,
|
|
1508
|
-
platform,
|
|
1509
|
-
xcodeScheme,
|
|
1510
|
-
sourceDir,
|
|
1511
|
-
xcodeProject
|
|
1512
|
-
});
|
|
1513
|
-
let builtAppPath;
|
|
1514
|
-
let logFilePath;
|
|
1515
|
-
try {
|
|
1516
|
-
({logFilePath} = await runXcodebuildWithLogging({
|
|
1517
|
-
args: buildArgs,
|
|
1518
|
-
env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
|
|
1519
|
-
sourceDir,
|
|
1520
|
-
logPrefix,
|
|
1521
|
-
header: [
|
|
1522
|
-
...header,
|
|
1523
|
-
`Project ${xcodeProject.name}`,
|
|
1524
|
-
`Scheme ${xcodeScheme}`,
|
|
1525
|
-
`Configuration ${configuration}`,
|
|
1526
|
-
`Platform ${platform}`,
|
|
1527
|
-
`Device type ${deviceType}`
|
|
1528
|
-
],
|
|
1529
|
-
title: `Building ${xcodeScheme}`,
|
|
1530
|
-
successMessage: `Built ${xcodeScheme}`,
|
|
1531
|
-
failureMessage: "Build failed"
|
|
1532
|
-
}));
|
|
1533
|
-
({appPath: builtAppPath} = await getBuildSettings({
|
|
1534
|
-
configuration,
|
|
1535
|
-
derivedDataPath,
|
|
1536
|
-
resolvedDestinations,
|
|
1537
|
-
xcodeScheme,
|
|
1538
|
-
sourceDir,
|
|
1539
|
-
xcodeProject
|
|
1540
|
-
}));
|
|
1541
|
-
} catch (error) {
|
|
1542
|
-
throw prettifyXcodebuildError(error);
|
|
1543
|
-
}
|
|
1544
|
-
const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
|
|
1545
|
-
await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
|
|
1546
|
-
return {
|
|
1547
|
-
appPath,
|
|
1548
|
-
logFilePath
|
|
1549
|
-
};
|
|
1550
|
-
} finally {
|
|
1551
|
-
await removeTmpDir(tmpDir);
|
|
1552
|
-
}
|
|
1553
|
-
};
|
|
1554
|
-
const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
|
|
1555
|
-
const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
|
|
1556
|
-
const args = [
|
|
1557
|
-
xcodeProject.isWorkspace ? "-workspace" : "-project",
|
|
1558
|
-
path.default.join(sourceDir, xcodeProject.name),
|
|
1559
|
-
"-scheme",
|
|
1560
|
-
xcodeScheme,
|
|
1561
|
-
"-configuration",
|
|
1562
|
-
configuration,
|
|
1563
|
-
"-sdk",
|
|
1564
|
-
sdk,
|
|
1565
|
-
"-derivedDataPath",
|
|
1566
|
-
derivedDataPath,
|
|
1567
|
-
`HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`,
|
|
1568
|
-
"build"
|
|
1569
|
-
];
|
|
1570
|
-
if (authenticationArgs) args.push(...authenticationArgs);
|
|
1571
|
-
args.push("-xcconfig", xcconfig);
|
|
1572
|
-
if (extraParams) args.push(...extraParams);
|
|
1573
|
-
for (const dest of resolvedDestinations) args.push("-destination", dest);
|
|
1574
|
-
return args;
|
|
1575
|
-
};
|
|
1576
|
-
const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configuration, resolvedDestinations, derivedDataPath }) => {
|
|
1577
|
-
const destinationArgs = resolvedDestinations.flatMap((dest) => ["-destination", dest]);
|
|
1578
|
-
const { stdout: buildSettings } = await (0, execa.execa)("xcodebuild", [
|
|
1579
|
-
xcodeProject.isWorkspace ? "-workspace" : "-project",
|
|
1580
|
-
xcodeProject.name,
|
|
1581
|
-
"-scheme",
|
|
1582
|
-
xcodeScheme,
|
|
1583
|
-
"-configuration",
|
|
1584
|
-
configuration,
|
|
1585
|
-
...destinationArgs,
|
|
1586
|
-
"-derivedDataPath",
|
|
1587
|
-
derivedDataPath,
|
|
1588
|
-
"-showBuildSettings",
|
|
1589
|
-
"-json"
|
|
1590
|
-
], { cwd: sourceDir });
|
|
1591
|
-
const settings = JSON.parse(buildSettings).filter(({ target }) => target !== "React" && target !== "React-Core");
|
|
1592
|
-
if (settings.length === 0) throw new Error("Failed to get build settings for your project");
|
|
1593
|
-
const targetSettings = settings[0].buildSettings;
|
|
1594
|
-
const wrapperExtension = targetSettings.WRAPPER_EXTENSION;
|
|
1595
|
-
if (wrapperExtension !== "app" && wrapperExtension !== "framework") throw new Error(`Expected wrapper extension to be "app" or "framework" but found: ${wrapperExtension}`);
|
|
1596
|
-
const targetBuildDir = targetSettings.TARGET_BUILD_DIR;
|
|
1597
|
-
const executableFolderPath = targetSettings.EXECUTABLE_FOLDER_PATH;
|
|
1598
|
-
const fullProductName = targetSettings.FULL_PRODUCT_NAME;
|
|
1599
|
-
const infoPlistPath = targetSettings.INFOPLIST_PATH;
|
|
1600
|
-
if (!targetBuildDir || !executableFolderPath || !fullProductName) throw new Error("Failed to get build paths from build settings");
|
|
1601
|
-
return {
|
|
1602
|
-
appPath: path.default.join(targetBuildDir, executableFolderPath),
|
|
1603
|
-
infoPlistPath: path.default.join(targetBuildDir, infoPlistPath)
|
|
1604
|
-
};
|
|
1605
|
-
};
|
|
1606
|
-
//#endregion
|
|
1607
|
-
//#region src/builder/exportXcodeArchive.ts
|
|
1608
|
-
/** Exports the archive into `exportPath`. */
|
|
1609
|
-
const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, logFilePath, title, authenticationArgs }) => {
|
|
1610
|
-
const exportArgs = prepareExportArgs({
|
|
1611
|
-
authenticationArgs,
|
|
1612
|
-
archivePath,
|
|
1613
|
-
exportPath,
|
|
1614
|
-
exportOptionsPlist,
|
|
1615
|
-
exportExtraParams
|
|
1616
|
-
});
|
|
1617
|
-
try {
|
|
1618
|
-
await runXcodebuildWithLogging({
|
|
1619
|
-
args: exportArgs,
|
|
1620
|
-
sourceDir,
|
|
1621
|
-
title: `Exporting ${title}`,
|
|
1622
|
-
successMessage: `Exported ${title}`,
|
|
1623
|
-
failureMessage: "Export failed",
|
|
1624
|
-
logPrefix,
|
|
1625
|
-
logFilePath
|
|
1626
|
-
});
|
|
1627
|
-
return { exportPath };
|
|
1628
|
-
} catch (error) {
|
|
1629
|
-
throw prettifyXcodebuildError(error);
|
|
1630
|
-
}
|
|
1631
|
-
};
|
|
1632
|
-
const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist, authenticationArgs }) => {
|
|
1633
|
-
const args = [
|
|
1634
|
-
"-exportArchive",
|
|
1635
|
-
"-archivePath",
|
|
1636
|
-
archivePath,
|
|
1637
|
-
"-exportPath",
|
|
1638
|
-
exportPath
|
|
1639
|
-
];
|
|
1640
|
-
if (authenticationArgs) args.push(...authenticationArgs);
|
|
1641
|
-
if (exportExtraParams) args.push(...exportExtraParams);
|
|
1642
|
-
args.push("-exportOptionsPlist", exportOptionsPlist);
|
|
1643
|
-
return args;
|
|
1100
|
+
const createAppStoreConnectToken = ({ privateKey, keyId, issuerId, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
|
|
1101
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
1102
|
+
const signingInput = `${encode({
|
|
1103
|
+
alg: "ES256",
|
|
1104
|
+
kid: keyId,
|
|
1105
|
+
typ: "JWT"
|
|
1106
|
+
})}.${encode({
|
|
1107
|
+
iss: issuerId,
|
|
1108
|
+
iat: issuedAtSeconds,
|
|
1109
|
+
exp: issuedAtSeconds + TOKEN_LIFETIME_SECONDS,
|
|
1110
|
+
aud: "appstoreconnect-v1"
|
|
1111
|
+
})}`;
|
|
1112
|
+
return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), {
|
|
1113
|
+
key: privateKey,
|
|
1114
|
+
dsaEncoding: "ieee-p1363"
|
|
1115
|
+
}).toString("base64url")}`;
|
|
1644
1116
|
};
|
|
1645
|
-
|
|
1646
|
-
//#region src/utils/assertXcodebuildExist.ts
|
|
1647
|
-
const assertXcodebuildExist = async () => {
|
|
1117
|
+
const readPrivateKey = async (keyPath) => {
|
|
1648
1118
|
try {
|
|
1649
|
-
|
|
1119
|
+
return crypto.default.createPrivateKey(await fs.default.promises.readFile(keyPath, "utf-8"));
|
|
1650
1120
|
} catch {
|
|
1651
|
-
|
|
1652
|
-
process.exit(1);
|
|
1121
|
+
throw new Error("The App Store Connect API key is not a .p8 private key.");
|
|
1653
1122
|
}
|
|
1654
1123
|
};
|
|
1655
|
-
|
|
1656
|
-
|
|
1124
|
+
const readApiErrors = async (response) => {
|
|
1125
|
+
const body = await response.json().catch(() => void 0);
|
|
1126
|
+
return Array.isArray(body?.errors) ? body.errors : [];
|
|
1127
|
+
};
|
|
1128
|
+
const describeDetails = (details) => details.map((detail) => `${detail.description ?? "(no description)"}${detail.code ? ` (${detail.code})` : ""}`);
|
|
1129
|
+
/** Answers a request may outlive: a timeout, a rate limit, a server error. */
|
|
1130
|
+
const isRetryableStatus = (status) => status === 408 || status === 429 || status >= 500;
|
|
1657
1131
|
/**
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1132
|
+
* Whether a request may succeed when sent again: a retryable answer, or a
|
|
1133
|
+
* network failure, which fetch rejects with a TypeError. A refused key or
|
|
1134
|
+
* request is not.
|
|
1661
1135
|
*/
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
const INFO_PLIST_KEYS = {
|
|
1666
|
-
bundleId: "CFBundleIdentifier",
|
|
1667
|
-
shortVersion: "CFBundleShortVersionString",
|
|
1668
|
-
bundleVersion: "CFBundleVersion"
|
|
1669
|
-
};
|
|
1136
|
+
const isRetryable = (error) => error instanceof AppStoreConnectError ? isRetryableStatus(error.status) : error instanceof TypeError;
|
|
1137
|
+
/** Whether App Store Connect refused a request for its rate limit only. */
|
|
1138
|
+
const isRateLimited = (error) => error instanceof AppStoreConnectError && error.status === 429;
|
|
1670
1139
|
/**
|
|
1671
|
-
*
|
|
1672
|
-
*
|
|
1140
|
+
* Runs `request`, again after a failure `retryable` accepts, up to the
|
|
1141
|
+
* attempts.
|
|
1673
1142
|
*/
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
const infoPlist = path.default.join(directory, "Info.plist");
|
|
1681
|
-
await (0, execa.execa)("unzip", [
|
|
1682
|
-
"-p",
|
|
1683
|
-
ipaPath,
|
|
1684
|
-
escapeUnzipPattern(entry)
|
|
1685
|
-
], { stdout: { file: infoPlist } });
|
|
1686
|
-
const info = {};
|
|
1687
|
-
for (const [field, key] of Object.entries(INFO_PLIST_KEYS)) {
|
|
1688
|
-
const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
|
|
1689
|
-
"-extract",
|
|
1690
|
-
key,
|
|
1691
|
-
"raw",
|
|
1692
|
-
"-o",
|
|
1693
|
-
"-",
|
|
1694
|
-
infoPlist
|
|
1695
|
-
], { reject: false });
|
|
1696
|
-
if (exitCode !== 0 || !value.trim()) throw new Error(`${path.default.basename(ipaPath)}'s Info.plist has no ${key}.`);
|
|
1697
|
-
info[field] = value.trim();
|
|
1698
|
-
}
|
|
1699
|
-
return info;
|
|
1700
|
-
} finally {
|
|
1701
|
-
await fs.default.promises.rm(directory, {
|
|
1702
|
-
recursive: true,
|
|
1703
|
-
force: true
|
|
1704
|
-
});
|
|
1143
|
+
const retrying = async (request, retryable = isRetryable) => {
|
|
1144
|
+
for (let attempt = 1;; attempt++) try {
|
|
1145
|
+
return await request();
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
if (!retryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
|
|
1148
|
+
await sleep(retryDelayMs(error, attempt));
|
|
1705
1149
|
}
|
|
1706
1150
|
};
|
|
1707
|
-
|
|
1708
|
-
|
|
1151
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1152
|
+
/** The longest wait before a retry, so a run stays bounded. */
|
|
1153
|
+
const MAX_RETRY_DELAY_MS = 6e4;
|
|
1709
1154
|
/**
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
* The key is resolved only here, for a device build of a scheme with
|
|
1713
|
-
* `signing`. Without `credentials`, it resolves from the environment or the
|
|
1714
|
-
* config's `secrets` adapter, and the temporary files are deleted when the
|
|
1715
|
-
* process exits.
|
|
1716
|
-
*/
|
|
1717
|
-
const resolveXcodeSigning = async ({ schemeConfig, credentials }) => {
|
|
1718
|
-
const reference = schemeConfig.signing?.ascApiKey;
|
|
1719
|
-
if (!reference) return;
|
|
1720
|
-
const apiKey = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
|
|
1721
|
-
kind: "asc-api-key",
|
|
1722
|
-
name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.ascApiKey")
|
|
1723
|
-
});
|
|
1724
|
-
return {
|
|
1725
|
-
authenticationArgs: [
|
|
1726
|
-
"-allowProvisioningUpdates",
|
|
1727
|
-
"-authenticationKeyPath",
|
|
1728
|
-
apiKey.keyPath,
|
|
1729
|
-
"-authenticationKeyID",
|
|
1730
|
-
apiKey.keyId,
|
|
1731
|
-
"-authenticationKeyIssuerID",
|
|
1732
|
-
apiKey.issuerId
|
|
1733
|
-
],
|
|
1734
|
-
apiKey
|
|
1735
|
-
};
|
|
1736
|
-
};
|
|
1737
|
-
const APP_STORE_CONNECT_EXPORT_OPTIONS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1738
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1739
|
-
<plist version="1.0">
|
|
1740
|
-
<dict>
|
|
1741
|
-
<key>method</key>
|
|
1742
|
-
<string>app-store-connect</string>
|
|
1743
|
-
<key>signingStyle</key>
|
|
1744
|
-
<string>automatic</string>
|
|
1745
|
-
</dict>
|
|
1746
|
-
</plist>
|
|
1747
|
-
`;
|
|
1748
|
-
/**
|
|
1749
|
-
* Writes the export options of a scheme that signs with an App Store Connect
|
|
1750
|
-
* API key and sets no `exportOptionsPlist`: an App Store Connect export with
|
|
1751
|
-
* automatic signing.
|
|
1752
|
-
*/
|
|
1753
|
-
const writeAppStoreConnectExportOptions = async (directory) => {
|
|
1754
|
-
const exportOptionsPlist = path.default.join(directory, "ExportOptions.plist");
|
|
1755
|
-
await fs.default.promises.writeFile(exportOptionsPlist, APP_STORE_CONNECT_EXPORT_OPTIONS);
|
|
1756
|
-
return exportOptionsPlist;
|
|
1757
|
-
};
|
|
1758
|
-
/** The export methods whose `.ipa` App Store Connect takes. */
|
|
1759
|
-
const APP_STORE_EXPORT_METHODS = /* @__PURE__ */ new Set(["app-store", "app-store-connect"]);
|
|
1760
|
-
/** The `method` of export options, or "" when it has none or can't be read. */
|
|
1761
|
-
const readExportMethod = async (exportOptionsPlist) => {
|
|
1762
|
-
const xml = (await fs.default.promises.readFile(exportOptionsPlist, "utf-8").catch(() => void 0))?.replace(/<!--[\s\S]*?-->/gu, "").match(/<key>\s*method\s*<\/key>\s*<string>\s*([^<]*?)\s*<\/string>/u);
|
|
1763
|
-
if (xml) return xml[1];
|
|
1764
|
-
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
1765
|
-
"-extract",
|
|
1766
|
-
"method",
|
|
1767
|
-
"raw",
|
|
1768
|
-
"-o",
|
|
1769
|
-
"-",
|
|
1770
|
-
exportOptionsPlist
|
|
1771
|
-
], { reject: false });
|
|
1772
|
-
return exitCode === 0 ? stdout.trim() : "";
|
|
1773
|
-
};
|
|
1774
|
-
/**
|
|
1775
|
-
* Whether a device build exports for App Store Connect: with its
|
|
1776
|
-
* `exportOptionsPlist`'s method, or, without one, as a scheme signing with
|
|
1777
|
-
* an App Store Connect API key does. Such an `.ipa` is signed with Apple's
|
|
1778
|
-
* cloud-managed distribution certificate, never with a development
|
|
1779
|
-
* certificate the archive was signed with.
|
|
1780
|
-
*/
|
|
1781
|
-
const exportsForAppStoreConnect = async (schemeConfig) => !schemeConfig.exportOptionsPlist || APP_STORE_EXPORT_METHODS.has(await readExportMethod(schemeConfig.exportOptionsPlist));
|
|
1782
|
-
//#endregion
|
|
1783
|
-
//#region src/uploadToTestFlight.ts
|
|
1784
|
-
const API_URL$1 = "https://api.appstoreconnect.apple.com";
|
|
1785
|
-
/** Apple accepts tokens that expire within 20 minutes. */
|
|
1786
|
-
const TOKEN_LIFETIME_SECONDS = 1200;
|
|
1787
|
-
/** A token is replaced well before it expires, so no request carries a stale one. */
|
|
1788
|
-
const TOKEN_REUSE_SECONDS = 600;
|
|
1789
|
-
/** The parts of an `.ipa` sent at once. */
|
|
1790
|
-
const UPLOAD_CONCURRENCY = 4;
|
|
1791
|
-
/** Attempts to create the build upload and to commit the uploaded `.ipa`. */
|
|
1792
|
-
const COMMIT_ATTEMPTS = 3;
|
|
1793
|
-
/** Attempts per part: a part is a plain PUT, safe to repeat. */
|
|
1794
|
-
const UPLOAD_ATTEMPTS = 3;
|
|
1795
|
-
const IN_PROGRESS_STATES = /* @__PURE__ */ new Set(["AWAITING_UPLOAD", "PROCESSING"]);
|
|
1796
|
-
/** The error App Store Connect answers with when a build number is taken. */
|
|
1797
|
-
const DUPLICATE_ERROR_CODE = "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE";
|
|
1798
|
-
/**
|
|
1799
|
-
* App Store Connect refused the upload: it already has a build with the
|
|
1800
|
-
* `.ipa`'s version and build number, from this or another upload, and takes
|
|
1801
|
-
* each build number once per version.
|
|
1802
|
-
*/
|
|
1803
|
-
var TestFlightBuildExistsError = class extends Error {
|
|
1804
|
-
shortVersion;
|
|
1805
|
-
bundleVersion;
|
|
1806
|
-
name = "TestFlightBuildExistsError";
|
|
1807
|
-
constructor(shortVersion, bundleVersion, options) {
|
|
1808
|
-
super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}.\nUse a new build number (CFBundleVersion).`, options);
|
|
1809
|
-
this.shortVersion = shortVersion;
|
|
1810
|
-
this.bundleVersion = bundleVersion;
|
|
1811
|
-
}
|
|
1812
|
-
};
|
|
1813
|
-
/** An App Store Connect API request that failed, with the errors it answered. */
|
|
1814
|
-
var AppStoreConnectError = class extends Error {
|
|
1815
|
-
status;
|
|
1816
|
-
errors;
|
|
1817
|
-
/** The answer's `Retry-After` header, for a rate limit. */
|
|
1818
|
-
retryAfter;
|
|
1819
|
-
constructor(action, status, errors, retryAfter = null) {
|
|
1820
|
-
const details = errors.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean);
|
|
1821
|
-
super(`${action} failed (HTTP ${status})${details.length > 0 ? `: ${details.join(" ")}` : "."}`);
|
|
1822
|
-
this.name = "AppStoreConnectError";
|
|
1823
|
-
this.status = status;
|
|
1824
|
-
this.errors = errors;
|
|
1825
|
-
this.retryAfter = retryAfter;
|
|
1826
|
-
}
|
|
1827
|
-
};
|
|
1828
|
-
/**
|
|
1829
|
-
* An App Store Connect API token: an ES256 JWT, whose signature JWS encodes
|
|
1830
|
-
* as the raw 64-byte r || s, not the DER Node signs by default.
|
|
1831
|
-
*/
|
|
1832
|
-
const createAppStoreConnectToken = ({ privateKey, keyId, issuerId, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
|
|
1833
|
-
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
1834
|
-
const signingInput = `${encode({
|
|
1835
|
-
alg: "ES256",
|
|
1836
|
-
kid: keyId,
|
|
1837
|
-
typ: "JWT"
|
|
1838
|
-
})}.${encode({
|
|
1839
|
-
iss: issuerId,
|
|
1840
|
-
iat: issuedAtSeconds,
|
|
1841
|
-
exp: issuedAtSeconds + TOKEN_LIFETIME_SECONDS,
|
|
1842
|
-
aud: "appstoreconnect-v1"
|
|
1843
|
-
})}`;
|
|
1844
|
-
return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), {
|
|
1845
|
-
key: privateKey,
|
|
1846
|
-
dsaEncoding: "ieee-p1363"
|
|
1847
|
-
}).toString("base64url")}`;
|
|
1848
|
-
};
|
|
1849
|
-
const readPrivateKey = async (keyPath) => {
|
|
1850
|
-
try {
|
|
1851
|
-
return crypto.default.createPrivateKey(await fs.default.promises.readFile(keyPath, "utf-8"));
|
|
1852
|
-
} catch {
|
|
1853
|
-
throw new Error("The App Store Connect API key is not a .p8 private key.");
|
|
1854
|
-
}
|
|
1855
|
-
};
|
|
1856
|
-
const readApiErrors = async (response) => {
|
|
1857
|
-
const body = await response.json().catch(() => void 0);
|
|
1858
|
-
return Array.isArray(body?.errors) ? body.errors : [];
|
|
1859
|
-
};
|
|
1860
|
-
const describeDetails = (details) => details.map((detail) => `${detail.description ?? "(no description)"}${detail.code ? ` (${detail.code})` : ""}`);
|
|
1861
|
-
/** Answers a request may outlive: a timeout, a rate limit, a server error. */
|
|
1862
|
-
const isRetryableStatus = (status) => status === 408 || status === 429 || status >= 500;
|
|
1863
|
-
/**
|
|
1864
|
-
* Whether a request may succeed when sent again: a retryable answer, or a
|
|
1865
|
-
* network failure, which fetch rejects with a TypeError. A refused key or
|
|
1866
|
-
* request is not.
|
|
1867
|
-
*/
|
|
1868
|
-
const isRetryable = (error) => error instanceof AppStoreConnectError ? isRetryableStatus(error.status) : error instanceof TypeError;
|
|
1869
|
-
/** Whether App Store Connect refused a request for its rate limit only. */
|
|
1870
|
-
const isRateLimited = (error) => error instanceof AppStoreConnectError && error.status === 429;
|
|
1871
|
-
/**
|
|
1872
|
-
* Runs `request`, again after a failure `retryable` accepts, up to the
|
|
1873
|
-
* attempts.
|
|
1874
|
-
*/
|
|
1875
|
-
const retrying = async (request, retryable = isRetryable) => {
|
|
1876
|
-
for (let attempt = 1;; attempt++) try {
|
|
1877
|
-
return await request();
|
|
1878
|
-
} catch (error) {
|
|
1879
|
-
if (!retryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
|
|
1880
|
-
await sleep(retryDelayMs(error, attempt));
|
|
1881
|
-
}
|
|
1882
|
-
};
|
|
1883
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1884
|
-
/** The longest wait before a retry, so a run stays bounded. */
|
|
1885
|
-
const MAX_RETRY_DELAY_MS = 6e4;
|
|
1886
|
-
/**
|
|
1887
|
-
* The first wait after a rate limit without `Retry-After`: App Store Connect
|
|
1888
|
-
* limits a key per rolling hour, so a retry within seconds meets it again.
|
|
1155
|
+
* The first wait after a rate limit without `Retry-After`: App Store Connect
|
|
1156
|
+
* limits a key per rolling hour, so a retry within seconds meets it again.
|
|
1889
1157
|
*/
|
|
1890
1158
|
const RATE_LIMIT_DELAY_MS = 15e3;
|
|
1891
1159
|
/**
|
|
@@ -2040,7 +1308,7 @@ const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
|
|
|
2040
1308
|
return `Bearer ${token.value}`;
|
|
2041
1309
|
};
|
|
2042
1310
|
return async (action, method, pathname, body) => {
|
|
2043
|
-
const response = await fetch(`${API_URL$
|
|
1311
|
+
const response = await fetch(`${API_URL$2}${pathname}`, {
|
|
2044
1312
|
method,
|
|
2045
1313
|
headers: {
|
|
2046
1314
|
Authorization: authorization(),
|
|
@@ -2227,72 +1495,1041 @@ const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, p
|
|
|
2227
1495
|
if (!isRetryable(error)) throw error;
|
|
2228
1496
|
lastError = readError = error;
|
|
2229
1497
|
});
|
|
2230
|
-
failedReads = read ? 0 : failedReads + 1;
|
|
2231
|
-
const state = read ? read.data.attributes?.state ?? {} : {};
|
|
2232
|
-
if (state.state === "PROCESSING") reportedProcessing = true;
|
|
2233
|
-
if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
|
|
2234
|
-
if (Date.now() >= deadline) {
|
|
2235
|
-
if (!reportedProcessing) throw new Error([
|
|
2236
|
-
`App Store Connect didn't report build ${info.bundleVersion} processing.`,
|
|
2237
|
-
lastError instanceof Error ? lastError.message : state.state ? `State: ${state.state}` : void 0,
|
|
2238
|
-
"Check the build in TestFlight:",
|
|
2239
|
-
testFlightUrl
|
|
2240
|
-
].filter(Boolean).join("\n"), { cause: lastError });
|
|
2241
|
-
_hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}.\nTestFlight lists it once processing finishes:\n${testFlightUrl}`);
|
|
2242
|
-
return {
|
|
2243
|
-
appId: app.id,
|
|
2244
|
-
buildUploadId,
|
|
2245
|
-
state: "PROCESSING",
|
|
2246
|
-
testFlightUrl,
|
|
2247
|
-
shortVersion: info.shortVersion,
|
|
2248
|
-
bundleVersion: info.bundleVersion
|
|
2249
|
-
};
|
|
2250
|
-
}
|
|
2251
|
-
await sleep(readError === void 0 ? pollIntervalMs : Math.max(pollIntervalMs, retryDelayMs(readError, failedReads)));
|
|
2252
|
-
continue;
|
|
2253
|
-
}
|
|
2254
|
-
for (const warning of describeDetails(state.warnings ?? [])) _hot_updater_cli_tools.p.log.warn(warning);
|
|
2255
|
-
if (state.state !== "COMPLETE") {
|
|
2256
|
-
const errors = describeDetails(state.errors ?? []);
|
|
2257
|
-
throw new Error([`App Store Connect rejected build ${info.bundleVersion} of version ${info.shortVersion} (${state.state}).`, ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
2258
|
-
}
|
|
2259
|
-
return {
|
|
2260
|
-
appId: app.id,
|
|
2261
|
-
buildUploadId,
|
|
2262
|
-
state: "COMPLETE",
|
|
2263
|
-
testFlightUrl,
|
|
2264
|
-
shortVersion: info.shortVersion,
|
|
2265
|
-
bundleVersion: info.bundleVersion
|
|
2266
|
-
};
|
|
1498
|
+
failedReads = read ? 0 : failedReads + 1;
|
|
1499
|
+
const state = read ? read.data.attributes?.state ?? {} : {};
|
|
1500
|
+
if (state.state === "PROCESSING") reportedProcessing = true;
|
|
1501
|
+
if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
|
|
1502
|
+
if (Date.now() >= deadline) {
|
|
1503
|
+
if (!reportedProcessing) throw new Error([
|
|
1504
|
+
`App Store Connect didn't report build ${info.bundleVersion} processing.`,
|
|
1505
|
+
lastError instanceof Error ? lastError.message : state.state ? `State: ${state.state}` : void 0,
|
|
1506
|
+
"Check the build in TestFlight:",
|
|
1507
|
+
testFlightUrl
|
|
1508
|
+
].filter(Boolean).join("\n"), { cause: lastError });
|
|
1509
|
+
_hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}.\nTestFlight lists it once processing finishes:\n${testFlightUrl}`);
|
|
1510
|
+
return {
|
|
1511
|
+
appId: app.id,
|
|
1512
|
+
buildUploadId,
|
|
1513
|
+
state: "PROCESSING",
|
|
1514
|
+
testFlightUrl,
|
|
1515
|
+
shortVersion: info.shortVersion,
|
|
1516
|
+
bundleVersion: info.bundleVersion
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
await sleep(readError === void 0 ? pollIntervalMs : Math.max(pollIntervalMs, retryDelayMs(readError, failedReads)));
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
for (const warning of describeDetails(state.warnings ?? [])) _hot_updater_cli_tools.p.log.warn(warning);
|
|
1523
|
+
if (state.state !== "COMPLETE") {
|
|
1524
|
+
const errors = describeDetails(state.errors ?? []);
|
|
1525
|
+
throw new Error([`App Store Connect rejected build ${info.bundleVersion} of version ${info.shortVersion} (${state.state}).`, ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1526
|
+
}
|
|
1527
|
+
return {
|
|
1528
|
+
appId: app.id,
|
|
1529
|
+
buildUploadId,
|
|
1530
|
+
state: "COMPLETE",
|
|
1531
|
+
testFlightUrl,
|
|
1532
|
+
shortVersion: info.shortVersion,
|
|
1533
|
+
bundleVersion: info.bundleVersion
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
/**
|
|
1538
|
+
* Sends each part App Store Connect asked for: the bytes at its offset, with
|
|
1539
|
+
* only the headers it lists, since the URLs are presigned: another header,
|
|
1540
|
+
* or the App Store Connect token, would break their signature.
|
|
1541
|
+
*/
|
|
1542
|
+
const uploadParts = async ({ ipaPath, operations, fetch }) => {
|
|
1543
|
+
const file = await fs.default.promises.open(ipaPath, "r");
|
|
1544
|
+
try {
|
|
1545
|
+
await runConcurrently(operations, UPLOAD_CONCURRENCY, async (operation, index) => {
|
|
1546
|
+
const body = Buffer.alloc(operation.length);
|
|
1547
|
+
const { bytesRead } = await file.read(body, 0, operation.length, operation.offset);
|
|
1548
|
+
if (bytesRead !== operation.length) throw new Error(`${path.default.basename(ipaPath)} changed while it was uploading.`);
|
|
1549
|
+
const headers = {};
|
|
1550
|
+
for (const { name, value } of operation.requestHeaders ?? []) headers[name] = value;
|
|
1551
|
+
for (let attempt = 1;; attempt++) {
|
|
1552
|
+
const response = await fetch(operation.url, {
|
|
1553
|
+
method: operation.method,
|
|
1554
|
+
headers,
|
|
1555
|
+
body
|
|
1556
|
+
}).catch((error) => error);
|
|
1557
|
+
if (!(response instanceof Error) && response.ok) return;
|
|
1558
|
+
if (!(response instanceof Error || isRetryableStatus(response.status)) || attempt === UPLOAD_ATTEMPTS) throw new Error(`Uploading part ${index + 1} of ${operations.length} failed: ${response instanceof Error ? response.message : `HTTP ${response.status}`}.`);
|
|
1559
|
+
await sleep(retryDelayMs(response, attempt));
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
} finally {
|
|
1563
|
+
await file.close();
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
//#endregion
|
|
1567
|
+
//#region src/asc/devices.ts
|
|
1568
|
+
const API_URL$1 = "https://api.appstoreconnect.apple.com";
|
|
1569
|
+
const DEVICE_FIELDS = "name,udid,status,deviceClass";
|
|
1570
|
+
const PAGE_SIZE = 200;
|
|
1571
|
+
/** More pages than an account's yearly limit of devices can fill. */
|
|
1572
|
+
const MAX_PAGES = 50;
|
|
1573
|
+
const deviceOf = ({ id, attributes }) => ({
|
|
1574
|
+
id,
|
|
1575
|
+
name: attributes?.name ?? "",
|
|
1576
|
+
udid: attributes?.udid ?? "",
|
|
1577
|
+
status: attributes?.status ?? "",
|
|
1578
|
+
deviceClass: attributes?.deviceClass ?? null
|
|
1579
|
+
});
|
|
1580
|
+
/**
|
|
1581
|
+
* A 403 from this API means something else than an upload's. A key's role
|
|
1582
|
+
* decides it, and only the person who owns the key can change that, so no key
|
|
1583
|
+
* is issued here.
|
|
1584
|
+
*/
|
|
1585
|
+
const describingAccess = async (run) => {
|
|
1586
|
+
try {
|
|
1587
|
+
return await run();
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
1590
|
+
if (cause instanceof AppStoreConnectError && cause.status === 403 && !isProgramLicenseAgreementError(cause.errors)) throw new Error("App Store Connect doesn't let this API key register devices.\nUse a key with the Admin role: npx hot-updater apple login", { cause });
|
|
1591
|
+
throw error;
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
const readPages = async (request, firstPath) => {
|
|
1595
|
+
const devices = [];
|
|
1596
|
+
let pathname = firstPath;
|
|
1597
|
+
for (let page = 0; pathname !== void 0; page += 1) {
|
|
1598
|
+
if (page === MAX_PAGES) throw new Error("App Store Connect's device list didn't end.");
|
|
1599
|
+
const path = pathname;
|
|
1600
|
+
const answer = await retrying(() => request("Reading App Store Connect's devices", "GET", path));
|
|
1601
|
+
devices.push(...answer.data.map(deviceOf));
|
|
1602
|
+
const next = answer.links?.next;
|
|
1603
|
+
if (next !== void 0 && !next.startsWith(`${API_URL$1}/`)) throw new Error("App Store Connect's next page is on another host.");
|
|
1604
|
+
pathname = next?.slice(37);
|
|
1605
|
+
}
|
|
1606
|
+
return devices;
|
|
1607
|
+
};
|
|
1608
|
+
const listPath = (filter = {}) => `/v1/devices?${new URLSearchParams({
|
|
1609
|
+
"filter[platform]": "IOS",
|
|
1610
|
+
...filter,
|
|
1611
|
+
"fields[devices]": DEVICE_FIELDS,
|
|
1612
|
+
limit: String(PAGE_SIZE)
|
|
1613
|
+
})}`;
|
|
1614
|
+
/** Every iPhone and iPad the Apple account has registered. */
|
|
1615
|
+
const listAscDevices = async ({ apiKey, fetch = globalThis.fetch }) => {
|
|
1616
|
+
const privateKey = await readPrivateKey(apiKey.keyPath);
|
|
1617
|
+
const request = createAppStoreConnectRequest({
|
|
1618
|
+
apiKey,
|
|
1619
|
+
privateKey,
|
|
1620
|
+
fetch
|
|
1621
|
+
});
|
|
1622
|
+
return describingAccess(() => readPages(request, listPath()));
|
|
1623
|
+
};
|
|
1624
|
+
const keyOf = (udid) => (0, _hot_updater_protocol_native_artifact.normalizeIosUdid)(udid) ?? udid.toLowerCase();
|
|
1625
|
+
const findDevices = async (request, udids) => new Map((await readPages(request, listPath({ "filter[udid]": udids.join(",") }))).map((device) => [keyOf(device.udid), device]));
|
|
1626
|
+
/**
|
|
1627
|
+
* Registers devices with the Apple account, each at most once: a device that
|
|
1628
|
+
* is already there succeeds and changes nothing, so running it again is safe,
|
|
1629
|
+
* and so is a race with another run, which App Store Connect answers with 409.
|
|
1630
|
+
* A disabled device stays disabled, since its slot is already spent and someone
|
|
1631
|
+
* chose to disable it. It only registers: Xcode adds a registered device to the
|
|
1632
|
+
* profile at the next export, so an `.ipa` signed before never gains it.
|
|
1633
|
+
*/
|
|
1634
|
+
const addAscDevices = async ({ devices, apiKey, fetch = globalThis.fetch }) => {
|
|
1635
|
+
const privateKey = await readPrivateKey(apiKey.keyPath);
|
|
1636
|
+
const request = createAppStoreConnectRequest({
|
|
1637
|
+
apiKey,
|
|
1638
|
+
privateKey,
|
|
1639
|
+
fetch
|
|
1640
|
+
});
|
|
1641
|
+
return describingAccess(async () => {
|
|
1642
|
+
const found = await findDevices(request, devices.map(({ udid }) => udid));
|
|
1643
|
+
const outcomes = [];
|
|
1644
|
+
for (const { udid, name } of devices) {
|
|
1645
|
+
const existing = found.get(keyOf(udid));
|
|
1646
|
+
if (existing !== void 0) {
|
|
1647
|
+
outcomes.push({
|
|
1648
|
+
udid,
|
|
1649
|
+
outcome: existing.status === "DISABLED" ? "disabled" : "already-registered",
|
|
1650
|
+
device: existing
|
|
1651
|
+
});
|
|
1652
|
+
continue;
|
|
1653
|
+
}
|
|
1654
|
+
try {
|
|
1655
|
+
const { data } = await retrying(() => request(`Registering ${udid}`, "POST", "/v1/devices", { data: {
|
|
1656
|
+
type: "devices",
|
|
1657
|
+
attributes: {
|
|
1658
|
+
name,
|
|
1659
|
+
platform: "IOS",
|
|
1660
|
+
udid
|
|
1661
|
+
}
|
|
1662
|
+
} }));
|
|
1663
|
+
outcomes.push({
|
|
1664
|
+
udid,
|
|
1665
|
+
outcome: "registered",
|
|
1666
|
+
device: deviceOf(data)
|
|
1667
|
+
});
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
if (!(error instanceof AppStoreConnectError) || error.status !== 409) throw error;
|
|
1670
|
+
const raced = (await findDevices(request, [udid])).get(keyOf(udid));
|
|
1671
|
+
if (raced !== void 0) {
|
|
1672
|
+
outcomes.push({
|
|
1673
|
+
udid,
|
|
1674
|
+
outcome: raced.status === "DISABLED" ? "disabled" : "already-registered",
|
|
1675
|
+
device: raced
|
|
1676
|
+
});
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
const reason = error.errors.map(({ detail, title }) => detail ?? title).filter(Boolean).join(" ");
|
|
1680
|
+
outcomes.push({
|
|
1681
|
+
udid,
|
|
1682
|
+
outcome: "refused",
|
|
1683
|
+
reason: reason || "App Store Connect refused it.",
|
|
1684
|
+
limitReached: /maximum|limit/iu.test(reason)
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
return outcomes;
|
|
1689
|
+
});
|
|
1690
|
+
};
|
|
1691
|
+
//#endregion
|
|
1692
|
+
//#region src/utils/formatCommand.ts
|
|
1693
|
+
/**
|
|
1694
|
+
* A command to print, broken at spaces with ` \` once a line would pass
|
|
1695
|
+
* `width`, each continuation indented two spaces, so a terminal never wraps
|
|
1696
|
+
* it mid-word and it still pastes as one command.
|
|
1697
|
+
*/
|
|
1698
|
+
const formatCommand = (words, width = 74) => {
|
|
1699
|
+
const lines = [];
|
|
1700
|
+
let line = "";
|
|
1701
|
+
for (const word of words) {
|
|
1702
|
+
const next = line ? `${line} ${word}` : word;
|
|
1703
|
+
if (line && next.length > width - 2) {
|
|
1704
|
+
lines.push(`${line} \\`);
|
|
1705
|
+
line = ` ${word}`;
|
|
1706
|
+
} else line = next;
|
|
1707
|
+
}
|
|
1708
|
+
lines.push(line);
|
|
1709
|
+
return lines.join("\n");
|
|
1710
|
+
};
|
|
1711
|
+
//#endregion
|
|
1712
|
+
//#region src/utils/cocoapods.ts
|
|
1713
|
+
/**
|
|
1714
|
+
* A failed command's error: the command, then the last lines of its
|
|
1715
|
+
* output, stdout and stderr as printed, without execa's full message.
|
|
1716
|
+
*/
|
|
1717
|
+
const commandFailure = (command, error) => {
|
|
1718
|
+
const { all, stderr, stdout } = error ?? {};
|
|
1719
|
+
const detail = String(all || [stdout, stderr].filter(Boolean).join("\n") || "").trimEnd().split("\n").filter((line) => line.trim()).slice(-12).join("\n") || (error instanceof Error ? error.message : String(error));
|
|
1720
|
+
return new Error(`${command} failed.\n${detail}`, { cause: error });
|
|
1721
|
+
};
|
|
1722
|
+
/**
|
|
1723
|
+
* A line of the progress meter curl prints on stderr for each pod it
|
|
1724
|
+
* downloads, such as React Native's prebuilt core.
|
|
1725
|
+
*/
|
|
1726
|
+
const DOWNLOAD_PROGRESS = [
|
|
1727
|
+
/^% Total +% Received/,
|
|
1728
|
+
/^Dload +Upload/,
|
|
1729
|
+
/^\d{1,3} +[\d.]+[kMGT]? +\d{1,3} +[\d.]+[kMGT]? /
|
|
1730
|
+
];
|
|
1731
|
+
/**
|
|
1732
|
+
* `pod install`'s output: its live line shows the pod it installs, its log
|
|
1733
|
+
* keeps the whole output, and a failure prints CocoaPods' `[!]` errors and a
|
|
1734
|
+
* crash's `SomeError - …` line with its last lines, without the download
|
|
1735
|
+
* progress CocoaPods' curl prints on stderr.
|
|
1736
|
+
*/
|
|
1737
|
+
const createPodInstallLogger = () => new _hot_updater_cli_tools.BuildLogger({
|
|
1738
|
+
logPrefix: "pod-install",
|
|
1739
|
+
createPhaseReader: () => (line) => {
|
|
1740
|
+
const installing = /^Installing (\S+)/.exec(line);
|
|
1741
|
+
if (installing) return `Installing ${installing[1]}`;
|
|
1742
|
+
return /^(Analyzing dependencies|Downloading dependencies|Generating Pods project|Integrating client project)$/.test(line) ? line : void 0;
|
|
1743
|
+
},
|
|
1744
|
+
failureSummary: {
|
|
1745
|
+
lines: [/^[A-Z]\w*(::\w+)*Error - /],
|
|
1746
|
+
blocks: [{
|
|
1747
|
+
start: /^\[!\] /,
|
|
1748
|
+
end: /^$/,
|
|
1749
|
+
includeEnd: false
|
|
1750
|
+
}],
|
|
1751
|
+
ignore: DOWNLOAD_PROGRESS,
|
|
1752
|
+
tail: 4,
|
|
1753
|
+
stopAfter: /^\[!\] Oh no, an error occurred\.$/,
|
|
1754
|
+
maxWidth: 76
|
|
1755
|
+
}
|
|
1756
|
+
});
|
|
1757
|
+
/**
|
|
1758
|
+
* Runs `pod install` as a step whose failure prints CocoaPods' errors and
|
|
1759
|
+
* the log's path, then throws a `BuildFailedError`.
|
|
1760
|
+
*/
|
|
1761
|
+
const runPodInstall = async ({ command, env, sourceDir }) => {
|
|
1762
|
+
const logger = createPodInstallLogger();
|
|
1763
|
+
const failureMessage = `${command.join(" ")} failed`;
|
|
1764
|
+
await logger.start({
|
|
1765
|
+
title: "Installing CocoaPods dependencies",
|
|
1766
|
+
header: [`$ ${formatCommand([...Object.entries(env).map(([name, value]) => `${name}=${value}`), ...command])}`, ""]
|
|
1767
|
+
});
|
|
1768
|
+
try {
|
|
1769
|
+
const podProcess = (0, execa.execa)(command[0], command.slice(1), {
|
|
1770
|
+
cwd: sourceDir,
|
|
1771
|
+
env,
|
|
1772
|
+
all: true,
|
|
1773
|
+
buffer: false
|
|
1774
|
+
});
|
|
1775
|
+
const logging = podProcess.all ? logger.processStream(podProcess.all) : Promise.resolve();
|
|
1776
|
+
const [podResult, loggingResult] = await Promise.allSettled([podProcess, logging]);
|
|
1777
|
+
if (podResult.status === "rejected") throw podResult.reason;
|
|
1778
|
+
if (loggingResult.status === "rejected") throw loggingResult.reason;
|
|
1779
|
+
logger.stop("CocoaPods dependencies installed");
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
logger.writeError(error);
|
|
1782
|
+
const reason = error instanceof execa.ExecaError && error.exitCode !== void 0 ? `exited with code ${error.exitCode}` : error instanceof Error ? error.message : String(error);
|
|
1783
|
+
logger.stop(`${failureMessage} · ${reason}`, false);
|
|
1784
|
+
throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
|
|
1785
|
+
logPath: logger.logPath,
|
|
1786
|
+
summary: logger.failureSummary,
|
|
1787
|
+
cause: error
|
|
1788
|
+
});
|
|
1789
|
+
} finally {
|
|
1790
|
+
await logger.close();
|
|
1791
|
+
}
|
|
1792
|
+
};
|
|
1793
|
+
const installPodsIfNeeded = async (sourceDir) => {
|
|
1794
|
+
const podfilePath = path.default.join(sourceDir, "Podfile");
|
|
1795
|
+
if (!fs.default.existsSync(podfilePath)) {
|
|
1796
|
+
_hot_updater_cli_tools.p.log.info("No Podfile found, skipping CocoaPods installation");
|
|
1797
|
+
return {
|
|
1798
|
+
codegen: false,
|
|
1799
|
+
pods: false
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
const gemfilePaths = [path.default.join(sourceDir, "Gemfile"), path.default.join((0, _hot_updater_cli_tools.getCwd)(), "Gemfile")];
|
|
1803
|
+
const shouldUseBundler = (await Promise.all(gemfilePaths.map(checkShouldUseBundler))).some(Boolean);
|
|
1804
|
+
const reactNative = (0, _hot_updater_cli_tools.getReactNativeMetadatas)();
|
|
1805
|
+
const plan = resolvePodInstallPlan({
|
|
1806
|
+
version: reactNative.version,
|
|
1807
|
+
env: process.env
|
|
1808
|
+
});
|
|
1809
|
+
if (plan.runCodegen) await runReactNativeCodegen({
|
|
1810
|
+
projectRoot: (0, _hot_updater_cli_tools.getCwd)(),
|
|
1811
|
+
reactNativePath: reactNative.packagePath,
|
|
1812
|
+
sourceDir
|
|
1813
|
+
});
|
|
1814
|
+
if (shouldUseBundler) {
|
|
1815
|
+
const bundleSpinner = _hot_updater_cli_tools.p.spinner();
|
|
1816
|
+
bundleSpinner.start("Installing Ruby gems");
|
|
1817
|
+
try {
|
|
1818
|
+
await (0, execa.execa)("bundle", ["install"], {
|
|
1819
|
+
cwd: sourceDir,
|
|
1820
|
+
all: true
|
|
1821
|
+
});
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
bundleSpinner.error("bundle install failed");
|
|
1824
|
+
throw commandFailure("bundle install", error);
|
|
1825
|
+
}
|
|
1826
|
+
bundleSpinner.stop("Ruby gems installed");
|
|
1827
|
+
}
|
|
1828
|
+
await runPodInstall({
|
|
1829
|
+
command: shouldUseBundler ? [
|
|
1830
|
+
"bundle",
|
|
1831
|
+
"exec",
|
|
1832
|
+
"pod",
|
|
1833
|
+
"install"
|
|
1834
|
+
] : ["pod", "install"],
|
|
1835
|
+
env: plan.env,
|
|
1836
|
+
sourceDir
|
|
1837
|
+
});
|
|
1838
|
+
return {
|
|
1839
|
+
codegen: plan.runCodegen,
|
|
1840
|
+
pods: true
|
|
1841
|
+
};
|
|
1842
|
+
};
|
|
1843
|
+
const checkShouldUseBundler = async (gemfilePath) => {
|
|
1844
|
+
try {
|
|
1845
|
+
if (!fs.default.existsSync(gemfilePath)) return false;
|
|
1846
|
+
return (await fs.default.promises.readFile(gemfilePath, "utf-8")).includes("cocoapods");
|
|
1847
|
+
} catch {
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
/**
|
|
1852
|
+
* Runs React Native codegen into `<sourceDir>/build/generated/ios`, as the
|
|
1853
|
+
* React Native CLI does before `pod install`. Clearing the old output first
|
|
1854
|
+
* drops the modules of removed libraries.
|
|
1855
|
+
*/
|
|
1856
|
+
const runReactNativeCodegen = async ({ projectRoot, reactNativePath, sourceDir }) => {
|
|
1857
|
+
await fs.default.promises.rm(path.default.join(sourceDir, "build", "generated", "ios"), {
|
|
1858
|
+
recursive: true,
|
|
1859
|
+
force: true
|
|
1860
|
+
});
|
|
1861
|
+
const spinner = _hot_updater_cli_tools.p.spinner();
|
|
1862
|
+
spinner.start("Running React Native codegen");
|
|
1863
|
+
try {
|
|
1864
|
+
await (0, execa.execa)("node", [
|
|
1865
|
+
path.default.join(reactNativePath, "scripts", "generate-codegen-artifacts.js"),
|
|
1866
|
+
"-p",
|
|
1867
|
+
projectRoot,
|
|
1868
|
+
"-o",
|
|
1869
|
+
sourceDir,
|
|
1870
|
+
"-t",
|
|
1871
|
+
"ios"
|
|
1872
|
+
], {
|
|
1873
|
+
cwd: projectRoot,
|
|
1874
|
+
all: true
|
|
1875
|
+
});
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
spinner.error("React Native codegen failed");
|
|
1878
|
+
throw commandFailure("React Native codegen", error);
|
|
1879
|
+
}
|
|
1880
|
+
spinner.stop("React Native codegen done");
|
|
1881
|
+
};
|
|
1882
|
+
/**
|
|
1883
|
+
* The environment `pod install` runs with, and whether codegen runs first.
|
|
1884
|
+
*
|
|
1885
|
+
* From React Native 0.79, `RCT_IGNORE_PODS_DEPRECATION=1` silences the
|
|
1886
|
+
* deprecation notice for calling `pod install` directly and also sets
|
|
1887
|
+
* `RCT_SKIP_CODEGEN=1`, because the React Native CLI runs codegen itself
|
|
1888
|
+
* first. Hot Updater sets it the same way and runs codegen too, unless the
|
|
1889
|
+
* user's `RCT_SKIP_CODEGEN=1` takes codegen over. Before 0.79, and when the
|
|
1890
|
+
* version is unknown, CocoaPods runs codegen. A user's value of a flag wins.
|
|
1891
|
+
*/
|
|
1892
|
+
const resolvePodInstallPlan = ({ version, env }) => {
|
|
1893
|
+
const podsSkipCodegen = version.major > 0 || version.minor >= 79;
|
|
1894
|
+
const usePrebuiltReactNative = version.major > 0 || version.minor >= 81;
|
|
1895
|
+
const ignorePodsDeprecation = env["RCT_IGNORE_PODS_DEPRECATION"] ?? (podsSkipCodegen ? "1" : void 0);
|
|
1896
|
+
return {
|
|
1897
|
+
runCodegen: podsSkipCodegen && ignorePodsDeprecation === "1" && env["RCT_SKIP_CODEGEN"] !== "1",
|
|
1898
|
+
env: {
|
|
1899
|
+
...ignorePodsDeprecation !== void 0 && { RCT_IGNORE_PODS_DEPRECATION: ignorePodsDeprecation },
|
|
1900
|
+
RCT_USE_RN_DEP: env["RCT_USE_RN_DEP"] ?? (usePrebuiltReactNative ? "1" : "0"),
|
|
1901
|
+
RCT_USE_PREBUILT_RNCORE: env["RCT_USE_PREBUILT_RNCORE"] ?? (usePrebuiltReactNative ? "1" : "0")
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
};
|
|
1905
|
+
//#endregion
|
|
1906
|
+
//#region src/utils/platform.ts
|
|
1907
|
+
/**
|
|
1908
|
+
* Platform configurations for different Apple platforms
|
|
1909
|
+
*/
|
|
1910
|
+
const platformConfigs = { ios: {
|
|
1911
|
+
name: "ios",
|
|
1912
|
+
deviceSdk: "iphoneos",
|
|
1913
|
+
simulatorSdk: "iphonesimulator",
|
|
1914
|
+
supportsSimulator: true,
|
|
1915
|
+
deviceDestination: "platform=iOS",
|
|
1916
|
+
simulatorDestination: "platform=iOS Simulator"
|
|
1917
|
+
} };
|
|
1918
|
+
//#endregion
|
|
1919
|
+
//#region src/utils/destination.ts
|
|
1920
|
+
const buildDestinationString = ({ deviceType, platform, useGeneric }) => `${useGeneric ? "generic/" : ""}${deviceType === "device" ? platformConfigs[platform].deviceDestination : platformConfigs[platform].simulatorDestination}`;
|
|
1921
|
+
/**
|
|
1922
|
+
* Resolves IosBuildDestination to xcodebuild destination format
|
|
1923
|
+
*/
|
|
1924
|
+
const resolveDestination = ({ destination, useGeneric }) => {
|
|
1925
|
+
if (typeof destination === "object") {
|
|
1926
|
+
if ("id" in destination) return `id=${destination.id}`;
|
|
1927
|
+
if ("name" in destination) return `name=${destination.name}`;
|
|
1928
|
+
}
|
|
1929
|
+
switch (destination) {
|
|
1930
|
+
case "ios-device": return buildDestinationString({
|
|
1931
|
+
platform: "ios",
|
|
1932
|
+
deviceType: "device",
|
|
1933
|
+
useGeneric
|
|
1934
|
+
});
|
|
1935
|
+
case "ios-simulator": return buildDestinationString({
|
|
1936
|
+
platform: "ios",
|
|
1937
|
+
deviceType: "simulator",
|
|
1938
|
+
useGeneric
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
};
|
|
1942
|
+
const resolveDestinations = ({ destinations, useGeneric }) => {
|
|
1943
|
+
return destinations.map((destination) => resolveDestination({
|
|
1944
|
+
destination,
|
|
1945
|
+
useGeneric
|
|
1946
|
+
}));
|
|
1947
|
+
};
|
|
1948
|
+
const getDefaultDestination = ({ platform, useGeneric, deviceType }) => {
|
|
1949
|
+
return buildDestinationString({
|
|
1950
|
+
deviceType,
|
|
1951
|
+
platform,
|
|
1952
|
+
useGeneric
|
|
1953
|
+
});
|
|
1954
|
+
};
|
|
1955
|
+
//#endregion
|
|
1956
|
+
//#region src/utils/parseXcodeProjectInfo.ts
|
|
1957
|
+
const parseXcodeProjectInfo = async (sourceDir) => {
|
|
1958
|
+
const files = await fs.default.promises.readdir(sourceDir);
|
|
1959
|
+
const workspace = files.find((file) => file.endsWith(".xcworkspace"));
|
|
1960
|
+
if (workspace) return {
|
|
1961
|
+
isWorkspace: true,
|
|
1962
|
+
name: workspace,
|
|
1963
|
+
path: path.default.join(sourceDir, workspace)
|
|
1964
|
+
};
|
|
1965
|
+
const project = files.find((file) => file.endsWith(".xcodeproj"));
|
|
1966
|
+
if (project) return {
|
|
1967
|
+
isWorkspace: false,
|
|
1968
|
+
name: project,
|
|
1969
|
+
path: path.default.join(sourceDir, project)
|
|
1970
|
+
};
|
|
1971
|
+
throw new Error(`No Xcode project or workspace found in ${sourceDir}`);
|
|
1972
|
+
};
|
|
1973
|
+
//#endregion
|
|
1974
|
+
//#region src/utils/prettifyXcodebuildError.ts
|
|
1975
|
+
const prettifyXcodebuildError = (error) => {
|
|
1976
|
+
if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
|
|
1977
|
+
const errorLines = error.summary.join("\n");
|
|
1978
|
+
const hint = mentionsProgramLicenseAgreement(errorLines) ? PROGRAM_LICENSE_AGREEMENT_HINT : errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "No valid provisioning profile is set in the project.\nCheck the Signing & Capabilities tab in Xcode." : null;
|
|
1979
|
+
return new _hot_updater_cli_tools.BuildFailedError(hint ?? error.message, {
|
|
1980
|
+
logPath: error.logPath,
|
|
1981
|
+
summary: error.summary,
|
|
1982
|
+
hint,
|
|
1983
|
+
cause: error.cause
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
|
|
1987
|
+
};
|
|
1988
|
+
//#endregion
|
|
1989
|
+
//#region src/utils/xcodebuildPhase.ts
|
|
1990
|
+
/** The `(in target 'X' from project 'Y')` that ends an action's line. */
|
|
1991
|
+
const TARGET = /\(in target '([^']+)' from project '[^']+'\)\s*$/;
|
|
1992
|
+
/** A `PhaseScriptExecution` line's script phase, its spaces escaped. */
|
|
1993
|
+
const SCRIPT = /^PhaseScriptExecution ((?:\\.|[^\s\\])+)/;
|
|
1994
|
+
/** A batched `SwiftCompile`'s files: `Compiling\ A.swift,\ B.swift`. */
|
|
1995
|
+
const SWIFT_BATCH = /^SwiftCompile \S+ \S+ Compiling\\ ((?:\\.|[^\s\\])+)/;
|
|
1996
|
+
const unescape = (value) => value.replace(/\\(.)/g, "$1");
|
|
1997
|
+
/** Actions whose lines say what xcodebuild is doing, by their first word. */
|
|
1998
|
+
const ACTIONS = {
|
|
1999
|
+
CompileAssetCatalog: "Compiling assets",
|
|
2000
|
+
CompileAssetCatalogVariant: "Compiling assets",
|
|
2001
|
+
LinkAssetCatalog: "Compiling assets",
|
|
2002
|
+
CompileStoryboard: "Compiling storyboards",
|
|
2003
|
+
CompileXIB: "Compiling storyboards",
|
|
2004
|
+
LinkStoryboards: "Compiling storyboards",
|
|
2005
|
+
Ld: "Linking",
|
|
2006
|
+
Libtool: "Linking",
|
|
2007
|
+
CodeSign: "Signing",
|
|
2008
|
+
ProcessInfoPlistFile: "Processing",
|
|
2009
|
+
ProcessProductPackaging: "Processing",
|
|
2010
|
+
ProcessProductPackagingDER: "Processing",
|
|
2011
|
+
CopySwiftLibs: "Copying",
|
|
2012
|
+
CpResource: "Copying",
|
|
2013
|
+
GenerateDSYMFile: "Generating dSYM",
|
|
2014
|
+
Strip: "Stripping",
|
|
2015
|
+
Validate: "Validating"
|
|
2016
|
+
};
|
|
2017
|
+
const COMPILES = /* @__PURE__ */ new Set([
|
|
2018
|
+
"CompileC",
|
|
2019
|
+
"SwiftCompile",
|
|
2020
|
+
"CompileSwift"
|
|
2021
|
+
]);
|
|
2022
|
+
const PRECOMPILES = /* @__PURE__ */ new Set([
|
|
2023
|
+
"PrecompileModule",
|
|
2024
|
+
"ScanDependencies",
|
|
2025
|
+
"SwiftExplicitDependencyGeneratePcm",
|
|
2026
|
+
"SwiftExplicitDependencyCompileModuleFromInterface"
|
|
2027
|
+
]);
|
|
2028
|
+
const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
2029
|
+
/**
|
|
2030
|
+
* Reads the phase of an `xcodebuild` build, archive, or export from its raw
|
|
2031
|
+
* output: `Compiling React-Core · 412 files`, `Linking CodexRelay`,
|
|
2032
|
+
* `Signing CodexRelay`, `Bundling JavaScript`, with the target each
|
|
2033
|
+
* action's line names.
|
|
2034
|
+
*/
|
|
2035
|
+
const createXcodebuildPhaseReader = () => {
|
|
2036
|
+
let compiled = 0;
|
|
2037
|
+
let precompiled = 0;
|
|
2038
|
+
return (line) => {
|
|
2039
|
+
const space = line.indexOf(" ");
|
|
2040
|
+
const action = space === -1 ? line : line.slice(0, space);
|
|
2041
|
+
const target = TARGET.exec(line)?.[1];
|
|
2042
|
+
const withTarget = (phase) => target ? `${phase} ${target}` : phase;
|
|
2043
|
+
if (COMPILES.has(action)) {
|
|
2044
|
+
const batch = SWIFT_BATCH.exec(line)?.[1];
|
|
2045
|
+
compiled += batch ? unescape(batch).split(",").length : 1;
|
|
2046
|
+
return `${withTarget("Compiling")} · ${plural(compiled, "file")}`;
|
|
2047
|
+
}
|
|
2048
|
+
if (PRECOMPILES.has(action)) {
|
|
2049
|
+
precompiled += 1;
|
|
2050
|
+
return `Precompiling modules · ${precompiled}`;
|
|
2051
|
+
}
|
|
2052
|
+
if (action === "PhaseScriptExecution") {
|
|
2053
|
+
const script = unescape(SCRIPT.exec(line)?.[1] ?? "");
|
|
2054
|
+
if (script === "Bundle React Native code and images") return "Bundling JavaScript";
|
|
2055
|
+
return script ? `Running ${script}` : withTarget("Running a script");
|
|
2056
|
+
}
|
|
2057
|
+
const phase = ACTIONS[action];
|
|
2058
|
+
if (phase) return withTarget(phase);
|
|
2059
|
+
if (line === "Prepare packages" || line.startsWith("Resolve Package")) return "Resolving packages";
|
|
2060
|
+
if (line === "ComputeTargetDependencyGraph") return "Planning the build";
|
|
2061
|
+
};
|
|
2062
|
+
};
|
|
2063
|
+
//#endregion
|
|
2064
|
+
//#region src/utils/createXcodebuildLogger.ts
|
|
2065
|
+
const createXcodebuildLogger = ({ logPrefix, logFilePath }) => new _hot_updater_cli_tools.BuildLogger({
|
|
2066
|
+
logPrefix,
|
|
2067
|
+
logFilePath,
|
|
2068
|
+
createPhaseReader: createXcodebuildPhaseReader,
|
|
2069
|
+
failureSummary: {
|
|
2070
|
+
lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
|
|
2071
|
+
blocks: [{
|
|
2072
|
+
start: "The following build commands failed:",
|
|
2073
|
+
end: /^\(\d+ failures?\)$/,
|
|
2074
|
+
includeEnd: true
|
|
2075
|
+
}, {
|
|
2076
|
+
start: /^error: exportArchive /,
|
|
2077
|
+
end: /^$/,
|
|
2078
|
+
includeEnd: false
|
|
2079
|
+
}]
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
//#endregion
|
|
2083
|
+
//#region src/utils/runXcodebuildWithLogging.ts
|
|
2084
|
+
const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
|
|
2085
|
+
const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
|
|
2086
|
+
return (0, execa.execa)("xcodebuild", args, {
|
|
2087
|
+
cwd: sourceDir,
|
|
2088
|
+
all: true,
|
|
2089
|
+
buffer: false,
|
|
2090
|
+
env: {
|
|
2091
|
+
...process.env,
|
|
2092
|
+
...env,
|
|
2093
|
+
NSUnbufferedIO: "YES"
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
};
|
|
2097
|
+
/** Why xcodebuild failed, without its command line. */
|
|
2098
|
+
const describeFailure = (error) => {
|
|
2099
|
+
if (error instanceof execa.ExecaError) {
|
|
2100
|
+
if (error.signal) return `xcodebuild was stopped by ${error.signal}.`;
|
|
2101
|
+
if (error.exitCode !== void 0) return `xcodebuild exited with code ${error.exitCode}.`;
|
|
2102
|
+
}
|
|
2103
|
+
return error instanceof Error ? error.message : String(error);
|
|
2104
|
+
};
|
|
2105
|
+
/**
|
|
2106
|
+
* Runs xcodebuild as a step titled `title`: its live line shows the build's
|
|
2107
|
+
* phase, the log file keeps the command and the whole output, and a failure
|
|
2108
|
+
* prints the output's errors and the log's path on stderr.
|
|
2109
|
+
*/
|
|
2110
|
+
const runXcodebuildWithLogging = async ({ args, env, sourceDir, title, successMessage, failureMessage, logPrefix, logFilePath, header = [] }) => {
|
|
2111
|
+
const logger = createXcodebuildLogger({
|
|
2112
|
+
logPrefix,
|
|
2113
|
+
logFilePath
|
|
2114
|
+
});
|
|
2115
|
+
await logger.start({
|
|
2116
|
+
title,
|
|
2117
|
+
header: [
|
|
2118
|
+
...header,
|
|
2119
|
+
`$ ${formatCommand(["xcodebuild", ...args])}`,
|
|
2120
|
+
""
|
|
2121
|
+
]
|
|
2122
|
+
});
|
|
2123
|
+
const activeProcesses = [];
|
|
2124
|
+
const terminateActiveProcesses = () => {
|
|
2125
|
+
for (const activeProcess of activeProcesses) activeProcess.kill();
|
|
2126
|
+
};
|
|
2127
|
+
for (const signal of TERMINATION_SIGNALS$1) process.once(signal, terminateActiveProcesses);
|
|
2128
|
+
try {
|
|
2129
|
+
const xcodebuildProcess = createRawXcodebuildProcess({
|
|
2130
|
+
args,
|
|
2131
|
+
env,
|
|
2132
|
+
sourceDir
|
|
2133
|
+
});
|
|
2134
|
+
activeProcesses.push(xcodebuildProcess);
|
|
2135
|
+
const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
|
|
2136
|
+
const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput) : Promise.resolve();
|
|
2137
|
+
const [xcodebuildResult, loggingResult] = await Promise.allSettled([xcodebuildProcess, logging]);
|
|
2138
|
+
if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
|
|
2139
|
+
if (loggingResult.status === "rejected") throw loggingResult.reason;
|
|
2140
|
+
logger.stop(successMessage);
|
|
2141
|
+
return { logFilePath: logger.logFilePath };
|
|
2142
|
+
} catch (error) {
|
|
2143
|
+
logger.writeError(error);
|
|
2144
|
+
const reason = describeFailure(error);
|
|
2145
|
+
logger.stop(`${failureMessage} · ${reason.replace(/\.$/, "")}`, false);
|
|
2146
|
+
throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${reason}`, {
|
|
2147
|
+
logPath: logger.logPath,
|
|
2148
|
+
summary: logger.failureSummary,
|
|
2149
|
+
cause: error
|
|
2150
|
+
});
|
|
2151
|
+
} finally {
|
|
2152
|
+
for (const signal of TERMINATION_SIGNALS$1) process.off(signal, terminateActiveProcesses);
|
|
2153
|
+
await logger.close();
|
|
2154
|
+
}
|
|
2155
|
+
};
|
|
2156
|
+
//#endregion
|
|
2157
|
+
//#region src/utils/writeBuildXcconfig.ts
|
|
2158
|
+
const SDK_CONDITIONS = [
|
|
2159
|
+
"",
|
|
2160
|
+
"[sdk=iphoneos*]",
|
|
2161
|
+
"[sdk=iphonesimulator*]"
|
|
2162
|
+
];
|
|
2163
|
+
/**
|
|
2164
|
+
* Finds a setting of `keys` that an included xcconfig sets with a condition
|
|
2165
|
+
* the build xcconfig does not, such as `[config=Release]` or `[arch=arm64]`,
|
|
2166
|
+
* which would outrank the build's own. It follows `#include`s, which are
|
|
2167
|
+
* relative to the including file.
|
|
2168
|
+
*/
|
|
2169
|
+
const findConditionalSetting = async (files, keys, visited = /* @__PURE__ */ new Set()) => {
|
|
2170
|
+
for (const file of files) {
|
|
2171
|
+
if (visited.has(file) || !fs.default.existsSync(file)) continue;
|
|
2172
|
+
visited.add(file);
|
|
2173
|
+
const lines = (await fs.default.promises.readFile(file, "utf-8")).split("\n");
|
|
2174
|
+
for (const [index, line] of lines.entries()) {
|
|
2175
|
+
const include = line.match(/^\s*#include\??\s*"([^"]+)"/)?.[1];
|
|
2176
|
+
if (include) {
|
|
2177
|
+
const found = await findConditionalSetting([path.default.resolve(path.default.dirname(file), include)], keys, visited);
|
|
2178
|
+
if (found) return found;
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
const [, key, conditions] = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)((?:\[[^\]]*\])+)\s*=/) ?? [];
|
|
2182
|
+
if (key && keys.includes(key) && !SDK_CONDITIONS.includes(conditions)) return `${file}:${index + 1} sets ${key}${conditions}`;
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
};
|
|
2186
|
+
/**
|
|
2187
|
+
* Writes the xcconfig a build loads, which sets the CLI's build settings
|
|
2188
|
+
* over every other. xcodebuild ranks an `-xcconfig`'s settings above the
|
|
2189
|
+
* command line's, so the scheme's `xcconfig` and each `-xcconfig` in
|
|
2190
|
+
* `extraParams` are included, in order, before `settings`, and the build
|
|
2191
|
+
* loads only this one.
|
|
2192
|
+
*/
|
|
2193
|
+
const writeBuildXcconfig = async ({ directory, sourceDir, xcconfig, extraParams = [], settings }) => {
|
|
2194
|
+
const included = xcconfig ? [xcconfig] : [];
|
|
2195
|
+
const otherParams = [];
|
|
2196
|
+
for (let i = 0; i < extraParams.length; i++) if (extraParams[i] === "-xcconfig" && i + 1 < extraParams.length) included.push(extraParams[++i]);
|
|
2197
|
+
else otherParams.push(extraParams[i]);
|
|
2198
|
+
const includedFiles = included.map((file) => path.default.resolve(sourceDir, file));
|
|
2199
|
+
const conditional = await findConditionalSetting(includedFiles, Object.keys(settings));
|
|
2200
|
+
if (conditional) throw new Error(`${conditional}, which would outrank the value the build sets. Set it without that condition, or remove it.`);
|
|
2201
|
+
const buildXcconfig = path.default.join(directory, "HotUpdater.xcconfig");
|
|
2202
|
+
await fs.default.promises.writeFile(buildXcconfig, [
|
|
2203
|
+
...includedFiles.map((file) => `#include "${file}"`),
|
|
2204
|
+
...Object.entries(settings).flatMap(([key, value]) => SDK_CONDITIONS.map((condition) => `${key}${condition} = ${value}`)),
|
|
2205
|
+
""
|
|
2206
|
+
].join("\n"));
|
|
2207
|
+
return {
|
|
2208
|
+
xcconfig: buildXcconfig,
|
|
2209
|
+
extraParams: otherParams
|
|
2210
|
+
};
|
|
2211
|
+
};
|
|
2212
|
+
/**
|
|
2213
|
+
* Automatic signing over a target set to manual signing, whose provisioning
|
|
2214
|
+
* profile and identity Xcode would otherwise report as conflicting.
|
|
2215
|
+
*/
|
|
2216
|
+
const AUTOMATIC_SIGNING = {
|
|
2217
|
+
CODE_SIGN_STYLE: "Automatic",
|
|
2218
|
+
PROVISIONING_PROFILE: "",
|
|
2219
|
+
PROVISIONING_PROFILE_SPECIFIER: "",
|
|
2220
|
+
CODE_SIGN_IDENTITY: "Apple Development"
|
|
2221
|
+
};
|
|
2222
|
+
/** The sandbox layer's sources, by the file names the podspec excludes. */
|
|
2223
|
+
const SANDBOX_SOURCES = "HotUpdaterSandbox*";
|
|
2224
|
+
/** What each of those sources is wrapped in: without it, they compile to nothing. */
|
|
2225
|
+
const SANDBOX_CONDITION = "HOT_UPDATER_SANDBOX_LAYER";
|
|
2226
|
+
/**
|
|
2227
|
+
* The settings a build sets over the scheme's: the MIN_BUNDLE_ID the CLI
|
|
2228
|
+
* issues and reports, and automatic signing when an App Store Connect API key
|
|
2229
|
+
* signs, which a target set to manual signing would not use.
|
|
2230
|
+
*
|
|
2231
|
+
* A sandbox scheme brings the sandbox layer's sources back, and sets the
|
|
2232
|
+
* condition they compile under, which nothing else sets: any build can undo the
|
|
2233
|
+
* podspec's exclusion, and those sources then compile to nothing. Any other scheme
|
|
2234
|
+
* excludes them again: a scheme's `xcconfig`, which outranks the pod's settings
|
|
2235
|
+
* on every target, would otherwise replace the podspec's exclusion, and the
|
|
2236
|
+
* layer would compile into a store build.
|
|
2237
|
+
*/
|
|
2238
|
+
const buildSettingsOverScheme = ({ minBundleId, authenticationArgs, sandbox = false }) => ({
|
|
2239
|
+
HOT_UPDATER_MIN_BUNDLE_ID: minBundleId,
|
|
2240
|
+
...authenticationArgs ? AUTOMATIC_SIGNING : {},
|
|
2241
|
+
...sandbox ? {
|
|
2242
|
+
INCLUDED_SOURCE_FILE_NAMES: SANDBOX_SOURCES,
|
|
2243
|
+
SWIFT_ACTIVE_COMPILATION_CONDITIONS: `$(inherited) ${SANDBOX_CONDITION}`,
|
|
2244
|
+
GCC_PREPROCESSOR_DEFINITIONS: `$(inherited) ${SANDBOX_CONDITION}=1`
|
|
2245
|
+
} : { EXCLUDED_SOURCE_FILE_NAMES: `$(inherited) ${SANDBOX_SOURCES}` }
|
|
2246
|
+
});
|
|
2247
|
+
//#endregion
|
|
2248
|
+
//#region src/builder/archiveXcodeProject.ts
|
|
2249
|
+
/**
|
|
2250
|
+
* Archives the scheme into `archiveDirectory`. The build's xcconfig is
|
|
2251
|
+
* written to a temporary directory, removed whether the archive succeeds or
|
|
2252
|
+
* not.
|
|
2253
|
+
*/
|
|
2254
|
+
const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, header = [], minBundleId, authenticationArgs, sandbox }) => {
|
|
2255
|
+
if (installPods) await installPodsIfNeeded(sourceDir);
|
|
2256
|
+
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
2257
|
+
const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
|
|
2258
|
+
const archivePath = path.default.join(archiveDirectory, archiveName);
|
|
2259
|
+
const tmpDir = await createRandomTmpDir();
|
|
2260
|
+
try {
|
|
2261
|
+
const build = await writeBuildXcconfig({
|
|
2262
|
+
directory: tmpDir,
|
|
2263
|
+
sourceDir,
|
|
2264
|
+
xcconfig,
|
|
2265
|
+
extraParams,
|
|
2266
|
+
settings: buildSettingsOverScheme({
|
|
2267
|
+
minBundleId,
|
|
2268
|
+
authenticationArgs,
|
|
2269
|
+
sandbox
|
|
2270
|
+
})
|
|
2271
|
+
});
|
|
2272
|
+
const archiveArgs = prepareArchiveArgs({
|
|
2273
|
+
authenticationArgs,
|
|
2274
|
+
minBundleId,
|
|
2275
|
+
archivePath,
|
|
2276
|
+
platform,
|
|
2277
|
+
sourceDir,
|
|
2278
|
+
xcodeProject,
|
|
2279
|
+
configuration,
|
|
2280
|
+
extraParams: build.extraParams,
|
|
2281
|
+
destination,
|
|
2282
|
+
xcconfig: build.xcconfig,
|
|
2283
|
+
xcodeScheme
|
|
2284
|
+
});
|
|
2285
|
+
try {
|
|
2286
|
+
const { logFilePath } = await runXcodebuildWithLogging({
|
|
2287
|
+
args: archiveArgs,
|
|
2288
|
+
env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
|
|
2289
|
+
title: `Archiving ${xcodeScheme}`,
|
|
2290
|
+
successMessage: `Archived ${xcodeScheme}`,
|
|
2291
|
+
failureMessage: "Archive failed",
|
|
2292
|
+
logPrefix,
|
|
2293
|
+
header: [
|
|
2294
|
+
...header,
|
|
2295
|
+
`Project ${xcodeProject.name}`,
|
|
2296
|
+
`Scheme ${xcodeScheme}`,
|
|
2297
|
+
`Platform ${platform}`
|
|
2298
|
+
],
|
|
2299
|
+
sourceDir
|
|
2300
|
+
});
|
|
2301
|
+
return {
|
|
2302
|
+
archivePath,
|
|
2303
|
+
logFilePath
|
|
2304
|
+
};
|
|
2305
|
+
} catch (error) {
|
|
2306
|
+
throw prettifyXcodebuildError(error);
|
|
2307
|
+
}
|
|
2308
|
+
} finally {
|
|
2309
|
+
await removeTmpDir(tmpDir);
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId, authenticationArgs }) => {
|
|
2313
|
+
const args = [
|
|
2314
|
+
xcodeProject.isWorkspace ? "-workspace" : "-project",
|
|
2315
|
+
path.default.join(sourceDir, xcodeProject.name),
|
|
2316
|
+
"-scheme",
|
|
2317
|
+
xcodeScheme,
|
|
2318
|
+
"-configuration",
|
|
2319
|
+
configuration,
|
|
2320
|
+
"archive",
|
|
2321
|
+
"-archivePath",
|
|
2322
|
+
archivePath,
|
|
2323
|
+
`HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`
|
|
2324
|
+
];
|
|
2325
|
+
if (authenticationArgs) args.push(...authenticationArgs);
|
|
2326
|
+
if (xcconfig) args.push("-xcconfig", xcconfig);
|
|
2327
|
+
if (extraParams) args.push(...extraParams);
|
|
2328
|
+
const resolvedDestinations = resolveDestinations({
|
|
2329
|
+
destinations: destination,
|
|
2330
|
+
useGeneric: true
|
|
2331
|
+
});
|
|
2332
|
+
if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
|
|
2333
|
+
deviceType: "device",
|
|
2334
|
+
platform,
|
|
2335
|
+
useGeneric: true
|
|
2336
|
+
}));
|
|
2337
|
+
for (const destination of resolvedDestinations) args.push("-destination", destination);
|
|
2338
|
+
return args;
|
|
2339
|
+
};
|
|
2340
|
+
//#endregion
|
|
2341
|
+
//#region src/builder/buildXcodeProject.ts
|
|
2342
|
+
/**
|
|
2343
|
+
* Builds the scheme in a temporary DerivedData directory, copies the built
|
|
2344
|
+
* `.app` to `outputDirectory`, and removes DerivedData, which takes 10 GB or
|
|
2345
|
+
* more, whether the build succeeds or not.
|
|
2346
|
+
*/
|
|
2347
|
+
const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, xcconfig, extraParams, minBundleId, authenticationArgs, sandbox, header = [] }) => {
|
|
2348
|
+
if (installPods) await installPodsIfNeeded(sourceDir);
|
|
2349
|
+
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
2350
|
+
const tmpDir = await createRandomTmpDir();
|
|
2351
|
+
try {
|
|
2352
|
+
const derivedDataPath = path.default.join(tmpDir, "DerivedData");
|
|
2353
|
+
const resolvedDestinations = resolveDestinations({
|
|
2354
|
+
destinations: destination,
|
|
2355
|
+
useGeneric: useGenericDestination
|
|
2356
|
+
});
|
|
2357
|
+
if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
|
|
2358
|
+
deviceType,
|
|
2359
|
+
platform,
|
|
2360
|
+
useGeneric: useGenericDestination
|
|
2361
|
+
}));
|
|
2362
|
+
const build = await writeBuildXcconfig({
|
|
2363
|
+
directory: tmpDir,
|
|
2364
|
+
sourceDir,
|
|
2365
|
+
xcconfig,
|
|
2366
|
+
extraParams,
|
|
2367
|
+
settings: buildSettingsOverScheme({
|
|
2368
|
+
minBundleId,
|
|
2369
|
+
authenticationArgs,
|
|
2370
|
+
sandbox
|
|
2371
|
+
})
|
|
2372
|
+
});
|
|
2373
|
+
const buildArgs = prepareBuildArgs({
|
|
2374
|
+
authenticationArgs,
|
|
2375
|
+
xcconfig: build.xcconfig,
|
|
2376
|
+
minBundleId,
|
|
2377
|
+
configuration,
|
|
2378
|
+
derivedDataPath,
|
|
2379
|
+
deviceType,
|
|
2380
|
+
resolvedDestinations,
|
|
2381
|
+
extraParams: build.extraParams,
|
|
2382
|
+
platform,
|
|
2383
|
+
xcodeScheme,
|
|
2384
|
+
sourceDir,
|
|
2385
|
+
xcodeProject
|
|
2386
|
+
});
|
|
2387
|
+
let builtAppPath;
|
|
2388
|
+
let logFilePath;
|
|
2389
|
+
try {
|
|
2390
|
+
({logFilePath} = await runXcodebuildWithLogging({
|
|
2391
|
+
args: buildArgs,
|
|
2392
|
+
env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
|
|
2393
|
+
sourceDir,
|
|
2394
|
+
logPrefix,
|
|
2395
|
+
header: [
|
|
2396
|
+
...header,
|
|
2397
|
+
`Project ${xcodeProject.name}`,
|
|
2398
|
+
`Scheme ${xcodeScheme}`,
|
|
2399
|
+
`Configuration ${configuration}`,
|
|
2400
|
+
`Platform ${platform}`,
|
|
2401
|
+
`Device type ${deviceType}`
|
|
2402
|
+
],
|
|
2403
|
+
title: `Building ${xcodeScheme}`,
|
|
2404
|
+
successMessage: `Built ${xcodeScheme}`,
|
|
2405
|
+
failureMessage: "Build failed"
|
|
2406
|
+
}));
|
|
2407
|
+
({appPath: builtAppPath} = await getBuildSettings({
|
|
2408
|
+
configuration,
|
|
2409
|
+
derivedDataPath,
|
|
2410
|
+
resolvedDestinations,
|
|
2411
|
+
xcodeScheme,
|
|
2412
|
+
sourceDir,
|
|
2413
|
+
xcodeProject,
|
|
2414
|
+
xcconfig: build.xcconfig,
|
|
2415
|
+
extraParams: build.extraParams
|
|
2416
|
+
}));
|
|
2417
|
+
} catch (error) {
|
|
2418
|
+
throw prettifyXcodebuildError(error);
|
|
2419
|
+
}
|
|
2420
|
+
const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
|
|
2421
|
+
await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
|
|
2422
|
+
return {
|
|
2423
|
+
appPath,
|
|
2424
|
+
logFilePath
|
|
2425
|
+
};
|
|
2426
|
+
} finally {
|
|
2427
|
+
await removeTmpDir(tmpDir);
|
|
2428
|
+
}
|
|
2429
|
+
};
|
|
2430
|
+
const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
|
|
2431
|
+
const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
|
|
2432
|
+
const args = [
|
|
2433
|
+
xcodeProject.isWorkspace ? "-workspace" : "-project",
|
|
2434
|
+
path.default.join(sourceDir, xcodeProject.name),
|
|
2435
|
+
"-scheme",
|
|
2436
|
+
xcodeScheme,
|
|
2437
|
+
"-configuration",
|
|
2438
|
+
configuration,
|
|
2439
|
+
"-sdk",
|
|
2440
|
+
sdk,
|
|
2441
|
+
"-derivedDataPath",
|
|
2442
|
+
derivedDataPath,
|
|
2443
|
+
`HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`,
|
|
2444
|
+
"build"
|
|
2445
|
+
];
|
|
2446
|
+
if (authenticationArgs) args.push(...authenticationArgs);
|
|
2447
|
+
args.push("-xcconfig", xcconfig);
|
|
2448
|
+
if (extraParams) args.push(...extraParams);
|
|
2449
|
+
for (const dest of resolvedDestinations) args.push("-destination", dest);
|
|
2450
|
+
return args;
|
|
2451
|
+
};
|
|
2452
|
+
const BUILD_SETTING = /^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])*=/u;
|
|
2453
|
+
const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configuration, resolvedDestinations, derivedDataPath, xcconfig, extraParams }) => {
|
|
2454
|
+
const destinationArgs = resolvedDestinations.flatMap((dest) => ["-destination", dest]);
|
|
2455
|
+
const { stdout: buildSettings } = await (0, execa.execa)("xcodebuild", [
|
|
2456
|
+
xcodeProject.isWorkspace ? "-workspace" : "-project",
|
|
2457
|
+
xcodeProject.name,
|
|
2458
|
+
"-scheme",
|
|
2459
|
+
xcodeScheme,
|
|
2460
|
+
"-configuration",
|
|
2461
|
+
configuration,
|
|
2462
|
+
...destinationArgs,
|
|
2463
|
+
"-derivedDataPath",
|
|
2464
|
+
derivedDataPath,
|
|
2465
|
+
"-xcconfig",
|
|
2466
|
+
xcconfig,
|
|
2467
|
+
...extraParams.filter((param) => BUILD_SETTING.test(param)),
|
|
2468
|
+
"-showBuildSettings",
|
|
2469
|
+
"-json"
|
|
2470
|
+
], { cwd: sourceDir });
|
|
2471
|
+
const settings = JSON.parse(buildSettings).filter(({ target }) => target !== "React" && target !== "React-Core");
|
|
2472
|
+
if (settings.length === 0) throw new Error("Failed to get build settings for your project");
|
|
2473
|
+
const targetSettings = settings[0].buildSettings;
|
|
2474
|
+
const wrapperExtension = targetSettings.WRAPPER_EXTENSION;
|
|
2475
|
+
if (wrapperExtension !== "app" && wrapperExtension !== "framework") throw new Error(`Expected wrapper extension to be "app" or "framework" but found: ${wrapperExtension}`);
|
|
2476
|
+
const targetBuildDir = targetSettings.TARGET_BUILD_DIR;
|
|
2477
|
+
const executableFolderPath = targetSettings.EXECUTABLE_FOLDER_PATH;
|
|
2478
|
+
const fullProductName = targetSettings.FULL_PRODUCT_NAME;
|
|
2479
|
+
const infoPlistPath = targetSettings.INFOPLIST_PATH;
|
|
2480
|
+
if (!targetBuildDir || !executableFolderPath || !fullProductName) throw new Error("Failed to get build paths from build settings");
|
|
2481
|
+
return {
|
|
2482
|
+
appPath: path.default.join(targetBuildDir, executableFolderPath),
|
|
2483
|
+
infoPlistPath: path.default.join(targetBuildDir, infoPlistPath)
|
|
2484
|
+
};
|
|
2485
|
+
};
|
|
2486
|
+
//#endregion
|
|
2487
|
+
//#region src/builder/exportXcodeArchive.ts
|
|
2488
|
+
/** Exports the archive into `exportPath`. */
|
|
2489
|
+
const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, logFilePath, title, authenticationArgs }) => {
|
|
2490
|
+
const exportArgs = prepareExportArgs({
|
|
2491
|
+
authenticationArgs,
|
|
2492
|
+
archivePath,
|
|
2493
|
+
exportPath,
|
|
2494
|
+
exportOptionsPlist,
|
|
2495
|
+
exportExtraParams
|
|
2496
|
+
});
|
|
2497
|
+
try {
|
|
2498
|
+
await runXcodebuildWithLogging({
|
|
2499
|
+
args: exportArgs,
|
|
2500
|
+
sourceDir,
|
|
2501
|
+
title: `Exporting ${title}`,
|
|
2502
|
+
successMessage: `Exported ${title}`,
|
|
2503
|
+
failureMessage: "Export failed",
|
|
2504
|
+
logPrefix,
|
|
2505
|
+
logFilePath
|
|
2506
|
+
});
|
|
2507
|
+
return { exportPath };
|
|
2508
|
+
} catch (error) {
|
|
2509
|
+
throw prettifyXcodebuildError(error);
|
|
2267
2510
|
}
|
|
2268
2511
|
};
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2512
|
+
const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist, authenticationArgs }) => {
|
|
2513
|
+
const args = [
|
|
2514
|
+
"-exportArchive",
|
|
2515
|
+
"-archivePath",
|
|
2516
|
+
archivePath,
|
|
2517
|
+
"-exportPath",
|
|
2518
|
+
exportPath
|
|
2519
|
+
];
|
|
2520
|
+
if (authenticationArgs) args.push(...authenticationArgs);
|
|
2521
|
+
if (exportExtraParams) args.push(...exportExtraParams);
|
|
2522
|
+
args.push("-exportOptionsPlist", exportOptionsPlist);
|
|
2523
|
+
return args;
|
|
2524
|
+
};
|
|
2525
|
+
//#endregion
|
|
2526
|
+
//#region src/utils/assertXcodebuildExist.ts
|
|
2527
|
+
const assertXcodebuildExist = async () => {
|
|
2276
2528
|
try {
|
|
2277
|
-
await
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
const headers = {};
|
|
2282
|
-
for (const { name, value } of operation.requestHeaders ?? []) headers[name] = value;
|
|
2283
|
-
for (let attempt = 1;; attempt++) {
|
|
2284
|
-
const response = await fetch(operation.url, {
|
|
2285
|
-
method: operation.method,
|
|
2286
|
-
headers,
|
|
2287
|
-
body
|
|
2288
|
-
}).catch((error) => error);
|
|
2289
|
-
if (!(response instanceof Error) && response.ok) return;
|
|
2290
|
-
if (!(response instanceof Error || isRetryableStatus(response.status)) || attempt === UPLOAD_ATTEMPTS) throw new Error(`Uploading part ${index + 1} of ${operations.length} failed: ${response instanceof Error ? response.message : `HTTP ${response.status}`}.`);
|
|
2291
|
-
await sleep(retryDelayMs(response, attempt));
|
|
2292
|
-
}
|
|
2293
|
-
});
|
|
2294
|
-
} finally {
|
|
2295
|
-
await file.close();
|
|
2529
|
+
await (0, execa.execa)("which", ["xcodebuild"]);
|
|
2530
|
+
} catch {
|
|
2531
|
+
_hot_updater_cli_tools.p.log.error(`${_hot_updater_cli_tools.colors.blueBright("xcodebuild")} resolve failed. Ensure that xcode is installed on your machine or xcodebuild is included in your path`);
|
|
2532
|
+
process.exit(1);
|
|
2296
2533
|
}
|
|
2297
2534
|
};
|
|
2298
2535
|
//#endregion
|
|
@@ -2523,7 +2760,7 @@ var AppStoreConnectRefusal = class extends Error {
|
|
|
2523
2760
|
this.agreement = agreement;
|
|
2524
2761
|
}
|
|
2525
2762
|
};
|
|
2526
|
-
const refusal = async (action, response) => {
|
|
2763
|
+
const refusal$1 = async (action, response) => {
|
|
2527
2764
|
const body = await response.json().catch(() => void 0);
|
|
2528
2765
|
return new AppStoreConnectRefusal(`${action} failed (HTTP ${response.status}).`, isProgramLicenseAgreementError(Array.isArray(body?.errors) ? body.errors : []));
|
|
2529
2766
|
};
|
|
@@ -2561,7 +2798,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
|
|
|
2561
2798
|
})}`;
|
|
2562
2799
|
while (next) {
|
|
2563
2800
|
const response = await fetch(next, { headers });
|
|
2564
|
-
if (!response.ok) throw await refusal("Listing the team's certificates", response);
|
|
2801
|
+
if (!response.ok) throw await refusal$1("Listing the team's certificates", response);
|
|
2565
2802
|
const body = await response.json();
|
|
2566
2803
|
for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
|
|
2567
2804
|
next = body.links?.next;
|
|
@@ -2575,7 +2812,7 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
|
|
|
2575
2812
|
});
|
|
2576
2813
|
if (response.ok || response.status === 404) revoked.push(certificate);
|
|
2577
2814
|
else {
|
|
2578
|
-
agreement ||= (await refusal("Revoking", response)).agreement;
|
|
2815
|
+
agreement ||= (await refusal$1("Revoking", response)).agreement;
|
|
2579
2816
|
failed.push({
|
|
2580
2817
|
certificate,
|
|
2581
2818
|
status: response.status
|
|
@@ -2587,111 +2824,711 @@ const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fe
|
|
|
2587
2824
|
agreement ||= error instanceof AppStoreConnectRefusal && error.agreement;
|
|
2588
2825
|
for (const certificate of certificates) if (!revoked.includes(certificate)) failed.push({ certificate });
|
|
2589
2826
|
}
|
|
2590
|
-
const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
|
|
2591
|
-
if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(["Revoked the certificates Xcode created for this build:", ...revoked.flatMap(({ commonName, serialNumber }) => [commonName, `Serial ${serialNumber}`])].join("\n"));
|
|
2592
|
-
if (failed.length > 0) _hot_updater_cli_tools.p.log.warn([
|
|
2593
|
-
"Couldn't revoke the certificates Xcode created for this build.",
|
|
2594
|
-
...reason === void 0 ? [] : [reason],
|
|
2595
|
-
...agreement ? [PROGRAM_LICENSE_AGREEMENT_HINT] : [],
|
|
2596
|
-
...failed.flatMap(({ certificate, status }) => [certificate.commonName, `Serial ${certificate.serialNumber}${status === void 0 ? "" : ` (HTTP ${status})`}`]),
|
|
2597
|
-
CERTIFICATES_URL
|
|
2598
|
-
].join("\n"));
|
|
2827
|
+
const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
|
|
2828
|
+
if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(["Revoked the certificates Xcode created for this build:", ...revoked.flatMap(({ commonName, serialNumber }) => [commonName, `Serial ${serialNumber}`])].join("\n"));
|
|
2829
|
+
if (failed.length > 0) _hot_updater_cli_tools.p.log.warn([
|
|
2830
|
+
"Couldn't revoke the certificates Xcode created for this build.",
|
|
2831
|
+
...reason === void 0 ? [] : [reason],
|
|
2832
|
+
...agreement ? [PROGRAM_LICENSE_AGREEMENT_HINT] : [],
|
|
2833
|
+
...failed.flatMap(({ certificate, status }) => [certificate.commonName, `Serial ${certificate.serialNumber}${status === void 0 ? "" : ` (HTTP ${status})`}`]),
|
|
2834
|
+
CERTIFICATES_URL
|
|
2835
|
+
].join("\n"));
|
|
2836
|
+
return {
|
|
2837
|
+
revoked: revoked.map(describe),
|
|
2838
|
+
failed: failed.map(({ certificate }) => describe(certificate))
|
|
2839
|
+
};
|
|
2840
|
+
};
|
|
2841
|
+
const TERMINATION_SIGNALS = [
|
|
2842
|
+
"SIGHUP",
|
|
2843
|
+
"SIGINT",
|
|
2844
|
+
"SIGTERM"
|
|
2845
|
+
];
|
|
2846
|
+
/**
|
|
2847
|
+
* Runs `run`, the archive and export of a build that signs with an App
|
|
2848
|
+
* Store Connect API key, in a keychain of its own when `enabled` (in CI),
|
|
2849
|
+
* then revokes the certificates Xcode created in it and deletes it. A
|
|
2850
|
+
* termination signal does the same before the process ends with it: the
|
|
2851
|
+
* certificates are read from the keychain before it is deleted, and the key
|
|
2852
|
+
* is read into memory first, since the build's temporary key file is deleted
|
|
2853
|
+
* on the signal. A second signal restores the keychains at once.
|
|
2854
|
+
*
|
|
2855
|
+
* `keepDistribution` keeps a distribution certificate out of the revoking, for
|
|
2856
|
+
* an ad hoc export: Xcode may have signed the `.ipa` with one it created here,
|
|
2857
|
+
* rather than the cloud-managed one, and installs may need it valid. Only the
|
|
2858
|
+
* development certificates Xcode created for the archive are then revoked.
|
|
2859
|
+
*/
|
|
2860
|
+
const withSigningKeychain = async ({ enabled, keepDistribution = false, apiKey, run, security, lockPath, acquireLock, fetch }) => {
|
|
2861
|
+
if (!enabled) return run();
|
|
2862
|
+
const privateKey = crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8"));
|
|
2863
|
+
const keychain = await createSigningKeychain({
|
|
2864
|
+
security,
|
|
2865
|
+
lockPath,
|
|
2866
|
+
acquireLock
|
|
2867
|
+
});
|
|
2868
|
+
_hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own (CI is set).");
|
|
2869
|
+
let cleanup;
|
|
2870
|
+
const release = () => {
|
|
2871
|
+
cleanup ??= (async () => {
|
|
2872
|
+
const certificates = await keychain.certificates().catch(() => []);
|
|
2873
|
+
await keychain.dispose();
|
|
2874
|
+
await revokeKeychainCertificates({
|
|
2875
|
+
certificates: keepDistribution ? certificates.filter(({ commonName }) => !commonName.includes("Distribution")) : certificates,
|
|
2876
|
+
apiKey,
|
|
2877
|
+
privateKey,
|
|
2878
|
+
fetch
|
|
2879
|
+
});
|
|
2880
|
+
})();
|
|
2881
|
+
return cleanup;
|
|
2882
|
+
};
|
|
2883
|
+
const stopListening = () => {
|
|
2884
|
+
for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
|
|
2885
|
+
};
|
|
2886
|
+
const onSignal = (signal) => {
|
|
2887
|
+
if (cleanup) {
|
|
2888
|
+
stopListening();
|
|
2889
|
+
restoreLive();
|
|
2890
|
+
process.kill(process.pid, signal);
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
release().finally(() => {
|
|
2894
|
+
stopListening();
|
|
2895
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
2896
|
+
});
|
|
2897
|
+
};
|
|
2898
|
+
for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
|
|
2899
|
+
try {
|
|
2900
|
+
return await run();
|
|
2901
|
+
} finally {
|
|
2902
|
+
await release();
|
|
2903
|
+
stopListening();
|
|
2904
|
+
}
|
|
2905
|
+
};
|
|
2906
|
+
//#endregion
|
|
2907
|
+
//#region src/utils/readBuiltApp.ts
|
|
2908
|
+
const readInfoValue = async (infoPlist, key) => {
|
|
2909
|
+
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
2910
|
+
"-extract",
|
|
2911
|
+
key,
|
|
2912
|
+
"raw",
|
|
2913
|
+
"-o",
|
|
2914
|
+
"-",
|
|
2915
|
+
infoPlist
|
|
2916
|
+
], { reject: false });
|
|
2917
|
+
return exitCode === 0 && stdout.trim() ? stdout.trim() : null;
|
|
2918
|
+
};
|
|
2919
|
+
/**
|
|
2920
|
+
* What the built `.app` reports at runtime, from its Info.plist: the
|
|
2921
|
+
* `CFBundleShortVersionString`, `CFBundleVersion` (the build number) and
|
|
2922
|
+
* `HOT_UPDATER_CHANNEL` the build settings,
|
|
2923
|
+
* an `xcconfig` and `extraParams` expanded.
|
|
2924
|
+
*/
|
|
2925
|
+
const readBuiltApp = async (appPath) => {
|
|
2926
|
+
const infoPlist = path.default.join(appPath, "Info.plist");
|
|
2927
|
+
const [appVersion, channel, buildNumber] = await Promise.all([
|
|
2928
|
+
readInfoValue(infoPlist, "CFBundleShortVersionString"),
|
|
2929
|
+
readInfoValue(infoPlist, "HOT_UPDATER_CHANNEL"),
|
|
2930
|
+
readInfoValue(infoPlist, "CFBundleVersion")
|
|
2931
|
+
]);
|
|
2932
|
+
return {
|
|
2933
|
+
appVersion,
|
|
2934
|
+
channel,
|
|
2935
|
+
buildNumber
|
|
2936
|
+
};
|
|
2937
|
+
};
|
|
2938
|
+
//#endregion
|
|
2939
|
+
//#region src/sandboxTestFlight.ts
|
|
2940
|
+
/**
|
|
2941
|
+
* The states of a version Apple hasn't approved, which still takes builds.
|
|
2942
|
+
* Any other state, such as one Apple adds later, counts as closed: a raise
|
|
2943
|
+
* costs a sandbox build nothing, and an upload to a closed train costs one.
|
|
2944
|
+
*/
|
|
2945
|
+
const OPEN_VERSION_STATES = /* @__PURE__ */ new Set([
|
|
2946
|
+
"PREPARE_FOR_SUBMISSION",
|
|
2947
|
+
"READY_FOR_REVIEW",
|
|
2948
|
+
"WAITING_FOR_REVIEW",
|
|
2949
|
+
"IN_REVIEW",
|
|
2950
|
+
"WAITING_FOR_EXPORT_COMPLIANCE",
|
|
2951
|
+
"PENDING_CONTRACT",
|
|
2952
|
+
"INVALID_BINARY",
|
|
2953
|
+
"METADATA_REJECTED",
|
|
2954
|
+
"REJECTED",
|
|
2955
|
+
"DEVELOPER_REJECTED"
|
|
2956
|
+
]);
|
|
2957
|
+
/** A version as numbers: `1.6.2`, or null for one that isn't only numbers. */
|
|
2958
|
+
const parseVersion = (version) => /^\d+(\.\d+)*$/u.test(version) ? version.split(".").map(Number) : null;
|
|
2959
|
+
/** Negative, zero or positive as `a` is below, equal to or above `b`. */
|
|
2960
|
+
const compareVersions = (a, b) => {
|
|
2961
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
2962
|
+
const difference = (a[i] ?? 0) - (b[i] ?? 0);
|
|
2963
|
+
if (difference !== 0) return difference;
|
|
2964
|
+
}
|
|
2965
|
+
return 0;
|
|
2966
|
+
};
|
|
2967
|
+
/** The highest of `versions`, ignoring any that aren't only numbers. */
|
|
2968
|
+
const highestVersion = (versions) => {
|
|
2969
|
+
let highest = null;
|
|
2970
|
+
for (const text of versions) {
|
|
2971
|
+
const parts = parseVersion(text);
|
|
2972
|
+
if (parts && (!highest || compareVersions(parts, highest.parts) > 0)) highest = {
|
|
2973
|
+
text,
|
|
2974
|
+
parts
|
|
2975
|
+
};
|
|
2976
|
+
}
|
|
2977
|
+
return highest?.text ?? null;
|
|
2978
|
+
};
|
|
2979
|
+
/** The highest integer build number among `versions`. */
|
|
2980
|
+
const highestBuildNumberOf = (versions) => {
|
|
2981
|
+
const numbers = versions.filter((version) => /^\d+$/u.test(version)).map(Number);
|
|
2982
|
+
return numbers.length > 0 ? Math.max(...numbers) : null;
|
|
2983
|
+
};
|
|
2984
|
+
/**
|
|
2985
|
+
* Reads what a sandbox build must fit before it is built: the versions Apple
|
|
2986
|
+
* approved, from `/v1/appStoreVersions`, since a closed train's builds look
|
|
2987
|
+
* like any others in `/v1/builds`, and the app's highest build number.
|
|
2988
|
+
*/
|
|
2989
|
+
const readAppStoreTrain = async ({ bundleId, apiKey, fetch = globalThis.fetch }) => {
|
|
2990
|
+
const privateKey = await readPrivateKey(apiKey.keyPath);
|
|
2991
|
+
const request = createAppStoreConnectRequest({
|
|
2992
|
+
apiKey,
|
|
2993
|
+
privateKey,
|
|
2994
|
+
fetch
|
|
2995
|
+
});
|
|
2996
|
+
const { app } = await findApp(request, bundleId);
|
|
2997
|
+
const [{ data: versions }, { data: builds }] = await Promise.all([retrying(() => request("Reading the App Store versions", "GET", `/v1/apps/${app.id}/appStoreVersions?${new URLSearchParams({
|
|
2998
|
+
"filter[platform]": "IOS",
|
|
2999
|
+
"fields[appStoreVersions]": "versionString,appVersionState,appStoreState",
|
|
3000
|
+
limit: "200"
|
|
3001
|
+
})}`)), retrying(() => request("Reading the app's builds", "GET", `/v1/builds?${new URLSearchParams({
|
|
3002
|
+
"filter[app]": app.id,
|
|
3003
|
+
sort: "-uploadedDate",
|
|
3004
|
+
"fields[builds]": "version",
|
|
3005
|
+
limit: "200"
|
|
3006
|
+
})}`))]);
|
|
3007
|
+
const closed = versions.flatMap(({ attributes }) => {
|
|
3008
|
+
const state = attributes?.appVersionState ?? attributes?.appStoreState;
|
|
3009
|
+
return attributes?.versionString && !(state && OPEN_VERSION_STATES.has(state)) ? [attributes.versionString] : [];
|
|
3010
|
+
});
|
|
3011
|
+
return {
|
|
3012
|
+
highestClosedVersion: highestVersion(closed),
|
|
3013
|
+
highestBuildNumber: highestBuildNumberOf(builds.flatMap(({ attributes }) => attributes?.version ?? []))
|
|
3014
|
+
};
|
|
3015
|
+
};
|
|
3016
|
+
/**
|
|
3017
|
+
* What a sandbox device build needs from App Store Connect, read before the
|
|
3018
|
+
* build. An export for elsewhere has no train, and without an API key it
|
|
3019
|
+
* can't be read: the store would say so after the upload. What a closed train
|
|
3020
|
+
* does to the build waits for the archive (`patchSandboxArchive`): only the
|
|
3021
|
+
* archived app holds the version an `xcconfig` or `extraParams` settled on,
|
|
3022
|
+
* which the project's files don't.
|
|
3023
|
+
*/
|
|
3024
|
+
const prepareSandboxArchive = async ({ schemeConfig, apiKey, updateStrategy, fetch }) => {
|
|
3025
|
+
if (!await exportsForAppStoreConnect(schemeConfig)) return { updateStrategy };
|
|
3026
|
+
if (!apiKey) {
|
|
3027
|
+
_hot_updater_cli_tools.p.log.warn("No App Store Connect API key, so the sandbox build can't check for a closed version.\nSet signing.ascApiKey on the scheme.");
|
|
3028
|
+
return { updateStrategy };
|
|
3029
|
+
}
|
|
3030
|
+
return {
|
|
3031
|
+
train: await readAppStoreTrain({
|
|
3032
|
+
bundleId: schemeConfig.bundleIdentifier,
|
|
3033
|
+
apiKey,
|
|
3034
|
+
...fetch ? { fetch } : {}
|
|
3035
|
+
}),
|
|
3036
|
+
updateStrategy
|
|
3037
|
+
};
|
|
3038
|
+
};
|
|
3039
|
+
/** Whether Apple refuses a build of `version`, as `train` says. */
|
|
3040
|
+
const isVersionTrainClosed = (version, { highestClosedVersion }) => {
|
|
3041
|
+
const parts = parseVersion(version);
|
|
3042
|
+
const closed = highestClosedVersion === null ? null : parseVersion(highestClosedVersion);
|
|
3043
|
+
return parts !== null && closed !== null && compareVersions(parts, closed) <= 0;
|
|
3044
|
+
};
|
|
3045
|
+
/**
|
|
3046
|
+
* What a sandbox build does with a closed version train. Under the fingerprint
|
|
3047
|
+
* strategy it raises its own version past the closed one, which targets no
|
|
3048
|
+
* update. Under appVersion the version is what updates target, so it refuses
|
|
3049
|
+
* rather than move them silently.
|
|
3050
|
+
*/
|
|
3051
|
+
const sandboxVersionDecision = ({ version, train, updateStrategy }) => {
|
|
3052
|
+
if (!isVersionTrainClosed(version, train)) return { kind: "keep" };
|
|
3053
|
+
const closed = train.highestClosedVersion;
|
|
3054
|
+
if (updateStrategy === "appVersion") return {
|
|
3055
|
+
kind: "refuse",
|
|
3056
|
+
message: `App Store Connect closed version ${closed}, so TestFlight refuses ${version}.\nRaise the app version.`
|
|
3057
|
+
};
|
|
3058
|
+
const [major = 0, minor = 0, patch = 0] = parseVersion(closed);
|
|
3059
|
+
return {
|
|
3060
|
+
kind: "raise",
|
|
3061
|
+
version: `${major}.${minor}.${patch + 1}`
|
|
3062
|
+
};
|
|
3063
|
+
};
|
|
3064
|
+
/**
|
|
3065
|
+
* Refuses, before the upload, a sandbox `.ipa` Xcode didn't mark for internal
|
|
3066
|
+
* testing only: App Store Connect would let it reach App Store review.
|
|
3067
|
+
*/
|
|
3068
|
+
const assertIpaIsInternalOnly = async (ipaPath) => {
|
|
3069
|
+
if (!await readIpaInternalTestingOnly(ipaPath)) throw new Error(`${path.default.basename(ipaPath)} isn't marked for internal testing only (TFInternalTestingOnly), so it wasn't uploaded.\nExport it with testFlightInternalTestingOnly.`);
|
|
3070
|
+
};
|
|
3071
|
+
/** How long App Store Connect may take to list a build it accepted. */
|
|
3072
|
+
const AUDIENCE_TIMEOUT_MS = 3e5;
|
|
3073
|
+
const STATE_OF_INTERNAL_BUILD = {
|
|
3074
|
+
MISSING_EXPORT_COMPLIANCE: "missing-compliance",
|
|
3075
|
+
IN_EXPORT_COMPLIANCE_REVIEW: "missing-compliance",
|
|
3076
|
+
PROCESSING: "processing",
|
|
3077
|
+
PROCESSING_EXCEPTION: "failed",
|
|
3078
|
+
EXPIRED: "expired"
|
|
3079
|
+
};
|
|
3080
|
+
/**
|
|
3081
|
+
* One read of a version and build number: its audience, processing state,
|
|
3082
|
+
* expiry, and internal testing state. Null while App Store Connect lists none.
|
|
3083
|
+
*/
|
|
3084
|
+
const readBuildStatus = async (request, { appId, shortVersion, bundleVersion }) => {
|
|
3085
|
+
const { data, included } = await retrying(() => request("Reading the build", "GET", `/v1/builds?${new URLSearchParams({
|
|
3086
|
+
"filter[app]": appId,
|
|
3087
|
+
"filter[version]": bundleVersion,
|
|
3088
|
+
...shortVersion === null ? {} : { "filter[preReleaseVersion.version]": shortVersion },
|
|
3089
|
+
"fields[builds]": "buildAudienceType,processingState,expirationDate,expired,buildBetaDetail",
|
|
3090
|
+
include: "buildBetaDetail",
|
|
3091
|
+
"fields[buildBetaDetails]": "internalBuildState",
|
|
3092
|
+
limit: "1"
|
|
3093
|
+
})}`));
|
|
3094
|
+
const build = data[0];
|
|
3095
|
+
if (!build) return null;
|
|
3096
|
+
const internal = included?.find(({ type }) => type === "buildBetaDetails")?.attributes?.internalBuildState;
|
|
3097
|
+
const processing = build.attributes?.processingState;
|
|
3098
|
+
const expiration = build.attributes?.expirationDate;
|
|
3099
|
+
const state = build.attributes?.expired ? "expired" : processing === "PROCESSING" || !processing ? "processing" : processing !== "VALID" ? "failed" : STATE_OF_INTERNAL_BUILD[internal ?? ""] ?? "ready";
|
|
3100
|
+
return {
|
|
3101
|
+
buildId: build.id,
|
|
3102
|
+
audience: build.attributes?.buildAudienceType ?? null,
|
|
3103
|
+
state,
|
|
3104
|
+
...state === "missing-compliance" && internal === "IN_EXPORT_COMPLIANCE_REVIEW" ? { inExportComplianceReview: true } : {},
|
|
3105
|
+
expiresAtMs: expiration ? Date.parse(expiration) : null
|
|
3106
|
+
};
|
|
3107
|
+
};
|
|
3108
|
+
/**
|
|
3109
|
+
* Fails unless App Store Connect holds the uploaded sandbox build for internal
|
|
3110
|
+
* testing only, as `buildAudienceType` says once the build is listed, and
|
|
3111
|
+
* returns what that read says of it.
|
|
3112
|
+
*/
|
|
3113
|
+
const assertBuildIsInternalOnly = async ({ ipaPath, apiKey, fetch = globalThis.fetch, pollIntervalMs = 5e3, timeoutMs = AUDIENCE_TIMEOUT_MS }) => {
|
|
3114
|
+
const [info, privateKey] = await Promise.all([readIpaInfo(ipaPath), readPrivateKey(apiKey.keyPath)]);
|
|
3115
|
+
const request = createAppStoreConnectRequest({
|
|
3116
|
+
apiKey,
|
|
3117
|
+
privateKey,
|
|
3118
|
+
fetch
|
|
3119
|
+
});
|
|
3120
|
+
const { app, testFlightUrl } = await findApp(request, info.bundleId);
|
|
3121
|
+
const label = `${info.shortVersion} (${info.bundleVersion})`;
|
|
3122
|
+
const deadline = Date.now() + timeoutMs;
|
|
3123
|
+
for (;;) {
|
|
3124
|
+
const status = await readBuildStatus(request, {
|
|
3125
|
+
appId: app.id,
|
|
3126
|
+
shortVersion: info.shortVersion,
|
|
3127
|
+
bundleVersion: info.bundleVersion
|
|
3128
|
+
});
|
|
3129
|
+
if (status?.audience === "INTERNAL_ONLY") return {
|
|
3130
|
+
...status,
|
|
3131
|
+
appId: app.id,
|
|
3132
|
+
testFlightUrl
|
|
3133
|
+
};
|
|
3134
|
+
if (status?.audience) throw new Error(`App Store Connect holds sandbox build ${label} for ${status.audience}, not INTERNAL_ONLY.\nExpire it in TestFlight:\n${testFlightUrl}`);
|
|
3135
|
+
if (Date.now() >= deadline) throw new Error(`App Store Connect didn't say who sandbox build ${label} is for.\nCheck that it is internal only in TestFlight:\n${testFlightUrl}`);
|
|
3136
|
+
await sleep(pollIntervalMs);
|
|
3137
|
+
}
|
|
3138
|
+
};
|
|
3139
|
+
/**
|
|
3140
|
+
* The internal TestFlight group named `name`, read before anything is built.
|
|
3141
|
+
* An external group is refused: its builds go to Beta App Review, and a sandbox
|
|
3142
|
+
* build is for internal testing only.
|
|
3143
|
+
*/
|
|
3144
|
+
const findInternalBetaGroup = async ({ bundleId, name, apiKey, fetch = globalThis.fetch }) => {
|
|
3145
|
+
const privateKey = await readPrivateKey(apiKey.keyPath);
|
|
3146
|
+
const request = createAppStoreConnectRequest({
|
|
3147
|
+
apiKey,
|
|
3148
|
+
privateKey,
|
|
3149
|
+
fetch
|
|
3150
|
+
});
|
|
3151
|
+
const { app, testFlightUrl } = await findApp(request, bundleId);
|
|
3152
|
+
return readInternalBetaGroup(request, {
|
|
3153
|
+
appId: app.id,
|
|
3154
|
+
bundleId,
|
|
3155
|
+
name,
|
|
3156
|
+
testFlightUrl
|
|
3157
|
+
});
|
|
3158
|
+
};
|
|
3159
|
+
/** The group named `name` of an app, refused unless it is internal. */
|
|
3160
|
+
const readInternalBetaGroup = async (request, { appId, bundleId, name, testFlightUrl }) => {
|
|
3161
|
+
const { data } = await retrying(() => request("Reading the TestFlight groups", "GET", `/v1/betaGroups?${new URLSearchParams({
|
|
3162
|
+
"filter[app]": appId,
|
|
3163
|
+
"filter[name]": name,
|
|
3164
|
+
"fields[betaGroups]": "name,isInternalGroup,hasAccessToAllBuilds",
|
|
3165
|
+
limit: "200"
|
|
3166
|
+
})}`));
|
|
3167
|
+
const group = data.find(({ attributes }) => attributes?.name === name);
|
|
3168
|
+
if (!group) throw new Error(`App Store Connect has no TestFlight group ${name} for ${bundleId}.\nCreate it as an internal group:\n${testFlightUrl}`);
|
|
3169
|
+
if (group.attributes?.isInternalGroup !== true) throw new Error(`TestFlight group ${name} is external, and a sandbox build is for internal testing only.\nName an internal group.`);
|
|
2599
3170
|
return {
|
|
2600
|
-
|
|
2601
|
-
|
|
3171
|
+
id: group.id,
|
|
3172
|
+
name,
|
|
3173
|
+
hasAccessToAllBuilds: group.attributes.hasAccessToAllBuilds === true
|
|
2602
3174
|
};
|
|
2603
3175
|
};
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
];
|
|
3176
|
+
/** How long a build's processing may hold up its group and What to Test. */
|
|
3177
|
+
const DISTRIBUTION_TIMEOUT_MS = 9e5;
|
|
3178
|
+
/** What to Test's locale: the language the CLI writes it in. */
|
|
3179
|
+
const WHAT_TO_TEST_LOCALE = "en-US";
|
|
2609
3180
|
/**
|
|
2610
|
-
*
|
|
2611
|
-
* Store Connect
|
|
2612
|
-
*
|
|
2613
|
-
*
|
|
2614
|
-
* certificates are read from the keychain before it is deleted, and the key
|
|
2615
|
-
* is read into memory first, since the build's temporary key file is deleted
|
|
2616
|
-
* on the signal. A second signal restores the keychains at once.
|
|
3181
|
+
* Adds a processed build to its internal group and sets its What to Test, which
|
|
3182
|
+
* App Store Connect takes once the build is processed. A build still processing
|
|
3183
|
+
* after `timeoutMs` keeps its upload: a warning says what to do by hand. Returns
|
|
3184
|
+
* the build's state.
|
|
2617
3185
|
*/
|
|
2618
|
-
const
|
|
2619
|
-
|
|
2620
|
-
const
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
acquireLock
|
|
3186
|
+
const distributeTestFlightBuild = async ({ build: { appId, shortVersion, bundleVersion, testFlightUrl }, group, whatToTest, apiKey, fetch = globalThis.fetch, pollIntervalMs = 15e3, timeoutMs = DISTRIBUTION_TIMEOUT_MS }) => {
|
|
3187
|
+
const privateKey = await readPrivateKey(apiKey.keyPath);
|
|
3188
|
+
const request = createAppStoreConnectRequest({
|
|
3189
|
+
apiKey,
|
|
3190
|
+
privateKey,
|
|
3191
|
+
fetch
|
|
2625
3192
|
});
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
await keychain.dispose();
|
|
2632
|
-
await revokeKeychainCertificates({
|
|
2633
|
-
certificates,
|
|
2634
|
-
apiKey,
|
|
2635
|
-
privateKey,
|
|
2636
|
-
fetch
|
|
2637
|
-
});
|
|
2638
|
-
})();
|
|
2639
|
-
return cleanup;
|
|
3193
|
+
const build = {
|
|
3194
|
+
appId,
|
|
3195
|
+
shortVersion,
|
|
3196
|
+
bundleVersion,
|
|
3197
|
+
testFlightUrl
|
|
2640
3198
|
};
|
|
2641
|
-
const
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3199
|
+
const status = await waitUntilProcessed(request, build, await readBuildStatus(request, build), {
|
|
3200
|
+
pollIntervalMs,
|
|
3201
|
+
timeoutMs,
|
|
3202
|
+
unset: "its testers and What to Test"
|
|
3203
|
+
});
|
|
3204
|
+
if (!status || status.state === "processing") return status;
|
|
3205
|
+
const { buildId } = status;
|
|
3206
|
+
if (group && !group.hasAccessToAllBuilds) await addBuildToGroup(request, build, buildId, group);
|
|
3207
|
+
if (whatToTest !== void 0) await setWhatToTest(request, buildId, whatToTest);
|
|
3208
|
+
return status;
|
|
3209
|
+
};
|
|
3210
|
+
/**
|
|
3211
|
+
* Reads the build until App Store Connect has processed it, from the status a
|
|
3212
|
+
* read already has, for what it takes only then. A build still processing after
|
|
3213
|
+
* `timeoutMs` keeps its upload: a warning says what is `unset`. One that can't
|
|
3214
|
+
* be processed fails.
|
|
3215
|
+
*/
|
|
3216
|
+
const waitUntilProcessed = async (request, build, listed, { pollIntervalMs, timeoutMs, unset }) => {
|
|
3217
|
+
const label = `${build.shortVersion} (${build.bundleVersion})`;
|
|
3218
|
+
const deadline = Date.now() + timeoutMs;
|
|
3219
|
+
let status = listed;
|
|
3220
|
+
while (!status || status.state === "processing") {
|
|
3221
|
+
if (Date.now() >= deadline) {
|
|
3222
|
+
_hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${label}, so ${unset} aren't set.\nSet them in TestFlight:\n${build.testFlightUrl}`);
|
|
3223
|
+
return status;
|
|
2650
3224
|
}
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
3225
|
+
await sleep(pollIntervalMs);
|
|
3226
|
+
status = await readBuildStatus(request, build);
|
|
3227
|
+
}
|
|
3228
|
+
if (status.state === "failed") throw new Error(`App Store Connect couldn't process build ${label}.\nCheck it in TestFlight:\n${build.testFlightUrl}`);
|
|
3229
|
+
return status;
|
|
3230
|
+
};
|
|
3231
|
+
const addBuildToGroup = (request, { shortVersion, bundleVersion }, buildId, group) => retrying(() => request(`Adding build ${shortVersion} (${bundleVersion}) to ${group.name}`, "POST", `/v1/betaGroups/${group.id}/relationships/builds`, { data: [{
|
|
3232
|
+
type: "builds",
|
|
3233
|
+
id: buildId
|
|
3234
|
+
}] }));
|
|
3235
|
+
/**
|
|
3236
|
+
* Applies an internal group to a build already on TestFlight, as an upload does,
|
|
3237
|
+
* so a reused build reaches the group named now. One process reads the app, the
|
|
3238
|
+
* build and the group once each. Reading a build's `betaGroups` relationship
|
|
3239
|
+
* answered 403 when we tried it, so this doesn't check membership first: every
|
|
3240
|
+
* run adds the build. A build not `isOffered` gets nothing: it needs a new
|
|
3241
|
+
* build, which checks the group itself. A build still processing waits as an
|
|
3242
|
+
* upload's does, and the next run applies the group again.
|
|
3243
|
+
*/
|
|
3244
|
+
const applyTestFlightGroup = async ({ bundleId, shortVersion, bundleVersion, name, isOffered, apiKey, fetch = globalThis.fetch, pollIntervalMs = 15e3, timeoutMs = DISTRIBUTION_TIMEOUT_MS }) => {
|
|
3245
|
+
const privateKey = await readPrivateKey(apiKey.keyPath);
|
|
3246
|
+
const request = createAppStoreConnectRequest({
|
|
3247
|
+
apiKey,
|
|
3248
|
+
privateKey,
|
|
3249
|
+
fetch
|
|
3250
|
+
});
|
|
3251
|
+
const { app, testFlightUrl } = await findApp(request, bundleId);
|
|
3252
|
+
const build = {
|
|
3253
|
+
appId: app.id,
|
|
3254
|
+
shortVersion,
|
|
3255
|
+
bundleVersion,
|
|
3256
|
+
testFlightUrl
|
|
2655
3257
|
};
|
|
2656
|
-
|
|
3258
|
+
const listed = await readBuildStatus(request, build);
|
|
3259
|
+
if (!isOffered(listed)) return {
|
|
3260
|
+
testFlightUrl,
|
|
3261
|
+
status: listed,
|
|
3262
|
+
outcome: "not-offered"
|
|
3263
|
+
};
|
|
3264
|
+
const group = await readInternalBetaGroup(request, {
|
|
3265
|
+
appId: app.id,
|
|
3266
|
+
bundleId,
|
|
3267
|
+
name,
|
|
3268
|
+
testFlightUrl
|
|
3269
|
+
});
|
|
3270
|
+
if (group.hasAccessToAllBuilds) return {
|
|
3271
|
+
testFlightUrl,
|
|
3272
|
+
status: listed,
|
|
3273
|
+
outcome: "takes-every-build"
|
|
3274
|
+
};
|
|
3275
|
+
const status = await waitUntilProcessed(request, build, listed, {
|
|
3276
|
+
pollIntervalMs,
|
|
3277
|
+
timeoutMs,
|
|
3278
|
+
unset: "its testers"
|
|
3279
|
+
});
|
|
3280
|
+
if (!status || status.state === "processing") return {
|
|
3281
|
+
testFlightUrl,
|
|
3282
|
+
status,
|
|
3283
|
+
outcome: "pending"
|
|
3284
|
+
};
|
|
3285
|
+
await addBuildToGroup(request, build, status.buildId, group);
|
|
3286
|
+
return {
|
|
3287
|
+
testFlightUrl,
|
|
3288
|
+
status,
|
|
3289
|
+
outcome: "added"
|
|
3290
|
+
};
|
|
3291
|
+
};
|
|
3292
|
+
/** Sets a build's What to Test: creates it, or updates the one a rerun left. */
|
|
3293
|
+
const setWhatToTest = async (request, buildId, whatsNew) => {
|
|
2657
3294
|
try {
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
3295
|
+
await retrying(() => request("Setting What to Test", "POST", "/v1/betaBuildLocalizations", { data: {
|
|
3296
|
+
type: "betaBuildLocalizations",
|
|
3297
|
+
attributes: {
|
|
3298
|
+
locale: WHAT_TO_TEST_LOCALE,
|
|
3299
|
+
whatsNew
|
|
3300
|
+
},
|
|
3301
|
+
relationships: { build: { data: {
|
|
3302
|
+
type: "builds",
|
|
3303
|
+
id: buildId
|
|
3304
|
+
} } }
|
|
3305
|
+
} }));
|
|
3306
|
+
} catch (error) {
|
|
3307
|
+
if (!(error instanceof AppStoreConnectError) || error.status !== 409) throw error;
|
|
3308
|
+
const { data } = await retrying(() => request("Reading What to Test", "GET", `/v1/builds/${buildId}/betaBuildLocalizations?${new URLSearchParams({
|
|
3309
|
+
"fields[betaBuildLocalizations]": "locale",
|
|
3310
|
+
limit: "200"
|
|
3311
|
+
})}`));
|
|
3312
|
+
const existing = data.find(({ attributes }) => attributes?.locale === WHAT_TO_TEST_LOCALE);
|
|
3313
|
+
if (!existing) throw error;
|
|
3314
|
+
await retrying(() => request("Setting What to Test", "PATCH", `/v1/betaBuildLocalizations/${existing.id}`, { data: {
|
|
3315
|
+
type: "betaBuildLocalizations",
|
|
3316
|
+
id: existing.id,
|
|
3317
|
+
attributes: { whatsNew }
|
|
3318
|
+
} }));
|
|
2662
3319
|
}
|
|
2663
3320
|
};
|
|
3321
|
+
/**
|
|
3322
|
+
* What App Store Connect says of a build a run recorded as uploaded, which a
|
|
3323
|
+
* later run reuses: its page, and its state and expiry, or a null status when
|
|
3324
|
+
* it no longer lists the build.
|
|
3325
|
+
*/
|
|
3326
|
+
const readTestFlightBuildStatus = async ({ bundleId, shortVersion, bundleVersion, apiKey, fetch = globalThis.fetch }) => {
|
|
3327
|
+
const privateKey = await readPrivateKey(apiKey.keyPath);
|
|
3328
|
+
const request = createAppStoreConnectRequest({
|
|
3329
|
+
apiKey,
|
|
3330
|
+
privateKey,
|
|
3331
|
+
fetch
|
|
3332
|
+
});
|
|
3333
|
+
const { app, testFlightUrl } = await findApp(request, bundleId);
|
|
3334
|
+
return {
|
|
3335
|
+
testFlightUrl,
|
|
3336
|
+
status: await readBuildStatus(request, {
|
|
3337
|
+
appId: app.id,
|
|
3338
|
+
shortVersion,
|
|
3339
|
+
bundleVersion
|
|
3340
|
+
})
|
|
3341
|
+
};
|
|
3342
|
+
};
|
|
2664
3343
|
//#endregion
|
|
2665
|
-
//#region src/utils/
|
|
2666
|
-
const
|
|
3344
|
+
//#region src/utils/sandboxInfoPlist.ts
|
|
3345
|
+
const URL_TYPE_NAME = "hot-updater-sandbox";
|
|
3346
|
+
const CAMERA_USAGE = "Scans the QR code of a preview to open it in this build.";
|
|
3347
|
+
const LOCAL_NETWORK_USAGE = "Connects to the dev server you choose on this network.";
|
|
3348
|
+
const ATS_KEY = "NSAppTransportSecurity";
|
|
3349
|
+
const ATS_LOCAL_NETWORKING_KEY = "NSAllowsLocalNetworking";
|
|
3350
|
+
/** A refusal the command prints as it is: the build ran as it should. */
|
|
3351
|
+
const refusal = (message) => new _hot_updater_cli_tools.BuildFailedError(message, {
|
|
3352
|
+
logPath: null,
|
|
3353
|
+
summary: [],
|
|
3354
|
+
hint: message
|
|
3355
|
+
});
|
|
3356
|
+
const plutil = (args) => (0, execa.execa)("plutil", args);
|
|
3357
|
+
const readValue = async (plist, key) => {
|
|
2667
3358
|
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
2668
3359
|
"-extract",
|
|
2669
3360
|
key,
|
|
2670
3361
|
"raw",
|
|
2671
3362
|
"-o",
|
|
2672
3363
|
"-",
|
|
2673
|
-
|
|
3364
|
+
plist
|
|
2674
3365
|
], { reject: false });
|
|
2675
3366
|
return exitCode === 0 && stdout.trim() ? stdout.trim() : null;
|
|
2676
3367
|
};
|
|
3368
|
+
const setString = (plist, key, value) => plutil([
|
|
3369
|
+
"-replace",
|
|
3370
|
+
key,
|
|
3371
|
+
"-string",
|
|
3372
|
+
value,
|
|
3373
|
+
plist
|
|
3374
|
+
]);
|
|
3375
|
+
/** Appends a URL type with the sandbox's scheme to the app's own. */
|
|
3376
|
+
const addUrlScheme = async (plist, urlScheme) => {
|
|
3377
|
+
const urlType = JSON.stringify({
|
|
3378
|
+
CFBundleURLName: URL_TYPE_NAME,
|
|
3379
|
+
CFBundleURLSchemes: [urlScheme]
|
|
3380
|
+
});
|
|
3381
|
+
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
3382
|
+
"-extract",
|
|
3383
|
+
"CFBundleURLTypes",
|
|
3384
|
+
"json",
|
|
3385
|
+
"-o",
|
|
3386
|
+
"-",
|
|
3387
|
+
plist
|
|
3388
|
+
], { reject: false });
|
|
3389
|
+
if (exitCode !== 0) {
|
|
3390
|
+
await plutil([
|
|
3391
|
+
"-insert",
|
|
3392
|
+
"CFBundleURLTypes",
|
|
3393
|
+
"-json",
|
|
3394
|
+
`[${urlType}]`,
|
|
3395
|
+
plist
|
|
3396
|
+
]);
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
const existing = JSON.parse(stdout);
|
|
3400
|
+
await plutil([
|
|
3401
|
+
"-insert",
|
|
3402
|
+
`CFBundleURLTypes.${existing.length}`,
|
|
3403
|
+
"-json",
|
|
3404
|
+
urlType,
|
|
3405
|
+
plist
|
|
3406
|
+
]);
|
|
3407
|
+
};
|
|
3408
|
+
const hasKey = async (plist, key) => {
|
|
3409
|
+
const { exitCode } = await (0, execa.execa)("plutil", [
|
|
3410
|
+
"-extract",
|
|
3411
|
+
key,
|
|
3412
|
+
"xml1",
|
|
3413
|
+
"-o",
|
|
3414
|
+
"-",
|
|
3415
|
+
plist
|
|
3416
|
+
], { reject: false });
|
|
3417
|
+
return exitCode === 0;
|
|
3418
|
+
};
|
|
2677
3419
|
/**
|
|
2678
|
-
* What the
|
|
2679
|
-
*
|
|
2680
|
-
*
|
|
2681
|
-
* an `xcconfig` and `extraParams` expanded.
|
|
3420
|
+
* What the sandbox's screen asks the system for: the camera for its QR scanner,
|
|
3421
|
+
* and the local network for a dev server. A value the app already set stays, and
|
|
3422
|
+
* nested App Transport Security entries stay beside the one this adds.
|
|
2682
3423
|
*/
|
|
2683
|
-
const
|
|
2684
|
-
const
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
3424
|
+
const addSandboxPermissions = async (plist) => {
|
|
3425
|
+
for (const [key, value] of [["NSCameraUsageDescription", CAMERA_USAGE], ["NSLocalNetworkUsageDescription", LOCAL_NETWORK_USAGE]]) if (!await hasKey(plist, key)) await plutil([
|
|
3426
|
+
"-insert",
|
|
3427
|
+
key,
|
|
3428
|
+
"-string",
|
|
3429
|
+
value,
|
|
3430
|
+
plist
|
|
3431
|
+
]);
|
|
3432
|
+
if (!await hasKey(plist, ATS_KEY)) await plutil([
|
|
3433
|
+
"-insert",
|
|
3434
|
+
ATS_KEY,
|
|
3435
|
+
"-json",
|
|
3436
|
+
`{"${ATS_LOCAL_NETWORKING_KEY}":true}`,
|
|
3437
|
+
plist
|
|
3438
|
+
]);
|
|
3439
|
+
else if (!await hasKey(plist, `${ATS_KEY}.${ATS_LOCAL_NETWORKING_KEY}`)) await plutil([
|
|
3440
|
+
"-insert",
|
|
3441
|
+
`${ATS_KEY}.${ATS_LOCAL_NETWORKING_KEY}`,
|
|
3442
|
+
"-bool",
|
|
3443
|
+
"true",
|
|
3444
|
+
plist
|
|
3445
|
+
]);
|
|
3446
|
+
};
|
|
3447
|
+
/**
|
|
3448
|
+
* The app's dedicated URL scheme, default channel, and the sandbox's key. The
|
|
3449
|
+
* URL scheme and App Store Connect's app come from the scheme's
|
|
3450
|
+
* `bundleIdentifier`, while Xcode's build settings decide the app's ID, so an
|
|
3451
|
+
* app of another ID stops here, before anything is signed or uploaded.
|
|
3452
|
+
*/
|
|
3453
|
+
const patchAppInfoPlist = async (plist, scheme) => {
|
|
3454
|
+
const built = await readValue(plist, "CFBundleIdentifier");
|
|
3455
|
+
if (built !== scheme.bundleIdentifier) throw refusal(`The sandbox scheme ${scheme.hotUpdaterSchemeName} sets bundleIdentifier ${scheme.bundleIdentifier}, but Xcode built ${built ?? "no bundle ID"}.\nSet bundleIdentifier to the ID its configuration builds.`);
|
|
3456
|
+
await setString(plist, "HOT_UPDATER_CHANNEL", (0, _hot_updater_protocol_native_artifact.sandboxChannelOf)(scheme.hotUpdaterSchemeName));
|
|
3457
|
+
await plutil([
|
|
3458
|
+
"-replace",
|
|
3459
|
+
_hot_updater_protocol_native_artifact.SANDBOX_INFO_PLIST_KEY,
|
|
3460
|
+
"-bool",
|
|
3461
|
+
"true",
|
|
3462
|
+
plist
|
|
3463
|
+
]);
|
|
3464
|
+
await addUrlScheme(plist, (0, _hot_updater_protocol_native_artifact.sandboxUrlSchemeOf)(scheme.bundleIdentifier));
|
|
3465
|
+
await addSandboxPermissions(plist);
|
|
3466
|
+
};
|
|
3467
|
+
/** The Info.plist of each app extension inside an `.app`. */
|
|
3468
|
+
const extensionInfoPlists = (appPath) => ["PlugIns", "Extensions"].flatMap((directory) => {
|
|
3469
|
+
const extensions = path.default.join(appPath, directory);
|
|
3470
|
+
if (!fs.default.existsSync(extensions)) return [];
|
|
3471
|
+
return fs.default.readdirSync(extensions).filter((name) => name.endsWith(".appex")).map((name) => path.default.join(extensions, name, "Info.plist"));
|
|
3472
|
+
});
|
|
3473
|
+
/**
|
|
3474
|
+
* Makes the archived app a sandbox app before `-exportArchive`, which signs
|
|
3475
|
+
* what it finds, so no project file changes: its URL scheme, default channel
|
|
3476
|
+
* and key, and from App Store Connect, the version a closed train needs and
|
|
3477
|
+
* the next build number, in its extensions too, which must match it.
|
|
3478
|
+
*/
|
|
3479
|
+
const patchSandboxArchive = async ({ archivePath, appPath, schemeConfig, plan }) => {
|
|
3480
|
+
const appPlist = path.default.join(appPath, "Info.plist");
|
|
3481
|
+
await patchAppInfoPlist(appPlist, schemeConfig);
|
|
3482
|
+
const train = plan?.train;
|
|
3483
|
+
if (!train) return;
|
|
3484
|
+
const version = await readValue(appPlist, "CFBundleShortVersionString");
|
|
3485
|
+
const buildNumber = await readValue(appPlist, "CFBundleVersion");
|
|
3486
|
+
const raised = version === null ? void 0 : sandboxVersionDecision({
|
|
3487
|
+
version,
|
|
3488
|
+
train,
|
|
3489
|
+
updateStrategy: plan.updateStrategy
|
|
3490
|
+
});
|
|
3491
|
+
if (raised?.kind === "refuse") throw refusal(raised.message);
|
|
3492
|
+
const newVersion = raised?.kind === "raise" ? raised.version : null;
|
|
3493
|
+
const newBuildNumber = nextBuildNumber(buildNumber, train.highestBuildNumber);
|
|
3494
|
+
const bundles = [appPlist, ...extensionInfoPlists(appPath)];
|
|
3495
|
+
const archivePlist = path.default.join(archivePath, "Info.plist");
|
|
3496
|
+
const properties = "ApplicationProperties";
|
|
3497
|
+
if (newVersion !== null) {
|
|
3498
|
+
for (const plist of bundles) await setString(plist, "CFBundleShortVersionString", newVersion);
|
|
3499
|
+
await setString(archivePlist, `${properties}.CFBundleShortVersionString`, newVersion);
|
|
3500
|
+
_hot_updater_cli_tools.p.log.info(`Sandbox version ${newVersion}: App Store Connect closed ${version}.`);
|
|
3501
|
+
}
|
|
3502
|
+
if (newBuildNumber !== null) {
|
|
3503
|
+
for (const plist of bundles) await setString(plist, "CFBundleVersion", newBuildNumber);
|
|
3504
|
+
await setString(archivePlist, `${properties}.CFBundleVersion`, newBuildNumber);
|
|
3505
|
+
}
|
|
3506
|
+
};
|
|
3507
|
+
/**
|
|
3508
|
+
* The build number to set over `current`: the one after App Store Connect's
|
|
3509
|
+
* highest, or null to keep `current`, already higher, or one that isn't an
|
|
3510
|
+
* integer such as `1.2.3`.
|
|
3511
|
+
*/
|
|
3512
|
+
const nextBuildNumber = (current, highest) => {
|
|
3513
|
+
if (highest === null) return null;
|
|
3514
|
+
if (current !== null && !/^\d+$/u.test(current)) return null;
|
|
3515
|
+
if (current !== null && Number(current) > highest) return null;
|
|
3516
|
+
return String(highest + 1);
|
|
3517
|
+
};
|
|
3518
|
+
/**
|
|
3519
|
+
* Makes a built simulator `.app` a sandbox app, and signs it again ad hoc, as
|
|
3520
|
+
* Xcode does for the simulator, since its signature covers the Info.plist.
|
|
3521
|
+
*/
|
|
3522
|
+
const patchSandboxSimulatorApp = async (appPath, schemeConfig) => {
|
|
3523
|
+
await patchAppInfoPlist(path.default.join(appPath, "Info.plist"), schemeConfig);
|
|
3524
|
+
await (0, execa.execa)("codesign", [
|
|
3525
|
+
"--force",
|
|
3526
|
+
"--sign",
|
|
3527
|
+
"-",
|
|
3528
|
+
"--timestamp=none",
|
|
3529
|
+
"--preserve-metadata=entitlements",
|
|
3530
|
+
appPath
|
|
2689
3531
|
]);
|
|
2690
|
-
return {
|
|
2691
|
-
appVersion,
|
|
2692
|
-
channel,
|
|
2693
|
-
buildNumber
|
|
2694
|
-
};
|
|
2695
3532
|
};
|
|
2696
3533
|
//#endregion
|
|
2697
3534
|
//#region src/buildIos.ts
|
|
@@ -2704,7 +3541,7 @@ const readBuiltApp = async (appPath) => {
|
|
|
2704
3541
|
* once the caller has copied it out. A failed build removes it, and every
|
|
2705
3542
|
* other directory the build used, itself.
|
|
2706
3543
|
*/
|
|
2707
|
-
const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, logHeader = [] }) => {
|
|
3544
|
+
const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials, sandbox, logHeader = [] }) => {
|
|
2708
3545
|
await assertXcodebuildExist();
|
|
2709
3546
|
if (schemeConfig.simulator && schemeConfig.destination.length) {
|
|
2710
3547
|
_hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
|
|
@@ -2717,6 +3554,7 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
|
|
|
2717
3554
|
schemeConfig,
|
|
2718
3555
|
minBundleId,
|
|
2719
3556
|
credentials,
|
|
3557
|
+
sandbox,
|
|
2720
3558
|
buildDirectory,
|
|
2721
3559
|
logHeader
|
|
2722
3560
|
}),
|
|
@@ -2727,7 +3565,7 @@ const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools
|
|
|
2727
3565
|
throw error;
|
|
2728
3566
|
}
|
|
2729
3567
|
};
|
|
2730
|
-
const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory, logHeader }) => {
|
|
3568
|
+
const buildInto = async ({ schemeConfig, minBundleId, credentials, sandbox, buildDirectory, logHeader }) => {
|
|
2731
3569
|
const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
|
|
2732
3570
|
if (schemeConfig.simulator) {
|
|
2733
3571
|
const appDir = path.default.join(buildDirectory, "app");
|
|
@@ -2742,10 +3580,13 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
|
|
|
2742
3580
|
configuration: schemeConfig.configuration,
|
|
2743
3581
|
deviceType: "simulator",
|
|
2744
3582
|
installPods: schemeConfig.installPods,
|
|
3583
|
+
xcconfig: schemeConfig.xcconfig,
|
|
2745
3584
|
extraParams: schemeConfig.extraParams,
|
|
2746
3585
|
useGenericDestination: true,
|
|
2747
|
-
minBundleId
|
|
3586
|
+
minBundleId,
|
|
3587
|
+
sandbox: schemeConfig.sandbox
|
|
2748
3588
|
});
|
|
3589
|
+
if (schemeConfig.sandbox) await patchSandboxSimulatorApp(appPath, schemeConfig);
|
|
2749
3590
|
return {
|
|
2750
3591
|
buildDirectory,
|
|
2751
3592
|
buildArtifactPath: appPath,
|
|
@@ -2760,17 +3601,21 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
|
|
|
2760
3601
|
if (!signing) return archiveAndExport({
|
|
2761
3602
|
schemeConfig,
|
|
2762
3603
|
minBundleId,
|
|
3604
|
+
sandbox,
|
|
2763
3605
|
buildDirectory,
|
|
2764
3606
|
logHeader
|
|
2765
3607
|
});
|
|
2766
|
-
const
|
|
2767
|
-
|
|
3608
|
+
const registeredDevices = await exportsForRegisteredDevices(schemeConfig);
|
|
3609
|
+
const ownKeychain = registeredDevices || await exportsForAppStoreConnect(schemeConfig);
|
|
3610
|
+
if ((0, _hot_updater_cli_tools.isCI)() && !ownKeychain) _hot_updater_cli_tools.p.log.info("Signing in the runner's own keychains (not an App Store Connect or ad hoc export).");
|
|
2768
3611
|
return withSigningKeychain({
|
|
2769
|
-
enabled: (0, _hot_updater_cli_tools.isCI)() &&
|
|
3612
|
+
enabled: (0, _hot_updater_cli_tools.isCI)() && ownKeychain,
|
|
3613
|
+
keepDistribution: registeredDevices,
|
|
2770
3614
|
apiKey: signing.apiKey,
|
|
2771
3615
|
run: () => archiveAndExport({
|
|
2772
3616
|
schemeConfig,
|
|
2773
3617
|
minBundleId,
|
|
3618
|
+
sandbox,
|
|
2774
3619
|
buildDirectory,
|
|
2775
3620
|
logHeader,
|
|
2776
3621
|
authenticationArgs: signing.authenticationArgs
|
|
@@ -2782,7 +3627,7 @@ const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirector
|
|
|
2782
3627
|
* Archives a device build and exports its `.ipa`, or returns the
|
|
2783
3628
|
* `.xcarchive` of a scheme it cannot export.
|
|
2784
3629
|
*/
|
|
2785
|
-
const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, logHeader, authenticationArgs }) => {
|
|
3630
|
+
const archiveAndExport = async ({ schemeConfig, minBundleId, sandbox, buildDirectory, logHeader, authenticationArgs }) => {
|
|
2786
3631
|
const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
|
|
2787
3632
|
const archiveDir = path.default.join(buildDirectory, "archive");
|
|
2788
3633
|
const exportDir = path.default.join(buildDirectory, "export");
|
|
@@ -2800,9 +3645,19 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, log
|
|
|
2800
3645
|
configuration: schemeConfig.configuration,
|
|
2801
3646
|
installPods: schemeConfig.installPods,
|
|
2802
3647
|
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
2803
|
-
header: logHeader
|
|
3648
|
+
header: logHeader,
|
|
3649
|
+
sandbox: schemeConfig.sandbox
|
|
2804
3650
|
});
|
|
2805
3651
|
const appFromArchive = extractAppFromXcarchive(archivePath);
|
|
3652
|
+
if (schemeConfig.sandbox) {
|
|
3653
|
+
if (!appFromArchive) throw new Error(`The archive ${archivePath} has no app to patch.`);
|
|
3654
|
+
await patchSandboxArchive({
|
|
3655
|
+
archivePath,
|
|
3656
|
+
appPath: appFromArchive,
|
|
3657
|
+
schemeConfig,
|
|
3658
|
+
plan: sandbox
|
|
3659
|
+
});
|
|
3660
|
+
}
|
|
2806
3661
|
const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
|
|
2807
3662
|
if (appFromArchive && archivedAppPath) await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
|
|
2808
3663
|
if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
|
|
@@ -2823,15 +3678,17 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, log
|
|
|
2823
3678
|
logFilePath: logFilePath ?? void 0,
|
|
2824
3679
|
title: schemeConfig.scheme
|
|
2825
3680
|
});
|
|
2826
|
-
|
|
3681
|
+
const { exportOptionsPlist } = schemeConfig;
|
|
3682
|
+
if (exportOptionsPlist && !schemeConfig.sandbox) await exportArchive(exportOptionsPlist);
|
|
2827
3683
|
else {
|
|
2828
3684
|
const exportOptionsDir = await createRandomTmpDir();
|
|
2829
3685
|
try {
|
|
2830
|
-
await exportArchive(await writeAppStoreConnectExportOptions(exportOptionsDir));
|
|
3686
|
+
await exportArchive(exportOptionsPlist ? await resolveSandboxExportOptions(schemeConfig.hotUpdaterSchemeName, exportOptionsPlist, exportOptionsDir) : await writeAppStoreConnectExportOptions(exportOptionsDir, { internalTestingOnly: schemeConfig.sandbox }));
|
|
2831
3687
|
} finally {
|
|
2832
3688
|
await removeTmpDir(exportOptionsDir);
|
|
2833
3689
|
}
|
|
2834
3690
|
}
|
|
3691
|
+
await keepOnlyIpas(exportDir);
|
|
2835
3692
|
const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
|
|
2836
3693
|
if (!ipaFile) throw new Error("IPA file not found after export");
|
|
2837
3694
|
return {
|
|
@@ -2841,6 +3698,17 @@ const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, log
|
|
|
2841
3698
|
builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
|
|
2842
3699
|
};
|
|
2843
3700
|
};
|
|
3701
|
+
/**
|
|
3702
|
+
* Leaves only the `.ipa` in an export's folder. Xcode writes `Packaging.log`
|
|
3703
|
+
* beside it, which holds the API key's bearer token for its 20 minutes, and the
|
|
3704
|
+
* build's output and its cache copy the whole build directory into the project.
|
|
3705
|
+
*/
|
|
3706
|
+
const keepOnlyIpas = async (exportDir) => {
|
|
3707
|
+
for (const entry of await fs.default.promises.readdir(exportDir)) if (!entry.endsWith(".ipa")) await fs.default.promises.rm(path.default.join(exportDir, entry), {
|
|
3708
|
+
recursive: true,
|
|
3709
|
+
force: true
|
|
3710
|
+
});
|
|
3711
|
+
};
|
|
2844
3712
|
const extractAppFromXcarchive = (archivePath) => {
|
|
2845
3713
|
const productsPath = path.default.join(archivePath, "Products", "Applications");
|
|
2846
3714
|
if (!fs.default.existsSync(productsPath)) return;
|
|
@@ -2849,28 +3717,6 @@ const extractAppFromXcarchive = (archivePath) => {
|
|
|
2849
3717
|
_hot_updater_cli_tools.p.log.warn("Failed to extract .app from xcarchive");
|
|
2850
3718
|
};
|
|
2851
3719
|
//#endregion
|
|
2852
|
-
//#region src/utils/enrichNativeBuildIosScheme.ts
|
|
2853
|
-
const resolveExportOptionsPlist = (exportOptionsPlist) => {
|
|
2854
|
-
const resolvedExportOptionsPlist = exportOptionsPlist ? path.default.isAbsolute(exportOptionsPlist) ? exportOptionsPlist : path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), exportOptionsPlist) : void 0;
|
|
2855
|
-
if (resolvedExportOptionsPlist && !fs.default.existsSync(resolvedExportOptionsPlist)) {
|
|
2856
|
-
_hot_updater_cli_tools.p.log.error(`exportOptionsPlist doesn't exist in ${resolvedExportOptionsPlist}`);
|
|
2857
|
-
process.exit(1);
|
|
2858
|
-
}
|
|
2859
|
-
return resolvedExportOptionsPlist;
|
|
2860
|
-
};
|
|
2861
|
-
const enrichNativeBuildIosScheme = async ({ scheme, hotUpdaterSchemeName }) => {
|
|
2862
|
-
return {
|
|
2863
|
-
platform: "ios",
|
|
2864
|
-
installPods: false,
|
|
2865
|
-
configuration: "Release",
|
|
2866
|
-
destination: [],
|
|
2867
|
-
simulator: false,
|
|
2868
|
-
...scheme,
|
|
2869
|
-
exportOptionsPlist: resolveExportOptionsPlist(scheme.exportOptionsPlist),
|
|
2870
|
-
hotUpdaterSchemeName
|
|
2871
|
-
};
|
|
2872
|
-
};
|
|
2873
|
-
//#endregion
|
|
2874
3720
|
//#region src/utils/extractBuiltInBundle.ts
|
|
2875
3721
|
/** The file React Native's Xcode build phase writes into the `.app`. */
|
|
2876
3722
|
const BUILT_IN_BUNDLE = "main.jsbundle";
|
|
@@ -2926,16 +3772,125 @@ const archiveAppBundle = async ({ appPath, outputPath }) => {
|
|
|
2926
3772
|
]);
|
|
2927
3773
|
};
|
|
2928
3774
|
//#endregion
|
|
3775
|
+
//#region src/simulator.ts
|
|
3776
|
+
const simctl = async (args) => {
|
|
3777
|
+
try {
|
|
3778
|
+
return (await (0, execa.execa)("xcrun", ["simctl", ...args])).stdout;
|
|
3779
|
+
} catch (error) {
|
|
3780
|
+
const { code, stderr, shortMessage } = error;
|
|
3781
|
+
if (code === "ENOENT") throw new Error("The iOS Simulator needs Xcode on a Mac.\nInstall Xcode, then run this again.");
|
|
3782
|
+
throw new Error(stderr?.trim() || shortMessage || String(error));
|
|
3783
|
+
}
|
|
3784
|
+
};
|
|
3785
|
+
/** Every simulator on this Mac, booted or not. */
|
|
3786
|
+
const listSimulators = async () => {
|
|
3787
|
+
const { devices } = JSON.parse(await simctl([
|
|
3788
|
+
"list",
|
|
3789
|
+
"devices",
|
|
3790
|
+
"--json"
|
|
3791
|
+
]));
|
|
3792
|
+
return Object.values(devices).flat().map(({ udid, name, state }) => ({
|
|
3793
|
+
udid,
|
|
3794
|
+
name,
|
|
3795
|
+
state
|
|
3796
|
+
}));
|
|
3797
|
+
};
|
|
3798
|
+
/**
|
|
3799
|
+
* The simulator to install on: `udid`, or else the only booted one, by its
|
|
3800
|
+
* UDID. `simctl`'s own `booted` alias picks one of several by itself.
|
|
3801
|
+
*/
|
|
3802
|
+
const selectSimulator = (simulators, udid) => {
|
|
3803
|
+
if (udid !== void 0) {
|
|
3804
|
+
const simulator = simulators.find((candidate) => candidate.udid === udid);
|
|
3805
|
+
if (simulator === void 0) throw new Error(`No iOS Simulator has the UDID ${udid}.\nList them: xcrun simctl list devices`);
|
|
3806
|
+
if (simulator.state !== "Booted") throw new Error(`${simulator.name} isn't booted.\nBoot it: xcrun simctl boot ${udid}`);
|
|
3807
|
+
return simulator;
|
|
3808
|
+
}
|
|
3809
|
+
const booted = simulators.filter(({ state }) => state === "Booted");
|
|
3810
|
+
const [only] = booted;
|
|
3811
|
+
if (only === void 0) throw new Error("No iOS Simulator is booted.\nBoot one: open -a Simulator");
|
|
3812
|
+
if (booted.length > 1) throw new Error([
|
|
3813
|
+
`${booted.length} iOS Simulators are booted:`,
|
|
3814
|
+
...booted.map(({ udid, name }) => ` ${udid} ${name}`),
|
|
3815
|
+
"Pick one with --simulator <udid>."
|
|
3816
|
+
].join("\n"));
|
|
3817
|
+
return only;
|
|
3818
|
+
};
|
|
3819
|
+
/**
|
|
3820
|
+
* Unpacks a simulator build's `.app.zip` into `outputPath`, and returns the
|
|
3821
|
+
* `.app` in it.
|
|
3822
|
+
*/
|
|
3823
|
+
const extractSimulatorApp = async ({ archivePath, outputPath }) => {
|
|
3824
|
+
await extractAppBundle({
|
|
3825
|
+
archivePath,
|
|
3826
|
+
outputPath
|
|
3827
|
+
});
|
|
3828
|
+
const app = (await fs.default.promises.readdir(outputPath)).find((entry) => entry.endsWith(".app"));
|
|
3829
|
+
if (app === void 0) throw new Error(`${path.default.basename(archivePath)} holds no .app.`);
|
|
3830
|
+
return path.default.join(outputPath, app);
|
|
3831
|
+
};
|
|
3832
|
+
/** Installs a `.app` on a booted simulator, and opens it. */
|
|
3833
|
+
const installOnSimulator = async ({ udid, appPath, appId }) => {
|
|
3834
|
+
await simctl([
|
|
3835
|
+
"install",
|
|
3836
|
+
udid,
|
|
3837
|
+
appPath
|
|
3838
|
+
]);
|
|
3839
|
+
await simctl([
|
|
3840
|
+
"launch",
|
|
3841
|
+
udid,
|
|
3842
|
+
appId
|
|
3843
|
+
]);
|
|
3844
|
+
};
|
|
3845
|
+
//#endregion
|
|
3846
|
+
//#region src/utils/enrichNativeBuildIosScheme.ts
|
|
3847
|
+
const resolveExportOptionsPlist = (exportOptionsPlist) => {
|
|
3848
|
+
const resolvedExportOptionsPlist = exportOptionsPlist ? path.default.isAbsolute(exportOptionsPlist) ? exportOptionsPlist : path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), exportOptionsPlist) : void 0;
|
|
3849
|
+
if (resolvedExportOptionsPlist && !fs.default.existsSync(resolvedExportOptionsPlist)) {
|
|
3850
|
+
_hot_updater_cli_tools.p.log.error(`exportOptionsPlist doesn't exist in ${resolvedExportOptionsPlist}`);
|
|
3851
|
+
process.exit(1);
|
|
3852
|
+
}
|
|
3853
|
+
return resolvedExportOptionsPlist;
|
|
3854
|
+
};
|
|
3855
|
+
const enrichNativeBuildIosScheme = async ({ scheme, hotUpdaterSchemeName }) => {
|
|
3856
|
+
return {
|
|
3857
|
+
platform: "ios",
|
|
3858
|
+
installPods: false,
|
|
3859
|
+
configuration: "Release",
|
|
3860
|
+
destination: [],
|
|
3861
|
+
simulator: false,
|
|
3862
|
+
...scheme,
|
|
3863
|
+
exportOptionsPlist: resolveExportOptionsPlist(scheme.exportOptionsPlist),
|
|
3864
|
+
hotUpdaterSchemeName
|
|
3865
|
+
};
|
|
3866
|
+
};
|
|
3867
|
+
//#endregion
|
|
2929
3868
|
exports.ASC_INTEGRATIONS_URL = ASC_INTEGRATIONS_URL;
|
|
2930
3869
|
exports.TestFlightBuildExistsError = TestFlightBuildExistsError;
|
|
3870
|
+
exports.addAscDevices = addAscDevices;
|
|
3871
|
+
exports.applyTestFlightGroup = applyTestFlightGroup;
|
|
2931
3872
|
exports.archiveAppBundle = archiveAppBundle;
|
|
3873
|
+
exports.assertBuildIsInternalOnly = assertBuildIsInternalOnly;
|
|
3874
|
+
exports.assertIpaIsInternalOnly = assertIpaIsInternalOnly;
|
|
3875
|
+
exports.assertSandboxExportOptions = assertSandboxExportOptions;
|
|
2932
3876
|
exports.assertTestFlightExportOptions = assertTestFlightExportOptions;
|
|
2933
3877
|
exports.buildIos = buildIos;
|
|
3878
|
+
exports.distributeTestFlightBuild = distributeTestFlightBuild;
|
|
2934
3879
|
exports.enrichNativeBuildIosScheme = enrichNativeBuildIosScheme;
|
|
2935
3880
|
exports.extractAppBundle = extractAppBundle;
|
|
2936
3881
|
exports.extractBuiltInBundle = extractBuiltInBundle;
|
|
3882
|
+
exports.extractSimulatorApp = extractSimulatorApp;
|
|
3883
|
+
exports.findInternalBetaGroup = findInternalBetaGroup;
|
|
2937
3884
|
exports.findTestFlightBuild = findTestFlightBuild;
|
|
3885
|
+
exports.installOnSimulator = installOnSimulator;
|
|
2938
3886
|
exports.installPodsIfNeeded = installPodsIfNeeded;
|
|
3887
|
+
exports.isVersionTrainClosed = isVersionTrainClosed;
|
|
2939
3888
|
exports.issueAscApiKey = issueAscApiKey;
|
|
3889
|
+
exports.listAscDevices = listAscDevices;
|
|
3890
|
+
exports.listSimulators = listSimulators;
|
|
3891
|
+
exports.prepareSandboxArchive = prepareSandboxArchive;
|
|
3892
|
+
exports.readIpaHasExportComplianceKey = readIpaHasExportComplianceKey;
|
|
3893
|
+
exports.readTestFlightBuildStatus = readTestFlightBuildStatus;
|
|
2940
3894
|
exports.resolveTestFlightUploadKey = resolveTestFlightUploadKey;
|
|
3895
|
+
exports.selectSimulator = selectSimulator;
|
|
2941
3896
|
exports.uploadToTestFlight = uploadToTestFlight;
|