mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
320 lines
9.7 KiB
JavaScript
Executable File
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.",
|
|
);
|