feat(web): support folder input

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

No files matched your search

+2
View File
@@ -2,3 +2,5 @@
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--bg:#0d0e10;--surface:#161719;--sunken:#131416;--line:#2a2b2e;--text:#eff0f2;--muted:#a0a2a6;--accent:#7baaff;--on-accent:#0b0f18;--warning:#e4bb74;--warning-bg:#262117;--error:#ffa6a6;--error-bg:#2d191b;--good:#4fb768}}
:root[data-theme=dark]{color-scheme:dark;--bg:#0d0e10;--surface:#161719;--sunken:#131416;--line:#2a2b2e;--text:#eff0f2;--muted:#a0a2a6;--accent:#7baaff;--on-accent:#0b0f18;--warning:#e4bb74;--warning-bg:#262117;--error:#ffa6a6;--error-bg:#2d191b;--good:#4fb768}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}button,input,select{font:inherit}button,a,select,input{touch-action:manipulation}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.45}a{color:var(--accent);text-underline-offset:3px}button:focus-visible,a:focus-visible,select:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:4px}code,pre,.eyebrow,.fine{font-family:ui-monospace,"Cascadia Mono",Consolas,monospace}code{font-size:.9em}h1,h2,h3,p{margin-top:0}[hidden]{display:none!important}header{border-bottom:1px solid var(--line);background:var(--surface)}.bar{max-width:1120px;margin:auto;min-height:68px;padding:12px 24px;display:flex;align-items:center;justify-content:space-between;gap:24px}.brand{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);font-weight:700;font-size:17px;white-space:nowrap}.brand>span:last-child{font-weight:400;color:var(--muted);font-size:14px}.mark{display:flex;gap:3px;align-items:center;margin-right:3px}.mark i{display:block;width:4px;height:20px;background:var(--text)}.mark i:last-child{opacity:.3}nav{display:flex;align-items:center;gap:24px;font-size:13px}nav a{color:var(--muted);text-decoration:none}nav a:hover{color:var(--text)}main{max-width:1024px;margin:auto;padding:58px 24px 36px}.intro{max-width:660px;margin-bottom:30px}.eyebrow{color:var(--muted);letter-spacing:.12em;font-size:11px;margin-bottom:12px}h1{font-size:clamp(29px,4vw,43px);line-height:1.15;letter-spacing:-.035em;font-weight:650;margin-bottom:18px}.converter{border:1px solid var(--line);border-radius:10px;background:var(--surface);overflow:hidden}.panel-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;border-bottom:1px solid var(--line);padding:19px 26px}.panel-heading h2{font-size:15px;margin:0;font-weight:600}.private{font-size:12px;color:var(--muted)}.private b{color:var(--good);font-size:9px;margin-right:5px}.drop{margin:24px;border:1px dashed var(--line);border-radius:6px;text-align:center;padding:28px 16px 17px;background:var(--bg)}.drop.dragging{border-color:var(--accent);background:var(--sunken)}.archive-icon{display:inline-flex;align-items:center;justify-content:center;width:40px;height:45px;border:1px solid var(--line);border-radius:4px;font-size:25px;color:var(--muted);margin-bottom:15px}.drop h3{font-size:19px;margin-bottom:8px;font-weight:600}.drop>p{color:var(--muted);font-size:13px;margin-bottom:20px}.button{display:inline-flex;align-items:center;justify-content:center;gap:18px;border:1px solid transparent;border-radius:6px;background:var(--accent);color:var(--on-accent);text-decoration:none;font-weight:600;font-size:13px;padding:11px 20px;white-space:nowrap}.button:hover:not(:disabled){filter:brightness(.94)}.drop .fine{margin:15px 0 0;font-size:10px}.fine{font-size:11px;line-height:1.6;color:var(--muted);margin-bottom:0}.plain{border:0;background:none;color:var(--muted);padding:8px;font-size:13px}.plain:hover{color:var(--text)}#theme{font-size:19px;padding:3px}.settings{display:grid;grid-template-columns:1fr 1fr;gap:36px;padding:0 26px 20px}fieldset{border:0;margin:0;padding:0;min-width:0}legend,.save-settings>label{display:block;font-size:12px;font-weight:600;margin-bottom:10px}.choices{display:flex;gap:10px}.choices label{flex:1;display:flex;gap:9px;align-items:center;border:1px solid var(--line);border-radius:6px;padding:10px;font-size:13px;cursor:pointer}.choices label:has(input:checked){border-color:var(--accent)}.choices span{font-size:11px;color:var(--muted);margin-left:auto}input{accent-color:var(--accent)}input[type=radio],input[type=checkbox]{margin:0;width:15px;height:15px;flex-shrink:0}select{width:100%;padding:10px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--text);font-size:13px}.save-settings .fine{margin-top:8px}.options{padding:0 26px 23px;font-size:12px;color:var(--muted)}.options label{display:flex;gap:9px;align-items:center}.action-row{border-top:1px solid var(--line);padding:17px 26px;display:flex;gap:12px;align-items:center}.action-row>p{margin:0 auto 0 0;font-size:12px}.muted{color:var(--muted)}.selection{display:flex;justify-content:space-between;align-items:center;padding:25px 26px;gap:16px}.selection h3{font-size:18px;margin-bottom:6px;overflow-wrap:anywhere}.selection p{margin-bottom:0;font-size:12px}.selection .eyebrow{font-size:10px;margin-bottom:6px}.warnings,.notice{padding:16px 20px;background:var(--warning-bg);color:var(--warning);font-size:13px;border:1px solid var(--line);border-radius:6px}.warnings{margin:0 26px 24px}.warnings ul{padding-left:18px;margin:0 0 12px}.warnings label{display:flex;gap:8px;align-items:center}.notice{margin-bottom:20px}.status{border-top:1px solid var(--line);padding:22px 26px}.status-line{display:flex;justify-content:space-between;font-size:13px;gap:16px}progress{appearance:none;width:100%;height:5px;marLine truncated
.input-actions{display:flex;justify-content:center;flex-wrap:wrap;gap:12px}
+18 -8
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.";
}
+26 -5
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);
+3 -3
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>
+10 -2
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 [
+2 -2
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.");