mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-18 23:07:07 -05:00
Merge pull request #5247 from devavijitsingh/feat/web-conversion-recovery
fix(web): remove incomplete folder output on failure
This commit is contained in:
6 files changed
+220
-13
No files matched your search
@@ -0,0 +1,31 @@
|
||||
name: Web tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/web-tests.yml
|
||||
- demo/web/**
|
||||
- tools/test_web.py
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: web-tests-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
output-directory:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check browser module syntax
|
||||
run: |
|
||||
node --check demo/web/app.mjs
|
||||
node --check demo/web/output-directory.mjs
|
||||
node --check demo/web/worker.mjs
|
||||
- name: Test folder output recovery
|
||||
run: node --test demo/web/tests/output-directory.test.mjs
|
||||
+24
-13
@@ -1,11 +1,15 @@
|
||||
import { abortAndDiscardOutput, OutputDirectoryTransaction } from "./output-directory.mjs";
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const stages = ["Converting executable", "Converting PRX modules", "Scanning syscalls", "Scanning instructions", "Converting instructions", "Analysing imports", "Building output image", "Writing output"];
|
||||
let worker;
|
||||
let file;
|
||||
let summary;
|
||||
let working = false;
|
||||
let failing = false;
|
||||
let sink;
|
||||
let outputDirectory;
|
||||
let outputTransaction;
|
||||
let downloadUrl;
|
||||
let logs = [];
|
||||
let release;
|
||||
@@ -38,7 +42,7 @@ function update() {
|
||||
$("save").querySelector('[value="memory"]').disabled = Boolean(tooLarge);
|
||||
const memoryBlocked = $("save").value === "memory" && tooLarge;
|
||||
if (summary) $("archive-info").textContent = `${summary.files} files · ${bytes(summary.totalBytes)} expanded · ${summary.modules} modules · ${bytes(selectedBytes)} ${target} conversion input` + (summary.root ? ` · Root: ${summary.root}` : "");
|
||||
$("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.";
|
||||
$("save-note").textContent = $("save").value === "disk" ? "Creates a subfolder with the executable, libs and app0. No ZIP compression. Incomplete output folders are removed after cancellation or failure. 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 && !conversionBlocked;
|
||||
$("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." : iconTooLarge ? "The Windows icon exceeds 16 MiB. Choose Linux or use the native relinker." : conversionTooLarge ? `This ${target} conversion input exceeds 256 MiB. Use the native relinker for this app.` : 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 || conversionBlocked || memoryBlocked || folderBlocked || (summary.warnings.length > 0 && !$("acknowledge").checked);
|
||||
@@ -50,22 +54,30 @@ function lock(value) {
|
||||
}
|
||||
|
||||
async function fail(message) {
|
||||
if (failing) return;
|
||||
failing = true;
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
working = false;
|
||||
working = true;
|
||||
summary = null;
|
||||
if (sink) {
|
||||
const active = sink;
|
||||
sink = null;
|
||||
try { await active.abort(); } catch (error) { message += `\nCould not discard the output file: ${error.message}`; }
|
||||
const transaction = outputTransaction;
|
||||
outputTransaction = null;
|
||||
outputDirectory = null;
|
||||
if (transaction) {
|
||||
$("status").hidden = false;
|
||||
$("phase").textContent = "Removing incomplete output folder";
|
||||
}
|
||||
if (outputDirectory) { message += `\nIncomplete files remain in ${outputDirectory.name}/.`; outputDirectory = null; }
|
||||
const activeSink = sink;
|
||||
sink = null;
|
||||
for (const failure of await abortAndDiscardOutput(transaction, activeSink)) message += `\n${failure}`;
|
||||
$("error").textContent = message;
|
||||
$("error").hidden = false;
|
||||
$("status").hidden = true;
|
||||
$("cancel").hidden = true;
|
||||
$("drop").hidden = false;
|
||||
$("file").value = "";
|
||||
working = false;
|
||||
failing = false;
|
||||
lock(false);
|
||||
update();
|
||||
}
|
||||
@@ -141,11 +153,13 @@ async function onMessage({ data }) {
|
||||
} else if (data.mode === "zip") {
|
||||
$("result-info").textContent += " · ZIP saved to disk";
|
||||
} else {
|
||||
$("result-info").textContent += ` · Saved in ${outputDirectory.name}/`;
|
||||
$("result-info").textContent += ` · Saved in ${outputTransaction.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;
|
||||
outputTransaction?.commit();
|
||||
outputTransaction = null;
|
||||
summary = null;
|
||||
worker.terminate();
|
||||
worker = null;
|
||||
@@ -206,11 +220,8 @@ $("convert").onclick = async () => {
|
||||
} 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 });
|
||||
outputTransaction = await OutputDirectoryTransaction.create(parent, name);
|
||||
outputDirectory = outputTransaction.directory;
|
||||
}
|
||||
$("status").hidden = false;
|
||||
$("cancel").hidden = false;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export class OutputDirectoryTransaction {
|
||||
#state = "open";
|
||||
|
||||
constructor(parent, name, directory) {
|
||||
this.parent = parent;
|
||||
this.name = name;
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
static async create(parent, name) {
|
||||
try {
|
||||
await parent.getDirectoryHandle(name);
|
||||
} catch (error) {
|
||||
if (error.name !== "NotFoundError") throw error;
|
||||
const directory = await parent.getDirectoryHandle(name, { create: true });
|
||||
return new OutputDirectoryTransaction(parent, name, directory);
|
||||
}
|
||||
throw new Error(`The folder ${name} already exists. Choose another destination or rename the existing folder.`);
|
||||
}
|
||||
|
||||
commit() {
|
||||
if (this.#state !== "open") throw new Error("Output directory transaction is no longer active");
|
||||
this.#state = "committed";
|
||||
}
|
||||
|
||||
async discard() {
|
||||
if (this.#state !== "open") return;
|
||||
await this.parent.removeEntry(this.name, { recursive: true });
|
||||
this.#state = "discarded";
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortAndDiscardOutput(transaction, sink) {
|
||||
const failures = [];
|
||||
if (sink) {
|
||||
try { await sink.abort(); }
|
||||
catch (error) { failures.push(`Could not discard the output file: ${error instanceof Error ? error.message : String(error)}`); }
|
||||
}
|
||||
if (transaction) {
|
||||
try { await transaction.discard(); }
|
||||
catch (error) { failures.push(`Could not remove incomplete output folder ${transaction.name}/: ${error instanceof Error ? error.message : String(error)}`); }
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { abortAndDiscardOutput, OutputDirectoryTransaction } from "../output-directory.mjs";
|
||||
|
||||
function missingDirectory() {
|
||||
const error = new Error("not found");
|
||||
error.name = "NotFoundError";
|
||||
return error;
|
||||
}
|
||||
|
||||
test("creates a new output root only after confirming the destination is absent", async () => {
|
||||
const directory = {};
|
||||
const calls = [];
|
||||
const parent = {
|
||||
async getDirectoryHandle(name, options) {
|
||||
calls.push([name, options]);
|
||||
if (!options) throw missingDirectory();
|
||||
return directory;
|
||||
},
|
||||
async removeEntry() {}
|
||||
};
|
||||
|
||||
const transaction = await OutputDirectoryTransaction.create(parent, "app-linux");
|
||||
|
||||
assert.equal(transaction.directory, directory);
|
||||
assert.deepEqual(calls, [["app-linux", undefined], ["app-linux", { create: true }]]);
|
||||
});
|
||||
|
||||
test("does not claim or delete a destination that already exists", async () => {
|
||||
const parent = {
|
||||
async getDirectoryHandle() { return {}; },
|
||||
async removeEntry() { assert.fail("existing destination must not be removed"); }
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
OutputDirectoryTransaction.create(parent, "app-linux"),
|
||||
/already exists/u
|
||||
);
|
||||
});
|
||||
|
||||
test("discards its incomplete output recursively", async () => {
|
||||
let removed;
|
||||
const parent = {
|
||||
async getDirectoryHandle(name, options) {
|
||||
if (!options) throw missingDirectory();
|
||||
return { name };
|
||||
},
|
||||
async removeEntry(...args) { removed = args; }
|
||||
};
|
||||
const transaction = await OutputDirectoryTransaction.create(parent, "app-linux");
|
||||
|
||||
await transaction.discard();
|
||||
await transaction.discard();
|
||||
|
||||
assert.deepEqual(removed, ["app-linux", { recursive: true }]);
|
||||
});
|
||||
|
||||
test("keeps completed output and reports cleanup failures", async () => {
|
||||
let removeCount = 0;
|
||||
const parent = {
|
||||
async getDirectoryHandle(name, options) {
|
||||
if (!options) throw missingDirectory();
|
||||
return { name };
|
||||
},
|
||||
async removeEntry() {
|
||||
removeCount++;
|
||||
throw new Error("permission denied");
|
||||
}
|
||||
};
|
||||
const completed = await OutputDirectoryTransaction.create(parent, "complete-linux");
|
||||
completed.commit();
|
||||
await completed.discard();
|
||||
assert.equal(removeCount, 0);
|
||||
|
||||
const failed = await OutputDirectoryTransaction.create(parent, "partial-linux");
|
||||
await assert.rejects(failed.discard(), /permission denied/u);
|
||||
assert.equal(removeCount, 1);
|
||||
});
|
||||
|
||||
test("aborts an open file before removing the incomplete output tree", async () => {
|
||||
const calls = [];
|
||||
const parent = {
|
||||
async getDirectoryHandle(name, options) {
|
||||
if (!options) throw missingDirectory();
|
||||
return { name };
|
||||
},
|
||||
async removeEntry(name, options) { calls.push(["remove", name, options]); }
|
||||
};
|
||||
const transaction = await OutputDirectoryTransaction.create(parent, "partial-linux");
|
||||
let finishAbort;
|
||||
const sink = { abort() { calls.push(["abort"]); return new Promise(resolve => { finishAbort = resolve; }); } };
|
||||
|
||||
const cleanup = abortAndDiscardOutput(transaction, sink);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(calls, [["abort"]]);
|
||||
finishAbort();
|
||||
assert.deepEqual(await cleanup, []);
|
||||
assert.deepEqual(calls, [["abort"], ["remove", "partial-linux", { recursive: true }]]);
|
||||
});
|
||||
|
||||
test("returns sink and folder cleanup failures without hiding either", async () => {
|
||||
const parent = {
|
||||
async getDirectoryHandle(name, options) {
|
||||
if (!options) throw missingDirectory();
|
||||
return { name };
|
||||
},
|
||||
async removeEntry() { throw new Error("directory permission denied"); }
|
||||
};
|
||||
const transaction = await OutputDirectoryTransaction.create(parent, "partial-linux");
|
||||
const sink = { async abort() { throw new Error("file is busy"); } };
|
||||
|
||||
assert.deepEqual(await abortAndDiscardOutput(transaction, sink), [
|
||||
"Could not discard the output file: file is busy",
|
||||
"Could not remove incomplete output folder partial-linux/: directory permission denied"
|
||||
]);
|
||||
});
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
[**Online relinker**](https://boykopovar.github.io/AnyPS5/) - files stay on your device.
|
||||
|
||||
### Browser folder output
|
||||
|
||||
The online converter can save Linux output as an uncompressed folder in supported browsers. It does not overwrite an existing destination folder. If conversion fails or is cancelled, it aborts the active file and attempts to remove the newly created output folder. If the browser prevents cleanup, the error names the folder that may contain incomplete files.
|
||||
|
||||
## Input and conversion
|
||||
|
||||
Use a clean ELF executable. Place its bundled ELF modules in `sce_module/`, `sce_modules/`, or `prx/` beside the input executable, or under the parent directory selected with `--sce-module-path <path>`. The option accepts absolute paths and paths relative to the current working directory; do not include the `sce_module` directory itself. Only files directly inside the module directories are processed. SELF/fSELF containers are skipped with a warning; unwrap them before relinking if they need to be included. `tools/unwrap_self.py <input.self> <output.elf>` does that for a decrypted container: it places the segments at the offsets of the SELF segment records and restores `p_offset % p_align == p_vaddr % p_align`, and it refuses a container whose load segments all look encrypted. `prx/` can coexist with either `sce_module/` or `sce_modules/`. Both `sce_module/` and `sce_modules/` present, or all three absent, is an error.
|
||||
|
||||
@@ -17,6 +17,7 @@ def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--build", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
subprocess.run(["node", "--test", str(ROOT / "demo/web/tests/output-directory.test.mjs")], check=True)
|
||||
with tempfile.TemporaryDirectory(prefix="anyps5-web-") as directory:
|
||||
fixture = Path(directory)
|
||||
(fixture / "main.elf").write_bytes(main_fixture())
|
||||
|
||||
Reference in new issue
Block a user