Files
penpot-nix/packages/render-wasm.nix
T
kurogeek 08b5496980 Package Penpot 2.18.3 for Nix with NixOS module; afk devShell
Vendored from debling/nix-penpot (MIT). Fixes on top:
- pnpm fetchers: drop --config.fetch-* flags (UND_ERR_INVALID_ARG on pnpm 11.27)
  and pin real store hashes (frontend x3, exporter, mcp)
- deps-lock.json: add transitive funcool/beicon2 v2.0 so fake-git resolves
  potok2's pin during the shadow-cljs classpath build
- expose frontend pnpm stores via passthru for rehashing
- x86_64-linux only; devShell ships afk, jj, git
2026-10-20 17:45:02 +00:00

295 lines
11 KiB
Nix

# Penpot's WebAssembly canvas renderer (the upstream `render-wasm/` tree):
# Rust compiled for wasm32-unknown-emscripten with emscripten, statically
# linking Penpot's prebuilt Skia binary distribution.
#
# Packaging notes (why this is not a plain buildRustPackage):
#
# - nixpkgs cannot cross-compile Rust for wasm32-unknown-emscripten: the
# target is absent from `pkgsCross` and nixpkgs' `rustc` ships no std for
# it. The Rust toolchain is therefore assembled from the official dist
# tarballs (version pinned to the one upstream's devenv uses), and
# nixpkgs' emscripten provides emcc/em++/emar. Upstream devenv pins
# emsdk 4.0.6; nixpkgs' emscripten (6.0.8) accepts the same flag set.
# - The skia-bindings build script normally downloads a prebuilt Skia
# tarball from GitHub at build time. We fetch that tarball with fetchurl
# and hand it to the build script via SKIA_BINARIES_URL=file://, so the
# build itself runs sandboxed with no network access.
# - Cargo dependencies are vendored with rustPlatform.fetchCargoVendor
# (itself a fixed-output derivation). When penpot (or its
# render-wasm/Cargo.lock) changes, recompute the hash: set it to
# lib.fakeHash, run `nix build .#penpot-render-wasm`, and copy the
# "got: sha256-..." value from the hash-mismatch error back into this
# file. If the Rust toolchain version (rustVersion below) or the Skia
# tarball URL changes, the corresponding fetchurl hashes must be
# recomputed as well (same procedure: set to lib.fakeHash, read `got:`).
{
lib,
stdenv,
runCommand,
fetchurl,
patchelf,
libgcc,
rustPlatform,
emscripten,
esbuild,
zlib,
penpot,
version,
}:
let
# Rust version pinned by upstream's devenv (1.91.0, released 2025-10-30).
rustVersion = "1.91.0";
rustDistDate = "2025-10-30";
wasmTarget = "wasm32-unknown-emscripten";
# Only the two systems this flake targets are wired up; the official dist
# tarballs are host-specific, hence the per-system hashes.
hostTriple =
{
x86_64-linux = "x86_64-unknown-linux-gnu";
aarch64-linux = "aarch64-unknown-linux-gnu";
}
.${stdenv.hostPlatform.system}
or (throw "penpot-render-wasm: unsupported system ${stdenv.hostPlatform.system}");
rustDistHashes =
{
x86_64-linux = {
rustc = "sha256-pxaejLYXSvL0VxdwM3A2PY3oLOVfbMuhhYkwRbk3CHQ=";
cargo = "sha256-cQPAP7ir6FsjMHAFqd/k8ByCaomUXYS5b6LQP9TS0Tg=";
hostStd = "sha256-ieZSCxbBK0NSZEApjS2g3LcHR8XMLQuOR9ObXamu70k=";
};
aarch64-linux = {
rustc = "sha256-8+o8lkt/O4hDN/LUEXZAMrvRci1/VVkqVHy7Ka/YfAM=";
cargo = "sha256-AD1wCCGcoNIlrR36MB98B5sSNJlDDuB4DIV4Lgh47v8=";
hostStd = "sha256-/yPcgfeW1k406GakT9C8rnJuNAFINTabj5OTpUQWfso=";
};
}
.${stdenv.hostPlatform.system};
rustDist =
name: hash:
fetchurl {
url = "https://static.rust-lang.org/dist/${rustDistDate}/${name}.tar.xz";
inherit hash;
};
# Minimal rustc + cargo installation (laid out like a rustup toolchain):
# std for the host is needed for build scripts and proc-macros, std for
# the emscripten target for the actual build. The dist binaries are
# unpatched upstream builds, so the ELF files get their interpreter and
# rpath pointed at nixpkgs' glibc/libgcc/zlib explicitly (autoPatchelfHook
# proved unreliable for these binaries).
rustToolchain =
runCommand "penpot-render-wasm-rust-toolchain-${rustVersion}"
{
nativeBuildInputs = [ patchelf ];
# The dist binaries ship pre-stripped; stripping them corrupts them.
dontStrip = true;
}
''
mkdir -p $out
tar -xJf ${rustDist "rustc-${rustVersion}-${hostTriple}" rustDistHashes.rustc} \
--strip-components=2 -C $out "rustc-${rustVersion}-${hostTriple}/rustc"
tar -xJf ${rustDist "cargo-${rustVersion}-${hostTriple}" rustDistHashes.cargo} \
--strip-components=2 -C $out "cargo-${rustVersion}-${hostTriple}/cargo"
tar -xJf ${rustDist "rust-std-${rustVersion}-${hostTriple}" rustDistHashes.hostStd} \
--strip-components=2 -C $out "rust-std-${rustVersion}-${hostTriple}/rust-std-${hostTriple}"
tar -xJf ${rustDist "rust-std-${rustVersion}-${wasmTarget}" "sha256-0oJ49jPVYxTJAE058YHy1vz7kdqXSTgOsp9Hzo+Bfk4="} \
--strip-components=2 -C $out "rust-std-${rustVersion}-${wasmTarget}/rust-std-${wasmTarget}"
rpath="$out/lib:${stdenv.cc.libc}/lib:${libgcc}/lib:${zlib}/lib"
interp="$(cat ${stdenv.cc.bintools}/nix-support/dynamic-linker)"
find $out -type f \( \
-path "*/bin/cargo" -o -path "*/bin/rustc" -o -path "*/bin/rustdoc" \
-o -name "librustc_driver*.so" -o -name "libLLVM.so.*" \
-o -name rust-analyzer-proc-macro-srv -o -path "*rustlib/*/bin/*" \
\) -print0 | while IFS= read -r -d "" f; do
# Skip linker scripts / completions etc. that match the globs.
[ "$(head -c 4 "$f" | od -An -tx1 | tr -d " \n")" = "7f454c46" ] || continue
if [ -n "$(patchelf --print-interpreter "$f" 2>/dev/null)" ]; then
patchelf --set-interpreter "$interp" "$f"
fi
patchelf --set-rpath "$rpath" "$f"
done
# Fail the derivation if the toolchain cannot actually run sandboxed.
$out/bin/cargo --version
$out/bin/rustc --version
'';
# Prebuilt Skia for wasm32/emscripten, pinned by the SKIA_BINARIES_URL
# default in penpot/render-wasm/_build_env. Handed to the skia-bindings
# build script, which accepts file:// URLs for exactly this purpose.
skiaBinaries = fetchurl {
url = "https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-${wasmTarget}-gl-svg-textlayout-binary-cache-webp.tar.gz";
hash = "sha256-kwqI/Cg2c0JrzDKOAnselx+JgSPjhn/y7wS40DpDTx0=";
};
cargoDeps = rustPlatform.fetchCargoVendor {
name = "penpot-render-wasm-cargo-deps";
src = penpot + "/render-wasm";
hash = "sha256-Qxv3feKgQe/0+43rEehoRWibCJoxvOAHFDLY3Sq5jWs=";
};
# Common emcc flags, verbatim from penpot/render-wasm/_build_env, plus
# -sDEFAULT_TO_CXX: the prebuilt Skia libraries are C++ objects that need
# libc++abi at link time, which newer emcc no longer pulls in when the
# linker is invoked as plain emcc (as rustc does).
emccCommonFlags = [
"-sDEFAULT_TO_CXX=1"
"--no-entry"
"--js-library"
"src/js/wapi.js"
"-sMALLOC=dlmalloc"
"-sINVOKE_RUN=0"
"-sALLOW_TABLE_GROWTH=0"
"-sALLOW_MEMORY_GROWTH=1"
"-sINITIAL_HEAP=268435456" # upstream: $((256 * 1024 * 1024))
"-sMEMORY_GROWTH_GEOMETRIC_STEP=0.8"
"-sERROR_ON_UNDEFINED_SYMBOLS=0"
"-sMAX_WEBGL_VERSION=2"
"-sEXPORT_NAME=createRustSkiaModule"
"-sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32"
# Upstream 2.17.x builds with -sENVIRONMENT=web; the extra node/worker
# environments are only needed by the (post-2.17) wasm-based exporter.
"-sENVIRONMENT=web"
"-sMODULARIZE=1"
"-sDISABLE_EXCEPTION_CATCHING=1"
"-sFILESYSTEM=0"
"-sEXPORT_ES6=1"
];
# Builds one flavor of the renderer. Upstream drives both flavors through
# render-wasm/build + _build_env; the differences are the cargo profile
# (--release vs --profile size) and the emcc optimization level.
buildRenderWasm =
flavor:
assert lib.assertOneOf "flavor" flavor [
"frontend"
"export"
];
let
isFrontend = flavor == "frontend";
cargoProfileFlag = if isFrontend then "--release" else "--profile size";
profileDir = if isFrontend then "release" else "size";
in
stdenv.mkDerivation {
pname = "penpot-render-wasm" + lib.optionalString (!isFrontend) "-export";
inherit version;
src = penpot + "/render-wasm";
nativeBuildInputs = [
rustToolchain
emscripten
esbuild
];
env = {
CARGO_BUILD_TARGET = wasmTarget;
SKIA_BINARIES_URL = "file://${skiaBinaries}";
EMCC_CFLAGS = lib.concatStringsSep " " (
(if isFrontend then [ "-O3" ] else [ "-Oz" ]) ++ [ "-sASSERTIONS=0" ] ++ emccCommonFlags
);
};
buildPhase = ''
runHook preBuild
export CARGO_TARGET_DIR="$PWD/cargo-target"
export CARGO_HOME="$PWD/cargo-home"
export CC_wasm32_unknown_emscripten=emcc
export AR_wasm32_unknown_emscripten=emar
# emcc needs a writable cache; seed it from the store copy so the
# sysroot libraries are not rebuilt.
export EM_CACHE="$PWD/emcc-cache"
cp -r --reflink=auto "${emscripten}/share/emscripten/cache/." "$EM_CACHE/"
chmod -R u+w "$EM_CACHE/"
# Point cargo at the vendored crates; fetchCargoVendor's config
# ships with a @vendor@ placeholder to fill in.
cp -r --reflink=auto "${cargoDeps}" ./cargo-vendor
chmod -R u+w ./cargo-vendor
mkdir -p .cargo
substitute "${cargoDeps}/.cargo/config.toml" .cargo/config.toml \
--subst-var-by vendor "$PWD/cargo-vendor"
${lib.optionalString (!isFrontend) ''
# Penpot 2.17.2 predates the size-optimized profile that newer
# upstream uses for the exporter flavor; add it (same definition
# as current upstream render-wasm/Cargo.toml).
if ! grep -q "^\[profile\.size\]" Cargo.toml; then
cat >> Cargo.toml <<'PROFILES'
[profile.size]
inherits = "release"
opt-level = "z"
PROFILES
fi
''}
cargo build ${cargoProfileFlag} --offline
runHook postBuild
'';
installPhase = ''
runHook preInstall
artifacts="$PWD/cargo-target/${wasmTarget}/${profileDir}"
mkdir -p "$out"${lib.optionalString isFrontend "/worker"}
cp "$artifacts/render_wasm.js" "$out/render-wasm.js"
cp "$artifacts/render_wasm.wasm" "$out/render-wasm.wasm"
# Match upstream copy_artifacts(): rename the wasm reference and add
# a version query for cache busting.
sed -i "s/render_wasm\.wasm/render-wasm.wasm?version=${version}/g" \
"$out/render-wasm.js"
shared="$(find "$PWD/cargo-target/${wasmTarget}" -name render_wasm_shared.js | head -n 1)"
cp "$shared" "$out/shared.js"
${lib.optionalString isFrontend ''
# Worker bundle, exactly as upstream copy_artifacts() builds it
# (the exporter imports the ESM module directly instead).
esbuild "$artifacts/render_wasm.js" \
--log-level=error \
--outfile="$out/worker/render.js" \
--platform=neutral \
--format=iife \
--global-name=WasmModule
''}
# Fail loudly instead of shipping an empty/broken artifact.
test -s "$out/render-wasm.js"
test -s "$out/render-wasm.wasm"
test -s "$out/shared.js"
runHook postInstall
'';
meta = {
description =
if isFrontend then
"Penpot WebAssembly canvas renderer (frontend flavor)"
else
"Penpot WebAssembly canvas renderer (exporter flavor)";
homepage = "https://penpot.app";
license = lib.licenses.mpl20;
platforms = lib.platforms.linux;
};
};
in
let
frontend = buildRenderWasm "frontend";
in
frontend
// {
passthru = frontend.passthru or { } // {
export = buildRenderWasm "export";
};
}