#!/usr/bin/env python3 """Assemble an unpublished desktop application; never run acceptance or typecheck.""" import argparse import hashlib import json import os from pathlib import Path import shutil import subprocess def active_native_builder(native): for process in Path('/proc').glob('[0-9]*'): try: if (process / 'cwd').resolve() == native.parent and (process / 'comm').read_text().strip() in {'ninja', 'make', 'gmake', 'cmake', 'wasm-ld', 'wasm-opt'}: return process.name except OSError: continue def write_json(path, value): path.write_text(json.dumps(value, indent=2) + "\n") def share_candidate_assets(resources, application): cache = resources.parent.parent / ".candidate-assets" cache.mkdir(exist_ok=True) for source in resources.rglob("*"): target = application / source.relative_to(resources) if source.is_symlink() or target.is_symlink() or not source.is_file() or not target.is_file(): continue if (source.stat().st_mode & 0o777) != (target.stat().st_mode & 0o777): continue with source.open("rb") as stream: digest = hashlib.file_digest(stream, "sha256").hexdigest() with target.open("rb") as stream: if hashlib.file_digest(stream, "sha256").hexdigest() != digest: continue anchor = cache / f"{digest}-{source.stat().st_mode & 0o777:o}" if not anchor.exists(): os.link(source, anchor) with anchor.open("rb") as stream: if anchor.is_symlink() or hashlib.file_digest(stream, "sha256").hexdigest() != digest: raise RuntimeError(f"Candidate asset cache differs: {anchor.name}") for path in (source, target): if os.path.samefile(anchor, path): continue temporary = path.with_name(f".{path.name}.share-{os.getpid()}") os.link(anchor, temporary) os.replace(temporary, path) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", required=True, help="New local candidate directory") parser.add_argument("--native-bin", default="build_web_blender6/bin", help="Directory containing web_engine.js and web_engine.wasm, or either file") args = parser.parse_args() root = Path(__file__).resolve().parents[2] output = (root / args.output).resolve() native = (root / args.native_bin).resolve() if native.is_file() and native.name in {"web_engine.js", "web_engine.wasm"}: native = native.parent # Reject a wrong input before copying the public tree or leaving a partial # candidate. This is packaging input handling, not runtime verification. missing = [name for name in ("web_engine.js", "web_engine.wasm") if not (native / name).is_file()] if missing: parser.error(f"Native directory {native} is missing: {', '.join(missing)}") # A fresh destination preserves both previous candidates and published assets. if builder := active_native_builder(native): parser.error(f"Native build PID {builder} is still active; retain pending edits and package after it exits") native_stamps = {name: (native / name).stat() for name in ("web_engine.js", "web_engine.wasm")} output.mkdir(parents=True, exist_ok=False) resources = output / "public" subprocess.run(["cp", "-a", "--reflink=auto", str(root / "web/app/public"), str(resources)], check=True) vendor = resources / "vendor/blender" hashes = {} for name in ("web_engine.js", "web_engine.wasm"): shutil.copy2(native / name, vendor / name) with (vendor / name).open("rb") as stream: hashes[name] = hashlib.file_digest(stream, "sha256").hexdigest() # Startup scripts, fonts, color management and Python's library are embedded # by the native target. Retain the source startup resources with the candidate # as well, without replacing any source or published runtime files. shutil.copytree(root / "blender-5.2.0/scripts/startup", output / "scripts/startup") shutil.copytree(root / "blender-5.2.0/scripts/presets", output / "scripts/presets") module_path = resources / "wasm-module-manifest.json" modules = json.loads(module_path.read_text()) for module in modules["modules"]: resource = module["resources"].get("single") if resource and resource["fileName"] == "web_engine.wasm": resource.update(url="/vendor/blender/web_engine.wasm", sha256=hashes["web_engine.wasm"]) write_json(module_path, modules) engine_path = resources / "engine-manifest.json" engine = json.loads(engine_path.read_text()) for variant in engine["variants"]: if variant["id"] == "single": for kind, name in (("js", "web_engine.js"), ("wasm", "web_engine.wasm")): variant["resources"][kind].update(url=f"/vendor/blender/{name}", sha256=hashes[name]) write_json(engine_path, engine) env = dict(os.environ, WEB_DESKTOP_CANDIDATE_PUBLIC=str(resources), WEB_DESKTOP_CANDIDATE_FACTORY=str(vendor / "web_engine.js")) # Vite emits frontend and Worker from current source; no test/typecheck wrapper. subprocess.run(["node", "node_modules/vite/bin/vite.js", "build", "--config", "app/vite.config.ts", "--outDir", str(output / "app")], cwd=root / "web", env=env, check=True) share_candidate_assets(resources, output / "app") if active_native_builder(native): parser.error("Native build started during packaging; no candidate manifest published; retain pending edits") for name, before in native_stamps.items(): after = (native / name).stat() if (before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns): parser.error(f"Native input changed during packaging: {name}; no candidate manifest published") write_json(output / "candidate.json", { "stageId": "main-desktop", "status": "implemented_unverified", "scopeComplete": False, "nativeUIComplete": False, "entry": "app/index.html", "nativeBin": str(native), "sha256": hashes, "runtimeURL": "/vendor/blender/web_engine.wasm", "startupResources": "embedded in WASM; source copy in scripts/", "deployment": "not_performed", "validation": "not_run", }) if __name__ == "__main__": main()