fix(web): reject windows folder output before writing files
This commit is contained in:
parent
36ea209e72
commit
dae5690e59
|
|
@ -5,13 +5,16 @@ let file;
|
|||
let summary;
|
||||
let working = false;
|
||||
let sink;
|
||||
let outputDirectory;
|
||||
let downloadUrl;
|
||||
let logs = [];
|
||||
let release;
|
||||
const canStream = typeof window.showSaveFilePicker === "function" && window.isSecureContext;
|
||||
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").value = canStream ? "disk" : "memory";
|
||||
$("save").querySelector('[value="zip"]').disabled = !canSaveZip;
|
||||
$("save").value = canSaveZip ? "zip" : "memory";
|
||||
|
||||
function bytes(value) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
|
|
@ -21,16 +24,20 @@ function bytes(value) {
|
|||
|
||||
function update() {
|
||||
const target = document.querySelector('input[name="target"]:checked').value;
|
||||
const folderBlocked = target === "windows" && $("save").value === "disk";
|
||||
const folderOption = $("save").querySelector('[value="disk"]');
|
||||
folderOption.disabled = !canStream || target === "windows";
|
||||
folderOption.textContent = target === "windows" ? "Save to folder · unavailable for Windows (DLL restrictions)" : "Save to folder · uncompressed";
|
||||
const libraryBytes = release?.platforms[target]?.expandedBytes;
|
||||
const estimate = summary && libraryBytes !== undefined ? Math.ceil(summary.totalBytes * 1.02) + summary.selectedBytes * 4 + libraryBytes + summary.files * 1024 + 8 * 1024 * 1024 : null;
|
||||
const tooLarge = estimate !== null && estimate > 256 * 1024 * 1024;
|
||||
$("save").querySelector('[value="memory"]').disabled = Boolean(tooLarge);
|
||||
const memoryBlocked = $("save").value === "memory" && tooLarge;
|
||||
$("save-note").textContent = $("save").value === "disk" ? "Writes the ZIP to your chosen file without holding it in memory." : "Up to 256 MiB including expanded files and output overhead.";
|
||||
$("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 a ZIP or folder to get started.";
|
||||
$("save-note").textContent = $("save").value === "disk" ? "Creates a subfolder with the executable, libs and app0. No ZIP compression. 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;
|
||||
$("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 ? "Browsers block direct DLL creation. Windows output requires ZIP. Choose Save ZIP to disk or Download in memory." : memoryBlocked ? "This archive 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.";
|
||||
$("convert").disabled = !supported || working || !summary || !release || memoryBlocked || folderBlocked || (summary.warnings.length > 0 && !$("acknowledge").checked);
|
||||
$("ready-note").textContent = summary ? (folderBlocked ? "Choose a ZIP save mode for Windows." : memoryBlocked ? "A direct disk save is required for this archive." : release ? `Libraries: ${release.tag} · ${$("save").value === "disk" ? "Uncompressed folder" : "ZIP compression level 9"}` : "Loading release information…") : "Choose a ZIP or folder to get started.";
|
||||
}
|
||||
|
||||
function lock(value) {
|
||||
|
|
@ -47,6 +54,7 @@ async function fail(message) {
|
|||
sink = null;
|
||||
try { await active.abort(); } catch (error) { message += `\nCould not discard the output file: ${error.message}`; }
|
||||
}
|
||||
if (outputDirectory) { message += `\nIncomplete files remain in ${outputDirectory.name}/.`; outputDirectory = null; }
|
||||
$("error").textContent = message;
|
||||
$("error").hidden = false;
|
||||
$("status").hidden = true;
|
||||
|
|
@ -89,6 +97,27 @@ async function onMessage({ data }) {
|
|||
$("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);
|
||||
|
|
@ -99,13 +128,20 @@ async function onMessage({ data }) {
|
|||
$("done").hidden = false;
|
||||
$("status").hidden = true;
|
||||
$("cancel").hidden = true;
|
||||
$("result-info").textContent = `${bytes(data.size)} ZIP · ${data.release} system libraries · ${data.outputName}`;
|
||||
$("result-info").textContent = `${bytes(data.size)} ${data.mode === "disk" ? "files" : "ZIP"} · ${data.release} system libraries · ${data.outputName}`;
|
||||
if (data.blob) {
|
||||
downloadUrl = URL.createObjectURL(data.blob);
|
||||
$("download").href = downloadUrl;
|
||||
$("download").download = `${data.outputName}.zip`;
|
||||
$("download").hidden = false;
|
||||
} else $("result-info").textContent += " · Saved to disk";
|
||||
} else if (data.mode === "zip") {
|
||||
$("result-info").textContent += " · ZIP saved to disk";
|
||||
} else {
|
||||
$("result-info").textContent += ` · Saved in ${outputDirectory.name}/`;
|
||||
if (document.querySelector('input[name="target"]:checked').value === "linux") $("result-info").textContent += ` · On Linux, run chmod +x "${data.outputName}" before launching.`;
|
||||
}
|
||||
$("result-note").hidden = data.mode === "disk";
|
||||
outputDirectory = null;
|
||||
summary = null;
|
||||
worker.terminate();
|
||||
worker = null;
|
||||
|
|
@ -153,16 +189,24 @@ for (const type of ["dragenter", "dragover"]) $("drop").addEventListener(type, e
|
|||
$("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 ZIP or folder again to restart.");
|
||||
$("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 {
|
||||
if ($("save").value === "disk") {
|
||||
if ($("save").value === "zip") {
|
||||
const handle = await window.showSaveFilePicker({ suggestedName: file.name.replace(/\.zip$/iu, "") + "-" + document.querySelector('input[name="target"]:checked').value + ".zip", 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 = file.name.replace(/\.zip$/iu, "") + "-" + document.querySelector('input[name="target"]:checked').value;
|
||||
let exists = false;
|
||||
try { await parent.getDirectoryHandle(name); exists = true; }
|
||||
catch (error) { if (error.name !== "NotFoundError") throw error; }
|
||||
if (exists) throw new Error(`The folder ${name} already exists. Choose another destination or rename the existing folder.`);
|
||||
outputDirectory = await parent.getDirectoryHandle(name, { create: true });
|
||||
}
|
||||
$("status").hidden = false;
|
||||
$("cancel").hidden = false;
|
||||
|
|
|
|||
|
|
@ -190,14 +190,69 @@ async function appendEntry(writer, name, entry, report) {
|
|||
entry.getData(stream.writable, { checkSignature: true }),
|
||||
writer.add(name, stream.readable.pipeThrough(limit), {
|
||||
versionMadeBy: 0x31e, externalFileAttributes: entry.externalFileAttributes,
|
||||
onprogress: completed => report({ phase: "Compressing ZIP", input: name, completed, total: entry.uncompressedSize })
|
||||
onprogress: completed => report({ phase: writer instanceof DirectoryWriter ? "Writing files" : "Compressing ZIP", input: name, completed, total: entry.uncompressedSize })
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
export async function packageArchive(archive, converted, outputName, libraryFile, destination, report) {
|
||||
class DirectoryWriter {
|
||||
constructor(destination) {
|
||||
this.destination = destination;
|
||||
}
|
||||
|
||||
async add(name, source, options = {}) {
|
||||
if (options.directory) {
|
||||
await this.destination.directory(name);
|
||||
return;
|
||||
}
|
||||
const output = await this.destination.file(name);
|
||||
if (source instanceof ReadableStream) {
|
||||
let completed = 0;
|
||||
const reader = source.getReader();
|
||||
const writer = output.getWriter();
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
for (let offset = 0; offset < value.length; offset += 65536) {
|
||||
const chunk = value.subarray(offset, offset + 65536);
|
||||
await writer.write(chunk);
|
||||
completed += chunk.length;
|
||||
options.onprogress?.(completed);
|
||||
}
|
||||
}
|
||||
await writer.close();
|
||||
} catch (error) {
|
||||
await reader.cancel(error);
|
||||
await writer.abort(error);
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
writer.releaseLock();
|
||||
}
|
||||
} else {
|
||||
await source.init();
|
||||
const writer = output.getWriter();
|
||||
try {
|
||||
for (let offset = 0; offset < source.size; offset += 65536) {
|
||||
await writer.write(await source.readUint8Array(offset, Math.min(65536, source.size - offset)));
|
||||
}
|
||||
await writer.close();
|
||||
} catch (error) {
|
||||
await writer.abort(error);
|
||||
throw error;
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close() {}
|
||||
}
|
||||
|
||||
export async function packageArchive(archive, converted, outputName, libraryFile, destination, report, directory = false) {
|
||||
const libraries = new ZipReader(new BoundedBlobReader(libraryFile));
|
||||
const writer = new ZipWriter(destination, { level: 9, zip64: true, bufferedWrite: false, useWebWorkers: false, useCompressionStream: false, preventClose: true });
|
||||
const writer = directory ? new DirectoryWriter(destination) : new ZipWriter(destination, { level: 9, zip64: true, bufferedWrite: false, useWebWorkers: false, useCompressionStream: false, preventClose: true });
|
||||
const names = new Set();
|
||||
const claim = name => {
|
||||
validPath(name);
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@
|
|||
<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="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="zip">Save ZIP to disk · large apps</option><option value="disk">Save to folder · uncompressed</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 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>
|
||||
<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 id="result-note" class="fine">Extract the entire ZIP before launching the executable.</p></div>
|
||||
</section>
|
||||
<p class="limits">Memory downloads have a 256 MiB budget, checked against expanded files and output overhead. Larger results require desktop Chrome or Edge with direct disk saving. Executable, modules and icon are limited to 256 MiB combined; use the native relinker for larger conversion inputs.</p>
|
||||
<p class="compatibility">Conversion does not guarantee that an app runs. Check the <a href="https://alxcbtw.github.io/anyps5-roadmap/">community compatibility list</a> for known blockers and reports.</p>
|
||||
|
|
|
|||
|
|
@ -75,9 +75,38 @@ for (const target of ["windows", "linux"]) {
|
|||
assert.deepEqual(await byName.get("app0/content/resource.bin").getData(new Uint8ArrayWriter(), { checkSignature: true }), resource);
|
||||
assert.equal((byName.get(input.outputName).externalFileAttributes >>> 16) & 0o777, 0o755);
|
||||
assert(maxChunk < resource.length);
|
||||
const saved = new Map();
|
||||
let active = false;
|
||||
let directoryChunk = 0;
|
||||
await packageArchive(archive, converted, input.outputName, libraries, {
|
||||
async directory(name) { saved.set(name, null); },
|
||||
async file(name) {
|
||||
assert.equal(active, false);
|
||||
active = true;
|
||||
const pieces = [];
|
||||
return new WritableStream({
|
||||
async write(chunk) {
|
||||
directoryChunk = Math.max(directoryChunk, chunk.length);
|
||||
pieces.push(chunk.slice());
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
},
|
||||
async close() {
|
||||
saved.set(name, new Uint8Array(await new Blob(pieces).arrayBuffer()));
|
||||
active = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, () => {}, true);
|
||||
assert.equal(active, false);
|
||||
assert(directoryChunk < resource.length);
|
||||
assert.equal(saved.size, entries.length);
|
||||
for (const entry of entries) {
|
||||
assert(saved.has(entry.filename));
|
||||
if (!entry.directory) assert.deepEqual(saved.get(entry.filename), await entry.getData(new Uint8ArrayWriter(), { checkSignature: true }));
|
||||
}
|
||||
await reader.close();
|
||||
await archive.close();
|
||||
console.log(`${kind}, ${target}: conversion, original files, modules, permissions and streamed ZIP verified`);
|
||||
console.log(`${kind}, ${target}: conversion, original files, modules, permissions, streamed ZIP and directory output verified`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,3 +129,13 @@ assert(memoryEstimate(200 * 1024 ** 3, 1024, 1024, 100) > MEMORY_LIMIT);
|
|||
const invalid = native.Convert({ target: "unsupported" }, () => {});
|
||||
assert.equal(invalid.ok, false);
|
||||
console.log("Archive validation, missing-folder warnings and large-download budget verified");
|
||||
|
||||
const workerMessages = [];
|
||||
globalThis.self = { postMessage: message => workerMessages.push(message) };
|
||||
await import(site("worker.mjs"));
|
||||
await self.onmessage({ data: { type: "convert", target: "windows", mode: "disk" } });
|
||||
assert.equal(workerMessages.length, 1);
|
||||
assert.equal(workerMessages[0].type, "error");
|
||||
assert.match(workerMessages[0].message, /browsers block DLL creation/u);
|
||||
delete globalThis.self;
|
||||
console.log("Windows folder output rejected before conversion or file writes");
|
||||
|
|
|
|||
|
|
@ -52,10 +52,11 @@ self.onmessage = async ({ data }) => {
|
|||
const summary = await archive.inspect(report);
|
||||
self.postMessage({ type: "inspected", summary });
|
||||
} else if (data.type === "convert") {
|
||||
if (data.target === "windows" && data.mode === "disk") throw new Error("Windows folder output is unavailable because browsers block DLL creation. Choose a ZIP save mode.");
|
||||
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.");
|
||||
if (data.mode === "memory" && estimate > MEMORY_LIMIT) throw new Error("This result exceeds the 256 MiB memory-download budget. Use Save ZIP to disk in desktop Chrome or Edge.");
|
||||
report({ phase: "Loading converter" });
|
||||
const { default: createRelinker } = await import("./native/relinker_wasm.mjs");
|
||||
const native = await createRelinker();
|
||||
|
|
@ -66,24 +67,43 @@ self.onmessage = async ({ data }) => {
|
|||
if (!converted.ok) throw new Error(converted.error);
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
const destination = new WritableStream({
|
||||
async write(chunk) {
|
||||
size += chunk.byteLength;
|
||||
if (data.mode === "memory") {
|
||||
if (size > MEMORY_LIMIT) throw new Error("The compressed ZIP exceeded 256 MiB. Use Save directly to disk.");
|
||||
chunks.push(chunk.slice());
|
||||
} else if (data.mode === "disk") {
|
||||
const bytes = chunk.slice();
|
||||
await new Promise(resolve => {
|
||||
pendingWrite = resolve;
|
||||
self.postMessage({ type: "write", bytes }, [bytes.buffer]);
|
||||
});
|
||||
} else throw new Error("Unknown save mode");
|
||||
}
|
||||
const send = (event, transfer = []) => new Promise(resolve => {
|
||||
pendingWrite = resolve;
|
||||
self.postMessage(event, transfer);
|
||||
});
|
||||
await packageArchive(archive, converted, outputName, libraries.file, destination, report);
|
||||
await destination.close();
|
||||
self.postMessage({ type: "complete", blob: data.mode === "memory" ? new Blob(chunks, { type: "application/zip" }) : null, outputName, size, release: libraries.release.tag });
|
||||
if (data.mode === "disk") {
|
||||
const destination = {
|
||||
directory: name => send({ type: "directory", name }),
|
||||
async file(name) {
|
||||
await send({ type: "open-file", name });
|
||||
return new WritableStream({
|
||||
async write(chunk) {
|
||||
size += chunk.byteLength;
|
||||
const bytes = chunk.slice();
|
||||
await send({ type: "write", bytes }, [bytes.buffer]);
|
||||
},
|
||||
close: () => send({ type: "close-file" })
|
||||
});
|
||||
}
|
||||
};
|
||||
await packageArchive(archive, converted, outputName, libraries.file, destination, report, true);
|
||||
} else if (data.mode === "memory" || data.mode === "zip") {
|
||||
const destination = new WritableStream({
|
||||
async write(chunk) {
|
||||
size += chunk.byteLength;
|
||||
if (data.mode === "zip") {
|
||||
const bytes = chunk.slice();
|
||||
await send({ type: "write", bytes }, [bytes.buffer]);
|
||||
return;
|
||||
}
|
||||
if (size > MEMORY_LIMIT) throw new Error("The compressed ZIP exceeded 256 MiB. Use Save ZIP to disk.");
|
||||
chunks.push(chunk.slice());
|
||||
}
|
||||
});
|
||||
await packageArchive(archive, converted, outputName, libraries.file, destination, report);
|
||||
await destination.close();
|
||||
} else throw new Error("Unknown save mode");
|
||||
self.postMessage({ type: "complete", mode: data.mode, blob: data.mode === "memory" ? new Blob(chunks, { type: "application/zip" }) : null, outputName, size, release: libraries.release.tag });
|
||||
await archive.close();
|
||||
archive = null;
|
||||
} else throw new Error("Unknown worker request");
|
||||
|
|
|
|||
Loading…
Reference in New Issue