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
This commit is contained in:
commit
08b5496980
21 files changed
+7840
No files matched your search
@@ -0,0 +1,47 @@
|
||||
# sfnt2woff / woff2sfnt, Jonathan Kew's reference WOFF <-> sfnt converters.
|
||||
# Required by the Penpot backend for font format conversion, but not packaged
|
||||
# in nixpkgs. Upstream's page (people.mozilla.org/~jkew/woff) is offline, so
|
||||
# this uses the source mirror several distributions rely on.
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchFromGitHub,
|
||||
zlib,
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "woff-tools";
|
||||
version = "20091003";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "wget";
|
||||
repo = "woff-tools";
|
||||
rev = "20091003";
|
||||
hash = "sha256-GDF07R4dNfVz9S1bWkDndq4Zy916kkuaWu6nXYlZRkU=";
|
||||
};
|
||||
|
||||
buildInputs = [ zlib ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 sfnt2woff $out/bin/sfnt2woff
|
||||
install -Dm755 woff2sfnt $out/bin/woff2sfnt
|
||||
install -Dm644 LICENSE $out/share/licenses/woff-tools/LICENSE
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Convert TrueType/OpenType (sfnt) fonts to WOFF format and back";
|
||||
homepage = "https://github.com/wget/woff-tools";
|
||||
# woff.c carries the classic Mozilla tri-license.
|
||||
license = with lib.licenses; [
|
||||
mpl11
|
||||
gpl2Plus
|
||||
lgpl21Plus
|
||||
];
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "sfnt2woff";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
# Penpot backend (Clojure API server), packaged as the upstream source
|
||||
# uberjar. Reproduces `backend/scripts/build`: a non-AOT uberjar executed
|
||||
# through `clojure.main`, shipped with the log4j2 config and the onboarding
|
||||
# template files mapped from the pinned penpot-files flake input.
|
||||
#
|
||||
# The uberjar is repacked by packages/jar-strip-deterministic.py (in the penpot
|
||||
# checkout), replacing stripJavaArchivesHook, whose reorder is O(n^2) and took
|
||||
# ~32 minutes on this jar.
|
||||
#
|
||||
# Output contract:
|
||||
# $out/share/penpot-backend/{penpot.jar,log4j2.xml,version.txt,manage.py}
|
||||
# $out/share/penpot-backend/builtin-templates/
|
||||
# $out/bin/penpot-backend (java wrapper, cwd = share dir)
|
||||
# $out/bin/penpot-manage (manage.py CLI via python3 + tabulate)
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
makeWrapper,
|
||||
callPackage,
|
||||
clojure,
|
||||
jdk25_headless,
|
||||
babashka,
|
||||
imagemagick,
|
||||
fontforge,
|
||||
woff2,
|
||||
fontconfig,
|
||||
util-linux,
|
||||
python3,
|
||||
penpot,
|
||||
penpot-files,
|
||||
version,
|
||||
subSrc,
|
||||
}:
|
||||
|
||||
let
|
||||
src = subSrc penpot [
|
||||
"backend"
|
||||
"common"
|
||||
"CHANGES.md"
|
||||
];
|
||||
|
||||
# Replaces stripJavaArchivesHook: a stdlib-python deterministic repack of the
|
||||
# uberjar, replicating strip-nondeterminism's jar/zip handlers (see the
|
||||
# script's docstring for the full list). Its per-member reorder is O(n^2),
|
||||
# which cost ~32 minutes on this jar's 54000 entries; this does it in ~20 s.
|
||||
jarNormalizer = ./jar-strip-deterministic.py;
|
||||
in
|
||||
let
|
||||
# sfnt2woff/woff2sfnt are not in nixpkgs; see backend-woff-tools.nix.
|
||||
woff-tools = callPackage ./backend-woff-tools.nix { };
|
||||
|
||||
# The clojure CLI with a JDK matching the runtime (upstream run.sh flags
|
||||
# need >= 24; the nixpkgs default CLI bundles JDK 21).
|
||||
clojureJdk25 = clojure.override { jdk = jdk25_headless; };
|
||||
|
||||
magickPolicy = callPackage ./imagemagick-policy.nix { inherit penpot; };
|
||||
|
||||
pythonEnv = python3.withPackages (ps: [ ps.tabulate ]);
|
||||
|
||||
# NOTE: upstream's docker image runs Zulu JDK 26 at 2.17.x; any JDK >= 24
|
||||
# satisfies the run.sh flags, so the nixpkgs JDK 25 is used here. Revisit
|
||||
# when bumping the penpot input.
|
||||
# Binaries the backend shells out to at runtime (image/font processing),
|
||||
# baked into the wrapper's PATH.
|
||||
# util-linux: provides prlimit; upstream runs font-processing subprocesses
|
||||
# under it (app.media.local/exec-font!), see also app.util.shell/prlimit-cmd.
|
||||
runtimeBinPath = lib.makeBinPath [
|
||||
imagemagick
|
||||
fontforge
|
||||
woff2
|
||||
woff-tools
|
||||
fontconfig
|
||||
util-linux
|
||||
];
|
||||
|
||||
# Offline Clojure dependencies: eval-time cache from deps-lock.json
|
||||
# (no fixed-output derivation) + fake-git shim + short-SHA expansion.
|
||||
cljOffline = callPackage ./clj-offline.nix { };
|
||||
|
||||
builtinTemplates = stdenv.mkDerivation {
|
||||
name = "penpot-backend-builtin-templates";
|
||||
src = "${src}/backend";
|
||||
|
||||
nativeBuildInputs = [
|
||||
babashka
|
||||
];
|
||||
|
||||
outputHashAlgo = "sha256";
|
||||
outputHashMode = "recursive";
|
||||
# The file contents come from the pinned penpot-files flake input, so
|
||||
# this hash only changes when that input (or the template list in
|
||||
# onboarding.edn) changes. On a penpot input bump: run
|
||||
# `nix flake update penpot-files` first (a missing file fails the build
|
||||
# loudly), then set to lib.fakeHash, build once, copy the `got:` hash
|
||||
# from the failure message.
|
||||
outputHash = "sha256-b03i34xCfKHie5AjT7is3o9kqIzcSU/nPl7mV1gu+u0=";
|
||||
dontFixup = true;
|
||||
|
||||
dontBuild = true;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
export HOME="$TMPDIR/home"
|
||||
mkdir -p "$HOME"
|
||||
|
||||
# Map onboarding.edn (id, file-uri) pairs to files from the pinned
|
||||
# penpot-files input. EDN-native parsing via babashka; basenames are
|
||||
# URL-decoded (upstream file-uris contain %20 etc.).
|
||||
cat > "$TMPDIR/map-templates.clj" <<'EOF'
|
||||
(require '[babashka.fs :as fs]
|
||||
'[clojure.edn :as edn]
|
||||
'[clojure.string :as str])
|
||||
(import '[java.net URLDecoder])
|
||||
|
||||
(let [[defs-path files-dir dest] *command-line-args*
|
||||
data (edn/read-string (slurp defs-path))]
|
||||
(fs/create-dirs dest)
|
||||
(doseq [{:keys [id file-uri]} data]
|
||||
;; Basename of file-uri, minus any query/fragment; literal `+`
|
||||
;; is protected before URL-decoding (`URLDecoder` maps `+` to
|
||||
;; space, but upstream names use `%20` for spaces).
|
||||
(let [fname (-> file-uri
|
||||
(str/split #"/") last
|
||||
(str/split #"[?#]") first
|
||||
(str/replace "+" "%2B")
|
||||
(URLDecoder/decode "UTF-8"))
|
||||
src-file (fs/file files-dir fname)]
|
||||
(when-not (fs/exists? src-file)
|
||||
(println (format "template file %s (id: %s) not found" fname id))
|
||||
(System/exit 1))
|
||||
(println (format "=> installing %s" id))
|
||||
(fs/copy src-file (fs/file dest id)))))
|
||||
EOF
|
||||
|
||||
bb "$TMPDIR/map-templates.clj" \
|
||||
resources/app/onboarding.edn \
|
||||
"${penpot-files}" \
|
||||
"$out/templates"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "penpot-backend";
|
||||
inherit version src;
|
||||
|
||||
nativeBuildInputs = [
|
||||
clojureJdk25
|
||||
cljOffline.fake-git
|
||||
cljOffline.clj-builder
|
||||
makeWrapper
|
||||
# Repacks the jar deterministically during fixup. Replaces
|
||||
# stripJavaArchivesHook, whose O(n^2) member reorder needs ~32 minutes on
|
||||
# this jar (54000 entries); see jarNormalizer above.
|
||||
python3
|
||||
];
|
||||
|
||||
configurePhase = ''
|
||||
runHook preConfigure
|
||||
|
||||
# Offline dependency resolution from the eval-time clj-nix cache.
|
||||
${cljOffline.setup}
|
||||
|
||||
runHook postConfigure
|
||||
'';
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
(
|
||||
cd backend
|
||||
mkdir -p target/classes
|
||||
echo "$version" > target/classes/version.txt
|
||||
cp ../CHANGES.md target/classes/changelog.md
|
||||
clojure -T:build jar
|
||||
)
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = /* sh */ ''
|
||||
runHook preInstall
|
||||
|
||||
share="$out/share/penpot-backend"
|
||||
mkdir -p "$share" "$out/bin"
|
||||
|
||||
install -Dm644 backend/target/penpot.jar "$share/penpot.jar"
|
||||
install -Dm644 backend/resources/log4j2.xml "$share/log4j2.xml"
|
||||
cp -r "${builtinTemplates}/templates" "$share/builtin-templates"
|
||||
echo "$version" > "$share/version.txt"
|
||||
|
||||
# Deterministic repack (see jarNormalizer). The uber task stamps
|
||||
# entries with wall-clock time, so this can't be skipped.
|
||||
python3 ${jarNormalizer} "$share/penpot.jar"
|
||||
|
||||
# manage.py hardcodes the prepl endpoint as the argparse default; patch
|
||||
# it to honor the PREPL_URI environment variable instead.
|
||||
install -Dm755 backend/scripts/manage.py "$share/manage.py"
|
||||
substituteInPlace "$share/manage.py" \
|
||||
--replace-fail 'import argparse' 'import argparse
|
||||
import os' \
|
||||
--replace-fail 'default="tcp://localhost:6063"' 'default=os.environ.get("PREPL_URI", "tcp://localhost:6063")'
|
||||
|
||||
# Flags mirror backend/scripts/run.template.sh, except
|
||||
# --add-opens=java.base/java.nio (not upstream; needed by
|
||||
# yetti/lettuce for direct NIO buffer access on modern JDKs).
|
||||
# $JAVA_OPTS and $PENPOT_ENTRYPOINT are embedded verbatim by
|
||||
# makeWrapper and expand at runtime, like in the upstream run.sh.
|
||||
makeWrapper "${jdk25_headless}/bin/java" "$out/bin/penpot-backend" \
|
||||
--chdir "$share" \
|
||||
--prefix PATH : "${runtimeBinPath}" \
|
||||
--set MAGICK_CONFIGURE_PATH "${magickPolicy}/etc/ImageMagick-7" \
|
||||
--set-default PENPOT_ENTRYPOINT app.main \
|
||||
--add-flags '-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager' \
|
||||
--add-flags '-Dlog4j2.configurationFile=log4j2.xml' \
|
||||
--add-flags '-XX:-OmitStackTraceInFastThrow' \
|
||||
--add-flags '--sun-misc-unsafe-memory-access=allow' \
|
||||
--add-flags '--enable-native-access=ALL-UNNAMED' \
|
||||
--add-flags '--add-opens=java.base/java.nio=ALL-UNNAMED' \
|
||||
--add-flags '--enable-preview' \
|
||||
--add-flags '$JAVA_OPTS' \
|
||||
--add-flags '-jar penpot.jar -m $PENPOT_ENTRYPOINT'
|
||||
|
||||
makeWrapper "${pythonEnv}/bin/python" "$out/bin/penpot-manage" \
|
||||
--add-flags "$share/manage.py"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit builtinTemplates;
|
||||
version = version;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "Penpot backend API server (Clojure uberjar)";
|
||||
homepage = "https://penpot.app";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "penpot-backend";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Shared offline Clojure dependency wiring, backed by clj-nix.
|
||||
#
|
||||
# `mk-deps-cache` materializes `deps-lock.json` (committed at the repo root)
|
||||
# purely at eval time — `fetchurl` per Maven artifact, `fetchgit` per git
|
||||
# dep — so consumer builds resolve fully offline and deterministically, with
|
||||
# no fixed-output derivation involved.
|
||||
#
|
||||
# Consumers: point tools.deps at the cache (same recipe as clj-nix's own
|
||||
# builder), expand short `:git/sha` pins to full SHAs from the lock, and
|
||||
# keep `fake-git` (a `git` shim answering rev-parse/tag/merge-base from lock
|
||||
# metadata) on PATH for any stray git invocation.
|
||||
{
|
||||
mk-deps-cache,
|
||||
fake-git,
|
||||
clj-builder,
|
||||
}:
|
||||
let
|
||||
cache = mk-deps-cache {
|
||||
lockfile = ../deps-lock.json;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit cache fake-git clj-builder;
|
||||
|
||||
# Shell fragment for consumer preBuild/configure phases. Must run in the
|
||||
# project root (the directory `deps-lock.json` is copied into), before any
|
||||
# `clojure` invocation. Uses a writable HOME with symlinks into the
|
||||
# read-only cache (pnpm and friends need a writable HOME; clojure
|
||||
# resolution follows the symlinks).
|
||||
setup = ''
|
||||
export HOME="$TMPDIR/home"
|
||||
mkdir -p "$HOME"
|
||||
ln -sfn "${cache}/.m2" "$HOME/.m2"
|
||||
ln -sfn "${cache}/.gitlibs" "$HOME/.gitlibs"
|
||||
ln -sfn "${cache}/.clojure" "$HOME/.clojure"
|
||||
# The JVM resolves user.home from /etc/passwd rather than $HOME; force
|
||||
# it so tools.deps resolves from the cache instead of attempting
|
||||
# network downloads.
|
||||
export JAVA_TOOL_OPTIONS="-Duser.home=$HOME"
|
||||
export CLJ_CONFIG="$HOME/.clojure"
|
||||
export CLJ_CACHE="$TMPDIR/cp_cache"
|
||||
export GITLIBS="$HOME/.gitlibs"
|
||||
cp "${../deps-lock.json}" deps-lock.json
|
||||
clj-builder patch-git-sha "$(pwd)"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
# Penpot exporter: the ClojureScript -> Node.js headless export service
|
||||
# (renders designs via Playwright/Chromium, talks to redis + the Penpot
|
||||
# internal API).
|
||||
#
|
||||
# Upstream builds this with a pnpm workspace + shadow-cljs and ships the
|
||||
# `target/` dir of exporter/scripts/build as the runtime bundle. This package
|
||||
# reproduces that bundle with everything materialized offline: node_modules
|
||||
# from a pre-fetched pnpm store and maven/git deps from the eval-time
|
||||
# clj-nix cache, and chromium comes
|
||||
# from nixpkgs instead of `playwright install`. (The render-wasm based
|
||||
# exporter rendering postdates the 2.17.x line this package tracks, so the
|
||||
# wasm artifacts are not consumed here.)
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
runCommand,
|
||||
callPackage,
|
||||
penpot,
|
||||
version,
|
||||
subSrc,
|
||||
nodejs_24,
|
||||
pnpm_11,
|
||||
fetchPnpmDeps,
|
||||
pnpmConfigHook,
|
||||
clojure,
|
||||
jdk25_headless,
|
||||
chromium,
|
||||
makeWrapper,
|
||||
imagemagick,
|
||||
poppler-utils,
|
||||
potrace,
|
||||
netpbm,
|
||||
makeFontsConf,
|
||||
noto-fonts-color-emoji,
|
||||
unifont,
|
||||
liberation_ttf,
|
||||
freefont_ttf,
|
||||
ipafont,
|
||||
wqy_zenhei,
|
||||
tlwg,
|
||||
}:
|
||||
|
||||
let
|
||||
# The exporter workspace plus the `common` cljs sources it compiles in
|
||||
# (exporter/deps.edn and shadow-cljs.edn reference ../common).
|
||||
src = subSrc penpot [
|
||||
"exporter"
|
||||
"common"
|
||||
];
|
||||
|
||||
# exporter/deps.edn's :shadow-cljs alias passes
|
||||
# `--sun-misc-unsafe-memory-access=allow` to the JVM; that option needs
|
||||
# JDK >= 23 while nixpkgs' default clojure bundles JDK 21.
|
||||
clojureJdk25 = clojure.override { jdk = jdk25_headless; };
|
||||
|
||||
# npm dependencies of the exporter workspace, fetched once into a pnpm
|
||||
# store in a fixed-output derivation and materialized offline in the main
|
||||
# build via pnpmConfigHook. Lifecycle scripts are skipped: nothing in this
|
||||
# dependency set needs them for runtime (playwright's postinstall is
|
||||
# informational only), and the playwright browser download is replaced by
|
||||
# the browsers dir below.
|
||||
exporterSrc = subSrc penpot [ "exporter" ];
|
||||
|
||||
# Same storeDir workaround as frontend.nix: 2.18 adds a shared
|
||||
# `storeDir: ../.pnpm-store` to the workspace's pnpm-workspace.yaml, which
|
||||
# breaks the per-workspace fixed-output fetch and the offline install;
|
||||
# strip it in this pre-fetch source copy (and again in the consumer build's
|
||||
# postPatch below). Named "source" so the dir-source unpack lands where
|
||||
# sourceRoot expects it.
|
||||
exporterSrcP = runCommand "source" { } ''
|
||||
mkdir "$out"
|
||||
cp -r "${exporterSrc}"/. "$out"/
|
||||
chmod -R u+w "$out"
|
||||
find "$out" -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
pname = "penpot-exporter";
|
||||
inherit version;
|
||||
src = exporterSrcP;
|
||||
sourceRoot = "source/exporter";
|
||||
pnpm = pnpm_11;
|
||||
# fetcherVersion 4 is required for pnpm 11 (older fetchers are rejected).
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-fGjKhjCEb4+2Nwd5xppow6YfjmgFQjXddHlsSSmr6Eo=";
|
||||
};
|
||||
|
||||
# Offline Clojure dependencies: eval-time cache from deps-lock.json
|
||||
# (no fixed-output derivation) + fake-git shim + short-SHA expansion.
|
||||
cljOffline = callPackage ./clj-offline.nix { };
|
||||
|
||||
magickPolicy = callPackage ./imagemagick-policy.nix { inherit penpot; };
|
||||
|
||||
# Fonts matching the upstream exporter image (docker/Dockerfile.exporter):
|
||||
# emoji, bitmap fallback (covers the cyrillic/scalable X fonts), latin,
|
||||
# gothic, Thai (tlwg) and CJK coverage.
|
||||
fontsConf = makeFontsConf {
|
||||
fontDirectories = [
|
||||
noto-fonts-color-emoji
|
||||
unifont
|
||||
liberation_ttf
|
||||
freefont_ttf
|
||||
ipafont
|
||||
wqy_zenhei
|
||||
tlwg
|
||||
];
|
||||
};
|
||||
|
||||
# Executable path layouts that playwright-core resolves below
|
||||
# $PLAYWRIGHT_BROWSERS_PATH (see its registry: EXECUTABLE_PATHS differ
|
||||
# between linux-x64 and linux-arm64). NOTE: these paths are
|
||||
# registry-version-dependent; re-verify against the vendored
|
||||
# playwright-core when the lockfile bumps playwright.
|
||||
# The headless-shell slot is served by the full chromium build (nixpkgs
|
||||
# does not package the separate headless shell).
|
||||
chromeDir = if stdenv.hostPlatform.isx86_64 then "chrome-linux64" else "chrome-linux";
|
||||
headlessShellDir =
|
||||
if stdenv.hostPlatform.isx86_64 then "chrome-headless-shell-linux64" else "chrome-linux";
|
||||
headlessShellBin =
|
||||
if stdenv.hostPlatform.isx86_64 then "chrome-headless-shell" else "headless_shell";
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "penpot-exporter";
|
||||
inherit version;
|
||||
|
||||
inherit src;
|
||||
|
||||
nativeBuildInputs = [
|
||||
clojureJdk25
|
||||
cljOffline.fake-git
|
||||
cljOffline.clj-builder
|
||||
makeWrapper
|
||||
nodejs_24
|
||||
pnpm_11
|
||||
pnpmConfigHook
|
||||
];
|
||||
|
||||
env.NODE_ENV = "production";
|
||||
|
||||
inherit pnpmDeps;
|
||||
|
||||
dontConfigure = true;
|
||||
# pnpmConfigHook is invoked manually inside installPhase (configure is
|
||||
# disabled), after cd'ing into the exporter workspace.
|
||||
dontPnpmConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Offline clojure dependency caches for the shadow-cljs release build.
|
||||
# Runs at the source root so patch-git-sha sees exporter/deps.edn and
|
||||
# common/deps.edn. The JVM resolves user.home from /etc/passwd rather
|
||||
# than $HOME; force it so tools.deps resolves from the eval-time cache
|
||||
# instead of attempting network downloads.
|
||||
${cljOffline.setup}
|
||||
|
||||
cd exporter
|
||||
|
||||
# Strip 2.18's shared pnpm storeDir so the offline install uses the
|
||||
# fetched store (see the note on exporterSrcP).
|
||||
find .. -name pnpm-workspace.yaml -maxdepth 3 -exec sed -i '/^storeDir:/d' {} +
|
||||
|
||||
# Materialize node_modules offline from the pre-fetched pnpm store.
|
||||
pnpmConfigHook
|
||||
|
||||
clojure -M:dev:shadow-cljs release main
|
||||
|
||||
# The compiled app.js carries a literal %version% placeholder
|
||||
# (upstream exporter/scripts/build substitutes it the same way).
|
||||
sed -i "s/%version%/${finalAttrs.version}/g" target/app.js
|
||||
|
||||
# Assemble the runtime dir (the equivalent of upstream's target/).
|
||||
mkdir -p $out/lib/penpot-exporter
|
||||
cp target/app.js target/app.js.map $out/lib/penpot-exporter/
|
||||
cp package.json pnpm-lock.yaml $out/lib/penpot-exporter/
|
||||
# Upstream ships pnpm-workspace.yaml emptied out; keep the same.
|
||||
touch $out/lib/penpot-exporter/pnpm-workspace.yaml
|
||||
cp -a --preserve=links node_modules $out/lib/penpot-exporter/node_modules
|
||||
|
||||
# Pre-materialized playwright browser dir: playwright-core resolves
|
||||
# $PLAYWRIGHT_BROWSERS_PATH/chromium-<rev>/... from its own
|
||||
# browsers.json, so derive the revision from the exporter's tree and
|
||||
# point both chromium flavors at the nixpkgs build.
|
||||
pwRevision=$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).browsers.find(b => b.name === 'chromium').revision" \
|
||||
node_modules/.pnpm/playwright-core@*/node_modules/playwright-core/browsers.json)
|
||||
browsers=$out/lib/penpot-exporter-browsers
|
||||
mkdir -p $browsers/chromium-$pwRevision/${chromeDir}
|
||||
ln -s ${chromium}/bin/chromium $browsers/chromium-$pwRevision/${chromeDir}/chrome
|
||||
mkdir -p $browsers/chromium_headless_shell-$pwRevision/${headlessShellDir}
|
||||
ln -s ${chromium}/bin/chromium \
|
||||
$browsers/chromium_headless_shell-$pwRevision/${headlessShellDir}/${headlessShellBin}
|
||||
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs_24}/bin/node $out/bin/penpot-exporter \
|
||||
--add-flags "$out/lib/penpot-exporter/app.js" \
|
||||
--chdir $out/lib/penpot-exporter \
|
||||
--prefix PATH : ${
|
||||
lib.makeBinPath [
|
||||
imagemagick
|
||||
poppler-utils
|
||||
potrace
|
||||
netpbm
|
||||
nodejs_24
|
||||
]
|
||||
} \
|
||||
--set NODE_ENV production \
|
||||
--set MAGICK_CONFIGURE_PATH "${magickPolicy}/etc/ImageMagick-7" \
|
||||
--set FONTCONFIG_FILE ${fontsConf} \
|
||||
--set PLAYWRIGHT_BROWSERS_PATH $out/lib/penpot-exporter-browsers
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru.version = finalAttrs.version;
|
||||
|
||||
meta = {
|
||||
description = "Penpot exporter service (headless design export via Chromium)";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "penpot-exporter";
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
# Penpot frontend: compiles the upstream ClojureScript/JS monorepo into the
|
||||
# static site served by nginx ($out/dist, mirroring upstream `bundles/frontend`).
|
||||
#
|
||||
# Mirrors upstream `frontend/scripts/build`:
|
||||
# 1. render-wasm artifacts are placed where the wasm cljs glue expects them
|
||||
# (instead of building the wasm in-tree).
|
||||
# 2. MCP plugin is built (its own pnpm workspace).
|
||||
# 3. Official plugins are built (their own pnpm workspace); the plugins
|
||||
# runtime library is built first since the cljs build links it.
|
||||
# 4. shadow-cljs release build of the `main` and `worker` builds.
|
||||
# 5. esbuild bundling of the JS libs.
|
||||
# 6. Asset compilation (styles, sprites, translations, templates, ...).
|
||||
# 7. Finalization: worker render.js cache-busting, rsync into dist,
|
||||
# plugins, version.txt.
|
||||
{
|
||||
lib,
|
||||
callPackage,
|
||||
penpot,
|
||||
version,
|
||||
subSrc,
|
||||
stdenv,
|
||||
stdenvNoCC,
|
||||
runCommand,
|
||||
nodejs_24,
|
||||
pnpm_11,
|
||||
clojure,
|
||||
jdk25_headless,
|
||||
rsync,
|
||||
patchelf,
|
||||
fetchPnpmDeps,
|
||||
pnpmConfigHook,
|
||||
# Named renderWasm (not `wasm`) so pkgs.callPackage cannot auto-fill it
|
||||
# with nixpkgs' unrelated `pkgs.wasm` (the OCaml interpreter).
|
||||
renderWasm ? callPackage ./render-wasm.nix { inherit penpot version; },
|
||||
}:
|
||||
|
||||
let
|
||||
src = subSrc penpot [
|
||||
"frontend"
|
||||
"common"
|
||||
"mcp"
|
||||
"plugins"
|
||||
];
|
||||
|
||||
# The frontend workspace links `../plugins/libs/plugins-runtime` during
|
||||
# install, so plugins/ must be present in its fetch source too.
|
||||
frontendSrc = subSrc penpot [
|
||||
"frontend"
|
||||
"plugins"
|
||||
];
|
||||
pluginsSrc = subSrc penpot [ "plugins" ];
|
||||
mcpSrc = subSrc penpot [ "mcp" ];
|
||||
|
||||
# One pnpm store per workspace (fetched once, reused for the offline
|
||||
# installs in the main build via pnpmConfigHook).
|
||||
#
|
||||
# 2.18 adds a shared `storeDir: ../.pnpm-store` to every workspace's
|
||||
# pnpm-workspace.yaml; our fetches and consumer installs are per-workspace,
|
||||
# so strip that line before the fixed-output fetch (the main build removes
|
||||
# it again in postPatch).
|
||||
patchedPnpmSrc =
|
||||
name: wsrc:
|
||||
# Named "source" (see the note on `mcpSrcP` in mcp.nix): the deps FOD
|
||||
# relies on the dir-source unpack landing at /build/source so that
|
||||
# sourceRoot = "source/<workspace>" resolves.
|
||||
runCommand "source" { } ''
|
||||
mkdir "$out"
|
||||
cp -r "${wsrc}"/. "$out"/
|
||||
chmod -R u+w "$out"
|
||||
find "$out" -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
|
||||
fetchWorkspaceDeps =
|
||||
pname: sourceRoot: wsrc: hash:
|
||||
fetchPnpmDeps {
|
||||
inherit pname sourceRoot hash;
|
||||
pnpm = pnpm_11;
|
||||
fetcherVersion = 4;
|
||||
src = patchedPnpmSrc pname wsrc;
|
||||
};
|
||||
|
||||
frontendPnpmDeps =
|
||||
fetchWorkspaceDeps "penpot-frontend-frontend" "source/frontend" frontendSrc
|
||||
"sha256-zZxUFuyWLMxIse5AjPwEAmq/fJ9tbGYkcG2kG68HV4U=";
|
||||
pluginsPnpmDeps =
|
||||
fetchWorkspaceDeps "penpot-frontend-plugins" "source/plugins" pluginsSrc
|
||||
"sha256-Tq6aSL0lHFCfQo5H94d+XNGeY2oMNYG2J8YOQhYl3T8=";
|
||||
mcpPnpmDeps =
|
||||
fetchWorkspaceDeps "penpot-frontend-mcp" "source/mcp" mcpSrc
|
||||
"sha256-99ef/Qk3tx+etEqepQQVqAW3XL7T1Kr6qQJ7Uxf1z0Y=";
|
||||
|
||||
# Upstream's deps.edn passes --sun-misc-unsafe-memory-access=allow, which
|
||||
# needs a newer JDK than the one the clojure CLI wrapper ships with.
|
||||
clojureJdk25 = clojure.override { jdk = jdk25_headless; };
|
||||
|
||||
# Upstream leaves a few dev deps on "RELEASE"; pin them so the lockfile
|
||||
# generation and the (offline) shadow-cljs build resolve the same
|
||||
# deterministic, purely local maven versions.
|
||||
pinDepsEdn = ''
|
||||
sed -i frontend/deps.edn \
|
||||
-e 's|binaryage/devtools {:mvn/version "RELEASE"}|binaryage/devtools {:mvn/version "1.0.7"}|' \
|
||||
-e 's|org.clojure/tools.namespace {:mvn/version "RELEASE"}|org.clojure/tools.namespace {:mvn/version "1.5.1"}|' \
|
||||
-e 's|com.bhauman/rebel-readline {:mvn/version "RELEASE"}|com.bhauman/rebel-readline {:mvn/version "0.1.11"}|'
|
||||
'';
|
||||
|
||||
# Offline Clojure dependencies: eval-time cache from deps-lock.json
|
||||
# (no fixed-output derivation) + fake-git shim + short-SHA expansion.
|
||||
# Resolution runs offline from the cache on every build; no cpcache needed.
|
||||
cljOffline = callPackage ./clj-offline.nix { };
|
||||
in
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "penpot-frontend";
|
||||
inherit version;
|
||||
inherit src;
|
||||
|
||||
# Local carries of upstream plugin API fixes (see the patches for the
|
||||
# upstream issue references). Remove once the `penpot` input contains them.
|
||||
patches = [
|
||||
./patches/plugin-library-name-path.patch
|
||||
./patches/plugin-typography-lineheight.patch
|
||||
];
|
||||
|
||||
nativeBuildInputs = [
|
||||
nodejs_24
|
||||
pnpm_11
|
||||
pnpmConfigHook
|
||||
clojureJdk25
|
||||
cljOffline.fake-git
|
||||
cljOffline.clj-builder
|
||||
rsync
|
||||
patchelf
|
||||
];
|
||||
|
||||
# pnpmConfigHook is invoked once per workspace in preBuild instead
|
||||
# (dontPnpmConfigure disables the implicit single-workspace run).
|
||||
dontPnpmConfigure = true;
|
||||
|
||||
postPatch = ''
|
||||
${pinDepsEdn}
|
||||
# 2.18's shared pnpm store config would redirect the offline installs
|
||||
# away from the fetched per-workspace stores.
|
||||
find . -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
|
||||
env = {
|
||||
NODE_ENV = "production";
|
||||
# Baked into the cljs output (app.config/compiled-version-tag).
|
||||
VERSION = version;
|
||||
# Upstream appends the wall-clock build time (VERSION_TAG =
|
||||
# "$VERSION-$BUILD_TS"); a plain version keeps the build deterministic
|
||||
# while still busting caches. TEMPORARY: the -fix suffix forces clients
|
||||
# to fetch the patched bundle while the local patches are carried.
|
||||
VERSION_TAG = "${version}-fix2";
|
||||
# _helpers.js embeds these in the bundle when set; pin them for
|
||||
# reproducibility (upstream exports the wall clock).
|
||||
BUILD_DATE = "Thu Jan 01 00:00:00 UTC 1970";
|
||||
BUILD_TS = "0";
|
||||
};
|
||||
|
||||
preBuild = ''
|
||||
# pnpmConfigHook honors two variables: $pnpmRoot (where the workspace
|
||||
# lives; the hook pushd's into it) and $pnpmDeps (the fetched store,
|
||||
# visible to the hook via bash dynamic scoping from the local).
|
||||
installWorkspace() {
|
||||
local pnpmRoot="$1" pnpmDeps="$2"
|
||||
pnpmConfigHook
|
||||
}
|
||||
|
||||
installWorkspace frontend ${frontendPnpmDeps}
|
||||
installWorkspace plugins ${pluginsPnpmDeps}
|
||||
installWorkspace mcp ${mcpPnpmDeps}
|
||||
|
||||
# sass-embedded ships a prebuilt dart VM expecting the host loader.
|
||||
sassPkg=${
|
||||
if stdenvNoCC.hostPlatform.isx86_64 then "sass-embedded-linux-x64" else "sass-embedded-linux-arm64"
|
||||
}
|
||||
patchelf --set-interpreter "$(cat ${stdenv.cc.bintools}/nix-support/dynamic-linker)" \
|
||||
frontend/node_modules/.pnpm/$sassPkg@*/node_modules/$sassPkg/dart-sass/src/dart
|
||||
'';
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# 1. render-wasm artifacts.
|
||||
mkdir -p frontend/src/app/render_wasm/api frontend/resources/public/js/worker
|
||||
install -m644 ${renderWasm}/render-wasm.js ${renderWasm}/render-wasm.wasm frontend/resources/public/js/
|
||||
install -m644 ${renderWasm}/worker/render.js frontend/resources/public/js/worker/render.js
|
||||
install -m644 ${renderWasm}/shared.js frontend/src/app/render_wasm/api/shared.js
|
||||
|
||||
# 2. MCP plugin.
|
||||
(cd mcp && WS_URI=/mcp/ws pnpm run --filter mcp-plugin build)
|
||||
|
||||
# 3. Official plugins.
|
||||
(cd plugins/libs/plugins-runtime && pnpm run build)
|
||||
(cd plugins && pnpm run build:plugins)
|
||||
|
||||
# 4. Main cljs build, resolving offline from the eval-time clj-nix cache.
|
||||
${cljOffline.setup}
|
||||
|
||||
(cd frontend && pnpm run build:app:main)
|
||||
(cd frontend && pnpm run build:app:libs)
|
||||
(cd frontend && pnpm run build:app:assets)
|
||||
|
||||
# 5. Finalize the compiled assets (the dist assembly itself happens in
|
||||
# installPhase).
|
||||
sed -i "s|\./render.js|./render.js?version=$version|g" frontend/resources/public/js/worker/main*.js
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/dist
|
||||
rsync -a frontend/resources/public/ $out/dist/
|
||||
rsync -a plugins/dist/apps/ $out/dist/plugins/
|
||||
mkdir -p $out/dist/plugins/mcp
|
||||
rsync -a mcp/packages/plugin/dist/ $out/dist/plugins/mcp/
|
||||
echo "$version" > $out/dist/version.txt
|
||||
install -m644 ${penpot}/LICENSE $out/dist/LICENSE
|
||||
|
||||
# The NixOS module re-renders flags into the config template at system
|
||||
# build time; upstream also serves it from dist/js/config.js.
|
||||
install -m644 ${penpot}/docker/images/files/config.js $out/config.js
|
||||
install -m644 ${penpot}/docker/images/files/config.js $out/dist/js/config.js
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
# The pnpm stores are exposed so a version bump can rehash them directly
|
||||
# (`nix build .#penpot-frontend.<name>` with hash = lib.fakeHash).
|
||||
passthru = {
|
||||
inherit
|
||||
version
|
||||
frontendPnpmDeps
|
||||
pluginsPnpmDeps
|
||||
mcpPnpmDeps
|
||||
;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "Penpot frontend static bundle";
|
||||
homepage = "https://penpot.app";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Hardened ImageMagick policy shipped by the upstream images (resource caps
|
||||
# and PS/EPS/PDF/XPS coders disabled); nixpkgs' default policy is
|
||||
# unrestricted and the backend/exporter process user-controlled content.
|
||||
{
|
||||
runCommand,
|
||||
penpot,
|
||||
}:
|
||||
runCommand "penpot-imagemagick-policy" { } ''
|
||||
install -Dm644 "${penpot}/docker/images/files/imagemagick-policy.xml" \
|
||||
"$out/etc/ImageMagick-7/policy.xml"
|
||||
''
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic repack of a jar, replicating stripJavaArchivesHook for the
|
||||
penpot backend uberjar.
|
||||
|
||||
Nix's stripJavaArchivesHook runs strip-nondeterminism, which drives the
|
||||
perl Archive::Zip module. Two problems made it unusable here:
|
||||
|
||||
* its per-member removeMember/addMember reorder loop is O(n^2) in the entry
|
||||
count (linear member search + full array rebuild per entry), so the
|
||||
54000-entry, 109 MB penpot jar spent ~32 minutes there;
|
||||
* Archive::Zip derives the DOS timestamps with localtime, so the result
|
||||
depended on the build machine's timezone.
|
||||
|
||||
This script reproduces the same semantics (jar.pm + zip.pm handlers) with the
|
||||
stdlib zipfile module: read each entry once, sort the entry list, write it in a
|
||||
single pass. The result is byte-identical across runs and timezones, and costs
|
||||
seconds instead of minutes.
|
||||
|
||||
Semantics replicated from strip-nondeterminism:
|
||||
* entry order: META-INF/ first, then META-INF/MANIFEST.MF, then lexicographic
|
||||
* canonical time: Archive::Zip SAFE_EPOCH (1980-01-01 12:01:00 local), used
|
||||
here as a fixed UTC tuple so the output never depends on $TZ
|
||||
* .clj entries are stamped 2 DOS seconds (1 unix second) earlier, so that
|
||||
Clojure never mistakes a .clj for a stale .class
|
||||
* META-INF/MANIFEST.MF: drop Bnd-LastModified:/Built-By: lines
|
||||
* .html with a `<!-- Generated by javadoc` marker in the first 1 KB: bump
|
||||
the generation date to the canonical date
|
||||
* nested .jar entries: recurse with the same semantics
|
||||
* extra fields 0x5455 (extended timestamps), 0x000a (NTFS), 0x7875 (ux
|
||||
uid/gid) are normalized; unknown fields are kept verbatim
|
||||
* archives with a META-INF/*.SF signature are left untouched
|
||||
* javac.sh and plexus compiler-arguments files are dropped
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
# Archive::Zip SAFE_EPOCH: 1980-01-01 12:01:00. The DOS timestamp below is the
|
||||
# same instant, but written as a fixed tuple: no localtime call on the way out.
|
||||
DOS_DATE = (1980, 1, 1)
|
||||
DOS_TIME = (12, 1, 0)
|
||||
DOS_EPOCH_SECONDS = 315576060
|
||||
|
||||
MANIFEST = "META-INF/MANIFEST.MF"
|
||||
MANIFEST_DIR = "META-INF/"
|
||||
JAVADOC_WINDOW = 1024
|
||||
JAVADOC_MARKER = b"<!-- Generated by javadoc"
|
||||
|
||||
# From jar.pm's extract helper: a `YYYY-MM-DD` following this class is the
|
||||
# javadoc generation timestamp.
|
||||
JAVADOC_DATE_RE = re.compile(rb"(<!-- Generated by javadoc[^>]*?\son\s)(\d{4}-\d{2}-\d{2})")
|
||||
|
||||
|
||||
def order_key(name: str):
|
||||
if name == MANIFEST_DIR:
|
||||
return (0, name)
|
||||
if name == MANIFEST:
|
||||
return (1, name)
|
||||
return (2, name)
|
||||
|
||||
|
||||
def dropped(name: str) -> bool:
|
||||
return name == "javac.sh" or re.fullmatch(
|
||||
r"org\.codehaus\.plexus\.compiler\.javac\.JavacCompiler\d+arguments", name
|
||||
) is not None
|
||||
|
||||
|
||||
def canonical_tuple(entry_name: str):
|
||||
if entry_name.endswith(".clj"):
|
||||
# 2 DOS seconds earlier, i.e. one unix second (DOS has 2 s granularity).
|
||||
seconds = DOS_TIME[0] * 3600 + DOS_TIME[1] * 60 + DOS_TIME[2] - 2
|
||||
return DOS_DATE + (seconds // 3600, (seconds % 3600) // 60, seconds % 60)
|
||||
return DOS_DATE + DOS_TIME
|
||||
|
||||
|
||||
def normalize_manifest(data: bytes) -> bytes:
|
||||
out = []
|
||||
for line in data.splitlines(keepends=True):
|
||||
if line.startswith(b"Bnd-LastModified:") or line.startswith(b"Built-By:"):
|
||||
continue
|
||||
out.append(line)
|
||||
return b"".join(out)
|
||||
|
||||
|
||||
def normalize_javadoc(data: bytes) -> bytes:
|
||||
head = data[:JAVADOC_WINDOW]
|
||||
if JAVADOC_MARKER not in head:
|
||||
return data
|
||||
|
||||
def repl(match):
|
||||
return match.group(1) + b"1980-01-01"
|
||||
|
||||
return JAVADOC_DATE_RE.sub(repl, data, count=1)
|
||||
|
||||
|
||||
def normalize_extra(extra: bytes, filename: str) -> bytes:
|
||||
"""Normalize known timestamp/identity extra fields, keep the rest as-is."""
|
||||
want = canonical_tuple(filename)
|
||||
out = bytearray()
|
||||
pos = 0
|
||||
while pos + 4 <= len(extra):
|
||||
field_id, field_len = struct.unpack_from("<HH", extra, pos)
|
||||
data = extra[pos + 4 : pos + 4 + field_len]
|
||||
if field_id == 0x5455: # extended timestamp
|
||||
if len(data) >= 5:
|
||||
data = data[:1] + b"\x00\x00\x00\x00" + data[5:]
|
||||
elif field_id == 0x000a: # NTFS timestamp
|
||||
if len(data) >= 4:
|
||||
reserved = data[:4]
|
||||
tags = bytearray()
|
||||
tag_pos = 4
|
||||
while tag_pos + 4 <= len(data):
|
||||
tag, tag_size = struct.unpack_from("<HH", data, tag_pos)
|
||||
tags += data[tag_pos : tag_pos + 4]
|
||||
if tag == 0x0001 and tag_size == 24:
|
||||
# 32 bytes of 100ns ticks since 1601, i.e. 12 bytes of
|
||||
# zeros followed by the unix seconds in the low word.
|
||||
ticks = b"\x00" * 12 + struct.pack("<I", int(DOS_EPOCH_SECONDS))
|
||||
tags += ticks
|
||||
else:
|
||||
tags += data[tag_pos + 4 : tag_pos + 4 + tag_size]
|
||||
tag_pos += 4 + tag_size
|
||||
data = reserved + bytes(tags)
|
||||
elif field_id == 0x7875: # Info-ZIP new unix extra field
|
||||
if len(data) >= 3 and data[0] == 1:
|
||||
uid_len, gid_len = data[1], data[2 + data[1]]
|
||||
data = (
|
||||
b"\x01"
|
||||
+ bytes([uid_len])
|
||||
+ b"\x00" * uid_len
|
||||
+ bytes([gid_len])
|
||||
+ b"\x00" * gid_len
|
||||
)
|
||||
out += struct.pack("<HH", field_id, len(data)) + data
|
||||
pos += 4 + field_len
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def normalize_member(name: str, data: bytes) -> bytes:
|
||||
if name == MANIFEST:
|
||||
return normalize_manifest(data)
|
||||
if name.endswith(".html"):
|
||||
return normalize_javadoc(data)
|
||||
if name.endswith(".jar"):
|
||||
return normalize_jar_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
def _rewrite(source, target):
|
||||
zin = zipfile.ZipFile(source)
|
||||
if any(n.startswith("META-INF/") and n.endswith(".SF") for n in zin.namelist()):
|
||||
# Signed jar: normalization would break the signature.
|
||||
return False, 0
|
||||
|
||||
with zipfile.ZipFile(target, "w") as zout:
|
||||
written = 0
|
||||
for item in sorted(zin.infolist(), key=lambda i: order_key(i.filename)):
|
||||
if dropped(item.filename):
|
||||
continue
|
||||
data = normalize_member(item.filename, zin.read(item.filename))
|
||||
date_time = canonical_tuple(item.filename)
|
||||
info = zipfile.ZipInfo(item.filename, date_time=date_time)
|
||||
info.compress_type = item.compress_type
|
||||
info.external_attr = item.external_attr
|
||||
info.internal_attr = item.internal_attr
|
||||
info.create_system = 3
|
||||
info.extra = normalize_extra(item.extra, item.filename)
|
||||
zout.writestr(info, data)
|
||||
written += 1
|
||||
return True, written
|
||||
|
||||
|
||||
def normalize_jar_bytes(data: bytes) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zin:
|
||||
if any(n.startswith("META-INF/") and n.endswith(".SF") for n in zin.namelist()):
|
||||
return data
|
||||
with zipfile.ZipFile(buffer, "w") as zout:
|
||||
for item in sorted(zin.infolist(), key=lambda i: order_key(i.filename)):
|
||||
if dropped(item.filename):
|
||||
continue
|
||||
info = zipfile.ZipInfo(item.filename, date_time=canonical_tuple(item.filename))
|
||||
info.compress_type = item.compress_type
|
||||
info.external_attr = item.external_attr
|
||||
info.internal_attr = item.internal_attr
|
||||
info.create_system = 3
|
||||
info.extra = normalize_extra(item.extra, item.filename)
|
||||
zout.writestr(info, normalize_member(item.filename, zin.read(item.filename)))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print(f"usage: {sys.argv[0]} <jar>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
path = sys.argv[1]
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".jar")
|
||||
os.close(tmp_fd)
|
||||
try:
|
||||
handled, entries = _rewrite(path, tmp_path)
|
||||
if not handled:
|
||||
print(f"jar-strip: {path}: signed jar, leaving untouched", file=sys.stderr)
|
||||
return 0
|
||||
if entries == 0:
|
||||
print(f"jar-strip: {path}: no entries written", file=sys.stderr)
|
||||
return 1
|
||||
# Self-validation: reopen and verify every CRC before trusting the output.
|
||||
with zipfile.ZipFile(tmp_path) as check:
|
||||
bad = check.testzip()
|
||||
if bad is not None:
|
||||
print(f"jar-strip: {path}: CRC mismatch at {bad}", file=sys.stderr)
|
||||
return 1
|
||||
os.replace(tmp_path, path)
|
||||
print(f"jar-strip: {path}: normalized {entries} entries")
|
||||
return 0
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,117 @@
|
||||
# Penpot MCP server (upstream `mcp/` pnpm workspace).
|
||||
#
|
||||
# The esbuild bundle (packages/server/dist/index.js) keeps the node
|
||||
# dependencies external, so the runtime needs a real node_modules tree.
|
||||
# We materialize it offline with pnpm's `deploy` from the fetched store.
|
||||
{
|
||||
lib,
|
||||
penpot,
|
||||
version,
|
||||
subSrc,
|
||||
stdenv,
|
||||
stdenvNoCC,
|
||||
runCommand,
|
||||
makeWrapper,
|
||||
nodejs_24,
|
||||
pnpm_11,
|
||||
fetchPnpmDeps,
|
||||
pnpmConfigHook,
|
||||
}:
|
||||
let
|
||||
# Only the mcp/ workspace is needed from the Penpot source tree.
|
||||
mcpSrc = subSrc penpot [ "mcp" ];
|
||||
|
||||
# 2.18 adds a shared `storeDir: ../.pnpm-store` to the workspace's
|
||||
# pnpm-workspace.yaml; our pnpm fetches are per-workspace fixed-output
|
||||
# derivations, so strip the line before the fetch (postPatch strips the
|
||||
# consumer copy).
|
||||
# Named "source": stdenv's unpack of a directory source copies it to a
|
||||
# dir named after `stripHash(src)`, and the fetch below relies on
|
||||
# sourceRoot = "source/mcp" (matching the raw subSrc trees, which are
|
||||
# also named "source" in the store).
|
||||
mcpSrcP = runCommand "source" { } ''
|
||||
mkdir "$out"
|
||||
cp -r "${mcpSrc}"/. "$out"/
|
||||
chmod -R u+w "$out"
|
||||
find "$out" -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "penpot-mcp";
|
||||
inherit version;
|
||||
|
||||
src = mcpSrcP;
|
||||
# The filtered source tree keeps the upstream `mcp/` directory;
|
||||
# the workspace (pnpm-lock.yaml, pnpm-workspace.yaml) lives there.
|
||||
sourceRoot = "source/mcp";
|
||||
|
||||
# Fixed-output fetch of the whole pnpm store (fetcherVersion 3 is
|
||||
# unsupported for pnpm 11).
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
pname = "penpot-mcp";
|
||||
inherit (finalAttrs) src sourceRoot;
|
||||
pnpm = pnpm_11;
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-99ef/Qk3tx+etEqepQQVqAW3XL7T1Kr6qQJ7Uxf1z0Y=";
|
||||
};
|
||||
|
||||
# 2.18's shared pnpm store config would redirect the offline install away
|
||||
# from the fetched store.
|
||||
postPatch = ''
|
||||
find . -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
nodejs_24
|
||||
pnpm_11
|
||||
pnpmConfigHook
|
||||
];
|
||||
|
||||
# Same build as upstream `mcp/scripts/build` (build-types is not part of
|
||||
# it; ApiDocs loads the committed data/api_types.yml at runtime).
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
pnpm -r --filter "mcp-server" run build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Produce a standalone production runtime for the server. Unlike the
|
||||
# upstream dist/ bundle we materialize node_modules now (from the
|
||||
# offline store) instead of shipping a `setup` script that would run
|
||||
# `pnpm install` on the user's machine. The esbuild bundle keeps the
|
||||
# node dependencies external, so they must ship alongside it.
|
||||
pnpm --filter mcp-server \
|
||||
--config.inject-workspace-packages=true \
|
||||
deploy --prod --offline deploy
|
||||
|
||||
mkdir -p $out/lib/penpot-mcp $out/bin
|
||||
# dist contains index.js, static/ and data/ (instructions + api types).
|
||||
cp -a deploy/dist/. $out/lib/penpot-mcp/
|
||||
cp deploy/package.json $out/lib/penpot-mcp/package.json
|
||||
cp -a deploy/node_modules $out/lib/penpot-mcp/node_modules
|
||||
|
||||
# The server resolves data/ relative to the working directory, so the
|
||||
# wrapper chdirs into the runtime before exec'ing. Defaults to the
|
||||
# multi-user docker runtime; override arguments with PENPOT_MCP_ARGS.
|
||||
makeWrapper ${lib.getExe nodejs_24} $out/bin/penpot-mcp \
|
||||
--chdir $out/lib/penpot-mcp \
|
||||
--set-default PENPOT_MCP_SERVER_HOST 127.0.0.1 \
|
||||
--add-flags 'index.js' \
|
||||
--add-flags ''${PENPOT_MCP_ARGS:---multi-user}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = { inherit (finalAttrs) version pnpmDeps; };
|
||||
|
||||
meta = {
|
||||
description = "Penpot MCP server (Model Context Protocol integration for Penpot)";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "penpot-mcp";
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs
|
||||
index c7895ef7..fda13133 100644
|
||||
--- a/frontend/src/app/plugins/library.cljs
|
||||
+++ b/frontend/src/app/plugins/library.cljs
|
||||
@@ -10,6 +10,7 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.variant :as cfv]
|
||||
[app.common.geom.point :as gpt]
|
||||
+ [app.common.path-names :as cpn]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.component :as ctk]
|
||||
@@ -32,11 +33,25 @@
|
||||
[app.plugins.utils :as u]
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]
|
||||
+ [cuerdas.core :as str]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(declare lib-color-proxy)
|
||||
(declare lib-typography-proxy)
|
||||
|
||||
+(defn- rename-path-name
|
||||
+ "Compose the full path string used by the library rename events from an
|
||||
+ item and a new name. A `value` that contains a path separator is
|
||||
+ interpreted as an absolute path (matching the UI rename field); any
|
||||
+ other value keeps the item's current path. This makes the setters
|
||||
+ idempotent: assigning back a value read from `name`/`path` (e.g.
|
||||
+ \"Group / Name\") no longer appends the current path again and moves
|
||||
+ the element to a nested group on every set."
|
||||
+ [item value]
|
||||
+ (if (str/includes? value "/")
|
||||
+ value
|
||||
+ (cpn/merge-path-item (d/nilv (:path item) "") value)))
|
||||
+
|
||||
(defn lib-color-proxy? [p]
|
||||
(obj/type-of? p "LibraryColorProxy"))
|
||||
|
||||
@@ -60,7 +75,7 @@
|
||||
:set
|
||||
(fn [self value]
|
||||
(cond
|
||||
- (not (string? value))
|
||||
+ (or (not (string? value)) (str/empty? value))
|
||||
(u/not-valid plugin-id :name value)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
@@ -68,7 +83,7 @@
|
||||
|
||||
:else
|
||||
(let [color (u/proxy->library-color self)
|
||||
- value (dm/str (d/nilv (:path color) "") " / " value)]
|
||||
+ value (rename-path-name color value)]
|
||||
(st/emit! (dwl/rename-color file-id id value)))))}
|
||||
|
||||
:path
|
||||
@@ -307,7 +322,7 @@
|
||||
:set
|
||||
(fn [self value]
|
||||
(cond
|
||||
- (not (string? value))
|
||||
+ (or (not (string? value)) (str/empty? value))
|
||||
(u/not-valid plugin-id :name value)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
@@ -315,7 +330,7 @@
|
||||
|
||||
:else
|
||||
(let [typo (u/proxy->library-typography self)
|
||||
- value (dm/str (d/nilv (:path typo) "") " / " value)]
|
||||
+ value (rename-path-name typo value)]
|
||||
(st/emit! (-> (dwl/rename-typography file-id id value)
|
||||
(se/add-event plugin-id))))))}
|
||||
|
||||
@@ -754,7 +769,7 @@
|
||||
:set
|
||||
(fn [self value]
|
||||
(cond
|
||||
- (not (string? value))
|
||||
+ (or (not (string? value)) (str/empty? value))
|
||||
(u/not-valid plugin-id :name value)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
@@ -762,7 +777,7 @@
|
||||
|
||||
:else
|
||||
(let [component (u/proxy->library-component self)
|
||||
- value (dm/str (d/nilv (:path component) "") " / " value)]
|
||||
+ value (rename-path-name component value)]
|
||||
(st/emit! (dwv/rename-comp-or-variant-and-main id value)))))}
|
||||
|
||||
:path
|
||||
diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
index 32da4f5a..4bb5d4d4 100644
|
||||
--- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
+++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
@@ -98,6 +98,22 @@ describe('Library', () => {
|
||||
expect(color.opacity).toBeCloseTo(0.8, 2);
|
||||
});
|
||||
|
||||
+ // The `name` setter must be idempotent. Assigning a value that already
|
||||
+ // contains the path (the composite shown in the assets panel) must not
|
||||
+ // append the current path again and nest the element deeper on each set.
|
||||
+ test('color name with a path is idempotent', (ctx) => {
|
||||
+ const color = ctx.penpot.library.local.createColor();
|
||||
+ color.name = 'plugingroup/Name';
|
||||
+ expect(color.path).toBe('plugingroup');
|
||||
+ expect(color.name).toBe('Name');
|
||||
+ color.name = 'plugingroup/Name';
|
||||
+ expect(color.path).toBe('plugingroup');
|
||||
+ expect(color.name).toBe('Name');
|
||||
+ color.name = 'Simple';
|
||||
+ expect(color.path).toBe('plugingroup');
|
||||
+ expect(color.name).toBe('Simple');
|
||||
+ });
|
||||
+
|
||||
test('library color converts to fill and stroke', (ctx) => {
|
||||
const color = ctx.penpot.library.local.createColor();
|
||||
color.color = '#123456';
|
||||
@@ -188,6 +204,19 @@ describe('Library', () => {
|
||||
expect(typo.path).toBe('Text');
|
||||
});
|
||||
|
||||
+ test('typography name with a path is idempotent', (ctx) => {
|
||||
+ const typo = ctx.penpot.library.local.createTypography();
|
||||
+ typo.name = 'Text/Heading';
|
||||
+ expect(typo.path).toBe('Text');
|
||||
+ expect(typo.name).toBe('Heading');
|
||||
+ typo.name = 'Text/Heading';
|
||||
+ expect(typo.path).toBe('Text');
|
||||
+ expect(typo.name).toBe('Heading');
|
||||
+ typo.name = 'Body';
|
||||
+ expect(typo.path).toBe('Text');
|
||||
+ expect(typo.name).toBe('Body');
|
||||
+ });
|
||||
+
|
||||
test('typography plugin data round-trips', (ctx) => {
|
||||
const typo = ctx.penpot.library.local.createTypography();
|
||||
typo.setPluginData('k', 'v');
|
||||
@@ -279,6 +313,21 @@ describe('Library', () => {
|
||||
expect(comp.path).toBe('Controls');
|
||||
});
|
||||
|
||||
+ test('component name with a path is idempotent', (ctx) => {
|
||||
+ const rect = ctx.penpot.createRectangle();
|
||||
+ ctx.board.appendChild(rect);
|
||||
+ const comp = ctx.penpot.library.local.createComponent([rect]);
|
||||
+ comp.name = 'Controls/Button';
|
||||
+ expect(comp.path).toBe('Controls');
|
||||
+ expect(comp.name).toBe('Button');
|
||||
+ comp.name = 'Controls/Button';
|
||||
+ expect(comp.path).toBe('Controls');
|
||||
+ expect(comp.name).toBe('Button');
|
||||
+ comp.name = 'Input';
|
||||
+ expect(comp.path).toBe('Controls');
|
||||
+ expect(comp.name).toBe('Input');
|
||||
+ });
|
||||
+
|
||||
test('component plugin data round-trips', (ctx) => {
|
||||
const rect = ctx.penpot.createRectangle();
|
||||
ctx.board.appendChild(rect);
|
||||
@@ -0,0 +1,41 @@
|
||||
diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs
|
||||
index c7895ef7..fda13133 100644
|
||||
--- a/frontend/src/app/plugins/library.cljs
|
||||
+++ b/frontend/src/app/plugins/library.cljs
|
||||
@@ -440,7 +455,7 @@
|
||||
|
||||
:lineHeight
|
||||
{:this true
|
||||
- :get #(-> % u/proxy->library-typography :font-height)
|
||||
+ :get #(-> % u/proxy->library-typography :line-height)
|
||||
:set
|
||||
(fn [self value]
|
||||
(cond
|
||||
@@ -452,7 +467,7 @@
|
||||
|
||||
:else
|
||||
(let [typo (-> (u/proxy->library-typography self)
|
||||
- (assoc :font-height value))]
|
||||
+ (assoc :line-height value))]
|
||||
(st/emit! (dwl/update-typography typo file-id)))))}
|
||||
|
||||
:letterSpacing
|
||||
diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
index 32da4f5a..4bb5d4d4 100644
|
||||
--- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
+++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
@@ -238,9 +267,14 @@ describe('Library', () => {
|
||||
test('typography applies to a text shape', (ctx) => {
|
||||
const typo = ctx.penpot.library.local.createTypography();
|
||||
typo.fontSize = '22';
|
||||
+ // `lineHeight` must be stored under `:line-height` (the key the rest of
|
||||
+ // the app and the text attrs use), otherwise the applied typography
|
||||
+ // silently keeps the shape's current line height.
|
||||
+ typo.lineHeight = '1.5';
|
||||
const t = text(ctx);
|
||||
typo.applyToText(t);
|
||||
expect(t.fontSize).toBe('22');
|
||||
+ expect(t.lineHeight).toBe('1.5');
|
||||
});
|
||||
|
||||
test('typography applies to a text range', (ctx) => {
|
||||
@@ -0,0 +1,294 @@
|
||||
# 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";
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user