diff --git a/CMakeLists.txt b/CMakeLists.txt index 0e3ead0e0..c0ac81795 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,6 +10,19 @@ endfunction() option(BUILD_TESTING "Build and register relinker tests" OFF) option(ANYPS5_RELINKER_ONLY "Build the relinker without system libraries or third-party dependencies" OFF) +option(ANYPS5_BUILD_WASM "Build the browser relinker" OFF) +if(ANYPS5_BUILD_WASM) + if(NOT EMSCRIPTEN) + message(FATAL_ERROR "ANYPS5_BUILD_WASM requires emcmake and Emscripten") + endif() + if(BUILD_TESTING) + message(FATAL_ERROR "Use the native build for C++ tests and the browser tests for WASM") + endif() + set(ANYPS5_RELINKER_CLI OFF CACHE BOOL "Build the native relinker command line" FORCE) + add_subdirectory(core/relinker) + return() +endif() + if(BUILD_TESTING) enable_testing() if(MINGW) diff --git a/README.md b/README.md index 76dd74c31..aa8f5d6d5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Tool for automatic executables porting to Linux and Windows. +[**Online converter**](https://boykopovar.github.io/AnyPS5/) - files stay on your device. + +[Discord](https://discord.gg/BHFztBPUe) + Includes a [relinker](core/relinker) that converts executable to the target system's native format and implementations of [system prx libraries](core/libs/prx) suitable for dynamic linking. No emulation or separate runtime process. [Usage](docs/user/USAGE.md), [Build instructions](docs/dev/BUILD.md), [Architecture](docs/dev/ARCHITECTURE.md), [Technical debt of the project](docs/dev/TechnicalDebt.md), [code style conventions](docs/dev/CONVENTIONS.md), [contributing](CONTRIBUTING.md) @@ -12,7 +16,7 @@ Includes a [relinker](core/relinker) that converts executable to the target syst [![progress map](https://boykopovar.github.io/AnyPS5/progress.svg)](https://boykopovar.github.io/AnyPS5/) -[![progress history](https://boykopovar.github.io/AnyPS5/history.svg)](https://boykopovar.github.io/AnyPS5/history.html) +[Progress history](https://boykopovar.github.io/AnyPS5/history.html) * System libraries: percentage of the functions known to the project so far (declared in [core/libs/prx](core/libs/prx)), not of every PS5 system function. The total grows as more functions are declared. diff --git a/core/relinker/CMakeLists.txt b/core/relinker/CMakeLists.txt index 55807aa68..ce7b57d2a 100644 --- a/core/relinker/CMakeLists.txt +++ b/core/relinker/CMakeLists.txt @@ -87,6 +87,11 @@ if(BUILD_TESTING) set_tests_properties(relinker_conversion PROPERTIES TIMEOUT 20) endif() +if(ANYPS5_BUILD_WASM) + target_compile_options(relinker_core PRIVATE -fexceptions) + add_subdirectory(wasm) +endif() + if(NOT ANYPS5_RELINKER_CLI) return() endif() diff --git a/core/relinker/wasm/Bindings.cpp b/core/relinker/wasm/Bindings.cpp new file mode 100644 index 000000000..93ce31f6d --- /dev/null +++ b/core/relinker/wasm/Bindings.cpp @@ -0,0 +1,99 @@ +#include +#include +#include +#include + +namespace { + +class BrowserProgress final : public Domain::IProgress { +public: + explicit BrowserProgress(emscripten::val callback) : callback(std::move(callback)) {} + + void Report(const Domain::ProgressEvent& event) override { + auto report = emscripten::val::object(); + report.set("stage", static_cast(event.Stage)); + report.set("input", event.Input); + report.set("completed", static_cast(event.Completed)); + report.set("total", static_cast(event.Total)); + callback(report); + } + + void Message(std::string_view message) override { + auto report = emscripten::val::object(); + report.set("message", std::string(message)); + callback(report); + } + +private: + emscripten::val callback; +}; + +std::vector ReadBytes(const emscripten::val& bytes, std::size_t& remaining) { + if (!bytes.instanceof(emscripten::val::global("Uint8Array"))) throw std::invalid_argument("Expected Uint8Array input"); + const auto length = bytes["length"].as(); + if (length > remaining) throw std::invalid_argument("Executable, modules and icon exceed the 256 MiB conversion limit"); + remaining -= length; + std::vector result(length); + if (length != 0) emscripten::val(emscripten::typed_memory_view(length, result.data())).call("set", bytes); + return result; +} + +emscripten::val CopyBytes(const std::vector& bytes) { + return emscripten::val::global("Uint8Array").new_(emscripten::val(emscripten::typed_memory_view(bytes.size(), bytes.data()))); +} + +emscripten::val Convert(const emscripten::val& request, const emscripten::val& callback) { + auto result = emscripten::val::object(); + try { + const auto target = request["target"].as(); + if (target != "windows" && target != "linux") throw std::invalid_argument("Unknown target system"); + const auto name = request["name"].as(); + if (name.empty() || name.find_first_of("/\\") != std::string::npos || name == "." || name == "..") throw std::invalid_argument("Invalid executable name"); + std::size_t remaining = 256 * 1024 * 1024; + Relinker::ConversionInput input; + input.InputPath = Io::NativePath("/input/" + name); + input.OutputPath = Io::NativePath("/output/" + request["outputName"].as()); + if (input.OutputPath.parent_path() != "/output") throw std::invalid_argument("Invalid output name"); + input.ModuleRoot = "/input"; + input.Executable = ReadBytes(request["executable"], remaining); + const auto modules = request["modules"]; + const auto count = modules["length"].as(); + if (count > 4096) throw std::invalid_argument("Too many modules"); + for (unsigned index = 0; index < count; ++index) { + const auto module = modules[index]; + const auto path = Io::NativePath(module["name"].as()); + if (path.is_absolute() || path.lexically_normal() != path || path.begin() == path.end() || *path.begin() != "sce_module") throw std::invalid_argument("Invalid module path"); + input.Modules.push_back({Io::NativePath("/input") / path, ReadBytes(module["bytes"], remaining)}); + } + input.RuntimeDirectories.push_back("/input/sce_module"); + if (!request["icon"].isNull()) input.Icon = ReadBytes(request["icon"], remaining); + Relinker::ConversionOptions options; + options.Target = target == "windows" ? Relinker::TargetSystem::Windows : Relinker::TargetSystem::Linux; + options.WindowsGui = false; + options.WindowsDiagnostics = target == "windows"; + options.ToIntel = request["toIntel"].as(); + BrowserProgress progress(callback); + auto output = Relinker::Conversion(progress).Convert(std::move(input), options); + result.set("executable", CopyBytes(output.Executable)); + result.set("diagnostics", output.WindowsDiagnostics); + auto artifacts = emscripten::val::array(); + for (const auto& module : output.Modules) { + auto artifact = emscripten::val::object(); + artifact.set("name", Io::Utf8Path(module.Path.lexically_relative("/output"))); + artifact.set("bytes", CopyBytes(module.Bytes)); + artifacts.call("push", artifact); + } + result.set("modules", artifacts); + result.set("ok", true); + } catch (const std::exception& error) { + result.set("ok", false); + result.set("error", std::string(error.what())); + } + return result; +} + +} + +EMSCRIPTEN_BINDINGS(relinker) { + emscripten::function("Convert", &Convert); +} diff --git a/core/relinker/wasm/CMakeLists.txt b/core/relinker/wasm/CMakeLists.txt new file mode 100644 index 000000000..b92335d01 --- /dev/null +++ b/core/relinker/wasm/CMakeLists.txt @@ -0,0 +1,28 @@ +add_executable(relinker_wasm Bindings.cpp) +target_link_libraries(relinker_wasm PRIVATE relinker_core) +target_compile_options(relinker_wasm PRIVATE -fexceptions) +target_link_options(relinker_wasm PRIVATE + --bind + --no-entry + -fexceptions + -sMODULARIZE=1 + -sEXPORT_ES6=1 + -sEXPORT_NAME=createRelinker + -sALLOW_MEMORY_GROWTH=1 + -sMAXIMUM_MEMORY=2147483648 + -sSTACK_SIZE=1048576 + -sFILESYSTEM=0 + -sENVIRONMENT=web,worker,node +) +set_target_properties(relinker_wasm PROPERTIES + SUFFIX ".mjs" + CXX_EXTENSIONS OFF + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/web/native" +) + +find_package(Python3 REQUIRED COMPONENTS Interpreter) +add_custom_target(relinker_web + COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/build_web.py" --build "${CMAKE_BINARY_DIR}" + DEPENDS relinker_wasm + VERBATIM +) diff --git a/demo/web/app.css b/demo/web/app.css new file mode 100644 index 000000000..c5258a110 --- /dev/null +++ b/demo/web/app.css @@ -0,0 +1,6 @@ +:root{color-scheme:light;--bg:#f9fafb;--surface:#fff;--sunken:#f2f3f5;--line:#e1e3e6;--text:#151619;--muted:#67696c;--accent:#245fd4;--on-accent:#fff;--warning:#875410;--warning-bg:#fff8e8;--error:#a52222;--error-bg:#fff0f0;--good:#218740} +@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 new file mode 100644 index 000000000..ca615cf88 --- /dev/null +++ b/demo/web/app.mjs @@ -0,0 +1,235 @@ +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 sink; +let outputDirectory; +let downloadUrl; +let logs = []; +let release; +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").querySelector('[value="zip"]').disabled = !canSaveZip; +$("save").value = canSaveZip ? "zip" : "memory"; + +function bytes(value) { + if (value < 1024) return `${value} B`; + const unit = value >= 1024 ** 3 ? 3 : value >= 1024 ** 2 ? 2 : 1; + return `${(value / 1024 ** unit).toFixed(1)} ${["B", "KiB", "MiB", "GiB"][unit]}`; +} + +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" ? "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) { + 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) { + worker?.terminate(); + worker = null; + working = false; + summary = null; + if (sink) { + const active = sink; + 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; + $("cancel").hidden = true; + $("drop").hidden = false; + $("file").value = ""; + lock(false); + update(); +} + +async function onMessage({ data }) { + try { + if (data.type === "progress") { + if (data.message) { + logs.push(data.message.trim()); + logs = logs.slice(-150); + $("log").textContent = logs.join("\n"); + return; + } + $("phase").textContent = data.phase ?? stages[data.stage]; + $("current-file").textContent = data.input ?? ""; + if (Number.isFinite(data.total) && data.total > 0) { + const percent = Math.min(100, Math.floor(data.completed / data.total * 100)); + $("progress").value = percent; + $("percent").textContent = `${percent}%`; + } else { + $("progress").removeAttribute("value"); + $("percent").textContent = ""; + } + } else if (data.type === "inspected") { + summary = data.summary; + working = false; + $("selection").hidden = false; + $("drop").hidden = true; + $("archive-name").textContent = file.name; + $("archive-info").textContent = `${summary.files} files · ${bytes(summary.totalBytes)} expanded · ${summary.modules} modules · ${bytes(summary.selectedBytes)} conversion input` + (summary.root ? ` · Root: ${summary.root}` : ""); + $("warning-list").replaceChildren(...summary.warnings.map(message => { const item = document.createElement("li"); item.textContent = message; return item; })); + $("warnings").hidden = !summary.warnings.length; + $("status").hidden = true; + $("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); + worker?.postMessage({ type: "written" }); + } else if (data.type === "complete") { + if (sink) { await sink.close(); sink = null; } + working = false; + $("done").hidden = false; + $("status").hidden = true; + $("cancel").hidden = true; + $("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 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; + lock(false); + update(); + } else if (data.type === "error") await fail(data.message); + } catch (error) { await fail(error.message); } +} + +async function selectFile(selected, files) { + if (working || !supported || !selected) 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; + file = selected; + working = true; + logs = []; + $("log").textContent = ""; + for (const id of ["error", "done", "download", "warnings", "selection"]) $(id).hidden = true; + $("acknowledge").checked = false; + $("status").hidden = false; + $("cancel").hidden = false; + $("phase").textContent = "Inspecting files"; + $("current-file").textContent = file.name; + $("percent").textContent = ""; + $("progress").removeAttribute("value"); + lock(true); + update(); + 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(files ? { type: "inspect", files } : { type: "inspect", file }); +} + +$("choose").onclick = () => $("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. Choose the ZIP or folder again to restart."); +$("convert").onclick = async () => { + if ($("convert").disabled) return; + working = true; + lock(true); + update(); + try { + 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; + $("error").hidden = true; + worker.postMessage({ type: "convert", target: document.querySelector('input[name="target"]:checked').value, toIntel: $("intel").checked, mode: $("save").value }); + } catch (error) { + if (error.name === "AbortError") { working = false; lock(false); update(); } + else await fail(error.message); + } +}; +$("theme").onclick = () => { + const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches; + document.documentElement.dataset.theme = dark ? "light" : "dark"; +}; +update(); +lock(false); +if (supported) fetch("./release.json", { cache: "no-cache" }).then(response => { if (!response.ok) throw new Error(`Cannot load release information (${response.status})`); return response.json(); }).then(value => { + if (!value.tag || !["windows", "linux"].every(target => Number.isSafeInteger(value.platforms?.[target]?.expandedBytes))) throw new Error("Invalid release information"); + 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 new file mode 100644 index 000000000..20c958482 --- /dev/null +++ b/demo/web/archive.mjs @@ -0,0 +1,295 @@ +import { BlobReader, Uint8ArrayReader, ZipReader, ZipWriter, configure } from "https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.57/lib/zip-no-worker.js/+esm"; + +configure({ useWebWorkers: false, useCompressionStream: false, chunkSize: 65536 }); + +export const MEMORY_LIMIT = 256 * 1024 * 1024; +export const CONVERSION_LIMIT = 256 * 1024 * 1024; +export const ICON_LIMIT = 16 * 1024 * 1024; +const MAX_ENTRIES = 100000; + +class BoundedBlobReader extends BlobReader { + async readUint8Array(offset, length) { + if (length > 16 * 1024 * 1024) throw new Error("ZIP directory metadata exceeds 16 MiB"); + return super.readUint8Array(offset, length); + } +} + + +export function validPath(name) { + if (!name || name.includes("\\") || /[\x00-\x1f<>:"|?*]/u.test(name)) throw new Error(`Unsafe ZIP path: ${name}`); + const parts = name.replace(/\/$/u, "").split("/"); + if (parts.some(part => !part || part === "." || part === ".." || /[. ]$/u.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part))) throw new Error(`Unsafe ZIP path: ${name}`); + return name; +} + +function safeSize(value) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error("ZIP contains an unsupported file size"); + return value; +} + +async function signature(entry) { + if (entry.uncompressedSize < 4) return false; + const stop = new AbortController(); + const header = new Uint8Array(4); + let filled = 0; + try { + await entry.getData(new WritableStream({ + write(chunk) { + const length = Math.min(4 - filled, chunk.length); + header.set(chunk.subarray(0, length), filled); + filled += length; + if (filled === 4) stop.abort(); + } + }), { signal: stop.signal, checkSignature: false }); + } catch (error) { + if (!stop.signal.aborted || filled !== 4) throw error; + } + return header[0] === 0x7f && header[1] === 69 && header[2] === 76 && header[3] === 70; +} + +async function boundedBytes(entry, limit) { + if (entry.uncompressedSize > limit) throw new Error(`${entry.filename} exceeds the conversion memory limit`); + let size = 0; + const chunks = []; + await entry.getData(new WritableStream({ + write(chunk) { + size += chunk.byteLength; + if (size > limit || size > entry.uncompressedSize) throw new Error(`Unexpected expanded size: ${entry.filename}`); + chunks.push(chunk); + } + }), { checkSignature: true }); + if (size !== entry.uncompressedSize) throw new Error(`Incorrect expanded size: ${entry.filename}`); + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + 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 = Array.isArray(file) ? new FolderReader(file) : new ZipReader(new BoundedBlobReader(file)); + this.entries = []; + } + + async inspect(report) { + const paths = new Map(); + let total = 0; + for await (const entry of this.reader.getEntriesGenerator()) { + 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}`); + if (entry.encrypted) throw new Error(`Encrypted ZIP entries are not supported: ${entry.filename}`); + if (!entry.directory && entry.compressionMethod !== 0 && entry.compressionMethod !== 8) throw new Error(`Unsupported ZIP compression: ${entry.filename}`); + const mode = (entry.externalFileAttributes >>> 16) & 0xf000; + if (mode && mode !== 0x8000 && mode !== 0x4000) throw new Error(`Links and special files are not supported: ${entry.filename}`); + paths.set(key, entry.directory); + total = safeSize(total + safeSize(entry.uncompressedSize)); + this.entries.push(entry); + } + 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++) { + if (paths.get(parts.slice(0, index).join("/").toLowerCase()) === false) throw new Error(`A file is also used as a folder: ${entry.filename}`); + } + } + let prefix = ""; + const files = this.entries.filter(entry => !entry.directory); + while (files.every(entry => entry.filename.slice(prefix.length).includes("/"))) { + const first = files[0].filename.slice(prefix.length).split("/")[0]; + if (["sce_sys", "sce_module"].includes(first) || !this.entries.every(entry => entry.filename === prefix || entry.filename.startsWith(prefix + first + "/"))) break; + prefix += first + "/"; + } + this.prefix = prefix; + this.files = this.entries.filter(entry => entry.filename.startsWith(prefix) && entry.filename !== prefix).map(entry => ({ entry, name: entry.filename.slice(prefix.length) })); + const executables = []; + this.modules = []; + for (let index = 0; index < this.files.length; index++) { + const item = this.files[index]; + report({ phase: "Inspecting files", input: item.name, completed: index, total: this.files.length }); + if (item.entry.directory || item.name.startsWith("sce_sys/")) continue; + if (!item.name.startsWith("sce_module/") && item.name.includes("/")) continue; + if (item.name.startsWith("sce_module/") && item.name.endsWith(".guest.prx")) continue; + const elf = await signature(item.entry); + if (item.name.startsWith("sce_module/")) { + if (elf) this.modules.push(item); + } else if (elf) executables.push(item); + } + if (executables.length !== 1) throw new Error(`Expected exactly one executable ELF in the app root; found ${executables.length}`); + this.executable = executables[0]; + if (this.executable.name.includes("/")) throw new Error("Place the executable next to sce_sys and sce_module, not inside another folder"); + this.icon = this.files.find(item => item.name === "sce_sys/icon0.png" && !item.entry.directory); + if (this.icon && this.icon.entry.uncompressedSize > ICON_LIMIT) throw new Error("sce_sys/icon0.png exceeds 16 MiB"); + this.selectedBytes = [this.executable, ...this.modules, ...(this.icon ? [this.icon] : [])].reduce((sum, item) => sum + item.entry.uncompressedSize, 0); + if (this.selectedBytes > CONVERSION_LIMIT) throw new Error("Executable, modules and icon exceed the 256 MiB conversion limit. Use the native relinker for this app."); + const warnings = []; + if (!this.files.some(item => item.name.startsWith("sce_sys/"))) warnings.push("sce_sys is missing. The Windows executable will have no app icon."); + else if (!this.icon) warnings.push("sce_sys/icon0.png is missing. The Windows executable will have no app icon."); + if (!this.files.some(item => item.name.startsWith("sce_module/"))) warnings.push("sce_module is missing. If the original app included this folder, leaving it out can prevent the app from starting."); + this.totalBytes = total; + return { name: this.executable.name, files: files.length, modules: this.modules.length, totalBytes: total, selectedBytes: this.selectedBytes, root: prefix, warnings }; + } + + async conversionInput(target, toIntel, report) { + let remaining = CONVERSION_LIMIT; + const read = async item => { + report({ phase: "Reading conversion input", input: item.name }); + const bytes = await boundedBytes(item.entry, remaining); + remaining -= bytes.length; + return bytes; + }; + const name = this.executable.name; + const stem = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name; + const outputName = (stem || "app") + (target === "windows" ? ".exe" : ".elf"); + const input = { name, outputName, target, toIntel, executable: await read(this.executable), modules: [], icon: null }; + for (const item of this.modules) input.modules.push({ name: item.name, bytes: await read(item) }); + if (this.icon && target === "windows") input.icon = await read(this.icon); + return input; + } + + async close() { await this.reader.close(); } +} + +export function memoryEstimate(archiveBytes, selectedBytes, libraryBytes, entries) { + return safeSize(Math.ceil(archiveBytes * 1.02) + selectedBytes * 4 + libraryBytes + entries * 1024 + 8 * 1024 * 1024); +} + +async function appendEntry(writer, name, entry, report) { + const stream = new TransformStream(); + let produced = 0; + const limit = new TransformStream({ transform(chunk, controller) { + produced += chunk.length; + if (produced > entry.uncompressedSize) throw new Error(`Unexpected expanded size: ${name}`); + controller.enqueue(chunk); + }, flush() { + if (produced !== entry.uncompressedSize) throw new Error(`Incorrect expanded size: ${name}`); + } }); + await Promise.all([ + entry.getData(stream.writable, { checkSignature: true }), + writer.add(name, stream.readable.pipeThrough(limit), { + versionMadeBy: 0x31e, externalFileAttributes: entry.externalFileAttributes, + onprogress: completed => report({ phase: writer instanceof DirectoryWriter ? "Writing files" : "Compressing ZIP", input: name, completed, total: entry.uncompressedSize }) + }) + ]); +} + +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 = 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); + const key = name.replace(/\/$/u, "").toLowerCase(); + if (names.has(key)) throw new Error(`Output path collision: ${name}`); + names.add(key); + }; + try { + const libraryEntries = await libraries.getEntries(); + if (!libraryEntries.length || libraryEntries.length > 4096) throw new Error("Invalid library archive"); + for (const entry of libraryEntries) { + if (!entry.filename.startsWith("libs/") || entry.encrypted || ((entry.externalFileAttributes >>> 16) & 0xf000) === 0xa000) throw new Error("Invalid library archive layout"); + claim(entry.filename); + } + const replaced = new Set(converted.modules.map(module => module.name)); + const originalFiles = archive.files.filter(item => !replaced.has("app0/" + item.name)); + for (const item of originalFiles) claim("app0/" + item.name); + claim(outputName); + if (converted.diagnostics) claim("windows-diagnostics-imports.txt"); + for (const module of converted.modules) { + if (!module.name.startsWith("app0/sce_module/")) throw new Error("Invalid converted module path"); + claim(module.name); + } + const binary = { versionMadeBy: 0x31e, externalFileAttributes: (0o100755 << 16) >>> 0 }; + await writer.add(outputName, new Uint8ArrayReader(converted.executable), binary); + if (converted.diagnostics) await writer.add("windows-diagnostics-imports.txt", new Uint8ArrayReader(new TextEncoder().encode(converted.diagnostics))); + for (const module of converted.modules) await writer.add(module.name, new Uint8ArrayReader(module.bytes), binary); + for (const entry of libraryEntries) { + if (entry.directory) await writer.add(entry.filename, undefined, { directory: true }); + else await appendEntry(writer, entry.filename, entry, report); + } + for (const { entry, name } of originalFiles) { + if (entry.directory) await writer.add("app0/" + name, undefined, { directory: true }); + else await appendEntry(writer, "app0/" + name, entry, report); + } + await writer.close(); + } finally { + await libraries.close(); + } +} diff --git a/demo/web/index.html b/demo/web/index.html new file mode 100644 index 000000000..74686f5f1 --- /dev/null +++ b/demo/web/index.html @@ -0,0 +1,29 @@ + + + + + +AnyPS5 · Converter + + +
+
+

LOCAL CONVERSION

PS5 apps to Windows/Linux converter

+ +
+

Convert an app

Files stay on your device
+

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 a ZIP or folder to get started.

+ + + +
+

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.

+

Conversion does not guarantee that an app runs. Check the community compatibility list for known blockers and reports.

+
+ + diff --git a/demo/web/tests/archive.test.mjs b/demo/web/tests/archive.test.mjs new file mode 100644 index 000000000..4470741f7 --- /dev/null +++ b/demo/web/tests/archive.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import path from "node:path"; +import { register } from "node:module"; + +register("./cdn-loader.mjs", import.meta.url, { data: { url: process.env.ANYPS5_TEST_ZIP_URL, path: process.env.ANYPS5_TEST_ZIP_PATH } }); + +const site = name => pathToFileURL(path.join(process.argv[2], name)).href; +const { Archive, memoryEstimate, MEMORY_LIMIT, packageArchive } = await import(site("archive.mjs")); +const { BlobReader, BlobWriter, Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter } = await import(process.env.ANYPS5_TEST_ZIP_URL); +const { default: createRelinker } = await import(site("native/relinker_wasm.mjs")); +const native = await createRelinker(); +const executable = new Uint8Array(await readFile(path.join(process.argv[3], "main.elf"))); +const module = new Uint8Array(await readFile(path.join(process.argv[3], "module.elf"))); +const resource = new Uint8Array(2 * 1024 * 1024).fill(65); + +async function zip(entries) { + const output = new BlobWriter("application/zip"); + const writer = new ZipWriter(output, { zip64: true, level: 9, useWebWorkers: false, useCompressionStream: false }); + for (const [name, bytes, options] of entries) await writer.add(name, bytes ? new Uint8ArrayReader(bytes) : undefined, options); + return writer.close(); +} + +const source = await zip([["title/", null, { directory: true }], ["title/program.dat", executable], ["title/sce_module/library.prx.esbak", module], ["title/sce_module/library.prx", new Uint8Array([79, 21, 61, 29])], ["title/sce_module/library.prx.esbak.guest.prx", new Uint8Array([77, 90, 0, 0])], ["title/sce_sys/param.json", new TextEncoder().encode("{}")], ["title/shaders/example.frag", executable], ["title/content/resource.bin", resource]]); +const folder = [ + { path: "title/program.dat", file: new Blob([executable]) }, + { path: "title/sce_module/library.prx.esbak", file: new Blob([module]) }, + { path: "title/sce_module/library.prx", file: new Blob([new Uint8Array([79, 21, 61, 29])]) }, + { path: "title/sce_module/library.prx.esbak.guest.prx", file: new Blob([new Uint8Array([77, 90, 0, 0])]) }, + { path: "title/sce_sys/param.json", file: new Blob(["{}"]) }, + { path: "title/shaders/example.frag", file: new Blob([executable]) }, + { 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(selected); + const summary = await archive.inspect(() => {}); + assert.equal(summary.name, "program.dat"); + assert.equal(summary.root, "title/"); + assert.equal(summary.modules, 1); + assert.equal(summary.selectedBytes, executable.length + module.length); + const input = await archive.conversionInput(target, false, () => {}); + assert.equal(input.modules.length, 1); + assert.deepEqual(input.executable, executable); + assert.equal(input.modules[0].name, "sce_module/library.prx.esbak"); + const events = []; + const converted = native.Convert(input, event => events.push(event)); + assert.equal(converted.ok, true, converted.error); + assert.equal(converted.executable[0], target === "windows" ? 77 : 127); + if (target === "windows") { + const image = new DataView(converted.executable.buffer, converted.executable.byteOffset, converted.executable.byteLength); + assert.equal(image.getUint16(image.getUint32(60, true) + 24 + 68, true), 3); + } + assert(events.some(event => event.stage === 6 && event.completed > 0)); + const libraries = await zip([["libs/system.prx", new Uint8Array([1, 2, 3])]]); + const chunks = []; + let maxChunk = 0; + const destination = new WritableStream({ async write(chunk) { + maxChunk = Math.max(maxChunk, chunk.length); + chunks.push(chunk.slice()); + await new Promise(resolve => setTimeout(resolve, 1)); + } }); + await packageArchive(archive, converted, input.outputName, libraries, destination, () => {}); + await destination.close(); + const reader = new ZipReader(new BlobReader(new Blob(chunks))); + const entries = await reader.getEntries(); + const byName = new Map(entries.map(entry => [entry.filename, entry])); + assert(byName.has(input.outputName)); + assert(byName.has("libs/system.prx")); + assert.deepEqual(await byName.get("app0/shaders/example.frag").getData(new Uint8ArrayWriter(), { checkSignature: true }), executable); + assert.deepEqual(await byName.get("app0/sce_module/library.prx.esbak.guest.prx").getData(new Uint8ArrayWriter(), { checkSignature: true }), converted.modules[0].bytes); + assert.deepEqual(await byName.get("app0/sce_module/library.prx").getData(new Uint8ArrayWriter(), { checkSignature: true }), new Uint8Array([79, 21, 61, 29])); + assert.deepEqual(await byName.get("app0/program.dat").getData(new Uint8ArrayWriter(), { checkSignature: true }), executable); + 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, streamed ZIP and directory output verified`); +} +} + +for (const [entries, error] of [ + [[["one", executable], ["two", executable]], /exactly one/u], + [[["readme", new Uint8Array([1])]], /exactly one/u], + [[["app", executable], ["..\/escape", resource]], /Unsafe ZIP/u], + [[["app", executable], ["APP", resource]], /Duplicate ZIP/u], + [[["app", executable], ["folder", resource], ["folder/file", resource]], /also used as a folder/u], + [[["app", executable], ["link", resource, { versionMadeBy: 0x31e, externalFileAttributes: (0o120777 << 16) >>> 0 }]], /Links and special/u] +]) { + const archive = new Archive(await zip(entries)); + await assert.rejects(archive.inspect(() => {}), error); + await archive.close(); +} +const missing = new Archive(await zip([["any-name", executable]])); +assert.equal((await missing.inspect(() => {})).warnings.length, 2); +await missing.close(); +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"); diff --git a/demo/web/tests/cdn-loader.mjs b/demo/web/tests/cdn-loader.mjs new file mode 100644 index 000000000..13bc480b2 --- /dev/null +++ b/demo/web/tests/cdn-loader.mjs @@ -0,0 +1,9 @@ +let library; + +export function initialize(data) { + library = data; +} + +export function resolve(specifier, context, nextResolve) { + return nextResolve(specifier === library.url ? library.path : specifier, context); +} diff --git a/demo/web/worker.mjs b/demo/web/worker.mjs new file mode 100644 index 000000000..9a0017792 --- /dev/null +++ b/demo/web/worker.mjs @@ -0,0 +1,115 @@ +import { Archive, MEMORY_LIMIT, memoryEstimate, packageArchive } from "./archive.mjs"; + +let archive; +let busy = false; +let pendingWrite; +let lastReport = 0; +let lastPhase = ""; + +function report(event) { + const phase = event.phase ?? String(event.stage); + const now = performance.now(); + if (!event.message && phase === lastPhase && event.completed !== event.total && now - lastReport < 80) return; + lastReport = now; + lastPhase = phase; + self.postMessage({ type: "progress", ...event }); +} + +async function downloadLibraries(target) { + report({ phase: "Downloading system libraries" }); + const response = await fetch("./release.json", { cache: "no-cache" }); + if (!response.ok) throw new Error(`Cannot load release manifest (${response.status})`); + const release = await response.json(); + const asset = release.platforms[target]; + if (!asset || !/^libraries\/(windows|linux)-[a-f0-9]+\.zip$/u.test(asset.path) || !/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error("Invalid release manifest"); + const latestResponse = await fetch("https://api.github.com/repos/boykopovar/AnyPS5/releases/latest", { headers: { Accept: "application/vnd.github+json" } }); + if (!latestResponse.ok) throw new Error(`Cannot verify the latest release (${latestResponse.status}). Try again later.`); + const latest = await latestResponse.json(); + if (latest.tag_name !== release.tag) throw new Error("A new release is being published to this site. Try again after the Pages build finishes."); + const download = await fetch(asset.path); + if (!download.ok) throw new Error(`Cannot download system libraries (${download.status})`); + const bytes = await download.arrayBuffer(); + if (bytes.byteLength !== asset.size) throw new Error("System library download is incomplete"); + const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join(""); + if (digest !== asset.sha256) throw new Error("System library checksum mismatch"); + return { file: new Blob([bytes]), release, expandedBytes: asset.expandedBytes }; +} + +self.onmessage = async ({ data }) => { + if (data.type === "written") { + if (!pendingWrite) return; + const resolve = pendingWrite; + pendingWrite = null; + resolve(); + return; + } + if (busy) return; + busy = true; + try { + if (data.type === "inspect") { + if (archive) await archive.close(); + archive = new Archive(data.files ?? data.file); + 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 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(); + let input = await archive.conversionInput(data.target, data.toIntel, report); + const outputName = input.outputName; + const converted = native.Convert(input, report); + input = null; + if (!converted.ok) throw new Error(converted.error); + const chunks = []; + let size = 0; + const send = (event, transfer = []) => new Promise(resolve => { + pendingWrite = resolve; + self.postMessage(event, transfer); + }); + 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"); + } catch (error) { + self.postMessage({ type: "error", message: error instanceof Error ? error.message : String(error) }); + } finally { + busy = false; + } +}; diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 376452dae..e49936962 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -1,5 +1,7 @@ # Relinker usage +[**Online relinker**](https://boykopovar.github.io/AnyPS5/) - files stay on your device. + ## 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 `. 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. A bundled module in a SELF container is an error, as the executable is. `prx/` can coexist with either `sce_module/` or `sce_modules/`. Both `sce_module/` and `sce_modules/` present, or all three absent, is an error. diff --git a/tools/build_web.py b/tools/build_web.py new file mode 100644 index 000000000..849042afd --- /dev/null +++ b/tools/build_web.py @@ -0,0 +1,97 @@ +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import urllib.request +import zipfile + +ROOT = Path(__file__).resolve().parent.parent + + +def copy_sources(output): + source = ROOT / "demo/web" + for file in source.iterdir(): + if file.is_file(): + shutil.copy2(file, output / file.name) + (output / ".nojekyll").touch() + + +def request(url): + headers = {"User-Agent": "AnyPS5-Pages"} + token = os.environ.get("GITHUB_TOKEN") + if token and url.startswith("https://api.github.com/"): + headers["Authorization"] = f"Bearer {token}" + return urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60) + + +def publish_libraries(output): + with request("https://api.github.com/repos/boykopovar/AnyPS5/releases/latest") as response: + release = json.load(response) + tag = release["tag_name"] + if not re.fullmatch(r"v[0-9A-Za-z][0-9A-Za-z._-]*", tag): + raise ValueError("Invalid release tag") + manifest = {"tag": tag, "url": release["html_url"], "platforms": {}} + destination = output / "libraries" + destination.mkdir(exist_ok=True) + for platform in ("windows", "linux"): + name = f"prx-{platform}-{tag}.zip" + matches = [asset for asset in release["assets"] if asset["name"] == name] + if len(matches) != 1: + raise ValueError(f"Missing or ambiguous release asset: {name}") + asset = matches[0] + temporary = destination / f"{platform}.download" + try: + digest = hashlib.sha256() + size = 0 + with request(asset["browser_download_url"]) as response, temporary.open("wb") as target: + while chunk := response.read(1024 * 1024): + size += len(chunk) + if size > asset["size"] or size > 512 * 1024 * 1024: + raise ValueError("Release archive exceeds expected size") + digest.update(chunk) + target.write(chunk) + if size != asset["size"]: + raise ValueError("Incomplete release archive") + checksum = digest.hexdigest() + if asset.get("digest") and asset["digest"] != "sha256:" + checksum: + raise ValueError("Release archive checksum mismatch") + with zipfile.ZipFile(temporary) as archive: + files = archive.infolist() + if not files or len(files) > 4096: + raise ValueError("Invalid library archive") + for file in files: + if not file.filename.startswith("libs/") or any(part in ("..", ".", "") for part in file.filename.rstrip("/").split("/")) or "\\" in file.filename: + raise ValueError("Invalid library archive layout") + corrupt = archive.testzip() + if corrupt: + raise ValueError(f"Corrupt release entry: {corrupt}") + expanded = sum(file.file_size for file in files) + path = f"libraries/{platform}-{checksum}.zip" + temporary.replace(output / path) + manifest["platforms"][platform] = {"path": path, "size": size, "expandedBytes": expanded, "sha256": checksum} + finally: + temporary.unlink(missing_ok=True) + (output / "release.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + print(f"Published library assets from {tag}") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--build", type=Path, required=True) + parser.add_argument("--libraries", action="store_true") + args = parser.parse_args() + output = args.build.resolve() / "web" + for name in ("relinker_wasm.mjs", "relinker_wasm.wasm"): + if not (output / "native" / name).is_file(): + raise ValueError(f"Build relinker_wasm first: missing {name}") + copy_sources(output) + if args.libraries: + publish_libraries(output) + print(output) + + +if __name__ == "__main__": + main() diff --git a/tools/test_web.py b/tools/test_web.py new file mode 100644 index 000000000..c6c66fe23 --- /dev/null +++ b/tools/test_web.py @@ -0,0 +1,34 @@ +import argparse +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import urllib.request + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "core/relinker/relinker/tests")) +from test_guest_intel_trampolines import main_fixture +from test_guest_module_directories import module_with_symbol + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--build", type=Path, required=True) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="anyps5-web-") as directory: + fixture = Path(directory) + (fixture / "main.elf").write_bytes(main_fixture()) + (fixture / "module.elf").write_bytes(module_with_symbol(True)) + source = (args.build / "web/archive.mjs").read_text(encoding="utf-8") + url = re.search(r'from "(https://cdn.jsdelivr.net/[^"\n]+)"', source).group(1) + library = fixture / "zip.mjs" + with urllib.request.urlopen(url, timeout=30) as response: + library.write_bytes(response.read()) + environment = dict(os.environ, ANYPS5_TEST_ZIP_URL=url, ANYPS5_TEST_ZIP_PATH=library.as_uri()) + subprocess.run(["node", str(ROOT / "demo/web/tests/archive.test.mjs"), str(args.build.resolve() / "web"), str(fixture)], env=environment, check=True) + + +if __name__ == "__main__": + main()