feat(web): support folder input

This commit is contained in:
boykopovar 2026-10-13 14:24:33 +03:00
parent 5e762427b4
commit 1d99664fb9
6 changed files with 61 additions and 20 deletions

File diff suppressed because one or more lines are too long

View File

@ -30,11 +30,11 @@ function update() {
$("browser-note").hidden = supported && canStream && !memoryBlocked;
$("browser-note").textContent = !supported ? "This browser or connection does not support the converter. Use a current browser over HTTPS, or use the native relinker." : memoryBlocked ? "This archive exceeds the memory-download limit. Choose Save directly to disk. If that option is unavailable, open this page in desktop Chrome or Edge." : "Direct disk saving is unavailable in this browser or context. Small apps can still be downloaded in memory. For larger apps, use desktop Chrome or Edge over HTTPS.";
$("convert").disabled = !supported || working || !summary || !release || memoryBlocked || (summary.warnings.length > 0 && !$("acknowledge").checked);
$("ready-note").textContent = summary ? (memoryBlocked ? "A direct disk save is required for this archive." : release ? `Libraries: ${release.tag} · ZIP compression level 9` : "Loading release information…") : "Choose an archive to get started.";
$("ready-note").textContent = summary ? (memoryBlocked ? "A direct disk save is required for this archive." : release ? `Libraries: ${release.tag} · ZIP compression level 9` : "Loading release information…") : "Choose a ZIP or folder to get started.";
}
function lock(value) {
for (const id of ["choose", "change", "target", "save", "intel"]) $(id).disabled = value || !supported;
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) {
@ -115,9 +115,9 @@ async function onMessage({ data }) {
} catch (error) { await fail(error.message); }
}
async function selectFile(selected) {
async function selectFile(selected, files) {
if (working || !supported || !selected) return;
if (!selected.name.toLowerCase().endsWith(".zip")) { await fail("Choose a ZIP archive."); return; }
if (!files && !selected.name.toLowerCase().endsWith(".zip")) { await fail("Choose a ZIP archive."); return; }
worker?.terminate();
if (downloadUrl) { URL.revokeObjectURL(downloadUrl); downloadUrl = null; }
summary = null;
@ -129,7 +129,7 @@ async function selectFile(selected) {
$("acknowledge").checked = false;
$("status").hidden = false;
$("cancel").hidden = false;
$("phase").textContent = "Inspecting archive";
$("phase").textContent = "Inspecting files";
$("current-file").textContent = file.name;
$("percent").textContent = "";
$("progress").removeAttribute("value");
@ -138,17 +138,22 @@ async function selectFile(selected) {
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", file });
worker.postMessage(files ? { type: "inspect", files } : { type: "inspect", file });
}
$("choose").onclick = () => $("file").click();
$("change").onclick = () => { $("file").value = ""; $("file").click(); };
$("change").onclick = () => { $("drop").hidden = false; };
$("choose-folder").onclick = () => { $("folder").value = ""; $("folder").click(); };
$("folder").onchange = () => {
const files = [...$("folder").files].map(file => ({ path: file.webkitRelativePath, file }));
if (files.length) selectFile({ name: files[0].path.split("/")[0] }, files);
};
$("file").onchange = () => selectFile($("file").files[0]);
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; if (event.dataTransfer.files.length !== 1) { fail("Drop one ZIP archive at a time."); return; } selectFile(event.dataTransfer.files[0]); };
for (const id of ["save", "target", "acknowledge"]) $(id).addEventListener("change", update);
$("cancel").onclick = () => fail("Cancelled. No completed ZIP was saved. Choose the archive again to restart.");
$("cancel").onclick = () => fail("Cancelled. No completed ZIP was saved. Choose the ZIP or folder again to restart.");
$("convert").onclick = async () => {
if ($("convert").disabled) return;
working = true;
@ -179,3 +184,8 @@ if (supported) fetch("./release.json", { cache: "no-cache" }).then(response => {
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.";
}

View File

@ -65,9 +65,30 @@ async function boundedBytes(entry, limit) {
return bytes;
}
class FolderReader {
constructor(files) {
this.files = files;
}
async *getEntriesGenerator() {
for (const { path, file } of this.files) {
if (!(file instanceof Blob)) throw new Error(`Invalid folder file: ${path}`);
yield {
filename: path, uncompressedSize: file.size, directory: false, compressionMethod: 0,
externalFileAttributes: (0o100644 << 16) >>> 0,
getData(destination, options = {}) {
return file.stream().pipeTo(destination, { signal: options.signal });
}
};
}
}
async close() {}
}
export class Archive {
constructor(file) {
this.reader = new ZipReader(new BoundedBlobReader(file));
this.reader = Array.isArray(file) ? new FolderReader(file) : new ZipReader(new BoundedBlobReader(file));
this.entries = [];
}
@ -75,7 +96,7 @@ export class Archive {
const paths = new Map();
let total = 0;
for await (const entry of this.reader.getEntriesGenerator()) {
if (this.entries.length >= MAX_ENTRIES) throw new Error("ZIP contains more than 100,000 entries");
if (this.entries.length >= MAX_ENTRIES) throw new Error("Input contains more than 100,000 entries");
validPath(entry.filename);
const key = entry.filename.replace(/\/$/u, "").toLowerCase();
if (paths.has(key)) throw new Error(`Duplicate ZIP path: ${entry.filename}`);
@ -87,7 +108,7 @@ export class Archive {
total = safeSize(total + safeSize(entry.uncompressedSize));
this.entries.push(entry);
}
if (!this.entries.some(entry => !entry.directory)) throw new Error("The ZIP is empty");
if (!this.entries.some(entry => !entry.directory)) throw new Error("No files were selected");
for (const entry of this.entries) {
const parts = entry.filename.split("/");
for (let index = 1; index < parts.length; index++) {
@ -107,11 +128,11 @@ export class Archive {
this.modules = [];
for (let index = 0; index < this.files.length; index++) {
const item = this.files[index];
report({ phase: "Inspecting archive", input: item.name, completed: index, total: this.files.length });
report({ phase: "Inspecting files", input: item.name, completed: index, total: this.files.length });
if (item.entry.directory || item.name.startsWith("sce_sys/")) continue;
const elf = await signature(item.entry);
if (item.name.startsWith("sce_module/")) {
if (item.name.endsWith(".guest.prx")) throw new Error("Use the original archive, without previously converted .guest.prx files");
if (item.name.endsWith(".guest.prx")) throw new Error("Use the original files, without previously converted .guest.prx files");
if (elf) this.modules.push(item);
else if (/\.(elf|prx|sprx|suprx|debug_prx)$/iu.test(item.name)) throw new Error(`The module is not a decrypted ELF: ${item.name}`);
} else if (elf) executables.push(item);

View File

@ -12,12 +12,12 @@
<div id="browser-note" class="notice" role="status" hidden></div>
<section class="converter" aria-label="App converter">
<div class="panel-heading"><h2>Convert an app</h2><span class="private"><b aria-hidden="true">●</b> Files stay on your device</span></div>
<div id="drop" class="drop"><span class="archive-icon" aria-hidden="true">↓</span><h3>Drop your ZIP here</h3><p>One executable ELF, <code>sce_sys/</code> and <code>sce_module/</code>.<br>Other app files can stay in the archive.</p><button id="choose" class="button">Choose ZIP file</button><input id="file" type="file" accept=".zip,application/zip" hidden><p class="fine">ZIP &amp; ZIP64 · Decrypted ELF files only</p></div>
<div id="selection" class="selection" hidden><div><p class="eyebrow">SELECTED ARCHIVE</p><h3 id="archive-name"></h3><p id="archive-info" class="muted"></p></div><button id="change" class="plain">Change file</button></div>
<div id="drop" class="drop"><span class="archive-icon" aria-hidden="true">↓</span><h3>Choose a folder or drop a ZIP</h3><p>One executable ELF, <code>sce_sys/</code> and <code>sce_module/</code>.<br>Other app files can stay in the archive.</p><div class="input-actions"><button id="choose" class="button">Choose ZIP file</button><button id="choose-folder" class="button">Choose folder</button></div><input id="folder" type="file" webkitdirectory multiple hidden><input id="file" type="file" accept=".zip,application/zip" hidden><p class="fine">ZIP &amp; ZIP64 · Decrypted ELF files only</p></div>
<div id="selection" class="selection" hidden><div><p class="eyebrow">SELECTED INPUT</p><h3 id="archive-name"></h3><p id="archive-info" class="muted"></p></div><button id="change" class="plain">Change input</button></div>
<div id="warnings" class="warnings" hidden><ul id="warning-list"></ul><label><input id="acknowledge" type="checkbox"> I understand and want to continue.</label></div>
<div class="settings"><fieldset id="target"><legend>Convert for</legend><div class="choices"><label><input type="radio" name="target" value="windows" checked> Windows <span>.exe</span></label><label><input type="radio" name="target" value="linux"> Linux <span>.elf</span></label></div></fieldset><div class="save-settings"><label for="save">Save the result</label><select id="save"><option value="memory">Download in memory · small apps</option><option value="disk">Save directly to disk · large apps</option></select><p id="save-note" class="fine"></p></div></div>
<div class="options"><label><input type="checkbox" id="intel"> Convert AMD-specific instructions for Intel CPUs</label></div>
<div class="action-row"><p id="ready-note" class="muted">Choose an archive to get started.</p><button id="convert" class="button" disabled>Convert &amp; save →</button><button id="cancel" class="plain" hidden>Cancel</button></div>
<div class="action-row"><p id="ready-note" class="muted">Choose a ZIP or folder to get started.</p><button id="convert" class="button" disabled>Convert &amp; save →</button><button id="cancel" class="plain" hidden>Cancel</button></div>
<div id="status" class="status" hidden><div class="status-line"><strong id="phase" role="status"></strong><span id="percent"></span></div><progress id="progress" max="100"></progress><p id="current-file" class="fine"></p><details><summary>Conversion log</summary><pre id="log"></pre></details></div>
<div id="error" class="error" role="alert" hidden></div>
<div id="done" class="done" hidden><h3>Ready to run.</h3><p id="result-info"></p><a id="download" class="button" hidden>Download ZIP</a><p class="fine">Extract the entire ZIP before launching the executable.</p></div>

View File

@ -23,8 +23,15 @@ async function zip(entries) {
}
const source = await zip([["title/", null, { directory: true }], ["title/program.dat", executable], ["title/sce_module/library.elf", module], ["title/sce_sys/param.json", new TextEncoder().encode("{}")], ["title/content/resource.bin", resource]]);
const folder = [
{ path: "title/program.dat", file: new Blob([executable]) },
{ path: "title/sce_module/library.elf", file: new Blob([module]) },
{ path: "title/sce_sys/param.json", file: new Blob(["{}"]) },
{ path: "title/content/resource.bin", file: new Blob([resource]) }
];
for (const [kind, selected] of [["ZIP", source], ["folder", folder]]) {
for (const target of ["windows", "linux"]) {
const archive = new Archive(source);
const archive = new Archive(selected);
const summary = await archive.inspect(() => {});
assert.equal(summary.name, "program.dat");
assert.equal(summary.root, "title/");
@ -61,7 +68,8 @@ for (const target of ["windows", "linux"]) {
assert(maxChunk < resource.length);
await reader.close();
await archive.close();
console.log(`${target}: conversion, original files, modules, permissions and streamed ZIP verified`);
console.log(`${kind}, ${target}: conversion, original files, modules, permissions and streamed ZIP verified`);
}
}
for (const [entries, error] of [

View File

@ -48,11 +48,11 @@ self.onmessage = async ({ data }) => {
try {
if (data.type === "inspect") {
if (archive) await archive.close();
archive = new Archive(data.file);
archive = new Archive(data.files ?? data.file);
const summary = await archive.inspect(report);
self.postMessage({ type: "inspected", summary });
} else if (data.type === "convert") {
if (!archive) throw new Error("Select an archive first");
if (!archive) throw new Error("Select a ZIP or folder first");
const libraries = await downloadLibraries(data.target);
const estimate = memoryEstimate(archive.totalBytes, archive.selectedBytes, libraries.expandedBytes, archive.entries.length);
if (data.mode === "memory" && estimate > MEMORY_LIMIT) throw new Error("This result exceeds the 256 MiB memory-download budget. Use Save directly to disk in desktop Chrome or Edge.");