diff --git a/demo/web/app.css b/demo/web/app.css index 13cf3a3bb..c5258a110 100644 --- a/demo/web/app.css +++ b/demo/web/app.css @@ -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;margin:14px 0 8px;border:0;border-radius:3px;overflow:hidden;background:var(--line)}progress::-webkit-progress-bar{background:var(--line)}progress::-webkit-progress-value{background:var(--accent)}progress::-moz-progress-bar{background:var(--accent)}#current-file{overflow-wrap:anywhere;min-height:18px}details{margin-top:18px;font-size:11px;color:var(--muted)}summary{cursor:pointer}pre{max-height:180px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:11px}.error{margin:0 26px 24px;padding:15px;background:var(--error-bg);color:var(--error);border-radius:6px;white-space:pre-wrap;overflow-wrap:anywhere}.done{padding:24px 26px;border-top:1px solid var(--line)}.done h3{font-size:20px;color:var(--good);margin-bottom:8px}.done p{font-size:13px;color:var(--muted)}.done .fine{margin:15px 0 0}.compatibility{margin:12px 0 0;color:var(--muted);font-size:11px;line-height:1.8}.limits{border-top:1px solid var(--line);padding-top:22px;margin:30px 0 0;color:var(--muted);font-size:11px;line-height:1.8}footer{max-width:1024px;margin:auto;border-top:1px solid var(--line);padding:22px 24px;display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}footer div{display:flex;gap:20px}footer a{color:var(--muted);text-decoration:none}@media(max-width:680px){.bar{flex-wrap:wrap;gap:12px;padding:14px 20px}nav{gap:17px;font-size:12px}main{padding:34px 16px 26px}.panel-heading{padding:16px}.private{font-size:10px}.drop{margin:16px}.settings{grid-template-columns:1fr;padding:0 16px 18px;gap:22px}.options{padding:0 16px 20px}.action-row{padding:16px;flex-wrap:wrap}.action-row>p{width:100%}.selection{padding:20px 16px}.warnings{margin:0 16px 20px}.status,.done{padding:20px 16px}.error{margin:0 16px 20px}footer{flex-wrap:wrap;gap:12px}footer div{flex-wrap:wrap;gap:14px}} + +.input-actions{display:flex;justify-content:center;flex-wrap:wrap;gap:12px} diff --git a/demo/web/app.mjs b/demo/web/app.mjs index 24057c231..d0ce34bd0 100644 --- a/demo/web/app.mjs +++ b/demo/web/app.mjs @@ -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."; +} diff --git a/demo/web/archive.mjs b/demo/web/archive.mjs index eaad402cf..e9633fc43 100644 --- a/demo/web/archive.mjs +++ b/demo/web/archive.mjs @@ -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); diff --git a/demo/web/index.html b/demo/web/index.html index b4c2a0412..bc8c4c989 100644 --- a/demo/web/index.html +++ b/demo/web/index.html @@ -12,12 +12,12 @@

Convert an app

Files stay on your device
-

Drop your ZIP here

One executable ELF, sce_sys/ and sce_module/.
Other app files can stay in the archive.

ZIP & ZIP64 · Decrypted ELF files only

- +

Choose a folder or drop a ZIP

One executable ELF, sce_sys/ and sce_module/.
Other app files can stay in the archive.

ZIP & ZIP64 · Decrypted ELF files only

+
Convert for

-

Choose an archive to get started.

+

Choose a ZIP or folder to get started.

diff --git a/demo/web/tests/archive.test.mjs b/demo/web/tests/archive.test.mjs index cd75fea7b..4ab459b0c 100644 --- a/demo/web/tests/archive.test.mjs +++ b/demo/web/tests/archive.test.mjs @@ -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 [ diff --git a/demo/web/worker.mjs b/demo/web/worker.mjs index 72faa7b0b..69fa27804 100644 --- a/demo/web/worker.mjs +++ b/demo/web/worker.mjs @@ -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.");