mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-29 12:15:39 +00:00
342 lines
19 KiB
JavaScript
342 lines
19 KiB
JavaScript
import { abortAndDiscardOutput, OutputDirectoryTransaction } from "./output-directory.mjs";
|
|
import { batchMemoryEstimate } from "./batch.mjs";
|
|
|
|
const $ = id => document.getElementById(id);
|
|
const stages = ["Converting executable", "Converting PRX modules", "Scanning syscalls", "Scanning instructions", "Converting instructions", "Analysing imports", "Building output image", "Writing output"];
|
|
let worker;
|
|
let selectedInputs = [];
|
|
let summaries = [];
|
|
let inspectionComplete = false;
|
|
let working = false;
|
|
let failing = false;
|
|
let sink;
|
|
let outputDirectory;
|
|
let outputTransaction;
|
|
let downloadUrl;
|
|
let logs = [];
|
|
let release;
|
|
const canSaveZip = typeof window.showSaveFilePicker === "function" && window.isSecureContext;
|
|
const canStream = typeof window.showDirectoryPicker === "function" && window.isSecureContext;
|
|
const supported = window.isSecureContext && window.Worker && window.WebAssembly && window.ReadableStream && window.WritableStream && window.crypto?.subtle;
|
|
$("save").querySelector('[value="disk"]').disabled = !canStream;
|
|
$("save").querySelector('[value="zip"]').disabled = !canSaveZip;
|
|
$("save").value = canSaveZip ? "zip" : "memory";
|
|
|
|
function bytes(value) {
|
|
if (value < 1024) return `${value} B`;
|
|
const unit = value >= 1024 ** 3 ? 3 : value >= 1024 ** 2 ? 2 : 1;
|
|
return `${(value / 1024 ** unit).toFixed(1)} ${["B", "KiB", "MiB", "GiB"][unit]}`;
|
|
}
|
|
|
|
function sum(values) {
|
|
let total = 0;
|
|
for (const value of values) {
|
|
if (!Number.isSafeInteger(value) || value < 0 || total > Number.MAX_SAFE_INTEGER - value) return null;
|
|
total += value;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function update() {
|
|
const target = document.querySelector('input[name="target"]:checked').value;
|
|
const multi = selectedInputs.length > 1;
|
|
const inspected = inspectionComplete && summaries.length === selectedInputs.length;
|
|
const folderBlocked = $("save").value === "disk" && (target === "windows" || multi);
|
|
const folderOption = $("save").querySelector('[value="disk"]');
|
|
folderOption.disabled = !canStream || target === "windows" || multi;
|
|
folderOption.textContent = target === "windows" ? "Save to folder · unavailable for Windows (DLL restrictions)" : multi ? "Save to folder · unavailable for batch output" : "Save to folder · uncompressed";
|
|
const libraryBytes = release?.platforms[target]?.expandedBytes;
|
|
const selectedBytes = inspected ? sum(summaries.map(summary => target === "windows" ? summary.windowsSelectedBytes : summary.selectedBytes)) : null;
|
|
const conversionTooLarge = inspected && summaries.some(summary => (target === "windows" ? summary.windowsSelectedBytes : summary.selectedBytes) > 256 * 1024 * 1024);
|
|
const iconTooLarge = target === "windows" && inspected && summaries.some(summary => summary.windowsIconTooLarge);
|
|
const conversionBlocked = conversionTooLarge || iconTooLarge;
|
|
let estimate = null;
|
|
let estimateOverflow = false;
|
|
if (inspected && libraryBytes !== undefined) {
|
|
try { estimate = batchMemoryEstimate(summaries, target, libraryBytes); }
|
|
catch (error) {
|
|
if (!(error instanceof Error) || error.message !== "ZIP contains an unsupported file size") throw error;
|
|
estimateOverflow = true;
|
|
}
|
|
}
|
|
const tooLarge = estimateOverflow || (estimate !== null && estimate > 256 * 1024 * 1024);
|
|
$("save").querySelector('[value="memory"]').disabled = Boolean(tooLarge);
|
|
const memoryBlocked = $("save").value === "memory" && tooLarge;
|
|
if (inspected) {
|
|
const totalFiles = sum(summaries.map(summary => summary.files));
|
|
const totalBytes = sum(summaries.map(summary => summary.totalBytes));
|
|
const modules = sum(summaries.map(summary => summary.modules));
|
|
const sizeText = value => value === null ? "beyond the supported size range" : bytes(value);
|
|
const countText = value => value === null ? "beyond the supported range" : value;
|
|
$("archive-info").textContent = multi
|
|
? `${selectedInputs.length} apps · ${countText(totalFiles)} files · ${sizeText(totalBytes)} expanded · ${countText(modules)} modules · ${sizeText(selectedBytes)} ${target} conversion input`
|
|
: `${summaries[0].files} files · ${bytes(summaries[0].totalBytes)} expanded · ${summaries[0].modules} modules · ${bytes(target === "windows" ? summaries[0].windowsSelectedBytes : summaries[0].selectedBytes)} ${target} conversion input` + (summaries[0].root ? ` · Root: ${summaries[0].root}` : "");
|
|
}
|
|
$("save-note").textContent = multi ? $("save").value === "memory" ? "Creates one ZIP with each app in its own folder. The complete bundle must fit in the 256 MiB memory-download budget." : "Writes one compressed ZIP containing each app in its own folder, without holding the whole bundle in memory." : $("save").value === "disk" ? "Creates a subfolder with the executable, libs and app0. No ZIP compression. Incomplete output folders are removed after cancellation or failure. Browsers may block DLL and other restricted files; use ZIP for those apps." : $("save").value === "zip" ? "Writes a compressed ZIP directly to disk without holding the whole archive in memory." : "Up to 256 MiB including expanded files and output overhead.";
|
|
$("browser-note").hidden = supported && canStream && canSaveZip && !memoryBlocked && !folderBlocked && !conversionBlocked;
|
|
$("browser-note").textContent = !supported ? "This browser or connection does not support the converter. Use a current browser over HTTPS, or use the native relinker." : folderBlocked && multi ? "Batch conversion creates one ZIP bundle. Choose Save ZIP to disk or Download in memory." : folderBlocked ? "Browsers block direct DLL creation. Windows output requires ZIP. Choose Save ZIP to disk or Download in memory." : iconTooLarge ? "A Windows icon exceeds 16 MiB. Choose Linux or use the native relinker." : conversionTooLarge ? `A ${target} conversion input exceeds 256 MiB. Use the native relinker for that app.` : estimateOverflow ? "The combined output exceeds the browser's supported size range. Choose Save ZIP to disk." : memoryBlocked ? "This output exceeds the memory-download limit. Choose Save ZIP to disk. If that option is unavailable, open this page in desktop Chrome or Edge." : "Some direct disk saving options are unavailable in this browser or context. Small apps can still be downloaded in memory. For larger apps, use desktop Chrome or Edge over HTTPS.";
|
|
const hasWarnings = inspected && summaries.some(summary => summary.warnings.length > 0);
|
|
$("convert").disabled = !supported || working || !inspected || !release || conversionBlocked || memoryBlocked || folderBlocked || (hasWarnings && !$("acknowledge").checked);
|
|
$("ready-note").textContent = inspected ? (folderBlocked && multi ? "Choose a ZIP save mode for the app bundle." : folderBlocked ? "Choose a ZIP save mode for Windows." : iconTooLarge ? "A Windows icon exceeds 16 MiB. Choose Linux or use the native relinker." : conversionTooLarge ? `A ${target} conversion input exceeds 256 MiB. Use the native relinker for that app.` : memoryBlocked ? "A direct disk save is required for this output." : release ? `Libraries: ${release.tag} · ${multi ? "ZIP bundle" : $("save").value === "disk" ? "Uncompressed folder" : "ZIP compression level 9"}` : "Loading release information…") : "Choose one folder or one or more ZIP files to get started.";
|
|
}
|
|
|
|
function lock(value) {
|
|
for (const id of ["choose", "choose-folder", "change", "target", "save", "intel"]) $(id).disabled = value || !supported || (id === "choose-folder" && !("webkitdirectory" in $("folder")));
|
|
}
|
|
|
|
async function fail(message) {
|
|
if (failing) return;
|
|
failing = true;
|
|
worker?.terminate();
|
|
worker = null;
|
|
working = true;
|
|
selectedInputs = [];
|
|
summaries = [];
|
|
inspectionComplete = false;
|
|
const transaction = outputTransaction;
|
|
outputTransaction = null;
|
|
outputDirectory = null;
|
|
if (transaction) {
|
|
$("status").hidden = false;
|
|
$("phase").textContent = "Removing incomplete output folder";
|
|
}
|
|
const activeSink = sink;
|
|
sink = null;
|
|
for (const failure of await abortAndDiscardOutput(transaction, activeSink)) message += `\n${failure}`;
|
|
$("error").textContent = message;
|
|
$("error").hidden = false;
|
|
$("status").hidden = true;
|
|
$("cancel").hidden = true;
|
|
$("drop").hidden = false;
|
|
$("selection").hidden = true;
|
|
$("warnings").hidden = true;
|
|
$("app-list").replaceChildren();
|
|
$("file").value = "";
|
|
working = false;
|
|
failing = false;
|
|
lock(false);
|
|
update();
|
|
}
|
|
|
|
async function onMessage({ data }) {
|
|
try {
|
|
if (data.type === "progress") {
|
|
if (data.message) {
|
|
logs.push(`${data.appName ? `${data.appName}: ` : ""}${data.message.trim()}`);
|
|
logs = logs.slice(-150);
|
|
$("log").textContent = logs.join("\n");
|
|
return;
|
|
}
|
|
const phase = data.phase ?? stages[data.stage];
|
|
$("phase").textContent = data.appCount > 1 ? `App ${data.appIndex + 1}/${data.appCount} · ${phase}` : phase;
|
|
$("current-file").textContent = [data.appName, data.input].filter(Boolean).join(" · ");
|
|
if (Number.isFinite(data.total) && data.total > 0) {
|
|
const percent = Math.min(100, Math.floor(data.completed / data.total * 100));
|
|
$("progress").value = percent;
|
|
$("percent").textContent = `${percent}%`;
|
|
} else {
|
|
$("progress").removeAttribute("value");
|
|
$("percent").textContent = "";
|
|
}
|
|
} else if (data.type === "inspected") {
|
|
summaries[data.index] = data.summary;
|
|
} else if (data.type === "inspection-complete") {
|
|
inspectionComplete = true;
|
|
working = false;
|
|
$("selection").hidden = false;
|
|
$("drop").hidden = true;
|
|
$("archive-name").textContent = selectedInputs.length > 1 ? `${selectedInputs.length} app ZIPs` : selectedInputs[0].name;
|
|
$("app-list").replaceChildren(...selectedInputs.map((input, index) => {
|
|
const item = document.createElement("li");
|
|
const name = document.createElement("strong");
|
|
name.textContent = input.name;
|
|
const details = document.createElement("span");
|
|
const summary = summaries[index];
|
|
details.textContent = `${summary.files} files · ${bytes(summary.totalBytes)} expanded · ${summary.modules} modules`;
|
|
item.append(name, details);
|
|
return item;
|
|
}));
|
|
$("app-list").hidden = selectedInputs.length < 2;
|
|
const warningItems = [];
|
|
for (let index = 0; index < summaries.length; index++) {
|
|
for (const message of summaries[index].warnings) {
|
|
const item = document.createElement("li");
|
|
item.textContent = selectedInputs.length > 1 ? `${selectedInputs[index].name}: ${message}` : message;
|
|
warningItems.push(item);
|
|
}
|
|
}
|
|
$("warning-list").replaceChildren(...warningItems);
|
|
$("warnings").hidden = warningItems.length === 0;
|
|
$("status").hidden = true;
|
|
$("cancel").hidden = true;
|
|
lock(false);
|
|
update();
|
|
} else if (data.type === "directory" || data.type === "open-file") {
|
|
if (!outputDirectory) throw new Error("No output folder is open");
|
|
const parts = data.name.replace(/\/$/u, "").split("/");
|
|
if (parts.some(part => !part || part === "." || part === ".." || part.includes("\\"))) throw new Error("Invalid output path");
|
|
const filename = data.type === "open-file" ? parts.pop() : null;
|
|
let parent = outputDirectory;
|
|
for (const part of parts) parent = await parent.getDirectoryHandle(part, { create: true });
|
|
if (filename) {
|
|
try {
|
|
const handle = await parent.getFileHandle(filename, { create: true });
|
|
sink = await handle.createWritable();
|
|
} catch (error) {
|
|
throw new Error(`Cannot write ${data.name}: ${error.message}. Choose ZIP output if the browser blocks this file type.`);
|
|
}
|
|
}
|
|
worker?.postMessage({ type: "written" });
|
|
} else if (data.type === "close-file") {
|
|
if (!sink) throw new Error("No output file is open");
|
|
await sink.close();
|
|
sink = null;
|
|
worker?.postMessage({ type: "written" });
|
|
} else if (data.type === "write") {
|
|
if (!sink) throw new Error("No output file is open");
|
|
await sink.write(data.bytes);
|
|
worker?.postMessage({ type: "written" });
|
|
} else if (data.type === "complete") {
|
|
if (sink) { await sink.close(); sink = null; }
|
|
working = false;
|
|
$("done").hidden = false;
|
|
$("status").hidden = true;
|
|
$("cancel").hidden = true;
|
|
$("result-info").textContent = `${data.apps > 1 ? `${data.apps} apps · ` : ""}${bytes(data.size)} ${data.mode === "disk" ? "files" : "ZIP"} · ${data.release} system libraries · ${data.apps > 1 ? "combined app bundle" : data.outputName}`;
|
|
if (data.blob) {
|
|
downloadUrl = URL.createObjectURL(data.blob);
|
|
$("download").href = downloadUrl;
|
|
$("download").download = `${data.outputName}.zip`;
|
|
$("download").hidden = false;
|
|
} else if (data.mode === "zip") {
|
|
$("result-info").textContent += " · ZIP saved to disk";
|
|
} else {
|
|
$("result-info").textContent += ` · Saved in ${outputTransaction.name}/`;
|
|
if (document.querySelector('input[name="target"]:checked').value === "linux") $("result-info").textContent += ` · On Linux, run chmod +x "${data.outputName}" before launching.`;
|
|
}
|
|
$("result-note").textContent = data.apps > 1 ? "Extract the ZIP; each app is in its own folder with its executable, libs and app0 files. On Linux, run chmod +x */*.elf before launching." : "Extract the entire ZIP before launching the executable.";
|
|
$("result-note").hidden = data.mode === "disk";
|
|
outputDirectory = null;
|
|
outputTransaction?.commit();
|
|
outputTransaction = null;
|
|
selectedInputs = [];
|
|
summaries = [];
|
|
inspectionComplete = false;
|
|
worker.terminate();
|
|
worker = null;
|
|
lock(false);
|
|
update();
|
|
} else if (data.type === "error") await fail(data.message);
|
|
} catch (error) { await fail(error.message); }
|
|
}
|
|
|
|
async function selectInputs(inputs) {
|
|
if (working || !supported || !inputs.length) return;
|
|
if (inputs.some(input => !input.files && !input.name.toLowerCase().endsWith(".zip")) || (inputs.length > 1 && inputs.some(input => input.files))) {
|
|
await fail("Batch conversion accepts ZIP archives only. Choose one folder or select ZIP files.");
|
|
return;
|
|
}
|
|
worker?.terminate();
|
|
if (downloadUrl) { URL.revokeObjectURL(downloadUrl); downloadUrl = null; }
|
|
selectedInputs = inputs;
|
|
summaries = [];
|
|
inspectionComplete = false;
|
|
working = true;
|
|
logs = [];
|
|
$("log").textContent = "";
|
|
for (const id of ["error", "done", "download", "warnings", "selection"]) $(id).hidden = true;
|
|
$("acknowledge").checked = false;
|
|
$("status").hidden = false;
|
|
$("cancel").hidden = false;
|
|
$("phase").textContent = "Inspecting files";
|
|
$("current-file").textContent = inputs.length > 1 ? `Inspecting ${inputs.length} ZIP archives` : inputs[0].name;
|
|
$("percent").textContent = "";
|
|
$("progress").removeAttribute("value");
|
|
lock(true);
|
|
update();
|
|
worker = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
|
|
worker.onmessage = onMessage;
|
|
worker.onerror = event => fail(event.message || "The converter worker stopped. Try a smaller app or use the native relinker.");
|
|
worker.postMessage({ type: "inspect", inputs });
|
|
}
|
|
|
|
$("choose").onclick = () => { $("file").value = ""; $("file").click(); };
|
|
$("change").onclick = () => {
|
|
if (working) return;
|
|
worker?.terminate();
|
|
worker = null;
|
|
if (downloadUrl) { URL.revokeObjectURL(downloadUrl); downloadUrl = null; }
|
|
selectedInputs = [];
|
|
summaries = [];
|
|
inspectionComplete = false;
|
|
$("file").value = "";
|
|
$("folder").value = "";
|
|
$("app-list").replaceChildren();
|
|
for (const id of ["selection", "warnings", "error", "done", "download", "app-list"]) $(id).hidden = true;
|
|
$("acknowledge").checked = false;
|
|
$("drop").hidden = false;
|
|
update();
|
|
};
|
|
$("choose-folder").onclick = () => { $("folder").value = ""; $("folder").click(); };
|
|
$("folder").onchange = () => {
|
|
const files = [...$("folder").files].map(file => ({ path: file.webkitRelativePath, file }));
|
|
if (files.length) selectInputs([{ name: files[0].path.split("/")[0], files }]);
|
|
};
|
|
$("file").onchange = () => {
|
|
const inputs = [...$("file").files].map(file => ({ name: file.name, file }));
|
|
$("file").value = "";
|
|
selectInputs(inputs);
|
|
};
|
|
for (const type of ["dragenter", "dragover"]) $("drop").addEventListener(type, event => { event.preventDefault(); if (!working) $("drop").classList.add("dragging"); });
|
|
$("drop").ondragleave = () => $("drop").classList.remove("dragging");
|
|
$("drop").ondrop = event => {
|
|
event.preventDefault();
|
|
$("drop").classList.remove("dragging");
|
|
if (working) return;
|
|
selectInputs([...event.dataTransfer.files].map(file => ({ name: file.name, file })));
|
|
};
|
|
for (const id of ["save", "target", "acknowledge"]) $(id).addEventListener("change", update);
|
|
$("cancel").onclick = () => fail("Cancelled. Choose the ZIP or folder again to restart.");
|
|
$("convert").onclick = async () => {
|
|
if ($("convert").disabled) return;
|
|
working = true;
|
|
lock(true);
|
|
update();
|
|
try {
|
|
const target = document.querySelector('input[name="target"]:checked').value;
|
|
if ($("save").value === "zip") {
|
|
const name = selectedInputs.length > 1 ? `anyps5-${target}-apps.zip` : `${selectedInputs[0].name.replace(/\.zip$/iu, "")}-${target}.zip`;
|
|
const handle = await window.showSaveFilePicker({ suggestedName: name, types: [{ description: "ZIP archive", accept: { "application/zip": [".zip"] } }] });
|
|
sink = await handle.createWritable();
|
|
} else if ($("save").value === "disk") {
|
|
const parent = await window.showDirectoryPicker({ mode: "readwrite" });
|
|
const name = selectedInputs[0].name.replace(/\.zip$/iu, "") + "-" + target;
|
|
outputTransaction = await OutputDirectoryTransaction.create(parent, name);
|
|
outputDirectory = outputTransaction.directory;
|
|
}
|
|
$("status").hidden = false;
|
|
$("cancel").hidden = false;
|
|
$("error").hidden = true;
|
|
worker.postMessage({ type: "convert", target, toIntel: $("intel").checked, mode: $("save").value });
|
|
} catch (error) {
|
|
if (error.name === "AbortError") { working = false; lock(false); update(); }
|
|
else await fail(error.message);
|
|
}
|
|
};
|
|
$("theme").onclick = () => {
|
|
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
|
|
document.documentElement.dataset.theme = dark ? "light" : "dark";
|
|
};
|
|
update();
|
|
lock(false);
|
|
if (supported) fetch("./release.json", { cache: "no-cache" }).then(response => { if (!response.ok) throw new Error(`Cannot load release information (${response.status}).${response.status === 404 ? " release.json is generated during the site build, so it is missing when serving demo/web directly." : ""}`); return response.json(); }).then(value => {
|
|
if (!value.tag || !["windows", "linux"].every(target => Number.isSafeInteger(value.platforms?.[target]?.expandedBytes))) throw new Error("Invalid release information");
|
|
release = value;
|
|
update();
|
|
}).catch(error => fail(error.message));
|
|
|
|
if (!("webkitdirectory" in $("folder"))) {
|
|
$("choose-folder").disabled = true;
|
|
$("choose-folder").title = "Folder selection is unavailable in this browser. Choose a ZIP instead.";
|
|
}
|