t3-code-android-nightly/scripts/lib/update-manifest.ts
Evan Yu 6891c77d3e
Build for Windows ARM (#2080)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
2026-04-16 17:01:27 -07:00

277 lines
7.9 KiB
TypeScript

export interface UpdateManifestFile {
readonly url: string;
readonly sha512: string;
readonly size: number;
}
export type UpdateManifestScalar = string | number | boolean;
export interface UpdateManifest {
readonly version: string;
readonly releaseDate: string;
readonly files: ReadonlyArray<UpdateManifestFile>;
readonly extras: Readonly<Record<string, UpdateManifestScalar>>;
}
interface MutableUpdateManifestFile {
url?: string;
sha512?: string;
size?: number;
}
function stripSingleQuotes(value: string): string {
if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replace(/''/g, "'");
}
return value;
}
function parseFileRecord(
currentFile: MutableUpdateManifestFile | null,
sourcePath: string,
lineNumber: number,
platformLabel: string,
): UpdateManifestFile | null {
if (currentFile === null) {
return null;
}
if (
typeof currentFile.url !== "string" ||
typeof currentFile.sha512 !== "string" ||
typeof currentFile.size !== "number"
) {
throw new Error(
`Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`,
);
}
return {
url: currentFile.url,
sha512: currentFile.sha512,
size: currentFile.size,
};
}
function parseScalarValue(rawValue: string): UpdateManifestScalar {
const trimmed = rawValue.trim();
const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2;
const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed;
if (isQuoted) return value;
if (value === "true") return true;
if (value === "false") return false;
if (/^-?\d+(?:\.\d+)?$/.test(value)) {
return Number(value);
}
return value;
}
export function parseUpdateManifest(
raw: string,
sourcePath: string,
platformLabel: string,
): UpdateManifest {
const lines = raw.split(/\r?\n/);
const files: UpdateManifestFile[] = [];
const extras: Record<string, UpdateManifestScalar> = {};
let version: string | null = null;
let releaseDate: string | null = null;
let inFiles = false;
let currentFile: MutableUpdateManifestFile | null = null;
for (const [index, rawLine] of lines.entries()) {
const lineNumber = index + 1;
const line = rawLine.trimEnd();
if (line.length === 0) continue;
const fileUrlMatch = line.match(/^ - url:\s*(.+)$/);
if (fileUrlMatch?.[1]) {
const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel);
if (finalized) files.push(finalized);
currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) };
inFiles = true;
continue;
}
const fileShaMatch = line.match(/^ sha512:\s*(.+)$/);
if (fileShaMatch?.[1]) {
if (currentFile === null) {
throw new Error(
`Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`,
);
}
currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim());
continue;
}
const fileSizeMatch = line.match(/^ size:\s*(\d+)$/);
if (fileSizeMatch?.[1]) {
if (currentFile === null) {
throw new Error(
`Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`,
);
}
currentFile.size = Number(fileSizeMatch[1]);
continue;
}
if (line === "files:") {
inFiles = true;
continue;
}
if (inFiles && currentFile !== null) {
const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel);
if (finalized) files.push(finalized);
currentFile = null;
}
inFiles = false;
const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/);
if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) {
throw new Error(
`Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`,
);
}
const [, key, rawValue] = topLevelMatch;
const value = parseScalarValue(rawValue);
if (key === "version") {
if (typeof value !== "string") {
throw new Error(
`Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: version must be a string.`,
);
}
version = value;
continue;
}
if (key === "releaseDate") {
if (typeof value !== "string") {
throw new Error(
`Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`,
);
}
releaseDate = value;
continue;
}
if (key === "path" || key === "sha512") {
continue;
}
extras[key] = value;
}
const finalized = parseFileRecord(currentFile, sourcePath, lines.length, platformLabel);
if (finalized) files.push(finalized);
if (!version) {
throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing version.`);
}
if (!releaseDate) {
throw new Error(
`Invalid ${platformLabel} update manifest at ${sourcePath}: missing releaseDate.`,
);
}
if (files.length === 0) {
throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing files.`);
}
return {
version,
releaseDate,
files,
extras,
};
}
function mergeExtras(
primary: Readonly<Record<string, UpdateManifestScalar>>,
secondary: Readonly<Record<string, UpdateManifestScalar>>,
platformLabel: string,
): Record<string, UpdateManifestScalar> {
const merged: Record<string, UpdateManifestScalar> = { ...primary };
for (const [key, value] of Object.entries(secondary)) {
const existing = merged[key];
if (existing !== undefined && existing !== value) {
throw new Error(
`Cannot merge ${platformLabel} update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`,
);
}
merged[key] = value;
}
return merged;
}
export function mergeUpdateManifests(
primary: UpdateManifest,
secondary: UpdateManifest,
platformLabel: string,
): UpdateManifest {
if (primary.version !== secondary.version) {
throw new Error(
`Cannot merge ${platformLabel} update manifests with different versions (${primary.version} vs ${secondary.version}).`,
);
}
const filesByUrl = new Map<string, UpdateManifestFile>();
for (const file of [...primary.files, ...secondary.files]) {
const existing = filesByUrl.get(file.url);
if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) {
throw new Error(
`Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`,
);
}
filesByUrl.set(file.url, file);
}
return {
version: primary.version,
releaseDate:
primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate,
files: [...filesByUrl.values()],
extras: mergeExtras(primary.extras, secondary.extras, platformLabel),
};
}
function quoteYamlString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
function serializeScalarValue(value: UpdateManifestScalar): string {
if (typeof value === "string") {
return quoteYamlString(value);
}
return String(value);
}
export function serializeUpdateManifest(
manifest: UpdateManifest,
options: {
readonly platformLabel: string;
},
): string {
const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"];
for (const file of manifest.files) {
lines.push(` - url: ${file.url}`);
lines.push(` sha512: ${file.sha512}`);
lines.push(` size: ${file.size}`);
}
for (const key of Object.keys(manifest.extras).toSorted()) {
const value = manifest.extras[key];
if (value === undefined) {
throw new Error(
`Cannot serialize ${options.platformLabel} update manifest: missing value for '${key}'.`,
);
}
lines.push(`${key}: ${serializeScalarValue(value)}`);
}
lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`);
lines.push("");
return lines.join("\n");
}