From da8a14dd040cd997c4df4fea5122d9fa626c82a8 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Fri, 2 Oct 2026 11:35:29 -0400
Subject: [PATCH] Signed release pipeline (macOS/Windows/Linux/Web); README
community note
Ports photocraft's release workflow + packaging (signing, notarization,
MSI/DMG/deb/rpm/AppImage/web), adds `cargo xtask version`. File-type
associations omitted for now (route B); app keeps its own .desktop. Fresh
WiX UpgradeCode. README 'about' note updated.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/packaging-lint.yml | 53 ++++
.github/workflows/release.yml | 285 ++++++++++++++++++
README.md | 5 +-
packaging/.shellcheckrc | 3 +
packaging/env.sh | 58 ++++
...ai.storyteller.vectorcraft.metainfo.xml.in | 61 ++++
.../linux/ai.storyteller.vectorcraft.mime.xml | 3 +
.../flatpak/ai.storyteller.vectorcraft.yml | 56 ++++
packaging/linux/nfpm.yaml | 70 +++++
packaging/linux/package.sh | 120 ++++++++
packaging/linux/postinst.sh | 13 +
packaging/macos/Info.plist.in | 149 +++++++++
packaging/macos/entitlements.plist | 9 +
packaging/macos/import-cert.sh | 50 +++
packaging/macos/package.sh | 162 ++++++++++
packaging/web/.htaccess | 16 +
packaging/web/README.md | 89 ++++++
packaging/web/_headers | 13 +
packaging/web/package.sh | 39 +++
packaging/windows/package.ps1 | 100 ++++++
packaging/windows/sign.ps1 | 95 ++++++
packaging/windows/vectorcraft.wxs | 60 ++++
xtask/src/main.rs | 2 +
xtask/src/version.rs | 164 ++++++++++
24 files changed, 1672 insertions(+), 3 deletions(-)
create mode 100644 .github/workflows/packaging-lint.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 packaging/.shellcheckrc
create mode 100644 packaging/env.sh
create mode 100644 packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in
create mode 100644 packaging/linux/ai.storyteller.vectorcraft.mime.xml
create mode 100644 packaging/linux/flatpak/ai.storyteller.vectorcraft.yml
create mode 100644 packaging/linux/nfpm.yaml
create mode 100644 packaging/linux/package.sh
create mode 100644 packaging/linux/postinst.sh
create mode 100644 packaging/macos/Info.plist.in
create mode 100644 packaging/macos/entitlements.plist
create mode 100644 packaging/macos/import-cert.sh
create mode 100644 packaging/macos/package.sh
create mode 100644 packaging/web/.htaccess
create mode 100644 packaging/web/README.md
create mode 100644 packaging/web/_headers
create mode 100644 packaging/web/package.sh
create mode 100644 packaging/windows/package.ps1
create mode 100644 packaging/windows/sign.ps1
create mode 100644 packaging/windows/vectorcraft.wxs
create mode 100644 xtask/src/version.rs
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
new file mode 100644
index 00000000..addead6b
--- /dev/null
+++ b/.github/workflows/packaging-lint.yml
@@ -0,0 +1,53 @@
+# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
+# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
+name: Packaging lint
+
+on:
+ push:
+ branches: [main, release]
+ paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
+ pull_request:
+ paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: packaging-lint-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ env:
+ ACTIONLINT_VERSION: 1.7.12
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
+ curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
+ | tar -xz -C "$RUNNER_TEMP" actionlint
+ - name: actionlint
+ run: |
+ "$RUNNER_TEMP/actionlint" -color
+ - name: shellcheck
+ run: shellcheck packaging/*.sh packaging/*/*.sh
+ - name: PowerShell
+ shell: pwsh
+ run: |
+ foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
+ $errors = $null
+ [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
+ if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
+ Write-Output "ok $($f.Name)"
+ }
+ - name: XML, desktop entry, AppStream, Flatpak manifest
+ run: |
+ xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
+ desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
+ sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
+ appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
+ python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.vectorcraft.yml')); assert m['id'] == 'ai.storyteller.vectorcraft' and m['command'] == 'vectorcraft'"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..70ebb9c6
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,285 @@
+# Release pipeline: every push to `release` builds signed installers for every platform and
+# creates or updates the draft GitHub Release "VectorCraft v". See docs/releasing.md.
+#
+# All jobs run in the `release` environment (restricted to the `release` branch), which holds
+# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
+# warning, never a failed build.
+name: Release
+
+on:
+ push:
+ branches: [release]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version override for a test run, e.g. 0.2.0-rc.1 (empty: Cargo.toml)"
+ type: string
+ default: ""
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-${{ github.ref }}
+ # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
+ cancel-in-progress: false
+
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_INCREMENTAL: 0
+ RUST_BACKTRACE: 1
+ VECTORCRAFT_BUILD_SHA: ${{ github.sha }}
+ INPUT_VERSION: ${{ inputs.version }}
+
+jobs:
+ version:
+ name: version
+ runs-on: ubuntu-latest
+ environment: release
+ outputs:
+ version: ${{ steps.v.outputs.version }}
+ date: ${{ steps.v.outputs.date }}
+ prerelease: ${{ steps.v.outputs.prerelease }}
+ steps:
+ - uses: actions/checkout@v4
+ - id: v
+ name: Resolve version
+ run: |
+ . packaging/env.sh
+ v="${INPUT_VERSION#v}"
+ v="${v:-$VERSION}"
+ if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
+ echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
+ exit 1
+ fi
+ case "$v" in *-*) pre=true ;; *) pre=false ;; esac
+ {
+ echo "version=$v"
+ echo "date=$(date -u +%Y-%m-%d)"
+ echo "prerelease=$pre"
+ } >>"$GITHUB_OUTPUT"
+ echo "### VectorCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
+
+ macos:
+ name: macOS universal
+ needs: version
+ runs-on: macos-15
+ environment: release
+ env:
+ VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: aarch64-apple-darwin, x86_64-apple-darwin
+ - uses: Swatinem/rust-cache@v2
+ with:
+ key: release-macos-universal
+ - name: Apply version override
+ if: inputs.version != ''
+ run: cargo xtask version set "$VECTORCRAFT_VERSION"
+ - name: Import signing certificate
+ env:
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
+ run: packaging/macos/import-cert.sh
+ - name: Build, sign, notarize
+ env:
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: packaging/macos/package.sh --arch universal
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-macos
+ path: dist/release/*
+ if-no-files-found: error
+ - name: Remove temporary keychain
+ if: always()
+ run: |
+ if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
+
+ windows:
+ name: Windows ${{ matrix.arch }}
+ needs: version
+ runs-on: windows-latest
+ environment: release
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { arch: x64, target: x86_64-pc-windows-msvc }
+ - { arch: x86, target: i686-pc-windows-msvc }
+ env:
+ VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+ - uses: Swatinem/rust-cache@v2
+ with:
+ key: release-windows-${{ matrix.arch }}
+ - name: Apply version override
+ if: inputs.version != ''
+ shell: bash
+ run: cargo xtask version set "$VECTORCRAFT_VERSION"
+ - name: Install WiX
+ shell: pwsh
+ run: dotnet tool install --global wix --version 5.0.2
+ - name: Build, sign, package
+ shell: pwsh
+ env:
+ WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
+ WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
+ AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
+ AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
+ AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+ run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-windows-${{ matrix.arch }}
+ path: dist/release/*
+ if-no-files-found: error
+
+ linux:
+ name: Linux ${{ matrix.arch }}
+ needs: version
+ # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
+ # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
+ runs-on: ${{ matrix.runner }}
+ environment: release
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
+ - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
+ env:
+ VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ NFPM_VERSION: 2.47.0
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install build and packaging tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
+ desktop-file-utils appstream python3-yaml
+ curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
+ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
+ sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ key: release-linux-${{ matrix.arch }}
+ - name: Apply version override
+ if: inputs.version != ''
+ run: cargo xtask version set "$VECTORCRAFT_VERSION"
+ - name: Build and package (AppImage, deb, rpm, tar.gz)
+ run: packaging/linux/package.sh
+ - name: Check the packages
+ run: |
+ dpkg-deb --info dist/release/*.deb
+ dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/vectorcraft$|metainfo|applications'
+ ldd target/release/vectorcraft
+ python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.vectorcraft.yml')); assert m['id'] == 'ai.storyteller.vectorcraft'"
+ for appimage in dist/release/*.AppImage; do
+ chmod +x "$appimage"
+ APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
+ done
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-linux-${{ matrix.arch }}
+ path: dist/release/*
+ if-no-files-found: error
+
+ web:
+ name: Web (wasm)
+ needs: version
+ runs-on: ubuntu-latest
+ environment: release
+ env:
+ VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ TRUNK_VERSION: 0.21.14
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+ - uses: Swatinem/rust-cache@v2
+ with:
+ key: release-web
+ - name: Install trunk
+ run: |
+ curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
+ | tar -xz -C "$HOME/.cargo/bin" trunk
+ trunk --version
+ - name: Apply version override
+ if: inputs.version != ''
+ run: cargo xtask version set "$VECTORCRAFT_VERSION"
+ - name: Build and zip
+ run: packaging/web/package.sh
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-web
+ path: dist/release/*
+ if-no-files-found: error
+
+ release:
+ name: Draft GitHub Release
+ needs: [version, macos, windows, linux, web]
+ runs-on: ubuntu-latest
+ environment: release
+ permissions:
+ contents: write
+ env:
+ VERSION: ${{ needs.version.outputs.version }}
+ PRERELEASE: ${{ needs.version.outputs.prerelease }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: release-*
+ path: artifacts
+ merge-multiple: true
+ - name: Checksums
+ working-directory: artifacts
+ run: |
+ sha256sum -- * | tee SHA256SUMS.txt
+ - name: Create or update the draft release
+ working-directory: artifacts
+ run: |
+ tag="v$VERSION"
+ title="VectorCraft v$VERSION"
+ pre=()
+ if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
+ if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
+ if [ "$draft" != "true" ]; then
+ echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
+ exit 1
+ fi
+ echo "Updating draft $tag: replacing its assets"
+ gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
+ gh release delete-asset "$tag" "$asset" --yes
+ done
+ gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
+ gh release upload "$tag" --clobber -- *
+ else
+ echo "Creating draft $tag"
+ gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
+ fi
+ {
+ echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
+ echo
+ sed 's/^/ /' SHA256SUMS.txt
+ } >>"$GITHUB_STEP_SUMMARY"
diff --git a/README.md b/README.md
index 111e8e5d..d4076fe6 100644
--- a/README.md
+++ b/README.md
@@ -49,9 +49,8 @@
> [!NOTE]
-> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
-> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
-> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
+> **ArtCraft is a community of artists from all walks of life.** Digital, generative, music,
+> games — if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
A look around ·
diff --git a/packaging/.shellcheckrc b/packaging/.shellcheckrc
new file mode 100644
index 00000000..36c19d06
--- /dev/null
+++ b/packaging/.shellcheckrc
@@ -0,0 +1,3 @@
+# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml).
+source-path=SCRIPTDIR
+external-sources=true
diff --git a/packaging/env.sh b/packaging/env.sh
new file mode 100644
index 00000000..c70004ff
--- /dev/null
+++ b/packaging/env.sh
@@ -0,0 +1,58 @@
+# shellcheck shell=bash
+# Shared setup for the packaging scripts. Source it: `. "$(dirname "$0")/../env.sh"`.
+#
+# Exports:
+# ROOT workspace root
+# VERSION [workspace.package] version from Cargo.toml (override: VECTORCRAFT_VERSION)
+# DIST output directory for release artifacts (default: $ROOT/dist/release)
+# VECTORCRAFT_BUILD_SHA git commit baked into the binaries (see crates/engine/src/build_info.rs)
+# VECTORCRAFT_BUILD_DATE UTC build date, YYYY-MM-DD
+# CARGO_TARGET_DIR cargo's target dir (default: $ROOT/target)
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+export ROOT
+
+# The version lives in exactly one place: `[workspace.package] version` in the root Cargo.toml.
+# (`cargo xtask version` prints the same thing; awk avoids compiling xtask here.)
+workspace_version() {
+ awk '
+ /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
+ in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
+ ' "$ROOT/Cargo.toml"
+}
+
+VERSION="${VECTORCRAFT_VERSION:-$(workspace_version)}"
+if [ -z "$VERSION" ]; then
+ echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2
+ exit 1
+fi
+export VERSION
+
+DIST="${DIST:-$ROOT/dist/release}"
+mkdir -p "$DIST"
+export DIST
+
+if [ -z "${VECTORCRAFT_BUILD_SHA:-}" ]; then
+ VECTORCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)"
+fi
+export VECTORCRAFT_BUILD_SHA
+export VECTORCRAFT_BUILD_DATE="${VECTORCRAFT_BUILD_DATE:-$(date -u +%Y-%m-%d)}"
+export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
+
+# Emit a GitHub Actions warning (plain stderr outside Actions).
+warn() {
+ if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
+}
+
+# Copy licence and readme files that exist into a package directory.
+copy_docs() {
+ local dest="$1" f
+ for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do
+ if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
+ done
+}
+
+# Portable SHA-256 of a file (prints just the hash).
+sha256() {
+ if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
+}
diff --git a/packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in b/packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in
new file mode 100644
index 00000000..ade490d3
--- /dev/null
+++ b/packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in
@@ -0,0 +1,61 @@
+
+
+
+ ai.storyteller.vectorcraft
+ CC0-1.0
+ MIT OR Apache-2.0
+ VectorCraft
+ Draw and edit vector illustrations, SVG and PDF
+
+ Learning Machines LLC
+
+
+
+ VectorCraft is a vector graphics editor. Draw and edit vector illustrations, SVG and PDF
+
+
+ ai.storyteller.vectorcraft.desktop
+ ai.storyteller.vectorcraft
+
+ https://getartcraft.com/apps/vectorcraft
+ https://github.com/storytold/vectorcraft/issues
+ https://github.com/storytold/vectorcraft
+ https://discord.gg/artcraft
+
+ Graphics
+ RasterGraphics
+ Photography
+
+
+ photo
+ image editor
+ psd
+ layers
+
+
+
+ Editing The Great Wave off Kanagawa with type layers and adjustment layers
+ https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/images/vectorcraft-demo.jpg
+
+
+
+ #9fd0ff
+ #1f64ba
+
+
+ 760
+
+
+ keyboard
+ pointing
+ tablet
+
+
+ vectorcraft
+
+
+
+
+
+
diff --git a/packaging/linux/ai.storyteller.vectorcraft.mime.xml b/packaging/linux/ai.storyteller.vectorcraft.mime.xml
new file mode 100644
index 00000000..be4dcc22
--- /dev/null
+++ b/packaging/linux/ai.storyteller.vectorcraft.mime.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml
new file mode 100644
index 00000000..11038201
--- /dev/null
+++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml
@@ -0,0 +1,56 @@
+# Flatpak manifest for VectorCraft, ready for a Flathub submission.
+#
+# Flatpak builds run offline, so the crate sources must be vendored first. From the repo root:
+#
+# curl -fsSLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
+# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/linux/flatpak/cargo-sources.json
+# flatpak-builder --user --install --force-clean build-flatpak packaging/linux/flatpak/ai.storyteller.vectorcraft.yml
+# flatpak run ai.storyteller.vectorcraft
+#
+# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit.
+id: ai.storyteller.vectorcraft
+runtime: org.freedesktop.Platform
+runtime-version: '25.08'
+sdk: org.freedesktop.Sdk
+sdk-extensions:
+ - org.freedesktop.Sdk.Extension.rust-stable
+command: vectorcraft
+finish-args:
+ - --share=ipc
+ - --socket=wayland
+ - --socket=fallback-x11
+ - --device=dri
+ # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document
+ # portal, so broad filesystem access isn't needed. Pictures is granted for opening files by
+ # path from the command line.
+ - --filesystem=xdg-pictures
+build-options:
+ append-path: /usr/lib/sdk/rust-stable/bin
+modules:
+ - name: vectorcraft
+ buildsystem: simple
+ build-options:
+ env:
+ CARGO_HOME: /run/build/vectorcraft/cargo
+ CARGO_NET_OFFLINE: 'true'
+ build-commands:
+ - cargo --offline build --release --locked -p vectorcraft
+ - install -Dm755 target/release/vectorcraft /app/bin/vectorcraft
+ - install -Dm644 packaging/linux/ai.storyteller.vectorcraft.desktop /app/share/applications/ai.storyteller.vectorcraft.desktop
+ - install -Dm644 packaging/linux/ai.storyteller.vectorcraft.mime.xml /app/share/mime/packages/ai.storyteller.vectorcraft.xml
+ - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/
+ - >-
+ version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml);
+ install -d /app/share/metainfo;
+ sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/"
+ packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.vectorcraft.metainfo.xml
+ sources:
+ - type: dir
+ path: ../../..
+ skip:
+ - target
+ - dist
+ - corpus
+ - build-flatpak
+ - .flatpak-builder
+ - cargo-sources.json
diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml
new file mode 100644
index 00000000..572ed6fa
--- /dev/null
+++ b/packaging/linux/nfpm.yaml
@@ -0,0 +1,70 @@
+# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages
+# the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and
+# exports VERSION and NFPM_ARCH.
+#
+# nfpm package -f packaging/linux/nfpm.yaml -p deb -t dist/release/
+name: vectorcraft
+arch: ${NFPM_ARCH}
+platform: linux
+version: ${VERSION}
+# semver: 0.2.0-rc.1 becomes 0.2.0~rc.1 in deb/rpm, so pre-releases sort before the release.
+version_schema: semver
+release: 1
+section: graphics
+priority: optional
+maintainer: ${VECTORCRAFT_MAINTAINER}
+vendor: Learning Machines LLC
+homepage: https://getartcraft.com/apps/vectorcraft
+license: MIT OR Apache-2.0
+description: |-
+ Open-source, native image editor that works the way Photoshop users expect.
+ Layers, masks, adjustment layers, layer styles, type and brushes; opens and
+ saves layered PSD/PSB files; 8/16/32-bit documents; GPU compositing (wgpu).
+ Includes vectorcraft-cli, a headless converter and command runner.
+
+# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
+# GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here
+# rather than ELF NEEDED entries.
+overrides:
+ deb:
+ depends:
+ - libc6 (>= 2.35)
+ - libgcc-s1
+ - libxkbcommon0
+ - libvulkan1 | libegl1
+ recommends:
+ - libwayland-client0
+ - libx11-6
+ - libx11-xcb1
+ - libxcursor1
+ - libxi6
+ - libxrandr2
+ - mesa-vulkan-drivers
+ - xdg-desktop-portal
+ rpm:
+ depends:
+ - libxkbcommon
+ - (vulkan-loader or libglvnd-egl)
+ recommends:
+ - libwayland-client
+ - libX11
+ - libXcursor
+ - libXi
+ - libXrandr
+ - mesa-vulkan-drivers
+ - xdg-desktop-portal
+
+contents:
+ - src: ${STAGE}/usr/bin/vectorcraft
+ dst: /usr/bin/vectorcraft
+ file_info: { mode: 0755 }
+ - src: ${STAGE}/usr/bin/vectorcraft-cli
+ dst: /usr/bin/vectorcraft-cli
+ file_info: { mode: 0755 }
+ - src: ${STAGE}/usr/share/
+ dst: /usr/share/
+ type: tree
+
+scripts:
+ postinstall: packaging/linux/postinst.sh
+ postremove: packaging/linux/postinst.sh
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
new file mode 100644
index 00000000..1644d6a7
--- /dev/null
+++ b/packaging/linux/package.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+# Build and package VectorCraft for Linux ( is x86_64 or aarch64):
+#
+# $DIST/vectorcraft--linux-.AppImage any distro with glibc >= the build host's
+# $DIST/vectorcraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ...
+# $DIST/vectorcraft--linux-.rpm Fedora, openSUSE, RHEL, ...
+# $DIST/vectorcraft--linux-.tar.gz plain FHS-style tree (bin/, share/)
+#
+# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"]
+#
+# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
+# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
+# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli.
+set -euo pipefail
+# shellcheck source=../env.sh
+. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
+HERE="$ROOT/packaging/linux"
+APP_ID=ai.storyteller.vectorcraft
+
+SKIP_BUILD=0
+FORMATS="appimage deb rpm tar"
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --skip-build) SKIP_BUILD=1; shift ;;
+ --formats) FORMATS="$2"; shift 2 ;;
+ -h | --help) sed -n '2,15p' "$0"; exit 0 ;;
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
+ esac
+done
+
+ARCH="$(uname -m)"
+case "$ARCH" in
+ x86_64) DEB_ARCH=amd64 ;;
+ aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
+ *) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
+esac
+export VECTORCRAFT_MAINTAINER="${VECTORCRAFT_MAINTAINER:-VectorCraft maintainers }"
+BASENAME="vectorcraft-$VERSION-linux-$ARCH"
+
+echo "==> VectorCraft $VERSION for Linux $ARCH ($FORMATS)"
+
+if [ "$SKIP_BUILD" = 0 ]; then
+ (cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli)
+fi
+BIN="$CARGO_TARGET_DIR/release"
+WORK="$CARGO_TARGET_DIR/linux-package"
+STAGE="$WORK/root"
+rm -rf "$WORK"
+
+# ---- stage an FHS tree (shared by every format) -------------------------------------------------
+install -Dm755 "$BIN/vectorcraft" "$STAGE/usr/bin/vectorcraft"
+install -Dm755 "$BIN/vectorcraft-cli" "$STAGE/usr/bin/vectorcraft-cli"
+strip "$STAGE/usr/bin/vectorcraft" "$STAGE/usr/bin/vectorcraft-cli" 2>/dev/null || true
+install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
+install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
+mkdir -p "$STAGE/usr/share/metainfo"
+sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$VECTORCRAFT_BUILD_DATE/g" \
+ "$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
+mkdir -p "$STAGE/usr/share/icons"
+cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/usr/share/icons/"
+mkdir -p "$STAGE/usr/share/doc/vectorcraft"
+copy_docs "$STAGE/usr/share/doc/vectorcraft"
+
+if command -v desktop-file-validate >/dev/null; then
+ desktop-file-validate "$STAGE/usr/share/applications/$APP_ID.desktop"
+fi
+if command -v appstreamcli >/dev/null; then
+ appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
+fi
+
+has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
+
+# ---- .tar.gz ------------------------------------------------------------------------------------
+if has tar; then
+ mkdir -p "$WORK/tar"
+ cp -R "$STAGE/usr" "$WORK/tar/$BASENAME"
+ tar -C "$WORK/tar" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
+ echo "wrote $DIST/$BASENAME.tar.gz"
+fi
+
+# ---- .deb / .rpm --------------------------------------------------------------------------------
+if has deb || has rpm; then
+ command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/install/)" >&2; exit 1; }
+ export VERSION
+ export NFPM_ARCH="$DEB_ARCH"
+ # nfpm expands env vars in fields like `version` and `arch`, but not in `contents[].src`.
+ sed "s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml"
+ for fmt in deb rpm; do
+ if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi
+ done
+fi
+
+# ---- AppImage -----------------------------------------------------------------------------------
+if has appimage; then
+ APPDIR="$WORK/VectorCraft.AppDir"
+ cp -R "$STAGE" "$APPDIR"
+ mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
+ ln -s usr/bin/vectorcraft "$APPDIR/AppRun"
+ cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop"
+ cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
+ ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
+
+ TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
+ if [ -z "$TOOL" ]; then
+ TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
+ if [ ! -x "$TOOL" ]; then
+ curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
+ chmod +x "$TOOL"
+ fi
+ fi
+ OUT="$DIST/$BASENAME.AppImage"
+ # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime,
+ # so users don't need libfuse2 either.
+ ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream "$APPDIR" "$OUT"
+ echo "wrote $OUT"
+fi
+
+"$STAGE/usr/bin/vectorcraft-cli" --version
+echo "==> done"
+ls -lh "$DIST"
diff --git a/packaging/linux/postinst.sh b/packaging/linux/postinst.sh
new file mode 100644
index 00000000..775e4df9
--- /dev/null
+++ b/packaging/linux/postinst.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# deb/rpm post-install and post-remove: refresh the desktop, MIME and icon caches.
+# Every tool is optional; missing ones are skipped.
+if command -v update-desktop-database >/dev/null 2>&1; then
+ update-desktop-database -q /usr/share/applications || true
+fi
+if command -v update-mime-database >/dev/null 2>&1; then
+ update-mime-database /usr/share/mime || true
+fi
+if command -v gtk-update-icon-cache >/dev/null 2>&1; then
+ gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
+fi
+exit 0
diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in
new file mode 100644
index 00000000..95819c78
--- /dev/null
+++ b/packaging/macos/Info.plist.in
@@ -0,0 +1,149 @@
+
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ VectorCraft
+ CFBundleName
+ VectorCraft
+ CFBundleExecutable
+ VectorCraft
+ CFBundleIdentifier
+ ai.storyteller.vectorcraft
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundlePackageType
+ APPL
+ CFBundleSignature
+ ????
+ CFBundleShortVersionString
+ @SHORT_VERSION@
+ CFBundleVersion
+ @SHORT_VERSION@
+ VectorCraftVersion
+ @VERSION@
+ VectorCraftBuildCommit
+ @BUILD_SHA@
+ CFBundleIconFile
+ VectorCraft
+ LSApplicationCategoryType
+ public.app-category.graphics-design
+
+ LSMinimumSystemVersion
+ 11.0
+ NSHighResolutionCapable
+
+ NSSupportsAutomaticGraphicsSwitching
+
+ NSRequiresAquaSystemAppearance
+
+ NSHumanReadableCopyright
+ Copyright © the VectorCraft authors. MIT OR Apache-2.0.
+
+
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ ai.storyteller.vectorcraft.document
+ UTTypeDescription
+ VectorCraft Document
+ UTTypeConformsTo
+
+ public.data
+ public.image
+
+ UTTypeIconFile
+ VectorCraft
+ UTTypeTagSpecification
+
+ public.filename-extension
+ pcraft
+ public.mime-type
+ application/x-vectorcraft
+
+
+
+
+ UTImportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ com.adobe.photoshop-large-image
+ UTTypeDescription
+ Photoshop Large Document
+ UTTypeConformsTo
+
+ public.data
+ public.image
+
+ UTTypeTagSpecification
+
+ public.filename-extension
+ psb
+
+
+
+ UTTypeIdentifier
+ ai.storyteller.vectorcraft.qoi
+ UTTypeDescription
+ QOI Image
+ UTTypeConformsTo
+
+ public.data
+ public.image
+
+ UTTypeTagSpecification
+
+ public.filename-extension
+ qoi
+
+
+
+
+
+
+ CFBundleTypeName
+ Photoshop Document
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ com.adobe.photoshop-image
+ com.adobe.photoshop-large-image
+
+
+
+ CFBundleTypeName
+ Image
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.png
+ public.jpeg
+ public.tiff
+ org.webmproject.webp
+ com.compuserve.gif
+ com.microsoft.bmp
+ com.truevision.tga-image
+ com.microsoft.ico
+ public.pbm
+ com.ilm.openexr-image
+ public.radiance
+ public.avif
+ ai.storyteller.vectorcraft.qoi
+
+
+
+
+
diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist
new file mode 100644
index 00000000..4dff2c82
--- /dev/null
+++ b/packaging/macos/entitlements.plist
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh
new file mode 100644
index 00000000..3940a64a
--- /dev/null
+++ b/packaging/macos/import-cert.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# CI only: import the Developer ID certificate into a temporary keychain so codesign can use it.
+#
+# Inputs (env): APPLE_CERTIFICATE (base64 .p12), APPLE_CERTIFICATE_PASSWORD, KEYCHAIN_PASSWORD
+# (optional; random if unset), RUNNER_TEMP (or TMPDIR).
+# Outputs: MACOS_SIGN_IDENTITY (SHA-1 of the identity) and MACOS_KEYCHAIN, appended to
+# $GITHUB_ENV when running in Actions, otherwise printed.
+#
+# Missing certificate: prints a warning and exits 0, so package.sh falls back to ad-hoc signing.
+set -euo pipefail
+umask 077
+# shellcheck source=../env.sh
+. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
+
+if [ -z "${APPLE_CERTIFICATE:-}" ]; then
+ warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized"
+ exit 0
+fi
+
+TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
+KEYCHAIN="$TMP/vectorcraft-signing.keychain-db"
+CERT="$TMP/vectorcraft-signing.p12"
+KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
+
+trap 'rm -f "$CERT"' EXIT
+printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
+security create-keychain -p "$KC_PASS" "$KEYCHAIN"
+security set-keychain-settings -lut 21600 "$KEYCHAIN"
+security unlock-keychain -p "$KC_PASS" "$KEYCHAIN"
+security import "$CERT" -P "${APPLE_CERTIFICATE_PASSWORD:-}" -T /usr/bin/codesign -T /usr/bin/security -t cert -f pkcs12 -k "$KEYCHAIN"
+rm -f "$CERT"
+# Let codesign use the key without a UI prompt.
+security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$KEYCHAIN" >/dev/null
+# Put it on the search list (keeping the login keychain) so codesign and notarytool find it.
+# shellcheck disable=SC2046
+security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
+
+IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')"
+if [ -z "$IDENTITY" ]; then
+ security find-identity -v -p codesigning "$KEYCHAIN" >&2
+ echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2
+ exit 1
+fi
+echo "Imported signing identity $IDENTITY"
+if [ -n "${GITHUB_ENV:-}" ]; then
+ { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV"
+else
+ echo "MACOS_SIGN_IDENTITY=$IDENTITY"
+ echo "MACOS_KEYCHAIN=$KEYCHAIN"
+fi
diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh
new file mode 100644
index 00000000..6c07e5c9
--- /dev/null
+++ b/packaging/macos/package.sh
@@ -0,0 +1,162 @@
+#!/usr/bin/env bash
+# Build, sign and (optionally) notarize the macOS release artifacts:
+#
+# $DIST/vectorcraft--macos-.dmg VectorCraft.app on a drag-to-Applications DMG
+# $DIST/vectorcraft-cli--macos-.zip the headless CLI
+#
+# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build]
+#
+# Signing (env):
+# MACOS_SIGN_IDENTITY codesign identity (name or SHA-1). Default "-" = ad-hoc (local testing;
+# Gatekeeper will reject the result on other Macs). In CI, import-cert.sh sets it.
+# MACOS_KEYCHAIN keychain holding the identity (optional)
+# Notarization (env; all three needed, and a real identity):
+# APPLE_ID, APPLE_PASSWORD (app-specific password), APPLE_TEAM_ID
+set -euo pipefail
+# shellcheck source=../env.sh
+. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
+HERE="$ROOT/packaging/macos"
+
+ARCH=universal
+SKIP_BUILD=0
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --arch) ARCH="$2"; shift 2 ;;
+ --skip-build) SKIP_BUILD=1; shift ;;
+ -h | --help) sed -n '2,16p' "$0"; exit 0 ;;
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
+ esac
+done
+case "$ARCH" in
+ universal) TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) ;;
+ aarch64) TARGETS=(aarch64-apple-darwin) ;;
+ x86_64) TARGETS=(x86_64-apple-darwin) ;;
+ *) echo "unknown --arch $ARCH" >&2; exit 2 ;;
+esac
+
+# Keep in sync with LSMinimumSystemVersion in Info.plist.in.
+export MACOSX_DEPLOYMENT_TARGET=11.0
+IDENTITY="${MACOS_SIGN_IDENTITY:--}"
+SHORT_VERSION="${VERSION%%-*}"
+WORK="$CARGO_TARGET_DIR/macos-package"
+APP="$WORK/VectorCraft.app"
+DMG="$DIST/vectorcraft-$VERSION-macos-$ARCH.dmg"
+CLI_ZIP="$DIST/vectorcraft-cli-$VERSION-macos-$ARCH.zip"
+
+NOTARIZE=0
+if [ "$IDENTITY" = "-" ]; then
+ warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized"
+elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
+ NOTARIZE=1
+else
+ warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized"
+fi
+
+echo "==> VectorCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE"
+
+# ---- build -------------------------------------------------------------------------------------
+if [ "$SKIP_BUILD" = 0 ]; then
+ args=()
+ for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
+ (cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli "${args[@]}")
+fi
+
+rm -rf "$WORK"
+mkdir -p "$WORK/bin"
+for bin in vectorcraft vectorcraft-cli; do
+ inputs=()
+ for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done
+ lipo -create -output "$WORK/bin/$bin" "${inputs[@]}"
+ lipo -info "$WORK/bin/$bin"
+done
+
+# ---- signing helpers ---------------------------------------------------------------------------
+sign() {
+ # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped.
+ local ts=(--timestamp)
+ [ "$IDENTITY" = "-" ] && ts=(--timestamp=none)
+ local kc=()
+ [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN")
+ codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
+}
+
+notarize() {
+ local file="$1" out id status
+ echo "==> notarizing $(basename "$file") (this can take a few minutes)"
+ out="$(xcrun notarytool submit "$file" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID" --wait --timeout 1h --output-format json)" || true
+ echo "$out"
+ id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)"
+ status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)"
+ if [ "$status" != "Accepted" ]; then
+ if [ -n "$id" ]; then
+ xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true
+ fi
+ echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2
+ exit 1
+ fi
+}
+
+# ---- VectorCraft.app ----------------------------------------------------------------------------
+echo "==> assembling $APP"
+mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
+# Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile).
+cp "$WORK/bin/vectorcraft" "$APP/Contents/MacOS/VectorCraft"
+cp "$ROOT/assets/app-icon/vectorcraft.icns" "$APP/Contents/Resources/VectorCraft.icns"
+sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
+ -e "s/@BUILD_SHA@/${VECTORCRAFT_BUILD_SHA:-unknown}/g" \
+ "$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
+plutil -lint "$APP/Contents/Info.plist"
+printf 'APPL????' >"$APP/Contents/PkgInfo"
+
+# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
+# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
+sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/VectorCraft"
+sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
+codesign --verify --strict --deep --verbose=2 "$APP"
+
+if [ "$NOTARIZE" = 1 ]; then
+ ditto -c -k --keepParent "$APP" "$WORK/VectorCraft-notarize.zip"
+ notarize "$WORK/VectorCraft-notarize.zip"
+ xcrun stapler staple "$APP"
+ xcrun stapler validate "$APP"
+ spctl --assess --type execute -vvv "$APP"
+fi
+
+# ---- DMG ---------------------------------------------------------------------------------------
+echo "==> building $DMG"
+STAGE="$WORK/dmg"
+mkdir -p "$STAGE"
+ditto "$APP" "$STAGE/VectorCraft.app"
+ln -s /Applications "$STAGE/Applications"
+rm -f "$DMG" "$WORK/raw.dmg"
+# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
+# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
+hdiutil makehybrid -hfs -hfs-volume-name "VectorCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
+hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
+rm -f "$WORK/raw.dmg"
+sign "$DMG"
+codesign --verify --strict --verbose=2 "$DMG"
+if [ "$NOTARIZE" = 1 ]; then
+ notarize "$DMG"
+ xcrun stapler staple "$DMG"
+ xcrun stapler validate "$DMG"
+ spctl --assess --type open --context context:primary-signature -vvv "$DMG"
+fi
+
+# ---- CLI ---------------------------------------------------------------------------------------
+echo "==> building $CLI_ZIP"
+CLI_DIR="$WORK/vectorcraft-cli-$VERSION-macos-$ARCH"
+mkdir -p "$CLI_DIR"
+cp "$WORK/bin/vectorcraft-cli" "$CLI_DIR/"
+copy_docs "$CLI_DIR"
+sign --options runtime "$CLI_DIR/vectorcraft-cli"
+codesign --verify --strict --verbose=2 "$CLI_DIR/vectorcraft-cli"
+rm -f "$CLI_ZIP"
+ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
+# A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online.
+if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi
+
+"$WORK/bin/vectorcraft-cli" --version
+echo "==> done"
+ls -lh "$DMG" "$CLI_ZIP"
diff --git a/packaging/web/.htaccess b/packaging/web/.htaccess
new file mode 100644
index 00000000..7b7e6e18
--- /dev/null
+++ b/packaging/web/.htaccess
@@ -0,0 +1,16 @@
+# Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional.
+AddType application/wasm .wasm
+AddType text/javascript .js
+
+
+ AddOutputFilterByType DEFLATE application/wasm text/javascript text/html
+
+
+
+
+ Header set Cache-Control "public, max-age=31536000, immutable"
+
+
+ Header set Cache-Control "no-cache"
+
+
diff --git a/packaging/web/README.md b/packaging/web/README.md
new file mode 100644
index 00000000..b39ec0e9
--- /dev/null
+++ b/packaging/web/README.md
@@ -0,0 +1,89 @@
+# Hosting VectorCraft for the web
+
+`vectorcraft-web-.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a
+static site in `vectorcraft-web-/`:
+
+| File | What it is |
+|---|---|
+| `index.html` | The page. It loads everything through relative URLs. |
+| `vectorcraft-web-.js` | wasm-bindgen glue (generated, ES module) |
+| `vectorcraft-web-_bg.wasm` | The app, about 13 MB, or 5 MB with compression |
+| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
+
+There is no server-side code. Upload the folder's contents anywhere that serves static files.
+
+## Any path works
+
+All URLs in `index.html` are relative (`public_url = "./"` in `apps/vectorcraft-web/Trunk.toml`),
+so the site works at a domain root (`https://example.com/`), under a prefix
+(`https://example.com/tools/vectorcraft/`) and from a CDN bucket. The asset names carry a content
+hash, so they can be cached forever. Only `index.html` needs revalidation.
+
+## Required server settings
+
+- **MIME type:** serve `.wasm` as `application/wasm`. Browsers refuse to stream-compile it under
+ any other type, and the app then loads slowly or not at all. Serve `.js` as `text/javascript`.
+ Most hosts already do both. For nginx, check that `mime.types` has `application/wasm wasm;`.
+- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. That takes the
+ download from about 13 MB to about 5 MB. You can also precompress (`brotli -k *.wasm`) and let
+ the server send `Content-Encoding: br`.
+- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the hashed `.wasm` and
+ `.js` files, and `no-cache` on `index.html`.
+- **HTTPS:** WebGPU (and the clipboard) only work in a secure context, which means `https://`
+ or `http://localhost`. Over plain HTTP elsewhere, the app falls back to WebGL2.
+- **No special isolation headers:** VectorCraft doesn't use `SharedArrayBuffer`, so it doesn't
+ need `Cross-Origin-Opener-Policy` or `Cross-Origin-Embedder-Policy`. If your site already sends
+ COEP `require-corp`, also send `Cross-Origin-Resource-Policy: same-origin` (or `cross-origin`
+ when the files live on a CDN) on the app's files.
+
+nginx example:
+
+```nginx
+location /vectorcraft/ {
+ types { application/wasm wasm; text/javascript js; text/html html; }
+ gzip on;
+ gzip_types application/wasm text/javascript text/html;
+ location ~* \.(wasm|js)$ { add_header Cache-Control "public, max-age=31536000, immutable"; }
+ location ~* index\.html$ { add_header Cache-Control "no-cache"; }
+}
+```
+
+Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
+
+## Embedding in a page (iframe)
+
+```html
+
+```
+
+- The app fills the iframe and follows its size, so size the iframe and not the app.
+- Keyboard shortcuts go to the iframe after the user clicks into it, as with any embedded app.
+- **Cross-origin embeds** work. Preferences are kept in the iframe's `localStorage`. Browsers
+ that partition or block third-party storage may forget them between visits, and the app
+ then starts with defaults.
+- **Sandboxed iframes** need at least
+ `sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"`. Without
+ `allow-same-origin` there's no storage. Without `allow-downloads`, Save and Export (browser
+ downloads) are blocked.
+- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
+
+## Renderer selection and fallback flags
+
+VectorCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
+**WebGL2** on its own. URL query flags override this, and they work on the iframe `src` too:
+
+| Flag | Effect |
+|---|---|
+| *(none)* | WebGPU if available, otherwise WebGL2 |
+| `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves) |
+| `?cpu` | Force the CPU canvas path (slowest, most compatible) |
+
+For example: `