Files
Adrià fd58ca321e Merge pull request #6190 from devavijitsingh/feat/web-batch-conversion
feat(web): convert multiple app ZIPs as one bundle
2026-10-18 22:18:18 +02:00

202 lines
9.5 KiB
JavaScript

import { batchMemoryEstimate, batchOutputDirectory, MEMORY_LIMIT } from "./batch.mjs";
import { Archive, CONVERSION_LIMIT, createPackageWriter, packageArchive } from "./archive.mjs";
let archive;
let inputs = [];
let summaries = [];
let busy = false;
let pendingWrite;
let lastReport = 0;
let lastPhase = "";
function report(event) {
const phase = event.phase ?? String(event.stage);
const now = performance.now();
if (!event.message && phase === lastPhase && event.completed !== event.total && now - lastReport < 80) return;
lastReport = now;
lastPhase = phase;
self.postMessage({ type: "progress", ...event });
}
function reportFor(item, index) {
let started = false;
return event => {
if (!started) {
lastReport = 0;
lastPhase = "";
started = true;
}
report({ ...event, appIndex: index, appCount: inputs.length, appName: item.name });
};
}
export async function downloadLibraries(target) {
report({ phase: "Downloading system libraries" });
const response = await fetch("./release.json", { cache: "no-cache" });
if (!response.ok) throw new Error(`Cannot load release manifest (${response.status})`);
const release = await response.json();
const asset = release.platforms[target];
if (!asset || !Number.isSafeInteger(asset.size) || asset.size < 0 || asset.size > 512 * 1024 * 1024 || !/^libraries\/(windows|linux)-[a-f0-9]+\.zip$/u.test(asset.path) || !/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error("Invalid release manifest");
const latestResponse = await fetch("https://api.github.com/repos/boykopovar/AnyPS5/releases/latest", { headers: { Accept: "application/vnd.github+json" } });
if (!latestResponse.ok) {
const rateLimited = latestResponse.status === 429 || (latestResponse.status === 403 && latestResponse.headers.get("x-ratelimit-remaining") === "0");
if (!rateLimited) throw new Error(`Cannot verify the latest release (${latestResponse.status}). Try again later.`);
report({ phase: "Downloading system libraries", message: "GitHub API rate limit reached; using the libraries published with this site." });
} else {
const latest = await latestResponse.json();
if (latest.tag_name !== release.tag) throw new Error("A new release is being published to this site. Try again after the Pages build finishes.");
}
const download = await fetch(asset.path);
if (!download.ok) throw new Error(`Cannot download system libraries (${download.status})`);
const reader = download.body.getReader();
const chunks = [];
let size = 0;
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > asset.size) throw new Error("System library download is incomplete");
chunks.push(value);
}
if (size !== asset.size) throw new Error("System library download is incomplete");
} catch (error) {
await reader.cancel(error).catch(() => {});
throw error;
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("");
if (digest !== asset.sha256) throw new Error("System library checksum mismatch");
return { file: new Blob([bytes]), release, expandedBytes: asset.expandedBytes };
}
async function convertInput(item, index, native, libraries, data, destination, directory, writer) {
const current = archive ?? new Archive(item.files ?? item.file);
archive = null;
try {
const itemReport = reportFor(item, index);
if (inputs.length > 1) await current.inspect(itemReport);
const input = await current.conversionInput(data.target, data.toIntel, itemReport);
const converted = native.Convert(input, itemReport);
if (!converted.ok) throw new Error(converted.error);
const options = inputs.length > 1 ? { prefix: `${batchOutputDirectory(item.name, index)}/`, writer } : {};
await packageArchive(current, converted, input.outputName, libraries.file, destination, itemReport, directory, options);
return input.outputName;
} catch (error) {
throw new Error(`${item.name}: ${error instanceof Error ? error.message : String(error)}`);
} finally {
await current.close();
}
}
self.onmessage = async ({ data }) => {
if (data.type === "written") {
if (!pendingWrite) return;
const resolve = pendingWrite;
pendingWrite = null;
resolve();
return;
}
if (busy) return;
busy = true;
try {
if (data.type === "inspect") {
if (archive) await archive.close();
archive = null;
inputs = data.inputs ?? [{ name: data.file?.name ?? "selected app", file: data.file, files: data.files }];
if (!inputs.length) throw new Error("Choose at least one ZIP archive.");
summaries = [];
for (let index = 0; index < inputs.length; index++) {
const item = inputs[index];
const current = new Archive(item.files ?? item.file);
let keepOpen = false;
try {
const summary = await current.inspect(reportFor(item, index));
summaries.push(summary);
if (inputs.length === 1) {
archive = current;
keepOpen = true;
}
self.postMessage({ type: "inspected", index, count: inputs.length, name: item.name, summary });
} catch (error) {
throw new Error(`${item.name}: ${error instanceof Error ? error.message : String(error)}`);
} finally {
if (!keepOpen) await current.close();
}
}
self.postMessage({ type: "inspection-complete", count: inputs.length });
} else if (data.type === "convert") {
if (data.target !== "linux" && data.target !== "windows") throw new Error(`Unsupported conversion target: ${data.target}`);
if (!["memory", "zip", "disk"].includes(data.mode)) throw new Error("Unknown save mode");
if (data.target === "windows" && data.mode === "disk") throw new Error("Windows folder output is unavailable because browsers block DLL creation. Choose a ZIP save mode.");
if (!inputs.length || summaries.length !== inputs.length) throw new Error("Inspect the selected apps before converting.");
if (inputs.length > 1 && data.mode === "disk") throw new Error("Batch conversion requires a ZIP save mode.");
for (const summary of summaries) {
const selectedBytes = data.target === "windows" ? summary.windowsSelectedBytes : summary.selectedBytes;
if (data.target === "windows" && summary.windowsIconTooLarge) throw new Error("A Windows icon exceeds 16 MiB. Choose Linux or use the native relinker.");
if (selectedBytes > CONVERSION_LIMIT) throw new Error(`Selected ${data.target} conversion input exceeds the 256 MiB conversion limit. Use the native relinker for this app.`);
}
const libraries = await downloadLibraries(data.target);
if (data.mode === "memory" && batchMemoryEstimate(summaries, data.target, libraries.expandedBytes) > MEMORY_LIMIT) throw new Error("This result exceeds the 256 MiB memory-download budget. Use Save ZIP to disk in desktop Chrome or Edge.");
report({ phase: "Loading converter" });
const { default: createRelinker } = await import("./native/relinker_wasm.mjs");
const native = await createRelinker();
const chunks = [];
let size = 0;
let outputName = "anyps5-apps";
const send = (event, transfer = []) => new Promise(resolve => {
pendingWrite = resolve;
self.postMessage(event, transfer);
});
if (data.mode === "disk") {
const destination = {
directory: name => send({ type: "directory", name }),
async file(name) {
await send({ type: "open-file", name });
return new WritableStream({
async write(chunk) {
size += chunk.byteLength;
const bytes = chunk.slice();
await send({ type: "write", bytes }, [bytes.buffer]);
},
close: () => send({ type: "close-file" })
});
}
};
outputName = await convertInput(inputs[0], 0, native, libraries, data, destination, true);
} else {
const destination = new WritableStream({
async write(chunk) {
size += chunk.byteLength;
if (data.mode === "zip") {
const bytes = chunk.slice();
await send({ type: "write", bytes }, [bytes.buffer]);
return;
}
if (size > MEMORY_LIMIT) throw new Error("The compressed ZIP exceeded 256 MiB. Use Save ZIP to disk.");
chunks.push(chunk.slice());
}
});
const writer = inputs.length > 1 ? createPackageWriter(destination) : null;
for (let index = 0; index < inputs.length; index++) {
const name = await convertInput(inputs[index], index, native, libraries, data, destination, false, writer);
if (inputs.length === 1) outputName = name;
}
if (writer) await writer.close();
await destination.close();
}
self.postMessage({ type: "complete", mode: data.mode, blob: data.mode === "memory" ? new Blob(chunks, { type: "application/zip" }) : null, outputName, size, release: libraries.release.tag, apps: inputs.length });
inputs = [];
summaries = [];
} else throw new Error("Unknown worker request");
} catch (error) {
self.postMessage({ type: "error", message: error instanceof Error ? error.message : String(error) });
} finally {
busy = false;
}
};