feat(web): stream zip conversion and release library packaging

This commit is contained in:
boykopovar 2026-10-13 13:48:23 +03:00
parent 34766c8236
commit 78df1e3244
8 changed files with 542 additions and 0 deletions

3
.gitmodules vendored
View File

@ -37,3 +37,6 @@
[submodule "3rdparty/libusb-cmake"]
path = 3rdparty/libusb-cmake
url = https://github.com/libusb/libusb-cmake.git
[submodule "3rdparty/zip.js"]
path = 3rdparty/zip.js
url = https://github.com/gildas-lormeau/zip.js.git

1
3rdparty/zip.js vendored Submodule

@ -0,0 +1 @@
Subproject commit b608211a036192b665b5fd19589fa2454a315017

View File

@ -19,3 +19,10 @@ set_target_properties(relinker_wasm PROPERTIES
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
)

215
demo/web/archive.mjs Normal file
View File

@ -0,0 +1,215 @@
import { BlobReader, Uint8ArrayReader, ZipReader, ZipWriter, configure } from "./vendor/zip/zip-no-worker.js";
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;
}
export class Archive {
constructor(file) {
this.reader = 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("ZIP 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("The ZIP is empty");
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 archive", input: item.name, completed: index, total: this.files.length });
if (item.entry.directory || item.name.startsWith("sce_sys/")) continue;
const elf = await signature(item.entry);
if (item.name.startsWith("sce_module/")) {
if (item.name.endsWith(".guest.prx")) throw new Error("Use the original archive, without previously converted .guest.prx files");
if (elf) this.modules.push(item);
else if (/\.(elf|prx|sprx|suprx|debug_prx)$/iu.test(item.name)) throw new Error(`The module is not a decrypted ELF: ${item.name}`);
} else if (elf) executables.push(item);
}
if (executables.length !== 1) throw new Error(`Expected exactly one executable ELF outside sce_module; 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: "Compressing ZIP", input: name, completed, total: entry.uncompressedSize })
})
]);
}
export async function packageArchive(archive, converted, outputName, libraryFile, destination, report) {
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 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);
}
for (const item of archive.files) claim("app0/" + item.name);
claim(outputName);
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);
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 archive.files) {
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();
}
}

View File

@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import path from "node: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(site("vendor/zip/zip-no-worker.js"));
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.elf", module], ["title/sce_sys/param.json", new TextEncoder().encode("{}")], ["title/content/resource.bin", resource]]);
for (const target of ["windows", "linux"]) {
const archive = new Archive(source);
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.elf");
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);
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(byName.has("app0/sce_module/library.elf.guest.prx"));
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);
await reader.close();
await archive.close();
console.log(`${target}: conversion, original files, modules, permissions and streamed ZIP 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");

95
demo/web/worker.mjs Normal file
View File

@ -0,0 +1,95 @@
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.file);
const summary = await archive.inspect(report);
self.postMessage({ type: "inspected", summary });
} else if (data.type === "convert") {
if (!archive) throw new Error("Select an archive first");
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.");
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 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");
}
});
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 });
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;
}
};

114
tools/build_web.py Normal file
View File

@ -0,0 +1,114 @@
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)
library = ROOT / "3rdparty/zip.js/lib"
pending = [library / "zip-no-worker.js"]
copied = set()
while pending:
file = pending.pop().resolve()
if file in copied:
continue
if not file.is_relative_to(library.resolve()):
raise ValueError(f"ZIP source import escapes library: {file}")
text = file.read_text(encoding="utf-8")
copied.add(file)
destination = output / "vendor/zip" / file.relative_to(library.resolve())
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(file, destination)
for name in re.findall(r"(?:from\s+|import\s*)[\"'](\.[^\"']+)[\"']", text):
pending.append(file.parent / name)
shutil.copy2(ROOT / "3rdparty/zip.js/LICENSE", output / "vendor/zip/LICENSE")
(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()

25
tools/test_web.py Normal file
View File

@ -0,0 +1,25 @@
import argparse
from pathlib import Path
import subprocess
import sys
import tempfile
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))
subprocess.run(["node", str(ROOT / "demo/web/tests/archive.test.mjs"), str(args.build.resolve() / "web"), str(fixture)], check=True)
if __name__ == "__main__":
main()