Files
openhuman/scripts/ios-appstore-metadata.mjs
T

320 lines
9.7 KiB
JavaScript
Executable File

#!/usr/bin/env node
import crypto from "node:crypto";
import { readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const metadataDir = path.join(rootDir, "fastlane/metadata/en-US");
const screenshotDir = path.join(rootDir, "fastlane/screenshots/en-US");
const apiBase = "https://api.appstoreconnect.apple.com/v1";
const appId = process.env.ASC_APP_ID || "6761229174";
const locale = process.env.ASC_LOCALE || "en-US";
const platform = process.env.ASC_PLATFORM || "IOS";
const screenshotDisplayType =
process.env.ASC_SCREENSHOT_DISPLAY_TYPE || "APP_IPHONE_67";
const versionString =
process.env.ASC_VERSION_STRING ||
JSON.parse(await readFile(path.join(rootDir, "app/package.json"), "utf8"))
.version;
const keyId = process.env.ASC_KEY_ID;
const issuerId = process.env.ASC_ISSUER_ID;
const keyPath = process.env.ASC_KEY_PATH;
if (!keyId || !issuerId || !keyPath) {
throw new Error("ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_PATH are required.");
}
function base64Url(input) {
return Buffer.from(input)
.toString("base64")
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
async function createJwt() {
const privateKey = await readFile(keyPath, "utf8");
const now = Math.floor(Date.now() / 1000);
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
const payload = {
iss: issuerId,
aud: "appstoreconnect-v1",
iat: now,
exp: now + 15 * 60,
};
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}`;
const signature = crypto.sign("sha256", Buffer.from(signingInput), {
key: privateKey,
dsaEncoding: "ieee-p1363",
});
return `${signingInput}.${base64Url(signature)}`;
}
const jwt = await createJwt();
async function request(
method,
resourcePath,
body,
{ raw = false, headers = {} } = {},
) {
const res = await fetch(`${apiBase}${resourcePath}`, {
method,
headers: {
Authorization: `Bearer ${jwt}`,
Accept: "application/json",
...(body && !raw ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? (raw ? body : JSON.stringify(body)) : undefined,
});
const text = await res.text();
const payload = text ? JSON.parse(text) : null;
if (!res.ok) {
const message =
payload?.errors
?.map((e) => `${e.status} ${e.code}: ${e.detail}`)
.join("\n") || text;
throw new Error(`${method} ${resourcePath} failed: ${message}`);
}
return payload;
}
async function uploadOperation(operation, fileBuffer) {
const headers = Object.fromEntries(
(operation.requestHeaders || []).map((h) => [h.name, h.value]),
);
const offset = Number(operation.offset || 0);
const length = Number(operation.length || fileBuffer.length);
const chunk = fileBuffer.subarray(offset, offset + length);
const res = await fetch(operation.url, {
method: operation.method,
headers,
body: chunk,
});
if (!res.ok) {
throw new Error(
`asset upload failed: ${res.status} ${res.statusText} ${await res.text()}`,
);
}
}
async function textFile(name) {
return (await readFile(path.join(metadataDir, name), "utf8")).trim();
}
async function firstPage(resourcePath) {
const payload = await request("GET", resourcePath);
return payload.data || [];
}
async function getOrCreateAppInfoLocalization() {
const appInfos = await firstPage(`/apps/${appId}/appInfos?limit=10`);
if (!appInfos.length) {
throw new Error(`No appInfos found for app ${appId}.`);
}
const appInfo = appInfos[0];
const existing = await firstPage(
`/appInfos/${appInfo.id}/appInfoLocalizations?limit=50`,
);
const match = existing.find((item) => item.attributes?.locale === locale);
if (match) return match;
const created = await request("POST", "/appInfoLocalizations", {
data: {
type: "appInfoLocalizations",
attributes: { locale, name: await textFile("name.txt") },
relationships: {
appInfo: { data: { type: "appInfos", id: appInfo.id } },
},
},
});
return created.data;
}
async function updateAppInfoLocalization() {
const localization = await getOrCreateAppInfoLocalization();
await request("PATCH", `/appInfoLocalizations/${localization.id}`, {
data: {
type: "appInfoLocalizations",
id: localization.id,
attributes: {
name: await textFile("name.txt"),
subtitle: await textFile("subtitle.txt"),
privacyPolicyUrl: await textFile("privacy_url.txt"),
},
},
});
console.log(
`[ios-appstore-metadata] updated app info localization ${locale}`,
);
}
async function getOrCreateAppStoreVersion() {
const versions = await firstPage(
`/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=20`,
);
const editableStates = new Set([
"PREPARE_FOR_SUBMISSION",
"DEVELOPER_REJECTED",
"REJECTED",
"METADATA_REJECTED",
"INVALID_BINARY",
]);
const existing =
versions.find((v) => v.attributes?.versionString === versionString) ||
versions.find((v) => editableStates.has(v.attributes?.appStoreState));
if (existing) return existing;
const created = await request("POST", "/appStoreVersions", {
data: {
type: "appStoreVersions",
attributes: {
platform,
versionString,
copyright: await textFile("copyright.txt"),
},
relationships: {
app: { data: { type: "apps", id: appId } },
},
},
});
console.log(
`[ios-appstore-metadata] created ${platform} version ${versionString}`,
);
return created.data;
}
async function getOrCreateVersionLocalization(version) {
const existing = await firstPage(
`/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`,
);
const match = existing.find((item) => item.attributes?.locale === locale);
if (match) return match;
const created = await request("POST", "/appStoreVersionLocalizations", {
data: {
type: "appStoreVersionLocalizations",
attributes: { locale },
relationships: {
appStoreVersion: { data: { type: "appStoreVersions", id: version.id } },
},
},
});
return created.data;
}
async function updateVersionLocalization() {
const version = await getOrCreateAppStoreVersion();
const localization = await getOrCreateVersionLocalization(version);
await request("PATCH", `/appStoreVersionLocalizations/${localization.id}`, {
data: {
type: "appStoreVersionLocalizations",
id: localization.id,
attributes: {
description: await textFile("description.txt"),
keywords: await textFile("keywords.txt"),
marketingUrl: await textFile("marketing_url.txt"),
promotionalText: await textFile("promotional_text.txt"),
supportUrl: await textFile("support_url.txt"),
},
},
});
console.log(
`[ios-appstore-metadata] updated version localization ${locale} for ${version.attributes.versionString}`,
);
return localization;
}
async function deleteExistingScreenshotSet(localization) {
const sets = await firstPage(
`/appStoreVersionLocalizations/${localization.id}/appScreenshotSets?filter[screenshotDisplayType]=${screenshotDisplayType}&limit=50&include=appScreenshots`,
);
for (const set of sets) {
await request("DELETE", `/appScreenshotSets/${set.id}`);
console.log(
`[ios-appstore-metadata] deleted existing screenshot set ${set.id}`,
);
}
}
async function createScreenshotSet(localization) {
const created = await request("POST", "/appScreenshotSets", {
data: {
type: "appScreenshotSets",
attributes: { screenshotDisplayType },
relationships: {
appStoreVersionLocalization: {
data: { type: "appStoreVersionLocalizations", id: localization.id },
},
},
},
});
console.log(
`[ios-appstore-metadata] created screenshot set ${screenshotDisplayType}`,
);
return created.data;
}
async function uploadScreenshot(set, filePath) {
const fileName = path.basename(filePath);
const fileBuffer = await readFile(filePath);
const fileInfo = await stat(filePath);
const checksum = crypto.createHash("md5").update(fileBuffer).digest("hex");
const reservation = await request("POST", "/appScreenshots", {
data: {
type: "appScreenshots",
attributes: { fileName, fileSize: fileInfo.size },
relationships: {
appScreenshotSet: { data: { type: "appScreenshotSets", id: set.id } },
},
},
});
const operations = reservation.data.attributes.uploadOperations || [];
for (const operation of operations) {
await uploadOperation(operation, fileBuffer);
}
await request("PATCH", `/appScreenshots/${reservation.data.id}`, {
data: {
type: "appScreenshots",
id: reservation.data.id,
attributes: {
uploaded: true,
sourceFileChecksum: checksum,
},
},
});
console.log(`[ios-appstore-metadata] uploaded ${fileName}`);
}
async function uploadScreenshots(localization) {
const files = (await readdir(screenshotDir))
.filter((file) => file.endsWith(".png"))
.sort()
.map((file) => path.join(screenshotDir, file));
if (!files.length) {
throw new Error(`No screenshots found in ${screenshotDir}`);
}
await deleteExistingScreenshotSet(localization);
const set = await createScreenshotSet(localization);
for (const file of files) {
await uploadScreenshot(set, file);
}
}
await updateAppInfoLocalization();
const versionLocalization = await updateVersionLocalization();
await uploadScreenshots(versionLocalization);
console.log(
"[ios-appstore-metadata] metadata and screenshots submitted to App Store Connect.",
);