feat(web): support folder input
This commit is contained in:
parent
5e762427b4
commit
1d99664fb9
File diff suppressed because one or more lines are too long
|
|
@ -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.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 & 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 & 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>
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
Loading…
Reference in New Issue