@mpgd/cli 0.34.0 → 0.36.0
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/android-bundle-signer.d.ts +2 -0
- package/dist/android-bundle-signer.js +38 -0
- package/dist/android-signing-session.d.ts +22 -0
- package/dist/android-signing-session.js +198 -0
- package/dist/artifact-inspection-environment.d.ts +2 -0
- package/dist/artifact-inspection-environment.js +31 -0
- package/dist/asc-pin.json +26 -0
- package/dist/capacitor-shell-starter.d.ts +1 -0
- package/dist/capacitor-shell-starter.js +9 -6
- package/dist/deploy-planning.d.ts +64 -0
- package/dist/deploy-planning.js +471 -0
- package/dist/deploy-process.d.ts +28 -0
- package/dist/deploy-process.js +197 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +483 -13
- package/dist/ios-ipa-inspection.js +151 -0
- package/dist/ios-keychain-import.swift +92 -0
- package/dist/ios-plist.d.ts +2 -0
- package/dist/ios-plist.js +75 -0
- package/dist/ios-signing-profile.d.ts +12 -0
- package/dist/ios-signing-profile.js +60 -0
- package/dist/ios-signing-session.d.ts +22 -0
- package/dist/ios-signing-session.js +256 -0
- package/dist/kit-upgrade-target-readiness.d.ts +8 -0
- package/dist/kit-upgrade-target-readiness.js +119 -0
- package/dist/kit-upgrade.d.ts +41 -0
- package/dist/kit-upgrade.js +489 -0
- package/dist/native-build-info.json +5 -0
- package/dist/native-build-target.js +12032 -0
- package/dist/native-deploy-run.d.ts +35 -0
- package/dist/native-deploy-run.js +462 -0
- package/dist/native-deploy-submission.d.ts +40 -0
- package/dist/native-deploy-submission.js +244 -0
- package/dist/native-deploy-workflow.d.ts +11 -0
- package/dist/native-deploy-workflow.js +328 -0
- package/dist/play-internal-submission.d.ts +32 -0
- package/dist/play-internal-submission.js +247 -0
- package/dist/play-publisher-port.d.ts +30 -0
- package/dist/play-publisher-port.js +1 -0
- package/dist/play-sdk-adapter.js +78 -0
- package/dist/release-state.d.ts +140 -0
- package/dist/release-state.js +1046 -0
- package/dist/release-workspace.d.ts +61 -0
- package/dist/release-workspace.js +504 -0
- package/dist/testflight-submission.d.ts +49 -0
- package/dist/testflight-submission.js +462 -0
- package/package.json +20 -13
- package/templates/phaser-game/README.md +23 -8
- package/templates/phaser-game/src/main.ts +34 -33
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// tools/target/native-ios-inspection.ts
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
// tools/target/native-inspection-environment.ts
|
|
8
|
+
var allowedInspectionEnvironmentNames = [
|
|
9
|
+
"PATH",
|
|
10
|
+
"Path",
|
|
11
|
+
"HOME",
|
|
12
|
+
"TMPDIR",
|
|
13
|
+
"TMP",
|
|
14
|
+
"TEMP",
|
|
15
|
+
"LANG",
|
|
16
|
+
"LC_ALL",
|
|
17
|
+
"LC_CTYPE",
|
|
18
|
+
"JAVA_HOME",
|
|
19
|
+
"ANDROID_HOME",
|
|
20
|
+
"ANDROID_SDK_ROOT",
|
|
21
|
+
"DEVELOPER_DIR",
|
|
22
|
+
"SDKROOT",
|
|
23
|
+
"SystemRoot",
|
|
24
|
+
"windir",
|
|
25
|
+
"PATHEXT",
|
|
26
|
+
"USERPROFILE"
|
|
27
|
+
];
|
|
28
|
+
function nativeInspectionEnvironment(source = process.env) {
|
|
29
|
+
const result = {};
|
|
30
|
+
for (const name of allowedInspectionEnvironmentNames) {
|
|
31
|
+
const value = source[name];
|
|
32
|
+
if (value !== void 0) {
|
|
33
|
+
result[name] = value;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// tools/target/native-ios-inspection.ts
|
|
40
|
+
function inspectSignedIosArchive(archive, input) {
|
|
41
|
+
return inspectSignedIosApplication(path.join(archive, "Products/Applications/App.app"), input);
|
|
42
|
+
}
|
|
43
|
+
function inspectSignedIosIpa(ipa, input) {
|
|
44
|
+
if (!existsSync(ipa) || !statSync(ipa).isFile()) {
|
|
45
|
+
throw new Error("iOS store IPA is missing.");
|
|
46
|
+
}
|
|
47
|
+
const runner = input.runner ?? { run: runCommand };
|
|
48
|
+
const listing = runner.run("unzip", ["-Z1", ipa]);
|
|
49
|
+
if (listing.status !== 0) {
|
|
50
|
+
throw new Error("iOS store IPA could not be listed.");
|
|
51
|
+
}
|
|
52
|
+
const entries = listing.stdout.split(/\r?\n/u).filter(Boolean);
|
|
53
|
+
if (entries.some((entry) => entry.startsWith("/") || entry.includes("\\") || entry.split("/").some((segment) => segment === ".." || segment === "."))) {
|
|
54
|
+
throw new Error("iOS store IPA has an unsafe path.");
|
|
55
|
+
}
|
|
56
|
+
const appManifests = entries.filter((entry) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(entry));
|
|
57
|
+
if (appManifests.length !== 1) {
|
|
58
|
+
throw new Error("iOS store IPA must contain exactly one app manifest.");
|
|
59
|
+
}
|
|
60
|
+
const appRelative = path.dirname(appManifests[0] ?? "");
|
|
61
|
+
const extracted = mkdtempSync(path.join(tmpdir(), "mpgd-ios-inspection-"));
|
|
62
|
+
try {
|
|
63
|
+
const result = runner.run("unzip", ["-q", ipa, "-d", extracted]);
|
|
64
|
+
if (result.status !== 0) {
|
|
65
|
+
throw new Error("iOS store IPA could not be extracted.");
|
|
66
|
+
}
|
|
67
|
+
return inspectSignedIosApplication(path.join(extracted, appRelative), input);
|
|
68
|
+
} finally {
|
|
69
|
+
rmSync(extracted, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function inspectSignedIosApplication(appPath, input) {
|
|
73
|
+
const plist = path.join(appPath, "Info.plist");
|
|
74
|
+
if (!existsSync(plist) || !statSync(plist).isFile()) {
|
|
75
|
+
throw new Error("Signed iOS app manifest is missing.");
|
|
76
|
+
}
|
|
77
|
+
if (!existsSync(path.join(appPath, "Assets.car")) || existsSync(path.join(appPath, "Info-Smoke.plist"))) {
|
|
78
|
+
throw new Error("Signed iOS app contains smoke assets or omits release icons.");
|
|
79
|
+
}
|
|
80
|
+
const runner = input.runner ?? { run: runCommand };
|
|
81
|
+
const signature = runner.run("codesign", ["--verify", "--deep", "--strict", appPath]);
|
|
82
|
+
if (signature.status !== 0) {
|
|
83
|
+
throw new Error("iOS app code signature could not be verified.");
|
|
84
|
+
}
|
|
85
|
+
const signingInfo = runner.run("codesign", ["-dv", "--verbose=4", appPath]);
|
|
86
|
+
const signingText = `${signingInfo.stdout}
|
|
87
|
+
${signingInfo.stderr}`;
|
|
88
|
+
const expectedTeamLine = `TeamIdentifier=${input.expectedTeamId}`;
|
|
89
|
+
if (signingInfo.status !== 0 || /Signature=adhoc/iu.test(signingText) || !signingText.split(/\r?\n/u).some((line) => line.trim() === expectedTeamLine)) {
|
|
90
|
+
throw new Error("iOS app is not signed for the expected development team.");
|
|
91
|
+
}
|
|
92
|
+
const fields = [
|
|
93
|
+
["bundleId", "CFBundleIdentifier", input.expectedBundleId],
|
|
94
|
+
["marketingVersion", "CFBundleShortVersionString", input.expectedMarketingVersion],
|
|
95
|
+
["buildNumber", "CFBundleVersion", input.expectedBuildNumber]
|
|
96
|
+
];
|
|
97
|
+
const observed = Object.fromEntries(
|
|
98
|
+
fields.map(([name, key, expected]) => {
|
|
99
|
+
const result = runner.run("/usr/libexec/PlistBuddy", ["-c", `Print :${key}`, plist]);
|
|
100
|
+
if (result.status !== 0 || result.stdout.trim() !== expected) {
|
|
101
|
+
throw new Error(`iOS app ${name} does not match release identity.`);
|
|
102
|
+
}
|
|
103
|
+
return [name, expected];
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
const configFiles = [
|
|
107
|
+
path.join(appPath, "capacitor.config.json"),
|
|
108
|
+
path.join(appPath, "public/capacitor.config.json")
|
|
109
|
+
];
|
|
110
|
+
if (!configFiles.some((file) => existsSync(file))) {
|
|
111
|
+
throw new Error("Signed iOS app Capacitor configuration is missing.");
|
|
112
|
+
}
|
|
113
|
+
for (const configFile of configFiles) {
|
|
114
|
+
if (existsSync(configFile)) {
|
|
115
|
+
assertNoDebugServer(configFile);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { ...observed, teamId: input.expectedTeamId, signed: true };
|
|
119
|
+
}
|
|
120
|
+
function assertNoDebugServer(file) {
|
|
121
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
122
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
123
|
+
throw new Error("Signed iOS app has malformed Capacitor configuration.");
|
|
124
|
+
}
|
|
125
|
+
const config = value;
|
|
126
|
+
const server = config.server;
|
|
127
|
+
const ios = config.ios;
|
|
128
|
+
if (typeof server === "object" && server !== null && "url" in server || typeof ios === "object" && ios !== null && "webContentsDebuggingEnabled" in ios && ios.webContentsDebuggingEnabled === true) {
|
|
129
|
+
throw new Error("Signed iOS app retains a live-reload or debug bridge configuration.");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function runCommand(command, args) {
|
|
133
|
+
const result = spawnSync(command, [...args], {
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
env: nativeInspectionEnvironment(),
|
|
136
|
+
maxBuffer: 4 * 1024 * 1024
|
|
137
|
+
});
|
|
138
|
+
if (result.error !== void 0) {
|
|
139
|
+
throw new Error(`Required iOS inspection command is unavailable: ${command}.`);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
status: result.status,
|
|
143
|
+
stdout: result.stdout,
|
|
144
|
+
stderr: result.stderr
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export {
|
|
148
|
+
inspectSignedIosApplication,
|
|
149
|
+
inspectSignedIosArchive,
|
|
150
|
+
inspectSignedIosIpa
|
|
151
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
import Security
|
|
4
|
+
|
|
5
|
+
func requiredEnvironment(_ name: String) throws -> String {
|
|
6
|
+
guard let value = ProcessInfo.processInfo.environment[name], !value.isEmpty else {
|
|
7
|
+
throw NSError(domain: "mpgd.ios.signing", code: 1, userInfo: [
|
|
8
|
+
NSLocalizedDescriptionKey: "Missing iOS signing input: \(name)",
|
|
9
|
+
])
|
|
10
|
+
}
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
do {
|
|
15
|
+
let keychainPath = try requiredEnvironment("MPGD_IOS_SESSION_KEYCHAIN")
|
|
16
|
+
let keychainPassword = try requiredEnvironment("MPGD_IOS_SESSION_KEYCHAIN_PASSWORD")
|
|
17
|
+
let certificatePath = try requiredEnvironment("MPGD_IOS_SIGNING_P12")
|
|
18
|
+
let certificatePassword = try requiredEnvironment("MPGD_IOS_SIGNING_P12_PASSWORD")
|
|
19
|
+
let certificateData = try Data(contentsOf: URL(fileURLWithPath: certificatePath))
|
|
20
|
+
|
|
21
|
+
var keychain: SecKeychain?
|
|
22
|
+
let createStatus = keychainPath.withCString { path in
|
|
23
|
+
keychainPassword.withCString { password in
|
|
24
|
+
SecKeychainCreate(path, UInt32(keychainPassword.utf8.count), password, false, nil, &keychain)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
guard createStatus == errSecSuccess, let keychain else {
|
|
28
|
+
throw NSError(domain: "mpgd.ios.signing", code: Int(createStatus), userInfo: [
|
|
29
|
+
NSLocalizedDescriptionKey: "Could not create an isolated iOS signing keychain.",
|
|
30
|
+
])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var trustedCodesign: SecTrustedApplication?
|
|
34
|
+
let trustedStatus = SecTrustedApplicationCreateFromPath(
|
|
35
|
+
"/usr/bin/codesign", &trustedCodesign)
|
|
36
|
+
guard trustedStatus == errSecSuccess, let trustedCodesign else {
|
|
37
|
+
throw NSError(domain: "mpgd.ios.signing", code: Int(trustedStatus), userInfo: [
|
|
38
|
+
NSLocalizedDescriptionKey: "Could not grant code signing access to the isolated identity.",
|
|
39
|
+
])
|
|
40
|
+
}
|
|
41
|
+
var access: SecAccess?
|
|
42
|
+
let accessStatus = SecAccessCreate(
|
|
43
|
+
"mpgd isolated code signing" as CFString,
|
|
44
|
+
[trustedCodesign] as CFArray,
|
|
45
|
+
&access)
|
|
46
|
+
guard accessStatus == errSecSuccess, let access else {
|
|
47
|
+
throw NSError(domain: "mpgd.ios.signing", code: Int(accessStatus), userInfo: [
|
|
48
|
+
NSLocalizedDescriptionKey: "Could not create isolated code signing access controls.",
|
|
49
|
+
])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let options: [String: Any] = [
|
|
53
|
+
kSecImportExportPassphrase as String: certificatePassword,
|
|
54
|
+
kSecImportExportKeychain as String: keychain,
|
|
55
|
+
kSecImportExportAccess as String: access,
|
|
56
|
+
]
|
|
57
|
+
var imported: CFArray?
|
|
58
|
+
let importStatus = SecPKCS12Import(certificateData as CFData, options as CFDictionary, &imported)
|
|
59
|
+
guard importStatus == errSecSuccess,
|
|
60
|
+
let items = imported as? [[String: Any]],
|
|
61
|
+
items.count == 1,
|
|
62
|
+
let rawIdentity = items[0][kSecImportItemIdentity as String],
|
|
63
|
+
CFGetTypeID(rawIdentity as CFTypeRef) == SecIdentityGetTypeID() else {
|
|
64
|
+
throw NSError(domain: "mpgd.ios.signing", code: Int(importStatus), userInfo: [
|
|
65
|
+
NSLocalizedDescriptionKey: "Could not import one iOS signing identity into its keychain.",
|
|
66
|
+
])
|
|
67
|
+
}
|
|
68
|
+
let identity = rawIdentity as! SecIdentity
|
|
69
|
+
|
|
70
|
+
var certificate: SecCertificate?
|
|
71
|
+
let certificateStatus = SecIdentityCopyCertificate(identity, &certificate)
|
|
72
|
+
guard certificateStatus == errSecSuccess, let certificate else {
|
|
73
|
+
throw NSError(domain: "mpgd.ios.signing", code: Int(certificateStatus), userInfo: [
|
|
74
|
+
NSLocalizedDescriptionKey: "Imported iOS signing identity has no certificate.",
|
|
75
|
+
])
|
|
76
|
+
}
|
|
77
|
+
let certificateBytes = SecCertificateCopyData(certificate) as Data
|
|
78
|
+
let sha256 = SHA256.hash(data: certificateBytes)
|
|
79
|
+
.map { String(format: "%02X", $0) }.joined()
|
|
80
|
+
let sha1 = Insecure.SHA1.hash(data: certificateBytes)
|
|
81
|
+
.map { String(format: "%02X", $0) }.joined()
|
|
82
|
+
let response = ["certificateSha256": sha256, "certificateSha1": sha1]
|
|
83
|
+
let json = try JSONSerialization.data(withJSONObject: response, options: [.sortedKeys])
|
|
84
|
+
FileHandle.standardOutput.write(json)
|
|
85
|
+
FileHandle.standardOutput.write(Data([0x0A]))
|
|
86
|
+
} catch {
|
|
87
|
+
let failure = error as NSError
|
|
88
|
+
let detail = failure.domain == "mpgd.ios.signing"
|
|
89
|
+
? failure.localizedDescription : "Could not load the signing input."
|
|
90
|
+
FileHandle.standardError.write(Data("iOS signing identity import failed: \(detail)\n".utf8))
|
|
91
|
+
exit(1)
|
|
92
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { DOMParser } from '@xmldom/xmldom';
|
|
2
|
+
/** Parse the bounded XML representation produced by plutil for profile preflight. */
|
|
3
|
+
export function parseIosProfilePlist(source) {
|
|
4
|
+
if (Buffer.byteLength(source) > 1024 * 1024) {
|
|
5
|
+
throw new Error('iOS provisioning profile plist exceeds 1 MiB.');
|
|
6
|
+
}
|
|
7
|
+
const document = new DOMParser({
|
|
8
|
+
onError: () => {
|
|
9
|
+
throw new Error('iOS provisioning profile plist is malformed.');
|
|
10
|
+
},
|
|
11
|
+
}).parseFromString(source, 'application/xml');
|
|
12
|
+
const root = document.documentElement;
|
|
13
|
+
if (root === null || root.tagName !== 'plist') {
|
|
14
|
+
throw new Error('iOS provisioning profile plist has no plist root.');
|
|
15
|
+
}
|
|
16
|
+
const children = elements(root);
|
|
17
|
+
if (children.length !== 1) {
|
|
18
|
+
throw new Error('iOS provisioning profile plist must contain one value.');
|
|
19
|
+
}
|
|
20
|
+
return parseValue(children[0]);
|
|
21
|
+
}
|
|
22
|
+
function parseValue(element) {
|
|
23
|
+
switch (element.tagName) {
|
|
24
|
+
case 'dict': {
|
|
25
|
+
const children = elements(element);
|
|
26
|
+
if (children.length % 2 !== 0) {
|
|
27
|
+
throw new Error('iOS provisioning profile plist dictionary is malformed.');
|
|
28
|
+
}
|
|
29
|
+
const result = Object.create(null);
|
|
30
|
+
for (let index = 0; index < children.length; index += 2) {
|
|
31
|
+
const key = children[index];
|
|
32
|
+
const value = children[index + 1];
|
|
33
|
+
const name = (key.textContent ?? '').trim();
|
|
34
|
+
if (key.tagName !== 'key' || name === '' || Object.hasOwn(result, name)) {
|
|
35
|
+
throw new Error('iOS provisioning profile plist key is malformed or duplicated.');
|
|
36
|
+
}
|
|
37
|
+
result[name] = parseValue(value);
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
case 'array':
|
|
42
|
+
return elements(element).map((child) => parseValue(child));
|
|
43
|
+
case 'string':
|
|
44
|
+
case 'date':
|
|
45
|
+
return (element.textContent ?? '').trim();
|
|
46
|
+
case 'data':
|
|
47
|
+
return (element.textContent ?? '').replace(/\s+/gu, '');
|
|
48
|
+
case 'true':
|
|
49
|
+
return true;
|
|
50
|
+
case 'false':
|
|
51
|
+
return false;
|
|
52
|
+
case 'integer': {
|
|
53
|
+
const text = (element.textContent ?? '').trim();
|
|
54
|
+
if (!/^-?\d+$/u.test(text)) {
|
|
55
|
+
throw new Error('iOS provisioning profile plist integer is invalid.');
|
|
56
|
+
}
|
|
57
|
+
const value = Number(text);
|
|
58
|
+
if (!Number.isSafeInteger(value)) {
|
|
59
|
+
throw new Error('iOS provisioning profile plist integer is invalid.');
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
throw new Error(`Unsupported iOS provisioning profile plist value ${element.tagName}.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function elements(parent) {
|
|
68
|
+
const result = [];
|
|
69
|
+
for (let child = parent.firstChild; child !== null; child = child.nextSibling) {
|
|
70
|
+
if (child.nodeType === 1) {
|
|
71
|
+
result.push(child);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface IosDistributionProfile {
|
|
2
|
+
readonly uuid: string;
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly expiresAt: string;
|
|
5
|
+
}
|
|
6
|
+
/** Preflight only: the signed archive and IPA are still inspected after Xcode export. */
|
|
7
|
+
export declare function inspectIosDistributionProfile(value: unknown, input: {
|
|
8
|
+
readonly bundleId: string;
|
|
9
|
+
readonly teamId: string;
|
|
10
|
+
readonly certificateSha256: string;
|
|
11
|
+
readonly now?: Date;
|
|
12
|
+
}): IosDistributionProfile;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const profileUuidPattern = /^[0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}$/iu;
|
|
3
|
+
const certFingerprintPattern = /^[0-9A-F]{64}$/u;
|
|
4
|
+
/** Preflight only: the signed archive and IPA are still inspected after Xcode export. */
|
|
5
|
+
export function inspectIosDistributionProfile(value, input) {
|
|
6
|
+
if (!isRecord(value)) {
|
|
7
|
+
throw new Error('iOS provisioning profile is malformed.');
|
|
8
|
+
}
|
|
9
|
+
const uuid = value.UUID;
|
|
10
|
+
const name = value.Name;
|
|
11
|
+
const expiresAt = value.ExpirationDate;
|
|
12
|
+
const expiration = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
|
|
13
|
+
if (typeof uuid !== 'string' || !profileUuidPattern.test(uuid)
|
|
14
|
+
|| typeof name !== 'string' || name.trim() === ''
|
|
15
|
+
|| typeof expiresAt !== 'string'
|
|
16
|
+
|| !Number.isFinite(expiration) || expiration <= (input.now ?? new Date()).getTime()) {
|
|
17
|
+
throw new Error('iOS provisioning profile is malformed or expired.');
|
|
18
|
+
}
|
|
19
|
+
if (!/^[A-Z0-9]{10}$/u.test(input.teamId)
|
|
20
|
+
|| !certFingerprintPattern.test(input.certificateSha256)
|
|
21
|
+
|| !Array.isArray(value.TeamIdentifier)
|
|
22
|
+
|| !value.TeamIdentifier.includes(input.teamId)) {
|
|
23
|
+
throw new Error('iOS provisioning profile does not match the signing team.');
|
|
24
|
+
}
|
|
25
|
+
const appIdPrefixes = value.ApplicationIdentifierPrefix;
|
|
26
|
+
if (!Array.isArray(appIdPrefixes)
|
|
27
|
+
|| appIdPrefixes.length !== 1
|
|
28
|
+
|| typeof appIdPrefixes[0] !== 'string'
|
|
29
|
+
|| !/^[A-Z0-9]{10}$/u.test(appIdPrefixes[0])) {
|
|
30
|
+
throw new Error('iOS provisioning profile has an invalid App ID prefix.');
|
|
31
|
+
}
|
|
32
|
+
if (!isRecord(value.Entitlements)
|
|
33
|
+
|| value.Entitlements['application-identifier'] !== `${appIdPrefixes[0]}.${input.bundleId}`
|
|
34
|
+
|| value.Entitlements['get-task-allow'] === true
|
|
35
|
+
|| (value.Entitlements['com.apple.developer.team-identifier'] !== undefined
|
|
36
|
+
&& value.Entitlements['com.apple.developer.team-identifier'] !== input.teamId)) {
|
|
37
|
+
throw new Error('iOS provisioning profile does not match the app ID or distribution rights.');
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(value.DeveloperCertificates)
|
|
40
|
+
|| !value.DeveloperCertificates.some((certificate) => {
|
|
41
|
+
if (typeof certificate !== 'string' || certificate === '') {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const observed = createHash('sha256')
|
|
45
|
+
.update(Buffer.from(certificate, 'base64'))
|
|
46
|
+
.digest('hex')
|
|
47
|
+
.toUpperCase();
|
|
48
|
+
return observed === input.certificateSha256;
|
|
49
|
+
})) {
|
|
50
|
+
throw new Error('iOS provisioning profile does not contain the signing certificate.');
|
|
51
|
+
}
|
|
52
|
+
if (value.ProvisionsAllDevices === true
|
|
53
|
+
|| (Array.isArray(value.ProvisionedDevices) && value.ProvisionedDevices.length > 0)) {
|
|
54
|
+
throw new Error('iOS provisioning profile is not an App Store distribution profile.');
|
|
55
|
+
}
|
|
56
|
+
return { uuid, name, expiresAt };
|
|
57
|
+
}
|
|
58
|
+
function isRecord(value) {
|
|
59
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface IosSigningSessionInput {
|
|
2
|
+
readonly p12File: string;
|
|
3
|
+
readonly p12Password: string;
|
|
4
|
+
readonly provisioningProfileFile: string;
|
|
5
|
+
readonly bundleId: string;
|
|
6
|
+
readonly teamId: string;
|
|
7
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
8
|
+
readonly signal?: AbortSignal;
|
|
9
|
+
readonly temporaryParent?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface IosSigningSession {
|
|
12
|
+
readonly environment: NodeJS.ProcessEnv;
|
|
13
|
+
readonly certificateSha256: string;
|
|
14
|
+
readonly keychainFile: string;
|
|
15
|
+
readonly profileUuid: string;
|
|
16
|
+
readonly exportOptionsPlist: string;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
}
|
|
19
|
+
/** Import one identity into an owned keychain and pair it with an App Store profile. */
|
|
20
|
+
export declare function prepareIosSigningSession(input: IosSigningSessionInput): Promise<IosSigningSession>;
|
|
21
|
+
/** Preserve build errors when temporary signing material cleanup also fails. */
|
|
22
|
+
export declare function withIosSigningSession<T>(input: IosSigningSessionInput, action: (session: IosSigningSession) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { runReleaseProcess } from './deploy-process.js';
|
|
7
|
+
import { parseIosProfilePlist } from './ios-plist.js';
|
|
8
|
+
import { inspectIosDistributionProfile } from './ios-signing-profile.js';
|
|
9
|
+
const certificatePattern = /^[0-9A-F]{64}$/u;
|
|
10
|
+
const identityPattern = /^[0-9A-F]{40}$/u;
|
|
11
|
+
const maximumCredentialBytes = 64 * 1024 * 1024;
|
|
12
|
+
/** Import one identity into an owned keychain and pair it with an App Store profile. */
|
|
13
|
+
export async function prepareIosSigningSession(input) {
|
|
14
|
+
if (process.platform !== 'darwin') {
|
|
15
|
+
throw new Error('iOS signing sessions require macOS.');
|
|
16
|
+
}
|
|
17
|
+
if (input.p12Password === '' || !/^[A-Z0-9]{10}$/u.test(input.teamId)
|
|
18
|
+
|| !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/u.test(input.bundleId)) {
|
|
19
|
+
throw new Error('iOS signing requires a P12 password, team ID and explicit bundle ID.');
|
|
20
|
+
}
|
|
21
|
+
const p12File = requiredCredentialFile(input.p12File, 'iOS P12');
|
|
22
|
+
const profileFile = requiredCredentialFile(input.provisioningProfileFile, 'iOS profile');
|
|
23
|
+
const ownedRoot = mkdtempSync(path.join(input.temporaryParent ?? tmpdir(), 'mpgd-ios-signing-'));
|
|
24
|
+
const keychainFile = path.join(ownedRoot, 'signing.keychain-db');
|
|
25
|
+
const decodedProfile = path.join(ownedRoot, 'profile.plist');
|
|
26
|
+
const decodedXml = path.join(ownedRoot, 'profile.xml');
|
|
27
|
+
const exportOptionsPlist = path.join(ownedRoot, 'ExportOptions.plist');
|
|
28
|
+
const keychainPassword = randomBytes(32).toString('hex');
|
|
29
|
+
const environment = {
|
|
30
|
+
...(input.environment ?? process.env),
|
|
31
|
+
HOME: ownedRoot,
|
|
32
|
+
CFFIXED_USER_HOME: ownedRoot,
|
|
33
|
+
MPGD_IOS_SESSION_KEYCHAIN: keychainFile,
|
|
34
|
+
MPGD_IOS_SESSION_KEYCHAIN_PASSWORD: keychainPassword,
|
|
35
|
+
MPGD_IOS_SIGNING_P12: p12File,
|
|
36
|
+
MPGD_IOS_SIGNING_P12_PASSWORD: input.p12Password,
|
|
37
|
+
MPGD_IOS_TEAM_ID: input.teamId,
|
|
38
|
+
MPGD_IOS_SIGNING_STYLE: 'Manual',
|
|
39
|
+
MPGD_IOS_EXPORT_OPTIONS_PLIST: exportOptionsPlist,
|
|
40
|
+
};
|
|
41
|
+
let installedProfile;
|
|
42
|
+
try {
|
|
43
|
+
chmodSync(ownedRoot, 0o700);
|
|
44
|
+
mkdirSync(path.join(ownedRoot, 'Library', 'Preferences'), {
|
|
45
|
+
recursive: true,
|
|
46
|
+
mode: 0o700,
|
|
47
|
+
});
|
|
48
|
+
const helper = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ios-keychain-import.swift');
|
|
49
|
+
if (!existsSync(helper)) {
|
|
50
|
+
throw new Error('The installed CLI is missing its iOS signing helper.');
|
|
51
|
+
}
|
|
52
|
+
const imported = await runReleaseProcess({
|
|
53
|
+
command: 'swift',
|
|
54
|
+
args: [helper],
|
|
55
|
+
cwd: ownedRoot,
|
|
56
|
+
environment,
|
|
57
|
+
timeoutMs: 120_000,
|
|
58
|
+
maxOutputBytes: 1024 * 1024,
|
|
59
|
+
captureMachineStdout: true,
|
|
60
|
+
signal: input.signal,
|
|
61
|
+
});
|
|
62
|
+
const identity = JSON.parse(imported.machineStdout ?? '');
|
|
63
|
+
const certificateSha256 = identity.certificateSha256;
|
|
64
|
+
const certificateSha1 = identity.certificateSha1;
|
|
65
|
+
if (imported.truncated || typeof certificateSha256 !== 'string'
|
|
66
|
+
|| !certificatePattern.test(certificateSha256)
|
|
67
|
+
|| typeof certificateSha1 !== 'string' || !identityPattern.test(certificateSha1)) {
|
|
68
|
+
throw new Error('iOS signing identity helper returned an invalid certificate fingerprint.');
|
|
69
|
+
}
|
|
70
|
+
await runReleaseProcess({
|
|
71
|
+
command: 'security',
|
|
72
|
+
args: ['set-key-partition-list', '-S', 'apple:', '-s', keychainFile],
|
|
73
|
+
cwd: ownedRoot,
|
|
74
|
+
environment,
|
|
75
|
+
stdin: `${keychainPassword}\n`,
|
|
76
|
+
timeoutMs: 30_000,
|
|
77
|
+
signal: input.signal,
|
|
78
|
+
});
|
|
79
|
+
await runReleaseProcess({
|
|
80
|
+
command: 'security',
|
|
81
|
+
args: ['list-keychains', '-d', 'user', '-s', keychainFile],
|
|
82
|
+
cwd: ownedRoot,
|
|
83
|
+
environment,
|
|
84
|
+
timeoutMs: 10_000,
|
|
85
|
+
signal: input.signal,
|
|
86
|
+
});
|
|
87
|
+
const searchList = await runReleaseProcess({
|
|
88
|
+
command: 'security',
|
|
89
|
+
args: ['list-keychains', '-d', 'user'],
|
|
90
|
+
cwd: ownedRoot,
|
|
91
|
+
environment,
|
|
92
|
+
timeoutMs: 10_000,
|
|
93
|
+
captureMachineStdout: true,
|
|
94
|
+
signal: input.signal,
|
|
95
|
+
});
|
|
96
|
+
if (searchList.truncated || !searchList.machineStdout?.includes(keychainFile)) {
|
|
97
|
+
throw new Error('Isolated iOS signing keychain is not in its session search list.');
|
|
98
|
+
}
|
|
99
|
+
delete environment.MPGD_IOS_SIGNING_P12;
|
|
100
|
+
delete environment.MPGD_IOS_SIGNING_P12_PASSWORD;
|
|
101
|
+
delete environment.MPGD_IOS_SESSION_KEYCHAIN_PASSWORD;
|
|
102
|
+
const decodeEnvironment = { ...(input.environment ?? process.env) };
|
|
103
|
+
delete decodeEnvironment.MPGD_IOS_SIGNING_P12;
|
|
104
|
+
delete decodeEnvironment.MPGD_IOS_SIGNING_P12_PASSWORD;
|
|
105
|
+
delete decodeEnvironment.MPGD_IOS_SESSION_KEYCHAIN_PASSWORD;
|
|
106
|
+
await runReleaseProcess({
|
|
107
|
+
command: 'security',
|
|
108
|
+
args: ['cms', '-D', '-i', profileFile, '-o', decodedProfile],
|
|
109
|
+
cwd: ownedRoot,
|
|
110
|
+
environment: decodeEnvironment,
|
|
111
|
+
timeoutMs: 30_000,
|
|
112
|
+
signal: input.signal,
|
|
113
|
+
});
|
|
114
|
+
await runReleaseProcess({
|
|
115
|
+
command: 'plutil',
|
|
116
|
+
args: ['-convert', 'xml1', '-o', decodedXml, decodedProfile],
|
|
117
|
+
cwd: ownedRoot,
|
|
118
|
+
environment: decodeEnvironment,
|
|
119
|
+
timeoutMs: 30_000,
|
|
120
|
+
signal: input.signal,
|
|
121
|
+
});
|
|
122
|
+
const profile = inspectIosDistributionProfile(parseIosProfilePlist(readFileSync(decodedXml, 'utf8')), { bundleId: input.bundleId, teamId: input.teamId, certificateSha256 });
|
|
123
|
+
const profileDirectory = path.join(ownedRoot, 'Library', 'Developer', 'Xcode', 'UserData', 'Provisioning Profiles');
|
|
124
|
+
mkdirSync(profileDirectory, { recursive: true, mode: 0o700 });
|
|
125
|
+
const destination = path.join(profileDirectory, `${profile.uuid}.mobileprovision`);
|
|
126
|
+
if (isSymlink(destination)) {
|
|
127
|
+
throw new Error('The iOS provisioning profile destination must not be a symlink.');
|
|
128
|
+
}
|
|
129
|
+
if (existsSync(destination)) {
|
|
130
|
+
if (!readFileSync(destination).equals(readFileSync(profileFile))) {
|
|
131
|
+
throw new Error('An existing iOS provisioning profile has the same UUID but different bytes.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const profileSha256 = fileDigest(profileFile);
|
|
136
|
+
copyFileSync(profileFile, destination, constants.COPYFILE_EXCL);
|
|
137
|
+
installedProfile = { path: destination, sha256: profileSha256 };
|
|
138
|
+
chmodSync(destination, 0o600);
|
|
139
|
+
}
|
|
140
|
+
writeFileSync(exportOptionsPlist, exportPlist(input.teamId, input.bundleId, profile.uuid, certificateSha1), { mode: 0o600, flag: 'wx' });
|
|
141
|
+
environment.MPGD_IOS_PROVISIONING_PROFILE_SPECIFIER = profile.uuid;
|
|
142
|
+
environment.MPGD_IOS_SIGNING_IDENTITY = certificateSha1;
|
|
143
|
+
environment.MPGD_IOS_SIGNING_KEYCHAIN = keychainFile;
|
|
144
|
+
let disposed = false;
|
|
145
|
+
return {
|
|
146
|
+
environment,
|
|
147
|
+
certificateSha256,
|
|
148
|
+
keychainFile,
|
|
149
|
+
profileUuid: profile.uuid,
|
|
150
|
+
exportOptionsPlist,
|
|
151
|
+
dispose() {
|
|
152
|
+
if (disposed) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
removeOwnedSigningMaterial(ownedRoot, installedProfile);
|
|
156
|
+
disposed = true;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
try {
|
|
162
|
+
removeOwnedSigningMaterial(ownedRoot, installedProfile);
|
|
163
|
+
}
|
|
164
|
+
catch (cleanupError) {
|
|
165
|
+
throw new AggregateError([error, cleanupError], 'iOS signing setup and cleanup failed.');
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Preserve build errors when temporary signing material cleanup also fails. */
|
|
171
|
+
export async function withIosSigningSession(input, action) {
|
|
172
|
+
const session = await prepareIosSigningSession(input);
|
|
173
|
+
let outcome;
|
|
174
|
+
try {
|
|
175
|
+
outcome = { ok: true, value: await action(session) };
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
outcome = { ok: false, error };
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
session.dispose();
|
|
182
|
+
}
|
|
183
|
+
catch (cleanupError) {
|
|
184
|
+
if (!outcome.ok) {
|
|
185
|
+
throw new AggregateError([outcome.error, cleanupError], 'iOS signing build and cleanup both failed.');
|
|
186
|
+
}
|
|
187
|
+
throw cleanupError;
|
|
188
|
+
}
|
|
189
|
+
if (!outcome.ok) {
|
|
190
|
+
throw outcome.error;
|
|
191
|
+
}
|
|
192
|
+
return outcome.value;
|
|
193
|
+
}
|
|
194
|
+
function removeOwnedSigningMaterial(root, profile) {
|
|
195
|
+
const errors = [];
|
|
196
|
+
if (profile !== undefined) {
|
|
197
|
+
try {
|
|
198
|
+
if (existsSync(profile.path) || isSymlink(profile.path)) {
|
|
199
|
+
if (isSymlink(profile.path) || fileDigest(profile.path) !== profile.sha256) {
|
|
200
|
+
throw new Error('Owned iOS provisioning profile changed before cleanup.');
|
|
201
|
+
}
|
|
202
|
+
rmSync(profile.path);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
errors.push(error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
errors.push(error);
|
|
214
|
+
}
|
|
215
|
+
if (errors.length === 1) {
|
|
216
|
+
throw errors[0];
|
|
217
|
+
}
|
|
218
|
+
if (errors.length > 1) {
|
|
219
|
+
throw new AggregateError(errors, 'iOS signing cleanup failed.');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function fileDigest(file) {
|
|
223
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
224
|
+
}
|
|
225
|
+
function requiredCredentialFile(file, label) {
|
|
226
|
+
const absolute = path.resolve(file);
|
|
227
|
+
let stat;
|
|
228
|
+
try {
|
|
229
|
+
stat = lstatSync(absolute);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
throw new Error(`${label} is missing or unreadable.`);
|
|
233
|
+
}
|
|
234
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > maximumCredentialBytes) {
|
|
235
|
+
throw new Error(`${label} must be a nonempty regular file under 64 MiB.`);
|
|
236
|
+
}
|
|
237
|
+
return absolute;
|
|
238
|
+
}
|
|
239
|
+
function isSymlink(file) {
|
|
240
|
+
try {
|
|
241
|
+
return lstatSync(file).isSymbolicLink();
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function exportPlist(teamId, bundleId, profileUuid, certificateSha1) {
|
|
248
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
249
|
+
<plist version="1.0"><dict>
|
|
250
|
+
<key>method</key><string>app-store-connect</string>
|
|
251
|
+
<key>signingStyle</key><string>manual</string>
|
|
252
|
+
<key>teamID</key><string>${teamId}</string>
|
|
253
|
+
<key>signingCertificate</key><string>${certificateSha1}</string>
|
|
254
|
+
<key>provisioningProfiles</key><dict><key>${bundleId}</key><string>${profileUuid}</string></dict>
|
|
255
|
+
</dict></plist>\n`;
|
|
256
|
+
}
|