Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5954e2064 | ||
|
|
f12fa03f1c | ||
|
|
d4f23f5b87 | ||
|
|
f5ad546c62 | ||
|
|
52eb14b3c8 | ||
|
|
1843b38915 | ||
|
|
2927018af1 | ||
|
|
759b59d61d | ||
|
|
7eaa3cd072 | ||
|
|
00bd5d8b13 | ||
|
|
298ca15b97 | ||
|
|
1996647dfe | ||
|
|
45c37a5e73 | ||
|
|
fe57429603 | ||
|
|
094bfbdc60 | ||
|
|
414e0ee30c | ||
|
|
cc439c1152 | ||
|
|
e775ea478f | ||
|
|
dcb804e48e | ||
|
|
f380249c1c | ||
|
|
a87716dc5b | ||
|
|
109f737c2a | ||
|
|
d808c715fd |
@@ -0,0 +1,225 @@
|
||||
name: Release
|
||||
|
||||
# Builds the Docker image, the Windows installers (NSIS + portable) and the Linux
|
||||
# AppImage, then publishes all of it: the image goes to the Gitea container
|
||||
# registry as `latest` + the tag, the installers are attached to a Gitea release
|
||||
# for that tag. Flatpak is intentionally not built here — flatpak-builder's
|
||||
# sandbox (bwrap) needs working user namespaces, which a containerized Actions
|
||||
# runner is not guaranteed to have; it stays a local-only step via
|
||||
# scripts/publish.ps1.
|
||||
#
|
||||
# There is no Windows runner in this setup, so the "win" target (NSIS + portable)
|
||||
# is cross-built on the Linux runner via Wine, the same path
|
||||
# scripts/build-release.ps1 uses for -UseWsl. Unsigned either way.
|
||||
#
|
||||
# Requires two Actions secrets on this repo (Settings > Actions > Secrets), a
|
||||
# token with package read/write *and* repository write (write:repository) scope:
|
||||
# BUILD_USER - registry/API username
|
||||
# BUILD_TOKEN - registry/API token
|
||||
# (named BUILD_* rather than GITEA_* because Gitea rejects secrets whose name
|
||||
# starts with GITEA)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to publish (e.g. v1.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
IMAGE: git.azuze.fr/kawa/motionity
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: docker-build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # full tag history, for the changelog compare link
|
||||
|
||||
- name: Resolve tag
|
||||
id: vars
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
DISPATCH_TAG: ${{ inputs.tag }}
|
||||
REF_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
echo "tag=$DISPATCH_TAG" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=$REF_TAG" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install build tools (jq, Wine for the NSIS uninstaller)
|
||||
run: |
|
||||
sudo dpkg --add-architecture i386
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq wine locales
|
||||
# makensis converts its argv through the locale charset and aborts with
|
||||
# "FATAL: main argv conversion failed!" under C/POSIX. C.UTF-8 is a glibc
|
||||
# built-in only from 2.35 (Debian 12+), so en_US.UTF-8 is generated instead.
|
||||
#
|
||||
# `locale-gen <name>` is an Ubuntu extension: plain Debian's locale-gen
|
||||
# takes no arguments and only reads /etc/locale.gen, so the entry goes in
|
||||
# there first. The anchored grep does not match the line the package ships
|
||||
# commented out.
|
||||
grep -q '^en_US.UTF-8 UTF-8' /etc/locale.gen \
|
||||
|| echo 'en_US.UTF-8 UTF-8' | sudo tee -a /etc/locale.gen
|
||||
sudo locale-gen
|
||||
locale -a
|
||||
locale -a | grep -qiE '^en_US\.utf-?8$' || {
|
||||
echo "en_US.UTF-8 was not generated; makensis will abort on argv conversion" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Ensure PowerShell
|
||||
# Debian ships no snapd by default, so this pulls Microsoft's portable
|
||||
# linux-x64 tarball instead — no package repo or GPG key setup needed.
|
||||
# A no-op on repeat runs once it's on PATH, since this runner is a
|
||||
# persistent host, not a fresh container per job.
|
||||
run: |
|
||||
if command -v pwsh >/dev/null; then exit 0; fi
|
||||
url=$(curl -sL https://api.github.com/repos/PowerShell/PowerShell/releases/latest \
|
||||
| jq -r '.assets[] | select(.name | test("linux-x64\\.tar\\.gz$")) | .browser_download_url')
|
||||
[ -n "$url" ] || { echo "could not resolve a PowerShell linux-x64 release asset" >&2; exit 1; }
|
||||
sudo mkdir -p /opt/microsoft/powershell/7
|
||||
curl -sL "$url" | sudo tar xz -C /opt/microsoft/powershell/7
|
||||
sudo ln -sf /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh
|
||||
sudo chmod +x /opt/microsoft/powershell/7/pwsh
|
||||
|
||||
- name: makensis locale probe
|
||||
# Diagnostic only, never fails the job. If `-VERSION` prints a version under
|
||||
# en_US.UTF-8 and "FATAL: main argv conversion failed!" under C, the locale
|
||||
# really is the whole story. If it fails under both, it is not the locale and
|
||||
# the next thing to try is DEBUG=electron-builder on the build step to see the
|
||||
# argv makensis is actually handed. Prints nothing useful on the very first
|
||||
# run of a fresh runner, where the NSIS bundle has not been downloaded yet.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
locale || true
|
||||
mk=$(find "$HOME/.cache/electron-builder" /var/lib/gitea-runner/.cache/electron-builder \
|
||||
-type f -name makensis -path '*/linux/*' 2>/dev/null | head -n1)
|
||||
echo "makensis: ${mk:-<not downloaded yet>}"
|
||||
[ -n "$mk" ] || exit 0
|
||||
echo "--- LC_ALL=en_US.UTF-8"
|
||||
LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 "$mk" -VERSION || echo "failed, exit $?"
|
||||
echo "--- LC_ALL=C"
|
||||
LC_ALL=C LANG=C "$mk" -VERSION || echo "failed, exit $?"
|
||||
|
||||
- name: Build Windows installers + Linux AppImage
|
||||
shell: pwsh
|
||||
env:
|
||||
TAG: ${{ steps.vars.outputs.tag }}
|
||||
# makensis's argv conversion aborts with ERR_ELECTRON_BUILDER_CANNOT_EXECUTE
|
||||
# / "main argv conversion failed!" under the C/POSIX locale that a
|
||||
# minimal Debian box defaults to. en_US.UTF-8 is generated above.
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
run: ./scripts/build-release.ps1 -Targets win,linux-appimage -Tag $env:TAG
|
||||
|
||||
# Plain docker rather than docker/login-action + setup-buildx-action +
|
||||
# build-push-action. This is a single-platform build of one Dockerfile, which
|
||||
# dockerd's own integrated BuildKit does; the actions' only added value here
|
||||
# would be the docker-container driver, and standing that up means pulling
|
||||
# moby/buildkit and mounting the socket into it for no gain.
|
||||
#
|
||||
# --password-stdin, never -p: an argument is visible in ps and in the daemon's
|
||||
# process list for as long as the login runs.
|
||||
- name: Build and push image
|
||||
env:
|
||||
TAG: ${{ steps.vars.outputs.tag }}
|
||||
REGISTRY_USER: ${{ secrets.BUILD_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "$REGISTRY_TOKEN" \
|
||||
| docker login git.azuze.fr -u "$REGISTRY_USER" --password-stdin
|
||||
docker build -t "$IMAGE:latest" -t "$IMAGE:$TAG" .
|
||||
docker push "$IMAGE:latest"
|
||||
docker push "$IMAGE:$TAG"
|
||||
|
||||
# This runner is a persistent host, so the credentials written by docker login
|
||||
# would otherwise sit in ~/.docker/config.json between jobs. always(): a failed
|
||||
# build is exactly when they must not be left behind.
|
||||
- name: Log out of git.azuze.fr
|
||||
if: always()
|
||||
run: docker logout git.azuze.fr || true
|
||||
|
||||
- name: Publish Gitea release
|
||||
env:
|
||||
TAG: ${{ steps.vars.outputs.tag }}
|
||||
GITEA_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO_PATH: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="$SERVER_URL/api/v1/repos/$REPO_PATH/releases"
|
||||
auth=(-H "Authorization: token $GITEA_TOKEN")
|
||||
|
||||
# Fail here rather than on an opaque 401 four curls later.
|
||||
[ -n "${GITEA_TOKEN:-}" ] || { echo "the BUILD_TOKEN secret is empty" >&2; exit 1; }
|
||||
|
||||
# The tag before $TAG, for the compare link. $TAG is injected into the list
|
||||
# before sorting because it need not be a tag that exists: a
|
||||
# workflow_dispatch run publishes a release for a tag nobody has pushed,
|
||||
# and Gitea creates it. Without the injection grep matches nothing, and
|
||||
# under `set -e -o pipefail` that took the whole step down before the first
|
||||
# echo. `sort -uV` also puts v2.9 before v2.10, which -v:refname alone on a
|
||||
# list missing $TAG cannot help with.
|
||||
prev_tag=$( { git tag; echo "$TAG"; } | sort -uV | grep -B1 -x -F "$TAG" | head -n1 )
|
||||
if [ "$prev_tag" = "$TAG" ]; then prev_tag=""; fi
|
||||
if [ -n "$prev_tag" ]; then
|
||||
body="**Full Changelog**: $SERVER_URL/$REPO_PATH/compare/$prev_tag...$TAG"
|
||||
else
|
||||
body="**Full Changelog**: first release"
|
||||
fi
|
||||
echo "changelog: $body"
|
||||
|
||||
status=$(curl -s -o /tmp/release.json -w '%{http_code}' "${auth[@]}" "$api/tags/$TAG")
|
||||
if [ "$status" = "200" ]; then
|
||||
release_id=$(jq -r '.id' /tmp/release.json)
|
||||
echo "reusing release $TAG (id $release_id)"
|
||||
elif [ "$status" = "404" ]; then
|
||||
echo "creating release $TAG"
|
||||
payload=$(jq -n --arg tag "$TAG" --arg name "Motionity $TAG" --arg body "$body" \
|
||||
'{tag_name:$tag, name:$name, body:$body, draft:false}')
|
||||
status=$(curl -s -o /tmp/release.json -w '%{http_code}' -X POST "${auth[@]}" \
|
||||
-H "Content-Type: application/json" -d "$payload" "$api")
|
||||
[ "$status" = "201" ] || { echo "release creation failed ($status):"; cat /tmp/release.json; exit 1; }
|
||||
release_id=$(jq -r '.id' /tmp/release.json)
|
||||
else
|
||||
echo "unexpected status $status fetching release:"; cat /tmp/release.json; exit 1
|
||||
fi
|
||||
|
||||
# Keep the changelog link authoritative even when reusing an existing
|
||||
# release (a manual re-run after a later tag was pushed).
|
||||
curl -sf -X PATCH "${auth[@]}" -H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg body "$body" '{body:$body}')" \
|
||||
"$api/$release_id" -o /dev/null
|
||||
|
||||
for f in "dist/motionity-$TAG-win-x64-setup.exe" \
|
||||
"dist/motionity-$TAG-win-x64-portable.exe" \
|
||||
"dist/motionity-$TAG-linux-x86_64.AppImage" \
|
||||
"dist/SHA256SUMS.txt"; do
|
||||
[ -f "$f" ] || { echo "expected artifact missing: $f" >&2; exit 1; }
|
||||
name=$(basename "$f")
|
||||
existing_id=$(jq -r --arg n "$name" '.assets[]? | select(.name==$n) | .id' /tmp/release.json)
|
||||
if [ -n "$existing_id" ]; then
|
||||
echo "replacing attachment $name (asset $existing_id)"
|
||||
curl -sf -X DELETE "${auth[@]}" "$api/$release_id/assets/$existing_id" -o /dev/null
|
||||
else
|
||||
echo "adding attachment $name"
|
||||
fi
|
||||
encoded=$(jq -rn --arg n "$name" '$n|@uri')
|
||||
curl -sf "${auth[@]}" -F "attachment=@$f" "$api/$release_id/assets?name=$encoded" -o /dev/null
|
||||
done
|
||||
|
||||
echo "release: $SERVER_URL/$REPO_PATH/releases/tag/$TAG"
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Sync Gitea releases to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
GITEA_URL: https://git.azuze.fr
|
||||
GITEA_OWNER: kawa
|
||||
GITEA_REPO: Motionity
|
||||
|
||||
jobs:
|
||||
sync-releases:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install jq
|
||||
run: sudo apt-get update && sudo apt-get install -y jq
|
||||
|
||||
- name: Fetch Gitea releases and sync to GitHub
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AUTH_HEADER=()
|
||||
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||
AUTH_HEADER=(-H "Authorization: token ${GITEA_TOKEN}")
|
||||
fi
|
||||
# Récupère toutes les releases Gitea (pagination simple, 50 max, ajuster si besoin)
|
||||
releases=$(curl -sf "${AUTH_HEADER[@]}" \
|
||||
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases?limit=50")
|
||||
echo "$releases" | jq -c '.[]' | while read -r release; do
|
||||
tag=$(echo "$release" | jq -r '.tag_name')
|
||||
name=$(echo "$release" | jq -r '.name // .tag_name')
|
||||
body=$(echo "$release" | jq -r '.body // ""')
|
||||
prerelease=$(echo "$release" | jq -r '.prerelease')
|
||||
draft=$(echo "$release" | jq -r '.draft')
|
||||
# Skip si la release existe déjà sur GitHub
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
echo "Release $tag existe déjà sur GitHub, on passe."
|
||||
continue
|
||||
fi
|
||||
echo "Création de la release $tag sur GitHub..."
|
||||
flags=()
|
||||
[ "$prerelease" = "true" ] && flags+=(--prerelease)
|
||||
[ "$draft" = "true" ] && flags+=(--draft)
|
||||
# Le tag doit exister sur le repo GitHub ; s'il n'existe pas encore,
|
||||
# on utilise --notes-file avec target par défaut (branche par défaut)
|
||||
gh release create "$tag" \
|
||||
--title "$name" \
|
||||
--notes "$body" \
|
||||
"${flags[@]}"
|
||||
# Télécharge et attache les assets de la release Gitea
|
||||
asset_count=$(echo "$release" | jq '.assets | length')
|
||||
if [ "$asset_count" -gt 0 ]; then
|
||||
tmpdir=$(mktemp -d)
|
||||
echo "$release" | jq -c '.assets[]' | while read -r asset; do
|
||||
asset_name=$(echo "$asset" | jq -r '.name')
|
||||
asset_url=$(echo "$asset" | jq -r '.browser_download_url')
|
||||
echo "Téléchargement de $asset_name..."
|
||||
curl -sfL "${AUTH_HEADER[@]}" -o "${tmpdir}/${asset_name}" "$asset_url"
|
||||
gh release upload "$tag" "${tmpdir}/${asset_name}" --clobber
|
||||
done
|
||||
rm -rf "$tmpdir"
|
||||
fi
|
||||
done
|
||||
+204
-24
@@ -4,7 +4,7 @@ Three distribution targets share one source tree (`src/`, a plain static app):
|
||||
|
||||
| Target | Output | Build host |
|
||||
| --- | --- | --- |
|
||||
| Desktop | `Motionity Setup 1.0.0.exe`, `Motionity-1.0.0-x64.AppImage`, `.flatpak` | Windows for `.exe`, Linux (or WSL2) for AppImage/Flatpak |
|
||||
| Desktop | `Motionity Setup 1.0.0.exe`, `Motionity-1.0.0-x64.AppImage`, `.flatpak` | Linux or WSL2 builds all of them; Windows builds only the `.exe` targets |
|
||||
| Container | `motionity:latest`, ~86 MB on disk / ~57 MB pulled | any Docker host |
|
||||
| Bare metal | `src/` behind Node, nginx or Caddy | any |
|
||||
|
||||
@@ -63,12 +63,38 @@ npm run dist:win # NSIS installer + portable .exe -> dist/
|
||||
npm run dist:appimage # .AppImage -> dist/
|
||||
npm run dist:flatpak # .flatpak -> dist/
|
||||
npm run dist:linux # both Linux targets
|
||||
|
||||
npm run dist:linux:wsl # from Windows: Linux bundles, built in WSL
|
||||
npm run dist:win:wsl # from Windows: .exe targets, built and left in WSL
|
||||
npm run dist:win:wsl:portable # same, portable only (needs no Wine in the distro)
|
||||
```
|
||||
|
||||
Each `dist:*` script re-runs `vendor` and regenerates `build/icon.png`
|
||||
(`scripts/make-icon.cjs` rasterises the logo geometry with zlib only — no
|
||||
ImageMagick, no sharp).
|
||||
|
||||
### Why `desktopName` is `app.motionity.desktop.desktop`
|
||||
|
||||
The doubled suffix is correct, do not trim it. Electron reads the **root-level**
|
||||
`desktopName` from `package.json` and derives the Wayland `app_id` / X11
|
||||
`WM_CLASS` from it with the `.desktop` suffix stripped, so the value has to be
|
||||
the desktop *file name*, not the app id. Flatpak in turn installs the entry as
|
||||
`<appId>.desktop` and nothing can rename it — with `appId`
|
||||
`app.motionity.desktop`, the file is `app.motionity.desktop.desktop`.
|
||||
|
||||
`linux.syncDesktopName: true` makes electron-builder use the same base name for
|
||||
the AppImage's embedded entry and write a matching `StartupWMClass`. Without the
|
||||
pair, the build warns
|
||||
|
||||
```
|
||||
electron uses desktopName as app_id / WM_CLASS for window association.
|
||||
reason=desktopName is not set in package.json
|
||||
```
|
||||
|
||||
and the running window is not linked to its launcher entry: GNOME shows a
|
||||
generic icon and a second, unpinnable dock item instead of the installed app.
|
||||
Changing `appId` means changing `desktopName` in step with it.
|
||||
|
||||
### If the NSIS step fails with "Access denied" on `Motionity.exe`
|
||||
|
||||
On a locked-down Windows machine the security agent can take an exclusive lock
|
||||
@@ -85,9 +111,17 @@ WARNING: Cannot open 2 files
|
||||
The ACL is intact (the owner still has FullControl), so this is a filter
|
||||
driver, not a permissions problem. Fixes, in order of preference:
|
||||
|
||||
1. Exclude the repository's `dist/` directory in the endpoint protection agent.
|
||||
2. Build on a machine or CI runner without that agent.
|
||||
3. Ship `dist/win-unpacked/` — `electron-builder --win dir` is unaffected.
|
||||
1. **Build the Windows targets in WSL**, where the agent cannot see the files at
|
||||
all — `npm run dist:win:wsl`, covered in [its own section](#can-the-exe-be-built-in-wsl-too)
|
||||
below. Needs no admin rights on the Windows side.
|
||||
2. Exclude the repository's `dist/` directory in the endpoint protection agent.
|
||||
3. Build on a machine or CI runner without that agent.
|
||||
4. Ship `dist/win-unpacked/` — `electron-builder --win dir` is unaffected.
|
||||
|
||||
The same agent can also quarantine the *finished* unsigned `.exe` on write, not
|
||||
just lock it during the build. That is what `-KeepInWsl` is for: the artifact
|
||||
stays in the distro and is uploaded to the release from there, so it never
|
||||
crosses onto NTFS.
|
||||
|
||||
### Running `npm run dev` from a VS Code terminal
|
||||
|
||||
@@ -102,22 +136,61 @@ $env:ELECTRON_RUN_AS_NODE=$null; npm run dev # PowerShell
|
||||
|
||||
### Is WSL enough for AppImage and Flatpak?
|
||||
|
||||
**AppImage: yes.** electron-builder produces the squashfs itself, so no FUSE is
|
||||
needed at build time. To *run* the result inside WSL you need `libfuse2`
|
||||
(or `./Motionity-1.0.0-x64.AppImage --appimage-extract-and-run`), and WSLg on
|
||||
Windows 11 gives you the GUI.
|
||||
Yes, and `build-release.ps1 -UseWsl` does it for you from Windows (`-WinInWsl`
|
||||
moves the `.exe` targets there too — see [below](#can-the-exe-be-built-in-wsl-too)):
|
||||
|
||||
**Flatpak: technically yes, practically annoying.** `flatpak-builder` runs under
|
||||
WSL2 (the kernel has the user namespaces and `/dev/fuse` that bubblewrap needs),
|
||||
but you must install the runtimes by hand first and there is no
|
||||
`xdg-desktop-portal` to fall back on. If it fights you, build it in a Linux
|
||||
container instead — it is the same command with fewer moving parts.
|
||||
```powershell
|
||||
./scripts/build-release.ps1 -UseWsl # .exe on Windows, Linux bundles in WSL
|
||||
./scripts/build-release.ps1 -Targets linux -UseWsl # Linux bundles only
|
||||
./scripts/publish.ps1 -Tag v2.0.1 -PublishRelease -UseWsl # same, then attach to the Gitea release
|
||||
```
|
||||
|
||||
**`.exe`: no.** Build it on the Windows side. Cross-building NSIS from Linux
|
||||
needs Wine and rules out signing.
|
||||
Both bundles have been built this way on this machine (`Ubuntu`, WSL2 kernel
|
||||
6.18): a 172 MB AppImage and a 133 MB Flatpak, checksums verified with
|
||||
`sha256sum -c`. Under the hood:
|
||||
|
||||
This machine currently has no WSL distro other than `docker-desktop`, so the
|
||||
Linux targets have not been run here. Setup, from PowerShell:
|
||||
- the `npm ci`, `vendor` and icon steps run **once on the Windows side**, and the
|
||||
WSL build reads them back through `/mnt/c` — one worktree, no second checkout;
|
||||
- `-UseWsl` delegates only `linux-appimage` and `linux-flatpak`; add `-WinInWsl`
|
||||
to send `win` / `win-nsis` / `win-portable` as well. On a Linux host, or with no
|
||||
target selected for WSL, both warn and change nothing;
|
||||
- the distro is the first installed one that is not `docker-desktop`, override
|
||||
with `-WslDistro`. `docker-desktop` is skipped deliberately: it is Docker's own
|
||||
LinuxKit VM, with no apt and no home to install the flatpak runtimes into, and
|
||||
it is usually the *default* distro, so a blind `wsl --` lands there;
|
||||
- missing tooling fails **before** the build with the apt or `flatpak install`
|
||||
line to run, because electron-builder's own error for an absent flatpak ref is
|
||||
a bare exit code naming neither the ref nor the remote.
|
||||
|
||||
**Why the build stages in `~/.cache/motionity-build` and not in `dist/`.**
|
||||
electron-builder chmods every file it unpacks from the Electron zip, and `/mnt/c`
|
||||
is mounted without the `metadata` option, so chmod is refused:
|
||||
|
||||
```
|
||||
⨯ EPERM: operation not permitted, chmod '.../dist/linux-unpacked.tmp/locales/de.pak'
|
||||
```
|
||||
|
||||
The alternative fix is `options = "metadata"` under `[automount]` in
|
||||
`/etc/wsl.conf` plus a `wsl --shutdown` — a global, sudo-and-reboot change to the
|
||||
distro. Building into ext4 and copying the two finished bundles back into `dist/`
|
||||
needs neither, and is faster anyway. Reading `src/` over the mount is fine;
|
||||
nothing chmods the input. The copy back is `cp -f`, never `cp -p` — preserving
|
||||
modes means chmod, which is the EPERM being avoided.
|
||||
|
||||
**AppImage** needs no extra tooling: electron-builder downloads its own appimage
|
||||
bundle and writes the squashfs itself, no FUSE at build time. To *run* the result
|
||||
inside WSL you need `libfuse2` (or
|
||||
`./motionity-*.AppImage --appimage-extract-and-run`); WSLg gives you the GUI.
|
||||
|
||||
**Flatpak** needs `flatpak-builder` and the runtimes installed by hand — the
|
||||
WSL2 kernel has the user namespaces and `/dev/fuse` that bubblewrap wants, but
|
||||
there is no `xdg-desktop-portal` to fall back on.
|
||||
|
||||
**`.exe`: yes** — see the next section. It is opt-in (`-WinInWsl`) because Windows
|
||||
builds those targets natively too; the reason to move them is the endpoint agent
|
||||
described above, not portability.
|
||||
|
||||
One-time distro setup, from PowerShell:
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu
|
||||
@@ -127,22 +200,129 @@ Then inside Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y nodejs npm libfuse2 # AppImage
|
||||
sudo apt install -y nodejs npm libfuse2 # AppImage (libfuse2 only to run it)
|
||||
sudo apt install -y flatpak flatpak-builder elfutils # Flatpak
|
||||
sudo dpkg --add-architecture i386 && sudo apt update # only for the NSIS .exe (see below)
|
||||
sudo apt install -y wine # " "
|
||||
flatpak remote-add --if-not-exists --user flathub \
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install --user -y flathub \
|
||||
org.freedesktop.Platform//23.08 \
|
||||
org.freedesktop.Sdk//23.08 \
|
||||
org.electronjs.Electron2.BaseApp//23.08
|
||||
|
||||
cd /mnt/c/Users/<you>/git\ azuze/motionity-2
|
||||
npm install
|
||||
npm run dist:linux
|
||||
```
|
||||
|
||||
Note that building on `/mnt/c` is slow. Copying the tree into the WSL
|
||||
filesystem (`~/motionity`) is several times faster.
|
||||
### Can the `.exe` be built in WSL too?
|
||||
|
||||
Yes, and it is the way out of the "Access denied" failure above, since nothing
|
||||
unsigned is written to a Windows filesystem.
|
||||
|
||||
```powershell
|
||||
npm run dist:win:wsl # NSIS + portable, built and left in WSL — build only
|
||||
npm run dist:win:wsl:portable # portable only — no Wine, no sudo needed
|
||||
npm run release:binaries:wsl # same .exe targets, then uploaded to the Gitea release
|
||||
npm run release:wsl # everything incl. Linux + the container image push
|
||||
|
||||
./scripts/build-release.ps1 -WinInWsl # copy the .exe back into dist/
|
||||
./scripts/build-release.ps1 -WinInWsl -KeepInWsl # leave it in the distro
|
||||
```
|
||||
|
||||
Verified on this machine (`Ubuntu`, WSL2 kernel 6.18): a 138 MB portable
|
||||
`motionity-v2.0.2-win-x64-portable.exe`, `PE32 executable for MS Windows (GUI),
|
||||
Nullsoft Installer self-extracting archive`, with the icon and version resources
|
||||
applied, byte-identical whether read in the distro or after the copy back into
|
||||
`dist/`. The NSIS installer needs the wine setup below and has not been built this
|
||||
way yet.
|
||||
|
||||
**What each Windows target needs on the Linux side.**
|
||||
|
||||
| Target | Wine? | Why |
|
||||
| --- | --- | --- |
|
||||
| `-Targets win-portable` | no | electron-builder's NSIS bundle ships a native Linux `makensis`, and the exe's icon and version strings are written by the `resedit` JS package, not by `rcedit.exe`. |
|
||||
| `-Targets win-nsis` | **yes, 32-bit capable** | NSIS builds its uninstaller by *executing* the installer stub it has just linked, so a Windows PE has to run. |
|
||||
| `-Targets win` | yes | Both of the above in one packaging pass. |
|
||||
|
||||
**Why the NSIS target cannot avoid Wine.** electron-builder links the installer
|
||||
once with `BUILD_UNINSTALLER` defined, runs it to produce `uninstaller.exe`, then
|
||||
links the real installer, which *embeds that file*:
|
||||
`templates/nsis/include/installer.nsh` does
|
||||
`File "/oname=${UNINSTALL_FILENAME}" "${UNINSTALLER_OUT_FILE}"`. There is no
|
||||
option to skip the first pass. The stub it executes is **PE32/i386** even for an
|
||||
x64 app, so a 64-bit-only Wine is not enough either:
|
||||
|
||||
```bash
|
||||
sudo dpkg --add-architecture i386
|
||||
sudo apt update
|
||||
sudo apt install -y wine
|
||||
```
|
||||
|
||||
`build-release.ps1` probes for a usable wine before packaging (missing or broken
|
||||
wine is an error; a wine with no `i386-windows` directory is a warning), because
|
||||
the failure otherwise arrives ~200 MB into the build naming ntdll rather than the
|
||||
missing package.
|
||||
|
||||
**Do not use `toolsets.wine=1.0.1` for this.** electron-builder can download its
|
||||
own Wine 11 bundle instead of using the distro's, which looks like it would avoid
|
||||
the apt install, and it does download and verify cleanly. Its Linux build is
|
||||
unusable: `lib/wine/x86_64-unix/` only, with no `*-windows` PE builtin directory
|
||||
and no `syswow64`, so it fails after the app is already packaged with
|
||||
|
||||
```
|
||||
wine: failed to load .../wine-11.0-linux-x86_64-*/lib/wine/x86_64-unix/ntdll.dll error c0000135
|
||||
0024:err:environ:run_wineboot failed to start wineboot 1
|
||||
```
|
||||
|
||||
`c0000135` is `STATUS_DLL_NOT_FOUND`. Leaving `toolsets.wine` unset is what makes
|
||||
electron-builder use the distro's `wine` on Linux, which is the working path. If
|
||||
that bundle was already downloaded, `rm -rf ~/.cache/electron-builder/wine@1.0.1`
|
||||
reclaims it.
|
||||
|
||||
If you cannot install anything in the distro, `-Targets win-portable` is a
|
||||
complete answer: a single self-contained `.exe`, no installer, no Wine, no root.
|
||||
|
||||
**Why the build also passes `win.signExecutable=false`.** With no certificate
|
||||
configured, electron-builder still walks the signing path, and on Linux that
|
||||
path shells out to `signtool.exe` under Wine *before* discovering there is
|
||||
nothing to sign — `spawn wine ENOENT`, build over. `signExecutable: false` skips
|
||||
signing while still applying the icon and version metadata.
|
||||
(`signAndEditExecutable: false` would drop those too, which is not wanted.) Both
|
||||
overrides are passed on the command line for the WSL build only, so a native
|
||||
Windows build behaves exactly as before. These releases are unsigned either way.
|
||||
|
||||
**Uploading straight from the distro.** With `-KeepInWsl`, `build-release.ps1`
|
||||
writes `dist/wsl-artifacts.json` naming the distro, the staging directory and the
|
||||
files it deliberately did not copy back. `publish.ps1` reads it and runs the
|
||||
`curl` upload *inside* the distro for those files. The Gitea token reaches WSL
|
||||
through `WSLENV` and is written to a `mktemp` config file by bash — it is in
|
||||
neither `wsl.exe`'s arguments nor the distro's process table. `SHA256SUMS.txt`
|
||||
still covers every artifact, hashes for the staged ones coming from `sha256sum`
|
||||
in the distro; it is text, so nothing objects to it landing in `dist/`.
|
||||
|
||||
Two things to know when writing more of this plumbing:
|
||||
|
||||
- These `.ps1` files are stored with **CRLF**, so a multi-line here-string handed
|
||||
to `bash -lc` arrives with a `\r` on every line (`set: - : invalid option`,
|
||||
`cd: $'/path\r': No such file or directory`). `ConvertTo-BashScript` strips it.
|
||||
- Never combine `set -e` with an explicit `exit 0` under `bash -lc`. A login
|
||||
shell sources `~/.bash_logout`, Ubuntu's ends in a `clear_console` test that
|
||||
fails with no tty, and errexit promotes that to the shell's exit status:
|
||||
`wsl -e bash -lc 'set -e; exit 0'` returns **1**. `-l` has to stay, because
|
||||
node from nvm or fnm is only on the login `PATH`.
|
||||
|
||||
Those three refs must match `build.flatpak.runtimeVersion` / `baseVersion` in
|
||||
`package.json`; `build-release.ps1` reads them from there when it checks.
|
||||
|
||||
To build inside the distro directly instead — no `-UseWsl`, and faster still,
|
||||
since `src/` is read locally too:
|
||||
|
||||
```bash
|
||||
git clone <repo> ~/motionity && cd ~/motionity
|
||||
npm ci && npm run dist:linux
|
||||
```
|
||||
|
||||
`wsl.exe` writes its own listings as UTF-16LE, which PowerShell 5.1 renders as
|
||||
NUL-interleaved text — `wsl -l -v` can look like it has one distro when it has
|
||||
two. `$env:WSL_UTF8 = "1"` fixes it.
|
||||
|
||||
## 2. Docker
|
||||
|
||||
|
||||
@@ -1,35 +1,160 @@
|
||||
# Motionity
|
||||
<div align="center">
|
||||
|
||||
Web-based motion graphics editor with keyframing, masking, filters and text animations.
|
||||
<img src="src/assets/logo-full.svg" alt="Motionity" width="320">
|
||||
|
||||
This is a fork of the original [Motionity](https://github.com/alyssaxuu/motionity) by [@alyssaxuu](https://github.com/alyssaxuu), with bug fixes and enhancements.
|
||||
**Web-based motion graphics editor** — keyframing, masking, filters, text animations.
|
||||
A free alternative to After Effects and Canva, running entirely in the browser.
|
||||
|
||||
## Quick Start
|
||||
### ▶ [Try the live demo — motionity.kawa.zip](https://motionity.kawa.zip/)
|
||||
|
||||
[](https://motionity.kawa.zip/)
|
||||
[](LICENSE)
|
||||
[](https://git.azuze.fr/kawa/Motionity/releases)
|
||||
|
||||
---
|
||||
|
||||
Created by **[Alyssa X](https://github.com/alyssaxuu)** · maintained by **Kawa**
|
||||
|
||||
Fork of the original [Motionity](https://github.com/alyssaxuu/motionity), with bug fixes,
|
||||
vendored (offline-capable) assets, desktop builds and a hardened container image.
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## How to run
|
||||
|
||||
### Docker (recommended for self-hosting)
|
||||
|
||||
```bash
|
||||
docker compose up -d # http://localhost:8080
|
||||
```
|
||||
|
||||
Or without compose:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:8080 git.azuze.fr/kawa/motionity:latest
|
||||
```
|
||||
|
||||
Build the image yourself:
|
||||
|
||||
```bash
|
||||
docker build -t motionity:latest . # ~86 MB on disk
|
||||
docker build --build-arg WITH_FFMPEG=0 . # -23 MB, no MP4/GIF export
|
||||
```
|
||||
|
||||
Runtime is [static-web-server](https://github.com/static-web-server/static-web-server) on Alpine — no Node, no shell, runs as UID 65534, `read_only` filesystem.
|
||||
|
||||
### Windows app
|
||||
|
||||
Grab the installer or the portable build from **[Releases](https://git.azuze.fr/kawa/Motionity/releases)**:
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `Motionity Setup <ver>.exe` | NSIS installer, per-user, choosable install dir |
|
||||
| `Motionity-<ver>-x64.exe` | Portable, no install |
|
||||
|
||||
Build from source (on Windows):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dist:win # -> dist/
|
||||
```
|
||||
|
||||
### Linux app
|
||||
|
||||
From **[Releases](https://git.azuze.fr/kawa/Motionity/releases)**: `Motionity-<ver>-x86_64.AppImage` or the `.flatpak`.
|
||||
|
||||
```bash
|
||||
chmod +x Motionity-*.AppImage && ./Motionity-*.AppImage # needs libfuse2
|
||||
flatpak install ./Motionity-*.flatpak # or this
|
||||
```
|
||||
|
||||
Build from source (on Linux or WSL2):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dist:appimage # AppImage only
|
||||
npm run dist:flatpak # Flatpak only
|
||||
npm run dist:linux # both
|
||||
```
|
||||
|
||||
### From source — web
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run vendor # downloads third-party assets into src/vendor/ (~24 MB)
|
||||
npm start # http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
```bash
|
||||
HOST=0.0.0.0 PORT=3000 npm start # bind elsewhere
|
||||
```
|
||||
|
||||
### From source — desktop
|
||||
|
||||
**Web (localhost only):**
|
||||
```bash
|
||||
npm install
|
||||
npm run vendor
|
||||
npm start # http://127.0.0.1:8080
|
||||
npm run dev # Electron
|
||||
```
|
||||
|
||||
**Desktop:**
|
||||
```bash
|
||||
npm install
|
||||
npm run vendor
|
||||
npm run dev # Electron app
|
||||
```
|
||||
> In a VS Code terminal, `ELECTRON_RUN_AS_NODE=1` breaks this. Unset it:
|
||||
> `$env:ELECTRON_RUN_AS_NODE=$null; npm run dev`
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
npm run docker:build
|
||||
npm run docker:run # http://localhost:8080
|
||||
```
|
||||
### Two things to know
|
||||
|
||||
Full build instructions (Windows installers, Linux AppImage/Flatpak) in [PACKAGING.md](PACKAGING.md).
|
||||
- **`npm run vendor` is mandatory** for every non-Docker target. Without it the page loads but every script 404s. The Docker build runs it inside the image.
|
||||
- **Secure context required.** WebCodecs (fast export) and IndexedDB (project saving) only exist on `http://localhost` or HTTPS. Over plain HTTP on a LAN address they silently disappear: export falls back to slow real-time capture, projects stop saving. Anything beyond localhost needs TLS — `deploy/Caddyfile` is the shortest path.
|
||||
|
||||
## Notes
|
||||
Full packaging details, WSL notes and troubleshooting: **[PACKAGING.md](PACKAGING.md)**.
|
||||
|
||||
- `ffmpeg.wasm` is vendored; `npm install` + `npm run vendor` are required
|
||||
- WebCodecs and IndexedDB only work in secure context (`http://localhost` OK, plain HTTP over LAN is not)
|
||||
- Serve over TLS for remote access
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
Everything is vendored at build time — no CDN is contacted at runtime.
|
||||
|
||||
### Editor core
|
||||
|
||||
| Library | Role |
|
||||
| --- | --- |
|
||||
| [Fabric.js](https://github.com/fabricjs/fabric.js) | canvas object model, selection, transforms |
|
||||
| [anime.js](https://github.com/juliangarnier/anime) | keyframe animation engine |
|
||||
| [lottie-web](https://github.com/airbnb/lottie-web) | Lottie / Bodymovin playback |
|
||||
| [ffmpeg.wasm](https://github.com/ffmpegwasm/ffmpeg.wasm) | MP4 / GIF export (single-threaded [core](https://github.com/ffmpegwasm/ffmpeg.wasm-core)) |
|
||||
| [webm-writer-js](https://github.com/thenickdude/webm-writer-js) | WEBM muxing for the WebCodecs exporter |
|
||||
|
||||
### UI
|
||||
|
||||
| Library | Role |
|
||||
| --- | --- |
|
||||
| [jQuery](https://github.com/jquery/jquery) | DOM plumbing |
|
||||
| [Pickr](https://github.com/simonwep/pickr) | color picker |
|
||||
| [Selection.js](https://github.com/simonwep/selection) | timeline box-selection |
|
||||
| [Sortable](https://github.com/SortableJS/Sortable) | layer reordering |
|
||||
| [jquery-nice-select](https://github.com/hernansartorio/jquery-nice-select) | styled `<select>` |
|
||||
| range-slider | timeline / property sliders (vendored, no upstream banner) |
|
||||
| [Localbase](https://github.com/dannyconnell/localbase) | IndexedDB project storage |
|
||||
| [webfontloader](https://github.com/typekit/webfontloader) | Google Fonts loading |
|
||||
| [Inter](https://github.com/rsms/inter) | UI typeface |
|
||||
|
||||
### Build & runtime
|
||||
|
||||
| Package | Role |
|
||||
| --- | --- |
|
||||
| [Electron](https://github.com/electron/electron) | desktop shell |
|
||||
| [electron-builder](https://github.com/electron-userland/electron-builder) | NSIS / AppImage / Flatpak packaging |
|
||||
| [static-web-server](https://github.com/static-web-server/static-web-server) | container HTTP server |
|
||||
|
||||
`scripts/server.cjs` (the `npm start` server) has zero dependencies — Node 18+ builtins only.
|
||||
|
||||
### Still online by design
|
||||
|
||||
[Google Fonts](https://fonts.google.com/) (font picker), [Pixabay](https://pixabay.com/) (image/video/audio search), [Unsplash](https://unsplash.com/) (empty-state samples). All three degrade quietly when offline; the editor itself starts and exports without a network.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) — same as upstream.
|
||||
|
||||
@@ -315,3 +315,56 @@ held the Playwright profile lock), so the following still needs a manual pass:
|
||||
console shows `Rendering n%` (frame-accurate path) and not
|
||||
`Falling back to the real-time encoder`
|
||||
4. Play the result: check audio is in sync and the video layer is not smeared
|
||||
|
||||
---
|
||||
|
||||
## Upstream open issues (alyssaxuu/motionity)
|
||||
|
||||
The 13 bug reports still open upstream were replayed in Chromium against this
|
||||
fork. Nine were already fixed by the audit above — mp4 export (#16), blank
|
||||
render (#25), image download (#10), audio on download (#30), filters not
|
||||
retained (#24), `EyeDropper is not defined` (#15, #18), the endless
|
||||
*Loading video…* (#28) and text selection while resizing the timeline (#5).
|
||||
GIF export (#21) works through the same ffmpeg path. #8 (video from the search
|
||||
tab) needs a Pixabay API key, so it stays untested; #29 and #4 carry no
|
||||
reproducer. The remaining three are fixed here:
|
||||
|
||||
- [x] **#23 — Border radius behaved like a percentage** — `src/js/functions.js`,
|
||||
`src/js/ui.js`, `src/js/events.js`, `src/js/database.js`
|
||||
fabric applies `rx`/`ry` before the object's scale, and shapes are resized by
|
||||
scaling, so a radius typed as 20 drew at 60 px on a rect scaled 3x while the
|
||||
panel — which read `rx` back raw — still said 20. A new `cornerRadius` property
|
||||
stores the pixel value the user asked for; `rx`/`ry` are derived from it
|
||||
(`setCornerRadius`) and re-derived on `object:scaling` / `object:modified`
|
||||
(`syncCornerRadius`), so the same number means the same pixels at any size.
|
||||
`cornerRadius` was added to the four serialised property lists, and
|
||||
`getCornerRadius` falls back to `rx * scaleX` for projects saved before it
|
||||
existed. Known limit, unchanged from before: if `scaleX` is *keyframed*, the
|
||||
drawn radius still varies over the animation.
|
||||
Verified: typed 20 px draws a 20 px corner at scale 1 and at scale 3, the
|
||||
panel keeps reading 20, and the value survives save, JSON round-trip and a
|
||||
page reload.
|
||||
|
||||
- [x] **#27 — Cropping a rotated image** — `src/js/functions.js`
|
||||
`crop()` compared canvas-space edges and covered only three of the four
|
||||
quadrants, so any rotated image cropped the wrong region — and nothing at all
|
||||
when the crop window was centred on it, since all three branches need a
|
||||
strictly positive offset. The guards added in the audit stopped the crash but
|
||||
left the geometry wrong. `crop()` now works in the image's own frame
|
||||
(`rotateVector`), clamps the region to the bitmap, and re-centres the object on
|
||||
the region it kept; the crop window is created with the image's `angle`, and
|
||||
the "expand back to the full bitmap" shift in `cropImage` is rotated the same
|
||||
way. The four-branch soup is gone.
|
||||
Verified: at 0°, 30° and 45°, the pixels under the crop window are byte
|
||||
identical before and after the crop, everything outside it is dropped, and
|
||||
crop mode exits cleanly.
|
||||
|
||||
- [x] **#1 — A modal did not block the timeline** — `src/js/functions.js`
|
||||
Modals are painted over the timeline but never took its pointer events, so the
|
||||
resize handle, the seekbar, keyframes and layer bars all still reacted to a
|
||||
drag behind the dialog. The onboarding modal from the report does not exist in
|
||||
the open-source build; the export, import/export and credits modals all had
|
||||
the bug. `dragTimeline`, `dragSeekBar`, `dragKeyframe` and `dragObjectProps`
|
||||
now bail while `.modal-open` is present.
|
||||
Verified: with the export modal open a real mouse drag on the handle leaves
|
||||
the timeline height untouched, and dragging works again once it closes.
|
||||
|
||||
+1
-7
@@ -1,12 +1,6 @@
|
||||
services:
|
||||
motionity:
|
||||
build:
|
||||
context: .
|
||||
# WITH_FFMPEG: "0" trims 18.5 MB by fetching the MP4/GIF encoder from
|
||||
# archive.org at runtime instead of shipping it.
|
||||
args:
|
||||
WITH_FFMPEG: "1"
|
||||
image: motionity:latest
|
||||
image: git.azuze.fr/kawa/motionity:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
+9
-1
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "motionity",
|
||||
"productName": "Motionity",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"desktopName": "app.motionity.desktop.desktop",
|
||||
"description": "Web-based motion graphics editor with keyframing, masking, filters and text animations",
|
||||
"license": "MIT",
|
||||
"author": "Kawa",
|
||||
@@ -10,17 +11,23 @@
|
||||
"scripts": {
|
||||
"vendor": "node scripts/vendor.mjs",
|
||||
"icons": "node scripts/make-icon.cjs",
|
||||
"favicon": "node scripts/make-favicon.cjs",
|
||||
"start": "node scripts/server.cjs",
|
||||
"dev": "electron .",
|
||||
"dist:win": "npm run vendor && npm run icons && electron-builder --win",
|
||||
"dist:appimage": "npm run vendor && npm run icons && electron-builder --linux AppImage",
|
||||
"dist:flatpak": "npm run vendor && npm run icons && electron-builder --linux flatpak",
|
||||
"dist:linux": "npm run vendor && npm run icons && electron-builder --linux AppImage flatpak",
|
||||
"dist:linux:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1 -Targets linux -UseWsl",
|
||||
"dist:win:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1 -Targets win -WinInWsl -KeepInWsl",
|
||||
"dist:win:wsl:portable": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1 -Targets win-portable -WinInWsl -KeepInWsl",
|
||||
"docker:build": "docker build -t motionity:latest .",
|
||||
"docker:run": "docker run --rm -p 8080:8080 motionity:latest",
|
||||
"release:build": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1",
|
||||
"release:binaries": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -BinariesOnly",
|
||||
"release:binaries:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -BinariesOnly win -WinInWsl -KeepInWsl",
|
||||
"release": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease",
|
||||
"release:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease -UseWsl -WinInWsl -KeepInWsl",
|
||||
"test": "node --test \"test/**/*.test.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -78,6 +85,7 @@
|
||||
"icon": "build/icon.png",
|
||||
"category": "Graphics",
|
||||
"synopsis": "Motion graphics editor",
|
||||
"syncDesktopName": true,
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"Name": "Motionity",
|
||||
|
||||
+584
-17
@@ -26,12 +26,63 @@
|
||||
404s.
|
||||
|
||||
Windows builds the .exe targets; AppImage and Flatpak need a Linux host or
|
||||
WSL (see PACKAGING.md). Nothing here cross-builds.
|
||||
WSL (see PACKAGING.md).
|
||||
|
||||
-UseWsl makes that split automatic: the .exe targets run on Windows, and the
|
||||
Linux ones are handed to a WSL distro over /mnt/c, against this same worktree.
|
||||
The npm install, the vendor step and the icon all happen once on the Windows
|
||||
side and the WSL build reads them through the mount, so the artifacts still
|
||||
land in dist/ and the checksum step below sees every one of them.
|
||||
|
||||
-WinInWsl sends the .exe targets to that distro as well. The reason to want
|
||||
that is not portability: a locked-down Windows machine's endpoint agent takes an
|
||||
exclusive lock on the freshly written unsigned Motionity.exe and the 7-Zip step
|
||||
that packs the installer payload then cannot read it (PACKAGING.md has the
|
||||
error). Building in the distro's ext4 filesystem is not visible to that agent.
|
||||
Signing is the one thing lost, and these builds are unsigned either way — the
|
||||
WSL build passes win.signExecutable=false, because the signtool.exe path runs
|
||||
under Wine and there is no certificate for it to use.
|
||||
|
||||
What each Windows target needs on the Linux side:
|
||||
|
||||
win-portable nothing. electron-builder's NSIS bundle ships a native Linux
|
||||
makensis, and rcedit was replaced by the resedit JS package, so
|
||||
the icon and version strings are written without Wine.
|
||||
win-nsis Wine, with 32-bit support, unavoidably. NSIS builds its
|
||||
uninstaller by *executing* the installer stub it has just linked
|
||||
(NsisTarget's computeScriptAndSignUninstaller) because the
|
||||
installer then embeds that uninstaller as a file
|
||||
(templates/nsis/include/installer.nsh: `File "/oname=..."
|
||||
"${UNINSTALLER_OUT_FILE}"`). There is no flag to skip it, and the
|
||||
stub is PE32/i386, so a 64-bit-only Wine cannot run it either.
|
||||
Assert-WslWine checks for a usable one before building.
|
||||
|
||||
Do NOT reach for toolsets.wine=1.0.1 here. That bundle exists and
|
||||
downloads cleanly, but the Linux build of it is unusable: it ships
|
||||
lib/wine/x86_64-unix only, with no *-windows PE builtin directory
|
||||
and no syswow64, so wine dies with
|
||||
wine: failed to load .../x86_64-unix/ntdll.dll error c0000135
|
||||
after the app has already been packaged. The distro's own wine is
|
||||
the working path.
|
||||
|
||||
"win" is both, in one packaging pass. -Targets win-portable is the way to get a
|
||||
usable .exe out of a machine where you cannot apt-install anything.
|
||||
|
||||
-KeepInWsl goes further and never copies the WSL-built artifacts back: they
|
||||
stay in the staging directory, and dist/ gets only SHA256SUMS.txt plus
|
||||
wsl-artifacts.json naming them. Use it when the agent quarantines the finished
|
||||
unsigned .exe on write, not just during the build — publish.ps1 reads that
|
||||
manifest and uploads those files to Gitea from inside the distro, so the .exe
|
||||
never lands on NTFS at all.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/build-release.ps1
|
||||
Build every target at v<package.json version>.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/build-release.ps1 -UseWsl
|
||||
Same, with AppImage and Flatpak built in the first non-docker WSL distro.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/build-release.ps1 -Targets win -Tag v1.1.0
|
||||
Windows installers only, named v1.1.0.
|
||||
@@ -40,15 +91,36 @@
|
||||
./scripts/build-release.ps1 -Targets win,linux-appimage
|
||||
Windows installers plus the Linux AppImage — no Flatpak.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/build-release.ps1 -Targets linux -UseWsl -WslDistro Ubuntu-24.04
|
||||
Both Linux bundles, in a named distro.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/build-release.ps1 -WinInWsl
|
||||
Every target built in WSL, artifacts copied back into dist/. Nothing unsigned
|
||||
is written to NTFS while the build runs.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/build-release.ps1 -WinInWsl -KeepInWsl
|
||||
Same, but the artifacts stay in the distro. dist/ gets SHA256SUMS.txt and
|
||||
wsl-artifacts.json; publish.ps1 uploads from there.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/build-release.ps1 -Targets win-portable -WinInWsl -KeepInWsl
|
||||
The portable .exe only, built and left in WSL. Needs no Wine and no root in the
|
||||
distro, and writes nothing to NTFS.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/build-release.ps1 -SkipVendor -SkipDeps
|
||||
Reuse src/vendor/ and node_modules as they are — the fast rebuild.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Platforms to package. "win" is NSIS + portable; "linux-appimage" and
|
||||
# "linux-flatpak" are the two Linux bundles, and "linux" is shorthand for both.
|
||||
[ValidateSet("win", "linux", "linux-appimage", "linux-flatpak")]
|
||||
# Platforms to package. "win" is NSIS + portable in one pass, and "win-nsis" /
|
||||
# "win-portable" are those two separately — worth having because only the NSIS
|
||||
# one needs Wine when built in WSL. "linux-appimage" and "linux-flatpak" are the
|
||||
# two Linux bundles, and "linux" is shorthand for both.
|
||||
[ValidateSet("win", "win-nsis", "win-portable", "linux", "linux-appimage", "linux-flatpak")]
|
||||
[string[]]$Targets = @("win", "linux"),
|
||||
|
||||
# Version used in the artifact names. Defaults to v<package.json version>,
|
||||
@@ -62,6 +134,22 @@ param(
|
||||
# Skip the npm install even when node_modules is missing.
|
||||
[switch]$SkipDeps,
|
||||
|
||||
# Build the Linux targets inside WSL instead of warning that they cannot be
|
||||
# built on Windows. Ignored on a Linux host, where they build natively.
|
||||
[switch]$UseWsl,
|
||||
|
||||
# WSL distro to build in. Defaults to the first installed one that is not
|
||||
# docker-desktop.
|
||||
[string]$WslDistro,
|
||||
|
||||
# Send the "win" target to WSL too. Implies -UseWsl. Nothing unsigned is then
|
||||
# written to NTFS during the build, which is what the endpoint agent reacts to.
|
||||
[switch]$WinInWsl,
|
||||
|
||||
# Leave the WSL-built artifacts in the distro instead of copying them into
|
||||
# dist/. dist/ still gets SHA256SUMS.txt and wsl-artifacts.json.
|
||||
[switch]$KeepInWsl,
|
||||
|
||||
# Remove dist/ before building.
|
||||
[switch]$Clean
|
||||
)
|
||||
@@ -81,6 +169,54 @@ function Invoke-Checked {
|
||||
}
|
||||
}
|
||||
|
||||
function Set-Utf8Locale {
|
||||
<#
|
||||
electron-builder's native Linux makensis converts its own argv through the
|
||||
locale charset before it parses anything, and cannot do it under a non-UTF-8
|
||||
locale:
|
||||
|
||||
⨯ .../nsis-3.0.4.1/linux/makensis process failed ERR_ELECTRON_BUILDER_CANNOT_EXECUTE
|
||||
Exit code: 84
|
||||
Output: FATAL: main argv conversion failed!
|
||||
|
||||
A minimal Debian box — a CI runner in particular — defaults to C/POSIX,
|
||||
which is exactly that case. Setting it here rather than only in the CI
|
||||
workflow's env: block means the electron-builder child inherits it however
|
||||
this script was started: a runner that drops step env, a bare
|
||||
`pwsh ./scripts/build-release.ps1` over ssh, cron.
|
||||
|
||||
Which locales exist is a property of the host, so `locale -a` is consulted
|
||||
rather than guessed at: C.UTF-8 is a glibc built-in only from 2.35
|
||||
(Debian 12+), and en_US.UTF-8 exists only once someone has run locale-gen.
|
||||
#>
|
||||
$current = if ($env:LC_ALL) { $env:LC_ALL } else { $env:LANG }
|
||||
if ($current -match '(?i)\.utf-?8$') { return }
|
||||
|
||||
$available = @()
|
||||
try { $available = @(& locale -a 2>$null) } catch { }
|
||||
|
||||
# Debian spells them "C.utf8" / "en_US.utf8" in `locale -a` but wants
|
||||
# "en_US.UTF-8 UTF-8" in /etc/locale.gen; both spellings are accepted by
|
||||
# setlocale, so match either and fall back to any UTF-8 locale at all.
|
||||
$pick = @("C.UTF-8", "C.utf8", "en_US.UTF-8", "en_US.utf8") |
|
||||
Where-Object { $available -contains $_ } | Select-Object -First 1
|
||||
if (-not $pick) {
|
||||
$pick = $available | Where-Object { $_ -match '(?i)\.utf-?8$' } | Select-Object -First 1
|
||||
}
|
||||
if (-not $pick) {
|
||||
throw ("no UTF-8 locale on this host, and makensis aborts with 'FATAL: main argv " +
|
||||
"conversion failed!' without one. Generate one:`n" +
|
||||
" sudo apt-get install -y locales`n" +
|
||||
" echo 'en_US.UTF-8 UTF-8' | sudo tee -a /etc/locale.gen`n" +
|
||||
" sudo locale-gen`n" +
|
||||
"then re-run. 'locale -a' listed: $($available -join ', ')")
|
||||
}
|
||||
|
||||
Write-Host " locale: LC_ALL=$pick (was '$current'; makensis needs UTF-8)" -ForegroundColor DarkGray
|
||||
$env:LC_ALL = $pick
|
||||
$env:LANG = $pick
|
||||
}
|
||||
|
||||
function Get-ArtifactName {
|
||||
<#
|
||||
electron-builder rejects an artifactName that has no ${ext} macro, and
|
||||
@@ -91,6 +227,256 @@ function Get-ArtifactName {
|
||||
return $Stem + '.${ext}'
|
||||
}
|
||||
|
||||
# --- WSL plumbing -------------------------------------------------------------
|
||||
|
||||
function ConvertTo-BashScript {
|
||||
<#
|
||||
Strip CR. This file is stored with CRLF line endings, so a multi-line
|
||||
here-string handed to bash arrives with a \r on every line and bash treats it
|
||||
as part of the last token:
|
||||
|
||||
set: - : invalid option
|
||||
cd: $'/home/kawa/.cache/motionity-build\r': No such file or directory
|
||||
|
||||
Single-line commands are unaffected, which is exactly why this is easy to
|
||||
miss until a script gains a second line.
|
||||
#>
|
||||
param([Parameter(Mandatory)][AllowEmptyString()][string]$Script)
|
||||
return $Script -replace "`r", ""
|
||||
}
|
||||
|
||||
<#
|
||||
A second trap in the same area, worth stating once: never combine `set -e` with an
|
||||
explicit `exit 0` under `bash -lc`.
|
||||
|
||||
wsl -d Ubuntu -e bash -lc 'set -e; exit 0' -> 1
|
||||
wsl -d Ubuntu -e bash -lc 'exit 0' -> 0
|
||||
|
||||
A login shell sources ~/.bash_logout on the way out, and Ubuntu's default one ends
|
||||
in `[ -x /usr/bin/clear_console ] && /usr/bin/clear_console -q`, which fails with
|
||||
no tty attached. With errexit set, that failure becomes the shell's exit status,
|
||||
and a script that did its job reports failure. -l is not negotiable (node from nvm
|
||||
or fnm is only on the login PATH), so the scripts below use `set -u` and check the
|
||||
commands that matter by hand.
|
||||
#>
|
||||
|
||||
function Invoke-Wsl {
|
||||
param([Parameter(Mandatory)][string]$Distro, [Parameter(Mandatory)][string]$Command)
|
||||
Write-Host " > wsl -d $Distro -- $Command" -ForegroundColor DarkGray
|
||||
# bash -lc, so PATH matches an interactive shell: node installed through nvm
|
||||
# or fnm is not on the default non-login PATH.
|
||||
& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $Command)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "the WSL build in '$Distro' failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-WslCommand {
|
||||
param([Parameter(Mandatory)][string]$Distro, [Parameter(Mandatory)][string]$Command)
|
||||
& wsl.exe -d $Distro -e bash -lc $Command *> $null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
function Invoke-WslCapture {
|
||||
<#
|
||||
Like Invoke-Wsl, but returns the distro's stdout instead of echoing the
|
||||
command. For the small queries (a path, a checksum listing) whose output is
|
||||
the point and whose command line is noise.
|
||||
|
||||
Positional arguments go to bash as $1..$n; $0 is a label. Passing them this
|
||||
way rather than interpolating into $Command keeps quoting out of it.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Distro,
|
||||
[Parameter(Mandatory)][string]$Command,
|
||||
[string[]]$ScriptArgs = @()
|
||||
)
|
||||
# 2>&1: stderr is merged in so a failure can be reported with what the distro
|
||||
# actually said. Callers filter the lines they want, so the noise is harmless.
|
||||
$out = @(& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $Command) "motionity-build" @ScriptArgs 2>&1 |
|
||||
ForEach-Object { $_.ToString().Replace("`0", "") })
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "a query in WSL distro '$Distro' failed with exit code ${LASTEXITCODE}: $($out -join ' | ')"
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
function ConvertTo-BashArg {
|
||||
<#
|
||||
Single quotes, always. The artifactName arguments carry a literal ${ext}
|
||||
for electron-builder to expand, and bash would expand it to nothing first
|
||||
inside double quotes; the repo path has a space in it.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$Value)
|
||||
return "'" + $Value.Replace("'", "'\''") + "'"
|
||||
}
|
||||
|
||||
function Get-WslDistro {
|
||||
<#
|
||||
docker-desktop is excluded on purpose. It is Docker Desktop's own LinuxKit
|
||||
VM: no apt, no user home to install the flatpak runtimes into — and it is
|
||||
usually the *default* distro, so picking blind would land there.
|
||||
#>
|
||||
param([string]$Requested)
|
||||
|
||||
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
|
||||
throw "-UseWsl needs wsl.exe on PATH. Install a distro with 'wsl --install -d Ubuntu' (PACKAGING.md has the rest of the setup)."
|
||||
}
|
||||
|
||||
# wsl.exe writes its own listings as UTF-16LE, which PowerShell 5.1 reads back
|
||||
# as NUL-interleaved text. WSL_UTF8 fixes it at the source; the -replace is the
|
||||
# fallback for WSL older than 0.64.
|
||||
$previousUtf8 = $env:WSL_UTF8
|
||||
$env:WSL_UTF8 = "1"
|
||||
try { $listed = (& wsl.exe --list --quiet) -join "`n" }
|
||||
finally { $env:WSL_UTF8 = $previousUtf8 }
|
||||
|
||||
$distros = @(($listed -replace "`0", "") -split "`r?`n" |
|
||||
ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
|
||||
if ($Requested) {
|
||||
if ($distros -notcontains $Requested) {
|
||||
throw "WSL distro '$Requested' is not installed. Installed: $($distros -join ', ')."
|
||||
}
|
||||
return $Requested
|
||||
}
|
||||
|
||||
$usable = @($distros | Where-Object { $_ -notlike "docker-desktop*" })
|
||||
if (-not $usable.Count) {
|
||||
throw "no WSL distro that can build here (installed: $($distros -join ', ')). Run 'wsl --install -d Ubuntu', then the package setup in PACKAGING.md."
|
||||
}
|
||||
return $usable[0]
|
||||
}
|
||||
|
||||
function Get-WslPath {
|
||||
param([Parameter(Mandatory)][string]$Distro, [Parameter(Mandatory)][string]$WindowsPath)
|
||||
# -e wslpath rather than a shell: the backslashes and the space in the repo
|
||||
# path then reach wslpath as one literal argv entry, unquoted and unmangled.
|
||||
$out = (& wsl.exe -d $Distro -e wslpath -a -u $WindowsPath)
|
||||
$path = (@($out) -join "").Replace("`0", "").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $path) {
|
||||
throw "wslpath failed in '$Distro' for '$WindowsPath' — is the Windows drive mounted in that distro?"
|
||||
}
|
||||
return $path
|
||||
}
|
||||
|
||||
function Get-WslStageDir {
|
||||
<#
|
||||
Where the Linux build actually happens. It cannot be dist/ on /mnt/c:
|
||||
electron-builder chmods every file it unpacks out of the Electron zip, and
|
||||
drvfs answers chmod with EPERM unless /mnt/c was mounted with the metadata
|
||||
option — a global change to the distro needing sudo and a wsl --shutdown.
|
||||
|
||||
⨯ EPERM: operation not permitted, chmod '.../linux-unpacked.tmp/locales/de.pak'
|
||||
|
||||
Staging in the distro's own filesystem and copying the finished bundles back
|
||||
needs none of that, and is faster besides. Reading src/ over the mount is
|
||||
still fine — nothing chmods the input.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$Distro)
|
||||
# printf, not echo: no trailing newline to trim off the path.
|
||||
$out = (& wsl.exe -d $Distro -e bash -lc 'printf %s "${XDG_CACHE_HOME:-$HOME/.cache}/motionity-build"')
|
||||
$path = (@($out) -join "").Replace("`0", "").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or $path -notlike "/*") {
|
||||
throw "could not resolve a staging directory in '$Distro' (got '$path')."
|
||||
}
|
||||
return $path
|
||||
}
|
||||
|
||||
function Assert-WslWine {
|
||||
<#
|
||||
The NSIS target needs a wine in the distro that can run a 32-bit PE, because
|
||||
the installer stub it has to execute is PE32/i386 even for an x64 app. Checked
|
||||
up front: without it the build dies after packaging ~200 MB, with an error
|
||||
that names ntdll rather than the missing package.
|
||||
|
||||
The i386 check is a warning, not an error. The directory list below covers the
|
||||
usual layouts but cannot cover every distro or a hand-built wine, and a false
|
||||
negative must not block a build that would have worked.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$Distro)
|
||||
|
||||
$probe = @'
|
||||
set -u
|
||||
command -v wine >/dev/null 2>&1 || exit 10
|
||||
wine --version >/dev/null 2>&1 || exit 11
|
||||
for d in /usr/lib/wine /usr/lib64/wine /usr/lib/x86_64-linux-gnu/wine /usr/local/lib/wine /opt/wine*/lib/wine; do
|
||||
[ -d "$d/i386-windows" ] && exit 0
|
||||
done
|
||||
exit 12
|
||||
'@
|
||||
& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $probe) "motionity-build" *> $null
|
||||
$code = $LASTEXITCODE
|
||||
|
||||
$installHint = @"
|
||||
Inside the distro (the i386 architecture is what provides the 32-bit loader):
|
||||
sudo dpkg --add-architecture i386
|
||||
sudo apt update
|
||||
sudo apt install -y wine
|
||||
Or skip NSIS entirely — the portable .exe runs no PE, so it needs neither Wine nor root:
|
||||
./scripts/build-release.ps1 -Targets win-portable -WinInWsl -KeepInWsl
|
||||
"@
|
||||
|
||||
switch ($code) {
|
||||
10 { throw "no wine in WSL distro '$Distro', and the NSIS uninstaller cannot be built without one.`n$installHint" }
|
||||
11 { throw "wine is installed in '$Distro' but will not run ('wine --version' failed). A broken or partial install cannot build the NSIS uninstaller.`n$installHint" }
|
||||
12 { Write-Warning "wine in '$Distro' looks 64-bit only (no i386-windows directory found). The NSIS installer stub is PE32/i386, so the build will likely fail at 'building target=nsis'. If it does:`n$installHint" }
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-WslBuildEnv {
|
||||
<#
|
||||
Fail before the build rather than during it. electron-builder's own error
|
||||
for a missing flatpak ref is a bare flatpak-builder exit code that names
|
||||
neither the ref nor the remote.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Distro,
|
||||
[Parameter(Mandatory)][string[]]$WslTargets,
|
||||
[Parameter(Mandatory)]$Pkg
|
||||
)
|
||||
|
||||
if (-not (Test-WslCommand $Distro 'command -v node')) {
|
||||
throw "no node in WSL distro '$Distro'. Inside it: sudo apt update && sudo apt install -y nodejs npm"
|
||||
}
|
||||
|
||||
# NSIS links the installer and then *runs* it with BUILD_UNINSTALLER defined to
|
||||
# get the uninstaller out, so building it on Linux means executing a Windows PE.
|
||||
if (@($WslTargets | Where-Object { $_ -eq "win" -or $_ -eq "win-nsis" }).Count) {
|
||||
Assert-WslWine -Distro $Distro
|
||||
}
|
||||
|
||||
# AppImage and the Windows targets need nothing else: electron-builder downloads
|
||||
# its own appimage tooling and writes the squashfs itself, and it downloads the
|
||||
# NSIS bundle whose makensis is a native Linux binary. libfuse2 is only needed to
|
||||
# *run* an AppImage, which is not this script's job.
|
||||
if ($WslTargets -notcontains "linux-flatpak") { return }
|
||||
|
||||
if (-not (Test-WslCommand $Distro 'command -v flatpak-builder')) {
|
||||
throw "no flatpak-builder in WSL distro '$Distro'. Inside it: sudo apt install -y flatpak flatpak-builder elfutils"
|
||||
}
|
||||
|
||||
$runtimeVersion = $Pkg.build.flatpak.runtimeVersion
|
||||
$baseVersion = $Pkg.build.flatpak.baseVersion
|
||||
if (-not $runtimeVersion) { $runtimeVersion = "23.08" }
|
||||
if (-not $baseVersion) { $baseVersion = $runtimeVersion }
|
||||
|
||||
$refs = @(
|
||||
"org.freedesktop.Platform//$runtimeVersion",
|
||||
"org.freedesktop.Sdk//$runtimeVersion",
|
||||
"org.electronjs.Electron2.BaseApp//$baseVersion"
|
||||
)
|
||||
$missing = @($refs | Where-Object { -not (Test-WslCommand $Distro "flatpak info $_") })
|
||||
if ($missing.Count) {
|
||||
throw @"
|
||||
flatpak refs missing in '$Distro': $($missing -join ', ')
|
||||
Inside the distro:
|
||||
flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install --user -y flathub $($refs -join ' ')
|
||||
"@
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
@@ -110,8 +496,9 @@ try {
|
||||
Write-Host " output : $distDir"
|
||||
Write-Host ""
|
||||
|
||||
# Expand "linux" to its two concrete bundles and drop duplicates, so the rest
|
||||
# of the script only ever deals with win / linux-appimage / linux-flatpak.
|
||||
# Expand "linux" to its two concrete bundles and drop duplicates. "win" is left
|
||||
# alone rather than expanded the same way: electron-builder builds nsis and
|
||||
# portable from one packaging pass, and splitting it would unpack Electron twice.
|
||||
$resolvedTargets = @()
|
||||
foreach ($t in $Targets) {
|
||||
if ($t -eq "linux") { $resolvedTargets += "linux-appimage", "linux-flatpak" }
|
||||
@@ -119,11 +506,46 @@ try {
|
||||
}
|
||||
$resolvedTargets = @($resolvedTargets | Select-Object -Unique)
|
||||
|
||||
# electron-builder produces AppImage and Flatpak with Linux-only tooling
|
||||
# (appimagetool, flatpak-builder). Warned rather than blocked: the same script
|
||||
# runs under pwsh on a Linux box or in WSL, which is where that target belongs.
|
||||
if (($resolvedTargets -like "linux-*") -and $env:OS -eq "Windows_NT") {
|
||||
Write-Warning "the Linux targets need a Linux host or WSL — electron-builder cannot produce AppImage or Flatpak on Windows (PACKAGING.md has the WSL setup)."
|
||||
# electron-builder produces AppImage and Flatpak with Linux-only tooling (its
|
||||
# downloaded appimage bundle, and flatpak-builder). -UseWsl hands those two
|
||||
# targets to a WSL distro; without it they stay a warning rather than an error,
|
||||
# because the other right answer is running this whole script under pwsh on a
|
||||
# Linux box, where they build natively.
|
||||
#
|
||||
# -WinInWsl adds "win" to that set. Not because Windows cannot build it, but
|
||||
# because a locked-down Windows machine's endpoint agent interferes with the
|
||||
# unsigned .exe while the installer is being packed (see the header).
|
||||
$onWindows = ($env:OS -eq "Windows_NT")
|
||||
$wantWsl = $UseWsl -or $WinInWsl
|
||||
$wslTargets = @($resolvedTargets | Where-Object { $_ -like "linux-*" -or ($WinInWsl -and $_ -like "win*") })
|
||||
$useWslHere = $onWindows -and $wantWsl -and [bool]$wslTargets.Count
|
||||
|
||||
$linuxTargets = @($resolvedTargets | Where-Object { $_ -like "linux-*" })
|
||||
if ($onWindows -and $linuxTargets.Count -and -not $wantWsl) {
|
||||
Write-Warning "the Linux targets need a Linux host or WSL — electron-builder cannot produce AppImage or Flatpak on Windows. Add -UseWsl to build them in a WSL distro (PACKAGING.md has the setup)."
|
||||
}
|
||||
if ($wantWsl -and -not $onWindows) {
|
||||
Write-Warning "-UseWsl/-WinInWsl ignored: this is already a Linux host, so every target builds natively."
|
||||
}
|
||||
if ($wantWsl -and $onWindows -and -not $wslTargets.Count) {
|
||||
Write-Warning "-UseWsl ignored: no target was selected for WSL (-Targets $($Targets -join ', ')). Add -WinInWsl to send the Windows targets there too."
|
||||
}
|
||||
if ($KeepInWsl -and -not $useWslHere) {
|
||||
Write-Warning "-KeepInWsl ignored: nothing is being built in WSL."
|
||||
}
|
||||
|
||||
$wslRepo = $null
|
||||
$wslStage = $null
|
||||
if ($useWslHere) {
|
||||
$WslDistro = Get-WslDistro -Requested $WslDistro
|
||||
$wslRepo = Get-WslPath -Distro $WslDistro -WindowsPath $repoRoot
|
||||
$wslStage = Get-WslStageDir -Distro $WslDistro
|
||||
Write-Host "Targets going to WSL: $($wslTargets -join ', ')" -ForegroundColor Cyan
|
||||
Write-Host " distro : $WslDistro"
|
||||
Write-Host " worktree: $wslRepo"
|
||||
Write-Host " staging : $wslStage$(if ($KeepInWsl) { ' (left there — -KeepInWsl)' } else { ' (copied back into dist/)' })"
|
||||
Assert-WslBuildEnv -Distro $WslDistro -WslTargets $wslTargets -Pkg $pkg
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($Clean) {
|
||||
@@ -179,6 +601,23 @@ try {
|
||||
throw 'electron-builder not found in node_modules — run "npm ci" (or drop -SkipDeps).'
|
||||
}
|
||||
|
||||
# Clear this tag's previous bundles from the staging directory once, before the
|
||||
# loop — not per target. Per target, the glob would delete the artifacts an
|
||||
# earlier target had just staged, which only went unnoticed while every target
|
||||
# copied its output back immediately.
|
||||
if ($useWslHere) {
|
||||
Invoke-Wsl -Distro $WslDistro -Command (
|
||||
"mkdir -p $(ConvertTo-BashArg $wslStage) && rm -f $(ConvertTo-BashArg $wslStage)/$prefix-*")
|
||||
}
|
||||
|
||||
# Both Windows targets go through makensis (portable is an NSIS build too), and
|
||||
# on a Linux host that is a native binary this process's locale reaches. The WSL
|
||||
# path is not covered: wsl.exe does not carry $env:LC_ALL into the distro, and a
|
||||
# distro someone installed interactively has a UTF-8 locale already.
|
||||
if (-not $onWindows -and @($resolvedTargets | Where-Object { $_ -like "win*" }).Count) {
|
||||
Set-Utf8Locale
|
||||
}
|
||||
|
||||
foreach ($target in $resolvedTargets) {
|
||||
Write-Host "Packaging $target..." -ForegroundColor Cyan
|
||||
|
||||
@@ -193,6 +632,18 @@ try {
|
||||
"-c.portable.artifactName=$(Get-ArtifactName "$prefix-win-x64-portable")"
|
||||
)
|
||||
}
|
||||
"win-nsis" {
|
||||
$builderArgs = @(
|
||||
"--win", "nsis", "--publish", "never",
|
||||
"-c.nsis.artifactName=$(Get-ArtifactName "$prefix-win-x64-setup")"
|
||||
)
|
||||
}
|
||||
"win-portable" {
|
||||
$builderArgs = @(
|
||||
"--win", "portable", "--publish", "never",
|
||||
"-c.portable.artifactName=$(Get-ArtifactName "$prefix-win-x64-portable")"
|
||||
)
|
||||
}
|
||||
"linux-appimage" {
|
||||
$builderArgs = @(
|
||||
"--linux", "AppImage", "--publish", "never",
|
||||
@@ -207,7 +658,51 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Checked $builder $builderArgs
|
||||
# Two overrides that only apply to a Windows target built on Linux:
|
||||
#
|
||||
# signExecutable=false electron-builder still walks the signing path with no
|
||||
# certificate configured, and on Linux that path shells
|
||||
# out to signtool.exe under Wine before it discovers
|
||||
# there is nothing to sign — `spawn wine ENOENT`, build
|
||||
# over. false skips signing while still applying the
|
||||
# icon and version strings (signAndEditExecutable=false
|
||||
# would drop those too, which is not what is wanted).
|
||||
# toolsets.wine is deliberately NOT set: leaving it unset is what makes
|
||||
# electron-builder use the distro's own `wine` on Linux, and the 1.0.1 bundle
|
||||
# it would otherwise download is unusable there (see the header).
|
||||
#
|
||||
# Passed here rather than put in package.json so a native Windows build keeps
|
||||
# behaving exactly as it did. The same gap exists on a genuine Linux host (no
|
||||
# WSL involved) building "win" directly — e.g. CI — so this checks $onWindows
|
||||
# rather than $useWslHere.
|
||||
if (-not $onWindows -and $target -like "win*") {
|
||||
$builderArgs += "-c.win.signExecutable=false"
|
||||
}
|
||||
|
||||
if ($useWslHere -and $wslTargets -contains $target) {
|
||||
# `node cli.js` rather than node_modules/.bin/electron-builder: the
|
||||
# extensionless shim npm writes on Windows is a sh script, and whether
|
||||
# it is executable across the mount depends on the drvfs options.
|
||||
#
|
||||
# The output goes to the distro's filesystem (see Get-WslStageDir) and
|
||||
# the bundles are copied back afterwards. `cp -f`, never `cp -p`:
|
||||
# preserving modes means chmod, which is the EPERM this avoids.
|
||||
#
|
||||
# With -KeepInWsl there is no copy back at all: the point is that no
|
||||
# unsigned .exe is ever written to NTFS, and a `cp` into dist/ is exactly
|
||||
# the write the endpoint agent would quarantine.
|
||||
$quotedArgs = @($builderArgs | ForEach-Object { ConvertTo-BashArg $_ }) -join " "
|
||||
$stage = ConvertTo-BashArg $wslStage
|
||||
$wslCommand = "cd $(ConvertTo-BashArg $wslRepo)" +
|
||||
" && USE_HARD_LINKS=false node node_modules/electron-builder/cli.js $quotedArgs $(ConvertTo-BashArg "-c.directories.output=$wslStage")"
|
||||
if (-not $KeepInWsl) {
|
||||
$wslCommand += " && cp -f $stage/$prefix-* $(ConvertTo-BashArg "$wslRepo/dist")/"
|
||||
}
|
||||
Invoke-Wsl -Distro $WslDistro -Command $wslCommand
|
||||
}
|
||||
else {
|
||||
Invoke-Checked $builder $builderArgs
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
@@ -220,16 +715,80 @@ try {
|
||||
} | Remove-Item -Force
|
||||
|
||||
$built = @(Get-ChildItem $distDir -Filter "$prefix-*" -File | Sort-Object Name)
|
||||
if (-not $built.Count) {
|
||||
throw "electron-builder reported success but no $prefix-* artifact landed in $distDir."
|
||||
|
||||
# --- Artifacts left in the distro ------------------------------------------
|
||||
# With -KeepInWsl the bundles never crossed the mount, so their names and hashes
|
||||
# have to come from the distro. The same intermediates are cleared there first:
|
||||
# `<installer>.exe.blockmap` matches the $prefix-* glob too, and would otherwise
|
||||
# be listed as a release artifact.
|
||||
$manifestPath = Join-Path $distDir "wsl-artifacts.json"
|
||||
$stagedLines = @()
|
||||
if ($useWslHere -and $KeepInWsl) {
|
||||
$listScript = @'
|
||||
set -u
|
||||
cd "$1" || { echo "staging directory $1 is gone" >&2; exit 1; }
|
||||
rm -f ./*.blockmap ./*.nsis.7z ./latest*.yml ./builder-debug.yml
|
||||
shopt -s nullglob
|
||||
files=("$2"-*)
|
||||
if [ ${#files[@]} -gt 0 ]; then sha256sum "${files[@]}"; fi
|
||||
'@
|
||||
$stagedLines = @(Invoke-WslCapture -Distro $WslDistro -Command $listScript `
|
||||
-ScriptArgs @($wslStage, $prefix) | Where-Object { $_ -match '^[0-9a-f]{64}\s\s\S' })
|
||||
}
|
||||
$stagedNames = @($stagedLines | ForEach-Object { ($_ -split '\s\s', 2)[1] } | Sort-Object)
|
||||
|
||||
# A name built in WSL this run wins over a same-named file sitting in dist/ from
|
||||
# an earlier native build. That leftover is stale by definition, and it is also
|
||||
# the likeliest file on the machine to be locked or quarantined — which is the
|
||||
# whole reason for building in the distro.
|
||||
if ($stagedNames.Count) {
|
||||
$shadowed = @($built | Where-Object { $stagedNames -contains $_.Name })
|
||||
if ($shadowed.Count) {
|
||||
Write-Warning "ignoring $($shadowed.Count) stale file(s) in dist/ superseded by this run's WSL build: $(($shadowed | ForEach-Object Name) -join ', '). Delete them (or pass -Clean) to keep dist/ honest."
|
||||
$built = @($built | Where-Object { $stagedNames -notcontains $_.Name })
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $built.Count -and -not $stagedNames.Count) {
|
||||
throw "electron-builder reported success but no $prefix-* artifact landed in $(if ($KeepInWsl) { "$distDir or $wslStage" } else { $distDir })."
|
||||
}
|
||||
|
||||
# publish.ps1 globs dist/ for what to upload, so the files that stayed behind
|
||||
# need an explicit hand-off. A stale manifest from a previous -KeepInWsl run
|
||||
# would make it upload artifacts this build did not produce, so it is removed
|
||||
# whenever this run left nothing in the distro.
|
||||
if ($stagedNames.Count) {
|
||||
$manifest = [ordered]@{
|
||||
tag = $Tag
|
||||
distro = $WslDistro
|
||||
stageDir = $wslStage
|
||||
files = @($stagedNames)
|
||||
}
|
||||
[System.IO.File]::WriteAllText($manifestPath,
|
||||
($manifest | ConvertTo-Json -Depth 3), [System.Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
elseif (Test-Path $manifestPath) {
|
||||
Remove-Item -Force $manifestPath
|
||||
}
|
||||
|
||||
# --- Checksums ------------------------------------------------------------
|
||||
Write-Host "Writing checksums..." -ForegroundColor Cyan
|
||||
$sumsPath = Join-Path $distDir "SHA256SUMS.txt"
|
||||
$lines = foreach ($f in $built) {
|
||||
"$((Get-FileHash -Algorithm SHA256 $f.FullName).Hash.ToLower()) $($f.Name)"
|
||||
}
|
||||
# sha256sum's own output format is already `<hash> <name>`, so the lines from the
|
||||
# distro go in verbatim and the two sets sort together by file name.
|
||||
$lines = @(
|
||||
@(foreach ($f in $built) {
|
||||
try {
|
||||
"$((Get-FileHash -Algorithm SHA256 $f.FullName).Hash.ToLower()) $($f.Name)"
|
||||
}
|
||||
catch {
|
||||
# An artifact in dist/ that cannot even be read is the endpoint agent
|
||||
# again, and hashing is not the step to paper over it: publish.ps1
|
||||
# would try to upload the same unreadable file next.
|
||||
throw "cannot read $($f.FullName) to hash it ($($_.Exception.Message.Trim())). On a machine whose security agent locks unsigned executables, build with -WinInWsl -KeepInWsl and delete the leftovers in dist/ (or pass -Clean)."
|
||||
}
|
||||
}) + $stagedLines
|
||||
) | Sort-Object { ($_ -split '\s\s', 2)[1] }
|
||||
# ASCII with LF: a BOM or CRLF makes `sha256sum -c` reject the first line.
|
||||
[System.IO.File]::WriteAllText($sumsPath, ($lines -join "`n") + "`n", [System.Text.ASCIIEncoding]::new())
|
||||
Write-Host ""
|
||||
@@ -238,6 +797,14 @@ try {
|
||||
foreach ($f in $built) {
|
||||
Write-Host " $($f.FullName) ($([math]::Round($f.Length / 1MB, 1)) MB)" -ForegroundColor Green
|
||||
}
|
||||
if ($stagedNames.Count) {
|
||||
Write-Host " in WSL ($WslDistro), not copied to NTFS:" -ForegroundColor Green
|
||||
foreach ($n in $stagedNames) {
|
||||
Write-Host " $wslStage/$n" -ForegroundColor Green
|
||||
}
|
||||
Write-Host " reachable from Windows as \\wsl.localhost\$WslDistro$(($wslStage -replace '/', '\'))\" -ForegroundColor DarkGray
|
||||
Write-Host " publish.ps1 uploads these from inside the distro (dist/wsl-artifacts.json)" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Host " $sumsPath" -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
// Renders src/favicon.ico (32x32) from the same geometry as
|
||||
// src/assets/logo.svg: three coloured rounded bars on the app's dark background.
|
||||
//
|
||||
// Hand-rolled so packaging needs no image toolchain (no ImageMagick, no sharp).
|
||||
|
||||
const { writeFileSync } = require('node:fs');
|
||||
const { resolve } = require('node:path');
|
||||
|
||||
const SIZE = 32;
|
||||
const SS = 8; // supersampling factor, for antialiased edges at small size
|
||||
const BG = [0x14, 0x16, 0x29];
|
||||
const BG_RADIUS = 6; // squircle-ish corner on the icon plate
|
||||
|
||||
// viewBox="0 0 157 183" in src/assets/logo.svg
|
||||
const VIEW = { w: 157, h: 183 };
|
||||
const BARS = [
|
||||
{ x: 79, y: 0, w: 79, h: 47, r: 23.5, fg: [0x75, 0x4b, 0xff] },
|
||||
{ x: 0, y: 68, w: 157, h: 47, r: 23.5, fg: [0x60, 0x2f, 0xff] },
|
||||
{ x: 0, y: 136, w: 79, h: 47, r: 23.5, fg: [0x06, 0x93, 0xc0] },
|
||||
];
|
||||
|
||||
function insideRoundedRect(px, py, { x, y, w, h, r }) {
|
||||
const dx = Math.max(x + r - px, 0, px - (x + w - r));
|
||||
const dy = Math.max(y + r - py, 0, py - (y + h - r));
|
||||
return dx * dx + dy * dy <= r * r;
|
||||
}
|
||||
|
||||
// The logo occupies 62% of the plate, centred.
|
||||
const scale = (SIZE * 0.62) / VIEW.h;
|
||||
const offsetX = (SIZE - VIEW.w * scale) / 2;
|
||||
const offsetY = (SIZE - VIEW.h * scale) / 2;
|
||||
|
||||
const plate = { x: 0, y: 0, w: SIZE, h: SIZE, r: BG_RADIUS };
|
||||
|
||||
// BGRA pixels, row-major, top-to-bottom.
|
||||
const pixels = Buffer.alloc(SIZE * SIZE * 4);
|
||||
for (let py = 0; py < SIZE; py++) {
|
||||
for (let px = 0; px < SIZE; px++) {
|
||||
let plateHits = 0;
|
||||
const colorSum = [0, 0, 0];
|
||||
for (let sy = 0; sy < SS; sy++) {
|
||||
for (let sx = 0; sx < SS; sx++) {
|
||||
const fx = px + (sx + 0.5) / SS;
|
||||
const fy = py + (sy + 0.5) / SS;
|
||||
if (!insideRoundedRect(fx, fy, plate)) continue;
|
||||
plateHits++;
|
||||
const lx = (fx - offsetX) / scale;
|
||||
const ly = (fy - offsetY) / scale;
|
||||
const bar = BARS.find((b) => insideRoundedRect(lx, ly, b));
|
||||
const pixel = bar ? bar.fg : BG;
|
||||
for (let c = 0; c < 3; c++) colorSum[c] += pixel[c];
|
||||
}
|
||||
}
|
||||
const total = SS * SS;
|
||||
const alpha = Math.round((plateHits / total) * 255);
|
||||
const o = (py * SIZE + px) * 4;
|
||||
const r = plateHits ? Math.round(colorSum[0] / plateHits) : 0;
|
||||
const g = plateHits ? Math.round(colorSum[1] / plateHits) : 0;
|
||||
const b = plateHits ? Math.round(colorSum[2] / plateHits) : 0;
|
||||
pixels[o] = b;
|
||||
pixels[o + 1] = g;
|
||||
pixels[o + 2] = r;
|
||||
pixels[o + 3] = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
// Pack into a single-image .ico: ICONDIR + ICONDIRENTRY + BITMAPINFOHEADER
|
||||
// + 32bpp XOR data (bottom-up) + 1bpp AND mask (bottom-up, all-zero since
|
||||
// the XOR data already carries a full alpha channel).
|
||||
const xor = Buffer.alloc(SIZE * SIZE * 4);
|
||||
for (let py = 0; py < SIZE; py++) {
|
||||
const srcRow = py * SIZE * 4;
|
||||
const dstRow = (SIZE - 1 - py) * SIZE * 4; // bottom-up
|
||||
pixels.copy(xor, dstRow, srcRow, srcRow + SIZE * 4);
|
||||
}
|
||||
const andMaskRowBytes = Math.ceil(SIZE / 32) * 4; // 1bpp, padded to 4 bytes
|
||||
const and = Buffer.alloc(andMaskRowBytes * SIZE, 0);
|
||||
|
||||
const dib = Buffer.alloc(40);
|
||||
dib.writeUInt32LE(40, 0); // biSize
|
||||
dib.writeInt32LE(SIZE, 4); // biWidth
|
||||
dib.writeInt32LE(SIZE * 2, 8); // biHeight (XOR + AND)
|
||||
dib.writeUInt16LE(1, 12); // biPlanes
|
||||
dib.writeUInt16LE(32, 14); // biBitCount
|
||||
dib.writeUInt32LE(0, 16); // biCompression: BI_RGB
|
||||
|
||||
const imageData = Buffer.concat([dib, xor, and]);
|
||||
|
||||
const iconDir = Buffer.alloc(6);
|
||||
iconDir.writeUInt16LE(0, 0); // reserved
|
||||
iconDir.writeUInt16LE(1, 2); // type: icon
|
||||
iconDir.writeUInt16LE(1, 4); // image count
|
||||
|
||||
const entry = Buffer.alloc(16);
|
||||
entry[0] = SIZE; // width
|
||||
entry[1] = SIZE; // height
|
||||
entry[2] = 0; // color count
|
||||
entry[3] = 0; // reserved
|
||||
entry.writeUInt16LE(1, 4); // planes
|
||||
entry.writeUInt16LE(32, 6); // bit count
|
||||
entry.writeUInt32LE(imageData.length, 8); // bytes in resource
|
||||
entry.writeUInt32LE(6 + 16, 12); // image offset
|
||||
|
||||
const ico = Buffer.concat([iconDir, entry, imageData]);
|
||||
|
||||
const dest = resolve(__dirname, '..', 'src', 'favicon.ico');
|
||||
writeFileSync(dest, ico);
|
||||
console.log(`Wrote ${dest} (${SIZE}x${SIZE}, ${Math.round(ico.length / 1024)} KB)`);
|
||||
+11
-12
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// Renders build/icon.png (512x512) from the same geometry as
|
||||
// src/assets/logo.svg: three white rounded bars on the app's dark background.
|
||||
// src/assets/logo.svg: three coloured rounded bars on the app's dark background.
|
||||
//
|
||||
// Hand-rolled so packaging needs no image toolchain (no ImageMagick, no sharp).
|
||||
// electron-builder derives the Windows .ico and the Linux icon set from it.
|
||||
@@ -12,15 +12,14 @@ const { join, resolve } = require('node:path');
|
||||
const SIZE = 512;
|
||||
const SS = 4; // supersampling factor, for antialiased edges
|
||||
const BG = [0x14, 0x16, 0x29];
|
||||
const FG = [0xff, 0xff, 0xff];
|
||||
const BG_RADIUS = 96; // squircle-ish corner on the icon plate
|
||||
|
||||
// viewBox="0 0 24 19" in src/assets/logo.svg
|
||||
const VIEW = { w: 24, h: 19 };
|
||||
// viewBox="0 0 157 183" in src/assets/logo.svg
|
||||
const VIEW = { w: 157, h: 183 };
|
||||
const BARS = [
|
||||
{ x: 17.3193, y: 8.36011, w: 6.08, h: 10.64, r: 3.04 },
|
||||
{ x: 8.95996, y: 0, w: 6.08, h: 19, r: 3.04 },
|
||||
{ x: 0.599609, y: 0, w: 6.08, h: 19, r: 3.04 },
|
||||
{ x: 79, y: 0, w: 79, h: 47, r: 23.5, fg: [0x75, 0x4b, 0xff] },
|
||||
{ x: 0, y: 68, w: 157, h: 47, r: 23.5, fg: [0x60, 0x2f, 0xff] },
|
||||
{ x: 0, y: 136, w: 79, h: 47, r: 23.5, fg: [0x06, 0x93, 0xc0] },
|
||||
];
|
||||
|
||||
function insideRoundedRect(px, py, { x, y, w, h, r }) {
|
||||
@@ -43,7 +42,7 @@ for (let py = 0; py < SIZE; py++) {
|
||||
raw[rowStart] = 0;
|
||||
for (let px = 0; px < SIZE; px++) {
|
||||
let plateHits = 0;
|
||||
let barHits = 0;
|
||||
const colorSum = [0, 0, 0];
|
||||
for (let sy = 0; sy < SS; sy++) {
|
||||
for (let sx = 0; sx < SS; sx++) {
|
||||
const fx = px + (sx + 0.5) / SS;
|
||||
@@ -52,16 +51,16 @@ for (let py = 0; py < SIZE; py++) {
|
||||
plateHits++;
|
||||
const lx = (fx - offsetX) / scale;
|
||||
const ly = (fy - offsetY) / scale;
|
||||
if (BARS.some((bar) => insideRoundedRect(lx, ly, bar))) barHits++;
|
||||
const bar = BARS.find((b) => insideRoundedRect(lx, ly, b));
|
||||
const pixel = bar ? bar.fg : BG;
|
||||
for (let c = 0; c < 3; c++) colorSum[c] += pixel[c];
|
||||
}
|
||||
}
|
||||
const total = SS * SS;
|
||||
const alpha = Math.round((plateHits / total) * 255);
|
||||
// Blend bar coverage over the plate colour; alpha carries the plate edge.
|
||||
const mix = plateHits ? barHits / plateHits : 0;
|
||||
const o = rowStart + 1 + px * 4;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
raw[o + c] = Math.round(BG[c] + (FG[c] - BG[c]) * mix);
|
||||
raw[o + c] = plateHits ? Math.round(colorSum[c] / plateHits) : 0;
|
||||
}
|
||||
raw[o + 3] = alpha;
|
||||
}
|
||||
|
||||
+409
-39
@@ -13,11 +13,24 @@
|
||||
registry, so -PublishRelease attaches them to the Gitea release for that tag
|
||||
instead (creating the release if it does not exist).
|
||||
|
||||
An existing release is added to, not recreated: artifacts it does not have yet
|
||||
are appended, and ones it already carries under the same name are replaced by the
|
||||
freshly built file. That makes re-running a release after a rebuild safe, and
|
||||
keeps the attachments in agreement with the SHA256SUMS.txt uploaded beside them.
|
||||
-NoReplace turns a name collision back into an error.
|
||||
|
||||
-BinariesOnly ships just the installers: no docker build, no docker login, no
|
||||
image push, and the release upload is implied. It takes the target list to
|
||||
build (win, linux-appimage, linux-flatpak — comma-separated) and that list
|
||||
overrides -Targets. The Linux targets need a Linux host or WSL, which is where
|
||||
-BinariesOnly linux-appimage,linux-flatpak belongs.
|
||||
overrides -Targets. The Linux targets need a Linux host or WSL: on Windows,
|
||||
add -UseWsl and build-release.ps1 hands them to a WSL distro.
|
||||
|
||||
-WinInWsl and -KeepInWsl are forwarded to build-release.ps1 and together keep
|
||||
the unsigned .exe off NTFS entirely: it is built in the distro and, because
|
||||
-KeepInWsl skips the copy back, it is still there at upload time. This script
|
||||
then reads dist/wsl-artifacts.json and runs the curl upload *inside* the distro
|
||||
for those files, so the endpoint agent never sees a write it can quarantine.
|
||||
The token reaches the distro through WSLENV, not through the command line.
|
||||
|
||||
Credentials are read, in order of precedence:
|
||||
1. -Username / -Password parameters
|
||||
@@ -45,6 +58,16 @@
|
||||
./scripts/publish.ps1 -BinariesOnly win,linux-appimage -Tag v1.1.0
|
||||
Windows installers plus the Linux AppImage (no Flatpak), attached to v1.1.0.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/publish.ps1 -Tag v1.1.0 -PublishRelease -UseWsl
|
||||
Full release from Windows: image push, .exe installers built natively, AppImage
|
||||
and Flatpak built in WSL, everything attached to release v1.1.0.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/publish.ps1 -Tag v1.1.0 -PublishRelease -WinInWsl -KeepInWsl
|
||||
Full release with every installer built in WSL and uploaded from there — no
|
||||
unsigned binary is ever written to a Windows filesystem.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/publish.ps1 -BinariesOnly win -NoBinaryBuild -Tag v1.1.0
|
||||
Retry a failed upload: attach the installers already in dist/ without rebuilding
|
||||
@@ -100,11 +123,24 @@ param(
|
||||
# Skip building the desktop installers.
|
||||
[switch]$NoBinaries,
|
||||
|
||||
# Forwarded to build-release.ps1. "linux" is shorthand for both Linux bundles.
|
||||
[ValidateSet("win", "linux", "linux-appimage", "linux-flatpak")]
|
||||
# Forwarded to build-release.ps1. "linux" is shorthand for both Linux bundles;
|
||||
# "win" is NSIS + portable, and win-nsis / win-portable are those two on their
|
||||
# own (only the NSIS one needs Wine when built in WSL).
|
||||
[ValidateSet("win", "win-nsis", "win-portable", "linux", "linux-appimage", "linux-flatpak")]
|
||||
[string[]]$Targets = @("win", "linux"),
|
||||
[switch]$SkipVendor,
|
||||
|
||||
# Forwarded to build-release.ps1: build the Linux targets in a WSL distro
|
||||
# instead of warning that Windows cannot produce them.
|
||||
[switch]$UseWsl,
|
||||
[string]$WslDistro,
|
||||
|
||||
# Forwarded to build-release.ps1: build the Windows targets in WSL too, and
|
||||
# leave what WSL built inside the distro. With -KeepInWsl the upload below runs
|
||||
# in the distro instead of on Windows, so the .exe never reaches NTFS.
|
||||
[switch]$WinInWsl,
|
||||
[switch]$KeepInWsl,
|
||||
|
||||
# Reuse the installers already in dist/ instead of re-running the build. For
|
||||
# retrying a failed upload without paying for the build again.
|
||||
[switch]$NoBinaryBuild,
|
||||
@@ -113,7 +149,7 @@ param(
|
||||
# -PublishRelease, since building alone is what build-release.ps1 already does.
|
||||
# Takes the target list to build (comma-separated), which overrides -Targets:
|
||||
# -BinariesOnly win,linux-appimage
|
||||
[ValidateSet("win", "linux-appimage", "linux-flatpak")]
|
||||
[ValidateSet("win", "win-nsis", "win-portable", "linux-appimage", "linux-flatpak")]
|
||||
[string[]]$BinariesOnly,
|
||||
|
||||
# Attach the installers to the Gitea release for $Tag, creating the release if
|
||||
@@ -126,7 +162,14 @@ param(
|
||||
# Gitea base URL for the API. Defaults to https://<Registry>.
|
||||
[string]$ApiBase,
|
||||
|
||||
# Replace release attachments that already exist under the same name.
|
||||
# Fail instead of replacing an attachment that already exists under the same
|
||||
# name. The default is to replace, because re-running a release for the same tag
|
||||
# after a rebuild is the normal case and the new file is the one that matches
|
||||
# SHA256SUMS.txt.
|
||||
[switch]$NoReplace,
|
||||
|
||||
# Deprecated: replacing is now the default, so this does nothing. Kept so
|
||||
# existing commands and scripts do not start failing on an unknown parameter.
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
@@ -239,20 +282,248 @@ function Send-ReleaseAsset {
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-BashScript {
|
||||
<#
|
||||
Strip CR. This file is stored with CRLF line endings, so a multi-line
|
||||
here-string handed to bash arrives with a \r on every line and bash reads it
|
||||
as part of the last token — `set: - : invalid option`, and paths that end in
|
||||
a literal \r. Single-line commands never show it.
|
||||
#>
|
||||
param([Parameter(Mandatory)][AllowEmptyString()][string]$Script)
|
||||
return $Script -replace "`r", ""
|
||||
}
|
||||
|
||||
<#
|
||||
And the companion trap: `set -e` plus an explicit `exit 0` under `bash -lc` yields
|
||||
1, because a login shell sources ~/.bash_logout on exit and Ubuntu's ends in a
|
||||
`[ -x /usr/bin/clear_console ] && ...` that fails with no tty, which errexit then
|
||||
promotes to the shell's status. -l has to stay (node from nvm/fnm lives on the
|
||||
login PATH), so the scripts below set only `-u` and check what matters explicitly.
|
||||
#>
|
||||
|
||||
function Get-WslArtifactManifest {
|
||||
<#
|
||||
build-release.ps1 -KeepInWsl writes dist/wsl-artifacts.json for the artifacts
|
||||
it deliberately did not copy onto NTFS. It removes the file whenever a build
|
||||
leaves nothing behind, so its presence means "these files are in the distro";
|
||||
the tag is still checked, because a -NoBinaryBuild run for a different tag
|
||||
would otherwise upload the previous release's binaries under the new one.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$DistDir, [Parameter(Mandatory)][string]$Tag)
|
||||
|
||||
$path = Join-Path $DistDir "wsl-artifacts.json"
|
||||
if (-not (Test-Path $path)) { return $null }
|
||||
|
||||
$manifest = Get-Content $path -Raw | ConvertFrom-Json
|
||||
if (-not $manifest.files -or -not @($manifest.files).Count) { return $null }
|
||||
if ($manifest.tag -ne $Tag) {
|
||||
throw "$path was written for tag '$($manifest.tag)', not '$Tag' — those artifacts belong to another release. Rebuild, or delete the file if it is stale."
|
||||
}
|
||||
if (-not $manifest.distro -or -not $manifest.stageDir) {
|
||||
throw "$path is missing the distro or stageDir field — delete it and rebuild."
|
||||
}
|
||||
return $manifest
|
||||
}
|
||||
|
||||
function Invoke-WithWslEnv {
|
||||
<#
|
||||
Run a script block with $Name exported into WSL through WSLENV.
|
||||
|
||||
WSLENV is the only way to hand a value to a WSL process without putting it in
|
||||
an argument list, and an argument list is exactly where a token must not be:
|
||||
wsl.exe's own command line is readable from the Windows process table. The
|
||||
previous WSLENV is restored rather than overwritten, because a distro may
|
||||
rely on entries somebody else put there (PATH translation flags in
|
||||
particular are positional and easy to break).
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][string]$Value,
|
||||
[Parameter(Mandatory)][scriptblock]$Body
|
||||
)
|
||||
|
||||
$previousValue = [Environment]::GetEnvironmentVariable($Name, "Process")
|
||||
$previousWslEnv = $env:WSLENV
|
||||
[Environment]::SetEnvironmentVariable($Name, $Value, "Process")
|
||||
$env:WSLENV = if ($previousWslEnv) { "$previousWslEnv`:$Name" } else { $Name }
|
||||
try {
|
||||
& $Body
|
||||
}
|
||||
finally {
|
||||
[Environment]::SetEnvironmentVariable($Name, $previousValue, "Process")
|
||||
if ($null -eq $previousWslEnv) {
|
||||
Remove-Item Env:\WSLENV -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$env:WSLENV = $previousWslEnv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-WslArtifacts {
|
||||
<#
|
||||
Every file the manifest names must still be in the staging directory. Without
|
||||
this the first missing one surfaces as a curl error about an unreadable
|
||||
upload part, halfway through a release.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Distro,
|
||||
[Parameter(Mandatory)][string]$StageDir,
|
||||
[Parameter(Mandatory)][string[]]$Names
|
||||
)
|
||||
|
||||
$script = @'
|
||||
set -u
|
||||
cd "$1" || { echo "staging directory $1 is gone" >&2; exit 1; }
|
||||
shift
|
||||
missing=0
|
||||
for f in "$@"; do
|
||||
[ -f "$f" ] || { echo "$f" >&2; missing=1; }
|
||||
done
|
||||
exit $missing
|
||||
'@
|
||||
& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $script) "motionity-publish" $StageDir @Names 2>&1 |
|
||||
ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "artifacts named in dist/wsl-artifacts.json are missing from ${StageDir} in '$Distro' (listed above) — rebuild with -WinInWsl -KeepInWsl, or drop -NoBinaryBuild."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WslArtifactMtime {
|
||||
<#
|
||||
Oldest mtime among the staged artifacts, as a local DateTime, so the
|
||||
-NoBinaryBuild staleness check works on WSL-resident files too.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Distro,
|
||||
[Parameter(Mandatory)][string]$StageDir,
|
||||
[Parameter(Mandatory)][string[]]$Names
|
||||
)
|
||||
|
||||
$script = @'
|
||||
set -u
|
||||
cd "$1" || exit 1
|
||||
shift
|
||||
stat -c %Y -- "$@" | sort -n | head -n 1
|
||||
'@
|
||||
$out = (& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $script) "motionity-publish" $StageDir @Names)
|
||||
$epoch = (@($out) -join "").Replace("`0", "").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or $epoch -notmatch '^\d+$') { return $null }
|
||||
return [System.DateTimeOffset]::FromUnixTimeSeconds([int64]$epoch).LocalDateTime
|
||||
}
|
||||
|
||||
function Send-ReleaseAssetFromWsl {
|
||||
<#
|
||||
Upload one staged file as a release attachment, with curl running inside the
|
||||
distro. Same Gitea endpoint and the same --config indirection for the token as
|
||||
Send-ReleaseAsset; the only reason for a second implementation is that the
|
||||
file must not be copied to NTFS to be read.
|
||||
|
||||
The config file is written by bash from $GITEA_UPLOAD_TOKEN (arriving via
|
||||
WSLENV) rather than interpolated into the command string, so the token is in
|
||||
neither wsl.exe's arguments nor the distro's process table. mktemp creates it
|
||||
0600, and the trap removes it even if curl dies.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Distro,
|
||||
[Parameter(Mandatory)][string]$StageDir,
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][string]$Uri
|
||||
)
|
||||
|
||||
# fail-with-body needs curl 7.76+ (Ubuntu 22.04 ships 7.81); the Windows path
|
||||
# above already assumes it, so the two behave the same on an HTTP error.
|
||||
$script = @'
|
||||
set -u
|
||||
command -v curl >/dev/null 2>&1 || { echo "curl is not installed in this WSL distro: sudo apt install -y curl" >&2; exit 127; }
|
||||
[ -n "${GITEA_UPLOAD_TOKEN:-}" ] || { echo "GITEA_UPLOAD_TOKEN did not reach the distro — is WSLENV being overwritten?" >&2; exit 2; }
|
||||
cfg=$(mktemp) || { echo "could not create a temp file for the curl config" >&2; exit 1; }
|
||||
trap 'rm -f "$cfg"' EXIT
|
||||
printf 'header = "Authorization: token %s"\nsilent\nshow-error\nfail-with-body\n' "$GITEA_UPLOAD_TOKEN" > "$cfg" || exit 1
|
||||
cd "$1" || exit 1
|
||||
# Last command on purpose: curl's status is the script's status.
|
||||
curl --config "$cfg" --write-out ' http %{http_code}, %{size_upload} bytes uploaded\n' -F "attachment=@$2" "$3"
|
||||
'@
|
||||
Write-Host " > [$Distro] curl --config <temp> -F attachment=@$Name `"$Uri`"" -ForegroundColor DarkGray
|
||||
& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $script) "motionity-publish" $StageDir $Name $Uri
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "upload of '$StageDir/$Name' from '$Distro' failed (exit $LASTEXITCODE)."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ReleaseBody {
|
||||
<#
|
||||
The markdown shown on the Gitea release page: how to run each artifact.
|
||||
|
||||
A literal here-string, because an expandable one treats ``` as backtick
|
||||
escapes and the third one would swallow the newline as a line continuation.
|
||||
The two placeholders are substituted afterwards instead, and the content sits
|
||||
at column 0 because four leading spaces would make markdown read it as code.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$Tag)
|
||||
|
||||
$body = @'
|
||||
## Linux
|
||||
|
||||
### AppImage
|
||||
|
||||
Make it executable: `chmod +x motionity-%TAG%-linux-x86_64.AppImage`, then launch it (needs `libfuse2` installed).
|
||||
|
||||
### Flatpak
|
||||
|
||||
```
|
||||
flatpak install ./motionity-%TAG%-linux-x86_64.flatpak
|
||||
```
|
||||
|
||||
## Windows
|
||||
|
||||
Launch the installer or the portable version directly. A SmartScreen warning may appear, as the binary is not signed.
|
||||
|
||||
## Docker
|
||||
|
||||
Recommended: `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
motionity:
|
||||
image: %IMAGE%:%TAG%
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: unless-stopped
|
||||
```
|
||||
'@
|
||||
|
||||
return $body.Replace("%IMAGE%", "$Registry/$Owner/$Image").Replace("%TAG%", $Tag)
|
||||
}
|
||||
|
||||
function Publish-BinaryRelease {
|
||||
<#
|
||||
Attach the installers to the release for $Tag, creating that release if it
|
||||
does not exist yet. Re-uploading the same file name is a delete + upload,
|
||||
which needs -Force: overwriting an asset someone may already have linked is
|
||||
not something to do silently.
|
||||
does not exist yet. An existing release is added to, never recreated.
|
||||
|
||||
A file name the release already carries is replaced: Gitea does not treat
|
||||
attachment names as unique, so uploading over one without removing it first
|
||||
leaves two assets with the same name and no way for anyone to tell which is
|
||||
which. Replacing is the default because the alternative is a release whose
|
||||
binaries disagree with its own SHA256SUMS.txt after a rebuild; -NoReplace
|
||||
restores the strict behaviour.
|
||||
|
||||
Delete-then-upload, in that order, for the same reason — which does mean a
|
||||
failed upload leaves the old asset gone. Recover with -NoBinaryBuild, which
|
||||
re-attaches from dist/ (or from the distro) without rebuilding.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$ApiRoot,
|
||||
[Parameter(Mandatory)][string]$RepoPath,
|
||||
[Parameter(Mandatory)][string]$Tag,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][string[]]$Artifacts,
|
||||
[switch]$Force
|
||||
# Windows-side files, uploaded by curl.exe.
|
||||
[string[]]$Artifacts = @(),
|
||||
# Files still in a WSL staging directory, uploaded by curl inside the distro.
|
||||
[string[]]$WslArtifacts = @(),
|
||||
[string]$WslDistro,
|
||||
[string]$WslStageDir,
|
||||
[switch]$NoReplace
|
||||
)
|
||||
|
||||
$releasesUri = "$ApiRoot/repos/$RepoPath/releases"
|
||||
@@ -263,7 +534,7 @@ function Publish-BinaryRelease {
|
||||
$release = Invoke-GiteaApi -Method POST -Uri $releasesUri -Token $Token -Body @{
|
||||
tag_name = $Tag
|
||||
name = "Motionity $Tag"
|
||||
body = "Desktop installers — Windows NSIS + portable, Linux AppImage + Flatpak — with SHA256SUMS.txt. Container image: ${Registry}/${Owner}/${Image}:$Tag"
|
||||
body = (Get-ReleaseBody -Tag $Tag)
|
||||
draft = $false
|
||||
}
|
||||
if (-not $release) { throw "could not create release $Tag in $RepoPath (does the repo exist?)." }
|
||||
@@ -272,20 +543,39 @@ function Publish-BinaryRelease {
|
||||
Write-Host " reusing release $Tag (id $($release.id))" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
foreach ($path in $Artifacts) {
|
||||
$name = Split-Path -Leaf $path
|
||||
$existing = $release.assets | Where-Object { $_.name -eq $name }
|
||||
if ($existing) {
|
||||
if (-not $Force) {
|
||||
throw "release $Tag already has an attachment named '$name' — pass -Force to replace it."
|
||||
# One list so the asset-already-exists handling is written once: only the final
|
||||
# transfer differs between a file on NTFS and one left in the distro.
|
||||
$uploads = @()
|
||||
foreach ($path in $Artifacts) { $uploads += @{ Name = (Split-Path -Leaf $path); Path = $path; InWsl = $false } }
|
||||
foreach ($name in $WslArtifacts) { $uploads += @{ Name = $name; Path = $null; InWsl = $true } }
|
||||
|
||||
foreach ($upload in $uploads) {
|
||||
$name = $upload.Name
|
||||
# @() because a release can already hold several assets under one name — an
|
||||
# earlier run that uploaded without deleting, or a partial retry. Unwrapped,
|
||||
# $existing.id would be an array and the DELETE would go to a malformed URL.
|
||||
$existing = @($release.assets | Where-Object { $_.name -eq $name })
|
||||
if ($existing.Count) {
|
||||
if ($NoReplace) {
|
||||
throw "release $Tag already has an attachment named '$name', and -NoReplace was passed. Drop it to replace the file, or upload under a different tag."
|
||||
}
|
||||
foreach ($asset in $existing) {
|
||||
Write-Host " replacing attachment '$name' (asset $($asset.id))..." -ForegroundColor DarkGray
|
||||
Invoke-GiteaApi -Method DELETE -Token $Token `
|
||||
-Uri "$releasesUri/$($release.id)/assets/$($asset.id)" | Out-Null
|
||||
}
|
||||
Write-Host " replacing existing attachment '$name'..." -ForegroundColor DarkGray
|
||||
Invoke-GiteaApi -Method DELETE -Token $Token `
|
||||
-Uri "$releasesUri/$($release.id)/assets/$($existing.id)" | Out-Null
|
||||
}
|
||||
$encoded = [System.Uri]::EscapeDataString($name)
|
||||
Send-ReleaseAsset -Token $Token -Path $path `
|
||||
-Uri "$releasesUri/$($release.id)/assets?name=$encoded"
|
||||
else {
|
||||
Write-Host " adding attachment '$name'..." -ForegroundColor DarkGray
|
||||
}
|
||||
$encoded = [System.Uri]::EscapeDataString($name)
|
||||
$assetUri = "$releasesUri/$($release.id)/assets?name=$encoded"
|
||||
if ($upload.InWsl) {
|
||||
Send-ReleaseAssetFromWsl -Distro $WslDistro -StageDir $WslStageDir -Name $name -Uri $assetUri
|
||||
}
|
||||
else {
|
||||
Send-ReleaseAsset -Token $Token -Path $upload.Path -Uri $assetUri
|
||||
}
|
||||
}
|
||||
|
||||
return "$ApiRoot/repos/$RepoPath/releases/tags/$Tag"
|
||||
@@ -305,6 +595,12 @@ try {
|
||||
if ($NoBinaryBuild -and $NoBinaries) {
|
||||
throw "-NoBinaryBuild reuses the build that -NoBinaries skips entirely — pick one."
|
||||
}
|
||||
if ($Force) {
|
||||
Write-Warning "-Force is deprecated and ignored: replacing an attachment that already exists is now the default. -NoReplace is the opt-out."
|
||||
}
|
||||
if ($Force -and $NoReplace) {
|
||||
throw "-Force and -NoReplace ask for opposite things — drop -Force, it is already the default."
|
||||
}
|
||||
if ($binariesOnlyMode) {
|
||||
# Nothing to build, log into or push on the container side, and uploading
|
||||
# is the whole point (build-release.ps1 alone covers "just build them").
|
||||
@@ -350,7 +646,7 @@ try {
|
||||
else {
|
||||
Write-Host " image : skipped (-BinariesOnly)"
|
||||
}
|
||||
Write-Host " binaries : $(if ($NoBinaries) { 'skipped' } elseif ($NoBinaryBuild) { 'dist/ (reused, not rebuilt)' } else { $Targets -join ', ' })"
|
||||
Write-Host " binaries : $(if ($NoBinaries) { 'skipped' } elseif ($NoBinaryBuild) { 'dist/ (reused, not rebuilt)' } else { $Targets -join ', ' })$(if ($WinInWsl) { ' (all in WSL)' } elseif ($UseWsl) { ' (Linux ones in WSL)' })$(if ($KeepInWsl) { ', uploaded from the distro' })"
|
||||
Write-Host " release : $(if ($PublishRelease) { "$ReleaseRepo @ $Tag" } else { 'not uploaded' })"
|
||||
Write-Host ""
|
||||
|
||||
@@ -376,7 +672,10 @@ try {
|
||||
# --- Release artifacts ----------------------------------------------------
|
||||
# Built before the push so a failing build doesn't leave a pushed image with
|
||||
# no matching installers for the same tag.
|
||||
$artifacts = @()
|
||||
$artifacts = @()
|
||||
$wslArtifacts = @()
|
||||
$wslUploadDistro = $null
|
||||
$wslStageDir = $null
|
||||
if (-not $NoBinaries) {
|
||||
$distDir = Join-Path $repoRoot "dist"
|
||||
|
||||
@@ -387,12 +686,28 @@ try {
|
||||
}
|
||||
|
||||
# Uploading an installer older than the code it claims to be is the one
|
||||
# way this flag can quietly go wrong, so say so rather than assume.
|
||||
# way this flag can quietly go wrong, so say so rather than assume. The
|
||||
# artifacts a -KeepInWsl build left in the distro count as present here:
|
||||
# dist/ can legitimately hold nothing but SHA256SUMS.txt.
|
||||
$manifest = Get-WslArtifactManifest -DistDir $distDir -Tag $Tag
|
||||
$oldest = (Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File |
|
||||
Sort-Object LastWriteTime | Select-Object -First 1)
|
||||
if (-not $oldest) {
|
||||
throw "no installers matching motionity-$Tag-* in $distDir — what is on disk was built under a different tag. Drop -NoBinaryBuild."
|
||||
$oldestName = if ($oldest) { $oldest.Name } else { $null }
|
||||
$oldestTime = if ($oldest) { $oldest.LastWriteTime } else { $null }
|
||||
|
||||
if ($manifest) {
|
||||
Write-Host " $(@($manifest.files).Count) artifact(s) staged in '$($manifest.distro)':$($manifest.stageDir)" -ForegroundColor DarkGray
|
||||
Test-WslArtifacts -Distro $manifest.distro -StageDir $manifest.stageDir -Names @($manifest.files)
|
||||
$wslTime = Get-WslArtifactMtime -Distro $manifest.distro -StageDir $manifest.stageDir -Names @($manifest.files)
|
||||
if ($wslTime -and (-not $oldestTime -or $wslTime -lt $oldestTime)) {
|
||||
$oldestTime = $wslTime
|
||||
$oldestName = @($manifest.files)[0]
|
||||
}
|
||||
}
|
||||
if (-not $oldestTime) {
|
||||
throw "no installers matching motionity-$Tag-* in $distDir and no dist/wsl-artifacts.json for $Tag — what is on disk was built under a different tag. Drop -NoBinaryBuild."
|
||||
}
|
||||
|
||||
# src/ is the app: every extension the packaged tree actually serves,
|
||||
# plus the packaging scripts themselves.
|
||||
$newer = Get-ChildItem $repoRoot -Recurse -Include *.js, *.cjs, *.mjs, *.html, *.css, *.json -File |
|
||||
@@ -400,10 +715,10 @@ try {
|
||||
$_.FullName -notlike "$distDir*" -and
|
||||
$_.FullName -notlike "*\node_modules\*" -and
|
||||
$_.FullName -notlike "*/node_modules/*" -and
|
||||
$_.LastWriteTime -gt $oldest.LastWriteTime
|
||||
$_.LastWriteTime -gt $oldestTime
|
||||
}
|
||||
if ($newer) {
|
||||
Write-Warning "$($oldest.Name) predates $($newer.Count) source file(s) — the installers may not contain your latest changes (newest: $(($newer | Sort-Object LastWriteTime -Descending)[0].Name))."
|
||||
Write-Warning "$oldestName predates $($newer.Count) source file(s) — the installers may not contain your latest changes (newest: $(($newer | Sort-Object LastWriteTime -Descending)[0].Name))."
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -413,11 +728,42 @@ try {
|
||||
# `& script.ps1` leaves $LASTEXITCODE untouched, and with -NoBuild no
|
||||
# docker command has reset it, so checking it would rethrow whatever the
|
||||
# caller's shell last failed at.
|
||||
& (Join-Path $PSScriptRoot "build-release.ps1") -Tag $Tag -Targets $Targets -SkipVendor:$SkipVendor
|
||||
# -WslDistro is only passed when set: build-release.ps1 treats an empty
|
||||
# string as "not requested" either way, but splatting nothing keeps the
|
||||
# -WhatIf/-Verbose trace readable.
|
||||
$buildParams = @{
|
||||
Tag = $Tag
|
||||
Targets = $Targets
|
||||
SkipVendor = $SkipVendor
|
||||
UseWsl = $UseWsl
|
||||
WinInWsl = $WinInWsl
|
||||
KeepInWsl = $KeepInWsl
|
||||
}
|
||||
if ($WslDistro) { $buildParams["WslDistro"] = $WslDistro }
|
||||
& (Join-Path $PSScriptRoot "build-release.ps1") @buildParams
|
||||
|
||||
$manifest = Get-WslArtifactManifest -DistDir $distDir -Tag $Tag
|
||||
if ($manifest) {
|
||||
Test-WslArtifacts -Distro $manifest.distro -StageDir $manifest.stageDir -Names @($manifest.files)
|
||||
}
|
||||
}
|
||||
|
||||
$artifacts = @(Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File | ForEach-Object FullName)
|
||||
if (-not $artifacts.Count) { throw "no installers for $Tag found in $distDir." }
|
||||
if ($manifest) {
|
||||
$wslArtifacts = @($manifest.files)
|
||||
$wslUploadDistro = $manifest.distro
|
||||
$wslStageDir = $manifest.stageDir
|
||||
}
|
||||
|
||||
# A name that exists both in dist/ and in the distro is the WSL build's, and
|
||||
# the dist/ copy is a leftover from an earlier native build — uploading both
|
||||
# would collide on the release's asset names anyway.
|
||||
$artifacts = @(Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File |
|
||||
Where-Object { $wslArtifacts -notcontains $_.Name } | ForEach-Object FullName)
|
||||
if (-not $artifacts.Count -and -not $wslArtifacts.Count) {
|
||||
throw "no installers for $Tag found in $distDir."
|
||||
}
|
||||
# SHA256SUMS.txt covers both sets and is written on the Windows side either
|
||||
# way — it is text, so nothing objects to it landing in dist/.
|
||||
$sums = Join-Path $distDir "SHA256SUMS.txt"
|
||||
if (Test-Path $sums) { $artifacts += $sums }
|
||||
Write-Host ""
|
||||
@@ -447,13 +793,34 @@ try {
|
||||
# --- Release attachments --------------------------------------------------
|
||||
$releaseUrl = $null
|
||||
if ($PublishRelease) {
|
||||
if (-not $artifacts.Count) {
|
||||
if (-not $artifacts.Count -and -not $wslArtifacts.Count) {
|
||||
throw "-PublishRelease has nothing to upload (was -NoBinaries set?)."
|
||||
}
|
||||
Write-Host "Uploading artifacts to release $Tag..." -ForegroundColor Cyan
|
||||
$Password = Resolve-Token -Provided $Password -Purpose "release upload to $ReleaseRepo"
|
||||
$releaseUrl = Publish-BinaryRelease -ApiRoot $apiRoot -RepoPath $ReleaseRepo -Tag $Tag `
|
||||
-Token $Password -Artifacts $artifacts -Force:$Force
|
||||
$Password = Resolve-Token -Provided $Password -Purpose "release upload to $ReleaseRepo"
|
||||
|
||||
$publishArgs = @{
|
||||
ApiRoot = $apiRoot
|
||||
RepoPath = $ReleaseRepo
|
||||
Tag = $Tag
|
||||
Token = $Password
|
||||
Artifacts = $artifacts
|
||||
NoReplace = $NoReplace
|
||||
}
|
||||
if ($wslArtifacts.Count) {
|
||||
$publishArgs["WslArtifacts"] = $wslArtifacts
|
||||
$publishArgs["WslDistro"] = $wslUploadDistro
|
||||
$publishArgs["WslStageDir"] = $wslStageDir
|
||||
# The token is exported for the whole upload rather than per file: WSLENV
|
||||
# is process-wide state, and setting and restoring it around every
|
||||
# attachment is more windows in which a concurrent wsl.exe sees it.
|
||||
$releaseUrl = Invoke-WithWslEnv -Name "GITEA_UPLOAD_TOKEN" -Value $Password -Body {
|
||||
Publish-BinaryRelease @publishArgs
|
||||
}
|
||||
}
|
||||
else {
|
||||
$releaseUrl = Publish-BinaryRelease @publishArgs
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
@@ -462,10 +829,13 @@ try {
|
||||
Write-Host "Pushed:" -ForegroundColor Green
|
||||
foreach ($t in $tags) { Write-Host " $t" -ForegroundColor Green }
|
||||
}
|
||||
if ($artifacts.Count) {
|
||||
if ($artifacts.Count -or $wslArtifacts.Count) {
|
||||
$where = if ($PublishRelease) { "attached to release $Tag" } else { "built locally — attach to a release manually" }
|
||||
Write-Host "Artifacts ($where):" -ForegroundColor Green
|
||||
foreach ($a in $artifacts) { Write-Host " $a" -ForegroundColor Green }
|
||||
foreach ($a in $wslArtifacts) {
|
||||
Write-Host " [$wslUploadDistro] $wslStageDir/$a" -ForegroundColor Green
|
||||
}
|
||||
if ($releaseUrl) { Write-Host " $ApiBase/$ReleaseRepo/releases/tag/$Tag" -ForegroundColor Green }
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 23 KiB |
+1
-5
@@ -1,5 +1 @@
|
||||
<svg width="24" height="19" viewBox="0 0 24 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="17.3193" y="8.36011" width="6.08" height="10.64" rx="3.04" fill="white"/>
|
||||
<rect x="8.95996" width="6.08" height="19" rx="3.04" fill="white"/>
|
||||
<rect x="0.599609" width="6.08" height="19" rx="3.04" fill="white"/>
|
||||
</svg>
|
||||
<svg width="246" xmlns="http://www.w3.org/2000/svg" height="271" id="screenshot-4511fdde-0f5d-8050-8008-831c5de8b5f2" viewBox="-32 -32 246 271" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-4511fdde-0f5d-8050-8008-831c5de8b5f2" filter="url(#filter-render-1)" rx="0" ry="0"><defs><filter id="filter-render-1" x="-0.17088607594936708" y="-0.14754098360655737" width="1.4177215189873418" height="1.360655737704918" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="4" dy="4"/><feGaussianBlur stdDeviation="2"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/><feBlend mode="normal" in2="BackgroundImageFix" result="filter_2e51c812-4a20-800d-8008-830c8cbbedac"/><feBlend mode="normal" in="SourceGraphic" in2="filter_2e51c812-4a20-800d-8008-830c8cbbedac" result="shape"/></filter><filter id="filter-shadow-render-1" x="-0.17088607594936708" y="-0.14754098360655737" width="1.4177215189873418" height="1.360655737704918" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="4" dy="4"/><feGaussianBlur stdDeviation="2"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/><feBlend mode="normal" in2="BackgroundImageFix" result="filter_2e51c812-4a20-800d-8008-830c8cbbedac"/><feBlend mode="normal" in="SourceGraphic" in2="filter_2e51c812-4a20-800d-8008-830c8cbbedac" result="shape"/></filter></defs><g id="shape-4511fdde-0f5d-8050-8008-831c5de8b5f3"><g class="fills" id="fills-4511fdde-0f5d-8050-8008-831c5de8b5f3"><rect rx="23.5" ry="23.5" x="79" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="79" height="47" style="fill: rgb(117, 75, 255); fill-opacity: 1;"/></g></g><g id="shape-4511fdde-0f5d-8050-8008-831c5de8b5f4"><g class="fills" id="fills-4511fdde-0f5d-8050-8008-831c5de8b5f4"><rect rx="23.5" ry="23.5" x="0" y="68" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="157" height="47" style="fill: rgb(96, 47, 255); fill-opacity: 1;"/></g></g><g id="shape-4511fdde-0f5d-8050-8008-831c5de8b5f5"><g class="fills" id="fills-4511fdde-0f5d-8050-8008-831c5de8b5f5"><rect rx="23.5" ry="23.5" x="0" y="136" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="79" height="47" style="fill: rgb(6, 147, 192); fill-opacity: 1;"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 323 B After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
+27
-7
@@ -19,17 +19,17 @@
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://www.motionity.app/">
|
||||
<meta property="og:url" content="https://motionity.kawa.zip/">
|
||||
<meta property="og:title" content="Motionity - The web-based motion graphics editor for everyone">
|
||||
<meta property="og:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. ">
|
||||
<meta property="og:image" content="https://motionity.app/meta.png">
|
||||
<meta property="og:image" content="https://motionity.kawa.zip/meta.png">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="https://www.motionity.app/">
|
||||
<meta property="twitter:url" content="https://motionity.kawa.zip/">
|
||||
<meta property="twitter:title" content="Motionity - The web-based motion graphics editor for everyone">
|
||||
<meta property="twitter:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. ">
|
||||
<meta property="twitter:image" content="https://motionity.app/meta.png">
|
||||
<meta property="twitter:image" content="https://motionity.kawa.zip/meta.png">
|
||||
</head>
|
||||
<body draggable="false">
|
||||
<div id="disclaimer">
|
||||
@@ -37,7 +37,6 @@
|
||||
<div id="emoji">🤔</div>
|
||||
<div id="opt-title">Motionity isn't optimized for mobile</div>
|
||||
<div id="opt-desc">You need to use a computer to be able to create animations with Motionity.</div>
|
||||
<a href="https://twitter.com/alyssaxuu" target="_blank" id="opt-button">Other products by the maker</a>
|
||||
</div>
|
||||
<div id="disc-overlay"></div>
|
||||
</div>
|
||||
@@ -103,6 +102,22 @@
|
||||
<p class="header-2">Export this project</p>
|
||||
<div id="export-project"><img src="assets/download-icon.svg"> <span>Export</span></div>
|
||||
</div>
|
||||
<div id="credits-modal">
|
||||
<p class="header">Credits</p>
|
||||
<p class="subtitle">Created by <span><a href="https://github.com/alyssaxuu">Alyssa X</a></span></p>
|
||||
<p class="subtitle">Current version maintained by <span><a href="https://github.com/KawaKode/Motionity">Kawa</a></span></p>
|
||||
<hr style="margin-top: 15px; margin-bottom: 15px;">
|
||||
<p class="subheader">Libraries & Tools</p>
|
||||
<div id="credits-list">
|
||||
<div class="credit-item">Fabric.js</div>
|
||||
<div class="credit-item">Anime.js</div>
|
||||
<div class="credit-item">Lottie</div>
|
||||
<div class="credit-item">Pickr</div>
|
||||
<div class="credit-item">Sortable.js</div>
|
||||
<div class="credit-item">FFmpeg.wasm</div>
|
||||
<div class="credit-item">jQuery</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="background-overlay"></div>
|
||||
<div id="color-picker"></div>
|
||||
<div id="color-picker-fill"></div>
|
||||
@@ -290,14 +305,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="bottom-canvas">
|
||||
<a id="sponsor" href="https://github.com/sponsors/alyssaxuu" target="_blank"><img src="assets/sponsor.svg"> Sponsor</a>
|
||||
<a id="alyssa-credit" href="https://twitter.com/alyssaxuu" target="_blank">Made by <span>Alyssa X</span> <img src="assets/alyssaimg.jpeg"></a>
|
||||
</div>
|
||||
<img src="assets/replace-image.svg" id="replace-image">
|
||||
<img src="assets/loading-image.svg" id="load-image" class="load-media">
|
||||
<img src="assets/loading-video.svg" id="load-video" class="load-media">
|
||||
<canvas id="canvas"></canvas>
|
||||
</div>
|
||||
<div id="browser-handle" class="panel-handle noselect" title="Drag to resize">
|
||||
<div class="panel-toggle" id="browser-toggle"></div>
|
||||
</div>
|
||||
<div id="properties-handle" class="panel-handle noselect" title="Drag to resize">
|
||||
<div class="panel-toggle" id="properties-toggle"></div>
|
||||
</div>
|
||||
<div id="timeline-handle"></div>
|
||||
<div id="bottom-area" class="noselect">
|
||||
<div id="keyframe-properties">
|
||||
@@ -375,6 +394,7 @@
|
||||
<div id="share"><img src="assets/importexport.svg"> Import & export</div>
|
||||
<div id="download"><img src="assets/download-icon.svg"> Download</div>
|
||||
</div>
|
||||
<div id="credits-button">Credits</div>
|
||||
</div>
|
||||
|
||||
<video id="test-video"></video>
|
||||
|
||||
@@ -50,8 +50,11 @@ function checkDB() {
|
||||
'strokeUniform',
|
||||
'rx',
|
||||
'ry',
|
||||
'cornerRadius',
|
||||
'selectable',
|
||||
'hasControls',
|
||||
'hasBorders',
|
||||
'evented',
|
||||
'subTargetCheck',
|
||||
'id',
|
||||
'hoverCursor',
|
||||
@@ -226,8 +229,11 @@ async function autoSave() {
|
||||
'strokeUniform',
|
||||
'rx',
|
||||
'ry',
|
||||
'cornerRadius',
|
||||
'selectable',
|
||||
'hasControls',
|
||||
'hasBorders',
|
||||
'evented',
|
||||
'subTargetCheck',
|
||||
'id',
|
||||
'hoverCursor',
|
||||
@@ -346,6 +352,13 @@ function loadProject() {
|
||||
});
|
||||
replaceSource(canvas.getItemById(object.id), canvas);
|
||||
} else {
|
||||
// Projects saved before audio layers were flagged still carry
|
||||
// controls / borders, which show up as a phantom box at 0,0.
|
||||
canvas.getItemById(object.id).set({
|
||||
hasControls: false,
|
||||
hasBorders: false,
|
||||
evented: false,
|
||||
});
|
||||
renderProp('volume', canvas.getItemById(object.id));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -100,6 +100,8 @@ $(document).ready(function () {
|
||||
canvas.on('object:scaling', function (e) {
|
||||
e.target.hasControls = false;
|
||||
centerLines(e);
|
||||
// Keep the corner radius at its pixel value while the handle is dragged
|
||||
syncCornerRadius(e.target);
|
||||
if (cropping) {
|
||||
updateCropBounds();
|
||||
crop(canvas.getItemById('cropped'));
|
||||
@@ -135,12 +137,16 @@ $(document).ready(function () {
|
||||
canvas.renderAll();
|
||||
if (e.target.type == 'activeSelection') {
|
||||
const tempselection = canvas.getActiveObject();
|
||||
// Discarding first bakes the group transform into the children, so
|
||||
// their scale is final by the time the radius is re-derived
|
||||
canvas.discardActiveObject();
|
||||
e.target._objects.forEach(function (object) {
|
||||
syncCornerRadius(object);
|
||||
autoKeyframe(object, e, true);
|
||||
});
|
||||
reselect(tempselection);
|
||||
} else {
|
||||
syncCornerRadius(e.target);
|
||||
autoKeyframe(e.target, e, false);
|
||||
}
|
||||
updatePanelValues();
|
||||
|
||||
+359
-74
@@ -495,6 +495,7 @@ async function updateRecordCanvas() {
|
||||
'strokeUniform',
|
||||
'rx',
|
||||
'ry',
|
||||
'cornerRadius',
|
||||
'selectable',
|
||||
'hasControls',
|
||||
'subTargetCheck',
|
||||
@@ -684,6 +685,7 @@ function save() {
|
||||
'strokeUniform',
|
||||
'rx',
|
||||
'ry',
|
||||
'cornerRadius',
|
||||
'selectable',
|
||||
'hasControls',
|
||||
'subTargetCheck',
|
||||
@@ -1050,13 +1052,57 @@ $(document).on('click', '#redo', function () {
|
||||
}
|
||||
});
|
||||
|
||||
// Corner radius, in the canvas pixels the panel advertises.
|
||||
//
|
||||
// fabric applies rx/ry in the object's own coordinate space, before its scale,
|
||||
// and shapes are resized by scaling rather than by changing width/height. The
|
||||
// raw input was written straight to rx, so a radius typed as 20 drew at 60 on a
|
||||
// rect scaled 3x: the value behaved like a proportion of the shape instead of
|
||||
// pixels. `cornerRadius` stores what the user asked for and rx/ry are derived
|
||||
// from it, so the same number means the same number of pixels at any size.
|
||||
function getCornerRadius(object) {
|
||||
if (!object) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof object.cornerRadius == 'number') {
|
||||
return object.cornerRadius;
|
||||
}
|
||||
// Projects saved before cornerRadius existed only carry the raw rx
|
||||
return (
|
||||
(object.get('rx') || 0) * Math.abs(object.get('scaleX') || 1)
|
||||
);
|
||||
}
|
||||
|
||||
function setCornerRadius(object, radius) {
|
||||
if (!object) {
|
||||
return;
|
||||
}
|
||||
var px = radius > 0 ? radius : 0;
|
||||
object.cornerRadius = px;
|
||||
object.set({
|
||||
rx: px / Math.abs(object.get('scaleX') || 1),
|
||||
ry: px / Math.abs(object.get('scaleY') || 1),
|
||||
});
|
||||
}
|
||||
|
||||
// Re-derive rx/ry after a resize so the drawn radius keeps the typed value
|
||||
function syncCornerRadius(object) {
|
||||
if (!object || object.get('type') != 'rect') {
|
||||
return;
|
||||
}
|
||||
if (typeof object.cornerRadius != 'number') {
|
||||
return;
|
||||
}
|
||||
setCornerRadius(object, object.cornerRadius);
|
||||
}
|
||||
|
||||
// Generate keyframes
|
||||
function keyframeChanges(object, type, id, selection) {
|
||||
if (object.get('type') == 'rect') {
|
||||
object.set({
|
||||
rx: parseFloat($('#object-corners input').val()),
|
||||
ry: parseFloat($('#object-corners input').val()),
|
||||
});
|
||||
setCornerRadius(
|
||||
object,
|
||||
parseFloat($('#object-corners input').val())
|
||||
);
|
||||
} else if (object.get('type') == 'textbox') {
|
||||
object.set({
|
||||
charSpacing: parseFloat($('#text-h input').val()) * 10,
|
||||
@@ -3512,7 +3558,23 @@ function checkCrop(obj) {
|
||||
crop(canvas.getItemById('cropped'));
|
||||
}
|
||||
|
||||
// Rotate a vector by `radians` around the origin
|
||||
function rotateVector(x, y, radians) {
|
||||
if (!radians) {
|
||||
return { x: x, y: y };
|
||||
}
|
||||
var cos = Math.cos(radians);
|
||||
var sin = Math.sin(radians);
|
||||
return { x: x * cos - y * sin, y: x * sin + y * cos };
|
||||
}
|
||||
|
||||
// Perform a crop
|
||||
//
|
||||
// Everything is computed in the image's own frame rather than along the canvas
|
||||
// axes, so a rotated image crops the region the crop window actually covers.
|
||||
// The previous version compared canvas-space edges and only handled three of
|
||||
// the four quadrants, which cropped the wrong area of any rotated image (and
|
||||
// nothing at all when the crop window was centred on it).
|
||||
function crop(obj) {
|
||||
var cropUI = canvas.getItemById('crop');
|
||||
if (!obj || !cropUI || !cropobj) {
|
||||
@@ -3520,58 +3582,54 @@ function crop(obj) {
|
||||
}
|
||||
cropobj.setCoords();
|
||||
cropUI.setCoords();
|
||||
var cleft =
|
||||
cropUI.get('left') - (cropUI.get('width') * cropUI.get('scaleX')) / 2;
|
||||
var ctop =
|
||||
cropUI.get('top') - (cropUI.get('height') * cropUI.get('scaleY')) / 2;
|
||||
var height =
|
||||
(cropUI.get('height') / cropobj.get('scaleY')) * cropUI.get('scaleY');
|
||||
var width =
|
||||
(cropUI.get('width') / cropobj.get('scaleX')) * cropUI.get('scaleX');
|
||||
var img_height = cropobj.get('height') * cropobj.get('scaleY');
|
||||
var img_width = cropobj.get('width') * cropobj.get('scaleX');
|
||||
var left =
|
||||
cleft -
|
||||
(cropobj.get('left') -
|
||||
(cropobj.get('width') * cropobj.get('scaleX')) / 2);
|
||||
var top =
|
||||
ctop -
|
||||
(cropobj.get('top') -
|
||||
(cropobj.get('height') * cropobj.get('scaleY')) / 2);
|
||||
if (left < 0 && top > 0) {
|
||||
obj
|
||||
.set({ cropY: top / cropobj.get('scaleY'), height: height })
|
||||
.setCoords();
|
||||
canvas.renderAll();
|
||||
obj.set({
|
||||
top: ctop + (obj.get('height') * obj.get('scaleY')) / 2,
|
||||
});
|
||||
canvas.renderAll();
|
||||
} else if (top < 0 && left > 0) {
|
||||
obj
|
||||
.set({ cropX: left / cropobj.get('scaleX'), width: width })
|
||||
.setCoords();
|
||||
canvas.renderAll();
|
||||
obj.set({
|
||||
left: cleft + (obj.get('width') * obj.get('scaleX')) / 2,
|
||||
});
|
||||
canvas.renderAll();
|
||||
} else if (top > 0 && left > 0) {
|
||||
obj
|
||||
.set({
|
||||
cropX: left / cropobj.get('scaleX'),
|
||||
cropY: top / cropobj.get('scaleY'),
|
||||
height: height,
|
||||
width: width,
|
||||
})
|
||||
.setCoords();
|
||||
canvas.renderAll();
|
||||
obj.set({
|
||||
left: cleft + (obj.get('width') * obj.get('scaleX')) / 2,
|
||||
top: ctop + (obj.get('height') * obj.get('scaleY')) / 2,
|
||||
});
|
||||
canvas.renderAll();
|
||||
}
|
||||
// Read everything before writing: the final call passes cropobj as `obj`
|
||||
var scaleX = cropobj.get('scaleX') || 1;
|
||||
var scaleY = cropobj.get('scaleY') || 1;
|
||||
var ogWidth = cropobj.get('width');
|
||||
var ogHeight = cropobj.get('height');
|
||||
var centerX = cropobj.get('left');
|
||||
var centerY = cropobj.get('top');
|
||||
var radians = fabric.util.degreesToRadians(
|
||||
cropobj.get('angle') || 0
|
||||
);
|
||||
|
||||
// The crop window carries the image's angle, so its own width and height are
|
||||
// already axis-aligned with the image
|
||||
var width = (cropUI.get('width') * cropUI.get('scaleX')) / scaleX;
|
||||
var height = (cropUI.get('height') * cropUI.get('scaleY')) / scaleY;
|
||||
|
||||
// Where the crop window sits relative to the image centre, un-rotated
|
||||
var offset = rotateVector(
|
||||
cropUI.get('left') - centerX,
|
||||
cropUI.get('top') - centerY,
|
||||
-radians
|
||||
);
|
||||
var cropX = offset.x / scaleX + ogWidth / 2 - width / 2;
|
||||
var cropY = offset.y / scaleY + ogHeight / 2 - height / 2;
|
||||
|
||||
// Never leave the source bitmap: fabric draws nothing for a negative crop
|
||||
width = Math.max(1, Math.min(width, ogWidth));
|
||||
height = Math.max(1, Math.min(height, ogHeight));
|
||||
cropX = Math.max(0, Math.min(cropX, ogWidth - width));
|
||||
cropY = Math.max(0, Math.min(cropY, ogHeight - height));
|
||||
|
||||
// Put the cropped object back under the (clamped) region it represents
|
||||
var center = rotateVector(
|
||||
(cropX + width / 2 - ogWidth / 2) * scaleX,
|
||||
(cropY + height / 2 - ogHeight / 2) * scaleY,
|
||||
radians
|
||||
);
|
||||
obj
|
||||
.set({
|
||||
cropX: cropX,
|
||||
cropY: cropY,
|
||||
width: width,
|
||||
height: height,
|
||||
left: centerX + center.x,
|
||||
top: centerY + center.y,
|
||||
})
|
||||
.setCoords();
|
||||
canvas.renderAll();
|
||||
if (obj.get('id') != 'cropped') {
|
||||
canvas.remove(cropUI);
|
||||
canvas.remove(canvas.getItemById('crop-overlay'));
|
||||
@@ -3626,20 +3684,23 @@ function cropImage(object) {
|
||||
cropobj = object;
|
||||
canvas.uniformScaling = false;
|
||||
cropobj.setCoords();
|
||||
var left =
|
||||
cropobj.get('left') -
|
||||
(cropobj.get('width') * cropobj.get('scaleX')) / 2;
|
||||
var top =
|
||||
cropobj.get('top') -
|
||||
(cropobj.get('height') * cropobj.get('scaleY')) / 2;
|
||||
// The visible region, in the source bitmap's own pixels, before the image
|
||||
// is expanded back to its full size below
|
||||
var cropx = cropobj.get('cropX');
|
||||
var cropy = cropobj.get('cropY');
|
||||
var shownWidth = cropobj.get('width');
|
||||
var shownHeight = cropobj.get('height');
|
||||
var shownLeft = cropobj.get('left');
|
||||
var shownTop = cropobj.get('top');
|
||||
overlay();
|
||||
var cropUI = new fabric.Rect({
|
||||
left: object.get('left'),
|
||||
top: object.get('top'),
|
||||
width: object.get('width') * object.get('scaleX') - 5,
|
||||
height: object.get('height') * object.get('scaleY') - 5,
|
||||
// The crop window has to turn with the image, otherwise the region it
|
||||
// covers cannot be expressed as a crop at all
|
||||
angle: object.get('angle') || 0,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
id: 'crop',
|
||||
@@ -3674,15 +3735,19 @@ function cropImage(object) {
|
||||
})
|
||||
.setCoords();
|
||||
canvas.renderAll();
|
||||
// Growing back to the full bitmap moves the centre, so shift the image so
|
||||
// the region that was on screen stays exactly where it was. The offset is
|
||||
// in the image's own frame, hence the rotation.
|
||||
var shift = rotateVector(
|
||||
(cropx + shownWidth / 2 - cropobj.get('width') / 2) *
|
||||
cropobj.get('scaleX'),
|
||||
(cropy + shownHeight / 2 - cropobj.get('height') / 2) *
|
||||
cropobj.get('scaleY'),
|
||||
fabric.util.degreesToRadians(cropobj.get('angle') || 0)
|
||||
);
|
||||
cropobj.set({
|
||||
left:
|
||||
left +
|
||||
(cropobj.get('width') * cropobj.get('scaleX')) / 2 -
|
||||
cropx * cropobj.get('scaleX'),
|
||||
top:
|
||||
top +
|
||||
(cropobj.get('height') * cropobj.get('scaleY')) / 2 -
|
||||
cropy * cropobj.get('scaleY'),
|
||||
left: shownLeft - shift.x,
|
||||
top: shownTop - shift.y,
|
||||
});
|
||||
cropUI.setControlsVisibility({
|
||||
mt: false,
|
||||
@@ -4058,6 +4123,7 @@ function newRectangle(color) {
|
||||
backgroundColor: 'rgba(255,255,255,0)',
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
cornerRadius: 0,
|
||||
fill: color,
|
||||
cursorWidth: 1,
|
||||
cursorDuration: 1,
|
||||
@@ -4315,6 +4381,11 @@ function newAudioLayer(src) {
|
||||
duration: audio.duration * 1000,
|
||||
opacity: 0,
|
||||
selectable: false,
|
||||
// Audio layers are invisible placeholders. Selecting them (from the
|
||||
// layer list) must not paint controls / a bounding box on the canvas.
|
||||
hasControls: false,
|
||||
hasBorders: false,
|
||||
evented: false,
|
||||
volume: 0.5,
|
||||
assetType: 'audio',
|
||||
shadow: {
|
||||
@@ -4862,9 +4933,17 @@ function bindPointerDrag(e, el, onMove, onEnd) {
|
||||
window.addEventListener('blur', end);
|
||||
}
|
||||
|
||||
// A modal covers the timeline but does not cover pointer events, so every
|
||||
// timeline drag has to opt out while one is open - otherwise the seekbar,
|
||||
// keyframes, layer bars and the resize handle all still react to a drag
|
||||
// happening "behind" the dialog.
|
||||
function modalOpen() {
|
||||
return $('.modal-open').length > 0;
|
||||
}
|
||||
|
||||
// Dragging a keyframe
|
||||
function dragKeyframe(e) {
|
||||
if (e.which == 3) {
|
||||
if (e.which == 3 || modalOpen()) {
|
||||
return false;
|
||||
}
|
||||
e.stopPropagation();
|
||||
@@ -5014,7 +5093,7 @@ function updateTime(drag, check) {
|
||||
|
||||
// Dragging the seekbar
|
||||
function dragSeekBar(e) {
|
||||
if (e.which == 3) {
|
||||
if (e.which == 3 || modalOpen()) {
|
||||
return false;
|
||||
}
|
||||
// Stop the browser from turning the press into a native drag-and-drop.
|
||||
@@ -5085,7 +5164,7 @@ $(document).on(
|
||||
|
||||
// Dragging layer horizontally
|
||||
function dragObjectProps(e) {
|
||||
if (e.which == 3) {
|
||||
if (e.which == 3 || modalOpen()) {
|
||||
return false;
|
||||
}
|
||||
var drag = $(this).parent();
|
||||
@@ -5293,13 +5372,17 @@ function resetHeight() {
|
||||
'calc(100% - ' + (top + 97) + 'px)'
|
||||
);
|
||||
$('#properties').css('height', 'calc(100% - ' + (top + 97) + 'px)');
|
||||
$('.panel-handle').css(
|
||||
'height',
|
||||
'calc(100% - ' + (top + 97) + 'px)'
|
||||
);
|
||||
$('#timeline-handle').css('bottom', top + 95);
|
||||
resizeCanvas();
|
||||
}
|
||||
|
||||
// Dragging timeline vertically
|
||||
function dragTimeline(e) {
|
||||
if (e.which == 3) {
|
||||
if (e.which == 3 || modalOpen()) {
|
||||
return false;
|
||||
}
|
||||
// Suppress text selection for the duration of the drag only - leaving these
|
||||
@@ -5325,6 +5408,208 @@ $(document).on('pointerdown', '#timeline-handle', dragTimeline);
|
||||
|
||||
oldtimelinepos = $(window).height() - 92 - $('#timearea').height();
|
||||
|
||||
// Side panels: horizontal resize and hide/show
|
||||
// The widths live as CSS variables on :root - every panel rule is a calc() of
|
||||
// them, so moving a handle only has to write one number.
|
||||
const RAIL_WIDTH = 76;
|
||||
const BROWSER_MIN_WIDTH = 210;
|
||||
const BROWSER_MAX_WIDTH = 640;
|
||||
const PROPS_MIN_WIDTH = 300;
|
||||
const PROPS_MAX_WIDTH = 640;
|
||||
// Canvas width kept free while dragging, whatever the window size
|
||||
const CANVAS_MIN_WIDTH = 240;
|
||||
const PANEL_LAYOUT_KEY = 'motionity-panel-layout';
|
||||
|
||||
var browserwidth = 299;
|
||||
var propswidth = 300;
|
||||
var propshidden = false;
|
||||
// Tool to reopen the library with, set when it gets collapsed
|
||||
var lasttool = $('.tool-active').attr('id') || 'shape-tool';
|
||||
|
||||
function clampWidth(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function browserHidden() {
|
||||
return $('#browser').hasClass('collapsed');
|
||||
}
|
||||
|
||||
function applyPanelWidths() {
|
||||
const root = document.documentElement.style;
|
||||
const hidden = browserHidden();
|
||||
var browser = hidden ? 0 : browserwidth;
|
||||
var properties = propshidden ? 0 : propswidth;
|
||||
// Shrink to fit rather than squeeze the canvas out of a narrow window. The
|
||||
// stored widths are left alone, so the panels grow back on a wider one.
|
||||
var over =
|
||||
RAIL_WIDTH +
|
||||
browser +
|
||||
properties +
|
||||
CANVAS_MIN_WIDTH -
|
||||
$(window).width();
|
||||
if (over > 0 && properties > PROPS_MIN_WIDTH) {
|
||||
const cut = Math.min(over, properties - PROPS_MIN_WIDTH);
|
||||
properties -= cut;
|
||||
over -= cut;
|
||||
}
|
||||
if (over > 0 && browser > BROWSER_MIN_WIDTH) {
|
||||
browser -= Math.min(over, browser - BROWSER_MIN_WIDTH);
|
||||
}
|
||||
root.setProperty('--browser-w', browser + 'px');
|
||||
root.setProperty('--props-w', properties + 'px');
|
||||
// The layer list and the timeline keep their column width while the library
|
||||
// is hidden, otherwise layer names would get squashed into the tool rail.
|
||||
if (!hidden) {
|
||||
root.setProperty('--layers-w', RAIL_WIDTH + browser + 'px');
|
||||
}
|
||||
$('#browser-handle').toggleClass('panel-hidden', hidden);
|
||||
$('#properties-handle').toggleClass('panel-hidden', propshidden);
|
||||
$('#properties').toggleClass('collapsed', propshidden);
|
||||
$('#browser-toggle').attr(
|
||||
'title',
|
||||
hidden ? 'Show the library' : 'Hide the library'
|
||||
);
|
||||
$('#properties-toggle').attr(
|
||||
'title',
|
||||
propshidden ? 'Show the properties' : 'Hide the properties'
|
||||
);
|
||||
}
|
||||
|
||||
function savePanelLayout() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PANEL_LAYOUT_KEY,
|
||||
JSON.stringify({
|
||||
browser: browserwidth,
|
||||
properties: propswidth,
|
||||
propertieshidden: propshidden,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
// Private browsing or a full quota - the layout just won't persist
|
||||
}
|
||||
}
|
||||
|
||||
function restorePanelLayout() {
|
||||
var stored;
|
||||
try {
|
||||
stored = JSON.parse(localStorage.getItem(PANEL_LAYOUT_KEY));
|
||||
} catch (err) {
|
||||
stored = null;
|
||||
}
|
||||
if (stored) {
|
||||
if (typeof stored.browser == 'number') {
|
||||
browserwidth = clampWidth(
|
||||
stored.browser,
|
||||
BROWSER_MIN_WIDTH,
|
||||
BROWSER_MAX_WIDTH
|
||||
);
|
||||
}
|
||||
if (typeof stored.properties == 'number') {
|
||||
propswidth = clampWidth(
|
||||
stored.properties,
|
||||
PROPS_MIN_WIDTH,
|
||||
PROPS_MAX_WIDTH
|
||||
);
|
||||
}
|
||||
propshidden = stored.propertieshidden === true;
|
||||
}
|
||||
applyPanelWidths();
|
||||
}
|
||||
|
||||
// Dragging a side panel border
|
||||
function dragPanel(e) {
|
||||
if (e.which == 3 || modalOpen() || $(this).hasClass('panel-hidden')) {
|
||||
return false;
|
||||
}
|
||||
e.preventDefault();
|
||||
const handle = $(this);
|
||||
const isbrowser = handle.attr('id') == 'browser-handle';
|
||||
const startx = e.pageX;
|
||||
const startwidth = isbrowser ? browserwidth : propswidth;
|
||||
// Suppress text selection for the duration of the drag only
|
||||
const previousSelectStart = document.onselectstart;
|
||||
document.onselectstart = function () {
|
||||
return false;
|
||||
};
|
||||
handle.addClass('handle-dragging');
|
||||
|
||||
function draggingPanelBorder(ev) {
|
||||
// The properties panel is anchored to the right, so its width grows the
|
||||
// other way round
|
||||
const delta = isbrowser ? ev.pageX - startx : startx - ev.pageX;
|
||||
const otherwidth = isbrowser
|
||||
? propshidden
|
||||
? 0
|
||||
: propswidth
|
||||
: browserHidden()
|
||||
? 0
|
||||
: browserwidth;
|
||||
const room =
|
||||
$(window).width() - RAIL_WIDTH - CANVAS_MIN_WIDTH - otherwidth;
|
||||
if (isbrowser) {
|
||||
browserwidth = clampWidth(
|
||||
startwidth + delta,
|
||||
BROWSER_MIN_WIDTH,
|
||||
Math.min(BROWSER_MAX_WIDTH, room)
|
||||
);
|
||||
} else {
|
||||
propswidth = clampWidth(
|
||||
startwidth + delta,
|
||||
PROPS_MIN_WIDTH,
|
||||
Math.min(PROPS_MAX_WIDTH, room)
|
||||
);
|
||||
}
|
||||
applyPanelWidths();
|
||||
resizeCanvas();
|
||||
}
|
||||
function releasedPanelBorder() {
|
||||
document.onselectstart = previousSelectStart || null;
|
||||
handle.removeClass('handle-dragging');
|
||||
savePanelLayout();
|
||||
}
|
||||
bindPointerDrag(e, this, draggingPanelBorder, releasedPanelBorder);
|
||||
}
|
||||
$(document).on('pointerdown', '.panel-handle', dragPanel);
|
||||
|
||||
// Keep a click on a hide/show button from starting a resize drag
|
||||
$(document).on('pointerdown', '.panel-toggle', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
$(document).on('click', '#browser-toggle', function (e) {
|
||||
e.stopPropagation();
|
||||
if (browserHidden()) {
|
||||
// Reopening goes through the tool click so the rail icons stay in sync
|
||||
$('#' + lasttool).trigger('click');
|
||||
} else {
|
||||
collapsePanel();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('click', '#properties-toggle', function (e) {
|
||||
e.stopPropagation();
|
||||
propshidden = !propshidden;
|
||||
applyPanelWidths();
|
||||
savePanelLayout();
|
||||
resizeCanvas();
|
||||
});
|
||||
|
||||
// Re-fits the panels after the window itself changed size. Runs after the
|
||||
// resizeCanvas listener registered at the top of this file, so the canvas is
|
||||
// measured again once the new widths are in.
|
||||
window.addEventListener(
|
||||
'resize',
|
||||
function () {
|
||||
applyPanelWidths();
|
||||
resizeCanvas();
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
restorePanelLayout();
|
||||
resizeCanvas();
|
||||
|
||||
// Sync scrolling (vertical)
|
||||
function syncScroll(el1, el2) {
|
||||
var $el1 = $(el1);
|
||||
|
||||
+23
-6
@@ -570,7 +570,7 @@ function updatePanelValues() {
|
||||
o_slider.setValue(object.get('opacity') * 100);
|
||||
if (object.get('type') == 'rect') {
|
||||
$('#object-corners input').val(
|
||||
parseFloat(object.get('rx').toFixed(2))
|
||||
parseFloat(getCornerRadius(object).toFixed(2))
|
||||
);
|
||||
colormode = 'fill';
|
||||
o_fill.setColor(object.get('fill'));
|
||||
@@ -1219,12 +1219,13 @@ $(document).on(
|
||||
|
||||
// Switch tool
|
||||
function switchTool(e) {
|
||||
$('#browser').removeClass('collapsed');
|
||||
$('#canvas-area').removeClass('canvas-full');
|
||||
if ($(this).attr('id') == 'more-tool') {
|
||||
showMore();
|
||||
return false;
|
||||
}
|
||||
$('#browser').removeClass('collapsed');
|
||||
$('#behind-browser').removeClass('collapsed');
|
||||
applyPanelWidths();
|
||||
resizeCanvas();
|
||||
var act = $('.tool-active');
|
||||
if (act.attr('id') == 'image-tool') {
|
||||
@@ -1292,6 +1293,10 @@ function dragObject(e) {
|
||||
if (e.which == 3) {
|
||||
return false;
|
||||
}
|
||||
// Measure before the clone leaves the panel - panel items are sized in
|
||||
// percentages of a resizable panel, so a clone parented to <body> would
|
||||
// report the body width instead.
|
||||
var sourcewidth = $(this).width();
|
||||
var drag = $(this).clone();
|
||||
drag.css({
|
||||
background: 'transparent',
|
||||
@@ -1304,7 +1309,7 @@ function dragObject(e) {
|
||||
zIndex: 9999999,
|
||||
left: $(this).offset().left,
|
||||
top: $(this).offset().top,
|
||||
width: canvas.getZoom() * drag.width(),
|
||||
width: canvas.getZoom() * sourcewidth,
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
});
|
||||
@@ -1619,10 +1624,13 @@ $(document).on(
|
||||
|
||||
// Collapse library
|
||||
function collapsePanel() {
|
||||
var act = $('.tool-active');
|
||||
if (act.length > 0) {
|
||||
// Remembered so the handle button can reopen the same tab
|
||||
lasttool = act.attr('id');
|
||||
}
|
||||
$('#browser').addClass('collapsed');
|
||||
$('#behind-browser').addClass('collapsed');
|
||||
$('#canvas-area').addClass('canvas-full');
|
||||
var act = $('.tool-active');
|
||||
if (act.attr('id') == 'image-tool') {
|
||||
act.find('img').attr('src', 'assets/image.svg');
|
||||
} else if (act.attr('id') == 'text-tool') {
|
||||
@@ -1637,6 +1645,7 @@ function collapsePanel() {
|
||||
act.find('img').attr('src', 'assets/uploads.svg');
|
||||
}
|
||||
$('.tool-active').removeClass('tool-active');
|
||||
applyPanelWidths();
|
||||
resizeCanvas();
|
||||
}
|
||||
$(document).on('click', '#collapse', collapsePanel);
|
||||
@@ -1785,6 +1794,14 @@ function importExportModal() {
|
||||
}
|
||||
$('#share').on('click', importExportModal);
|
||||
|
||||
// Open credits modal
|
||||
function creditsModal() {
|
||||
hideModals();
|
||||
$('#credits-modal').toggleClass('modal-open');
|
||||
$('#background-overlay').toggleClass('modal-open');
|
||||
}
|
||||
$('#credits-button').on('click', creditsModal);
|
||||
|
||||
function searchInput() {
|
||||
var value = $(this).val().toLowerCase();
|
||||
if (value == '') {
|
||||
|
||||
+205
-76
@@ -7,6 +7,14 @@
|
||||
--input-color: #22233e;
|
||||
--accent-color: #166ef1;
|
||||
--button-hover: #262746;
|
||||
/* Panel geometry. Every rule that used to hardcode 76/299/300/375 derives
|
||||
from these so the resize handles only have to move one number each.
|
||||
--layers-w tracks --rail-w + --browser-w while the library panel is
|
||||
visible, but stays put when it is hidden so the layer names keep room. */
|
||||
--rail-w: 76px;
|
||||
--browser-w: 299px;
|
||||
--props-w: 300px;
|
||||
--layers-w: 375px;
|
||||
}
|
||||
/*
|
||||
:root {
|
||||
@@ -41,7 +49,7 @@ body {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: var(--panel-back);
|
||||
width: 76px;
|
||||
width: var(--rail-w);
|
||||
border-right: 1px solid var(--panel-stroke);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -204,6 +212,27 @@ body {
|
||||
.hand-active:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
#credits-button {
|
||||
width: 100px;
|
||||
position: absolute;
|
||||
right: 330px;
|
||||
bottom: 12px;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
text-align: center;
|
||||
background-color: #30314e;
|
||||
color: var(--main-text-color);
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0px 2px 2px rgba(255, 255, 255, 0.05),
|
||||
inset 0px -2px 1px rgba(0, 0, 0, 0.05);
|
||||
font-family: Inter;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
#credits-button:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* Bottom of canvas */
|
||||
#bottom-canvas {
|
||||
position: absolute;
|
||||
@@ -212,58 +241,6 @@ body {
|
||||
z-index: 9999999;
|
||||
width: 100%;
|
||||
}
|
||||
#sponsor {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: -10px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-color);
|
||||
background: rgba(22, 95, 205, 0.1);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
#sponsor img {
|
||||
margin-right: 3px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
#sponsor:hover {
|
||||
background: rgba(22, 95, 205, 0.3) !important;
|
||||
}
|
||||
#alyssa-credit {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
bottom: 15px;
|
||||
font-family: Inter;
|
||||
width: 200px;
|
||||
filter: drop-shadow(0px 1px 15px #141629);
|
||||
text-decoration: none !important;
|
||||
}
|
||||
#alyssa-credit:hover {
|
||||
filter: drop-shadow(0px 1px 10px #141629) !important;
|
||||
}
|
||||
#alyssa-credit span {
|
||||
color: var(--main-text-color) !important;
|
||||
text-decoration: none !important;
|
||||
display: inline-block;
|
||||
}
|
||||
#alyssa-credit img {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
width: 18px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.hide-folder {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -275,7 +252,7 @@ body {
|
||||
#toolbar {
|
||||
position: absolute;
|
||||
height: calc(100% - 340px);
|
||||
width: 76px;
|
||||
width: var(--rail-w);
|
||||
background-color: var(--panel-back);
|
||||
border-right: 1px solid var(--panel-stroke);
|
||||
left: 0px;
|
||||
@@ -339,8 +316,8 @@ body {
|
||||
#behind-browser {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 299px;
|
||||
left: 76px;
|
||||
width: var(--browser-w);
|
||||
left: var(--rail-w);
|
||||
background: var(--panel-back);
|
||||
border-right: 1px solid var(--panel-stroke);
|
||||
z-index: 1;
|
||||
@@ -349,10 +326,10 @@ body {
|
||||
#browser {
|
||||
position: absolute;
|
||||
height: calc(100% - 450px);
|
||||
width: 299px;
|
||||
width: var(--browser-w);
|
||||
background-color: var(--panel-back);
|
||||
border-right: 1px solid var(--panel-stroke);
|
||||
left: 76px;
|
||||
left: var(--rail-w);
|
||||
top: 110px;
|
||||
box-sizing: border-box;
|
||||
z-index: 999999;
|
||||
@@ -364,7 +341,7 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
#browser-container {
|
||||
width: 260px;
|
||||
width: calc(var(--browser-w) - 39px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
height: 100%;
|
||||
@@ -390,7 +367,7 @@ body {
|
||||
}
|
||||
.image-grid-item,
|
||||
.video-grid-item {
|
||||
width: 120px;
|
||||
width: 100%;
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
@@ -419,7 +396,7 @@ body {
|
||||
text-align: right;
|
||||
text-decoration: none;
|
||||
text-shadow: 0px 1px 5px #000000;
|
||||
width: 110px;
|
||||
width: calc(100% - 10px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -467,7 +444,9 @@ body {
|
||||
grid-gap: 15px;
|
||||
width: 100%;
|
||||
grid-auto-columns: auto;
|
||||
grid-template-columns: 52px 52px 52px 52px;
|
||||
/* Column count follows the panel width so the grid reflows when the library
|
||||
is resized instead of overflowing a fixed four-column track. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
|
||||
overflow: hidden;
|
||||
}
|
||||
.scroll-row:before {
|
||||
@@ -492,7 +471,7 @@ body {
|
||||
#search-fixed {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
width: 279px;
|
||||
width: calc(var(--browser-w) - 20px);
|
||||
padding-left: 19px;
|
||||
margin-left: -19px;
|
||||
z-index: 999999;
|
||||
@@ -807,12 +786,13 @@ body {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
width: 300px;
|
||||
width: var(--props-w);
|
||||
height: calc(100% - 340px);
|
||||
background-color: var(--panel-back);
|
||||
border-left: 1px solid var(--panel-stroke);
|
||||
z-index: 999;
|
||||
overflow-y: overlay;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#properties-overlay {
|
||||
width: 100%;
|
||||
@@ -861,9 +841,12 @@ hr {
|
||||
background-color: var(--panel-stroke);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#properties hr {
|
||||
width: calc(var(--props-w) - 40px);
|
||||
}
|
||||
/* Property sections */
|
||||
.panel-section {
|
||||
width: 260px;
|
||||
width: calc(var(--props-w) - 40px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -900,8 +883,17 @@ th {
|
||||
width: 189px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Keeps the inputs against the right edge once the column can be wider
|
||||
than its contents */
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Inside the properties panel the value column follows the panel width, so
|
||||
dropdowns and sliders grow when the panel is widened. 111px is the label
|
||||
column plus the panel gutters. The filters popup keeps the fixed width. */
|
||||
#properties .value-col {
|
||||
width: calc(var(--props-w) - 111px);
|
||||
}
|
||||
/* Dropdows */
|
||||
.nice-select,
|
||||
.list,
|
||||
@@ -1375,6 +1367,49 @@ input[type="number"] {
|
||||
display: block;
|
||||
z-index: 99999999999;
|
||||
}
|
||||
/* Credits modal */
|
||||
#credits-modal {
|
||||
width: 300px;
|
||||
max-height: 400px;
|
||||
background-color: var(--panel-back);
|
||||
border: 1px solid var(--panel-stroke);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.3);
|
||||
position: absolute;
|
||||
right: 280px;
|
||||
bottom: 60px;
|
||||
visibility: hidden;
|
||||
display: block;
|
||||
z-index: 99999999999;
|
||||
padding-bottom: 15px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#credits-modal .header {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
#credits-modal .subtitle {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 13px;
|
||||
}
|
||||
#credits-modal .subtitle span {
|
||||
color: var(--main-text-color) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
#credits-modal .subheader {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#credits-list {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
.credit-item {
|
||||
color: var(--secondary-text-color);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--secondary-text-color);
|
||||
font-family: Inter;
|
||||
@@ -1538,16 +1573,12 @@ label span {
|
||||
#canvas-area {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 375px;
|
||||
left: calc(var(--rail-w) + var(--browser-w));
|
||||
height: calc(100% - 342px);
|
||||
width: calc(100% - 675px);
|
||||
width: calc(100% - var(--rail-w) - var(--browser-w) - var(--props-w));
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.canvas-full {
|
||||
left: 76px !important;
|
||||
width: calc(100% - 376px) !important;
|
||||
}
|
||||
.canvas-container {
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
@@ -1602,7 +1633,7 @@ canvas {
|
||||
#layer-list {
|
||||
position: absolute;
|
||||
height: 245px;
|
||||
width: 375px;
|
||||
width: var(--layers-w);
|
||||
left: 0px;
|
||||
bottom: 60px;
|
||||
background-color: var(--panel-back);
|
||||
@@ -1804,7 +1835,7 @@ layer:nth-child(even) .properties {
|
||||
color: var(--secondary-text-color);
|
||||
line-height: 40px;
|
||||
height: 40px;
|
||||
width: 375px;
|
||||
width: var(--layers-w);
|
||||
position: fixed;
|
||||
margin-top: -35px;
|
||||
background-color: var(--panel-back);
|
||||
@@ -1828,10 +1859,10 @@ layer:nth-child(even) .properties {
|
||||
#timearea {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 375px;
|
||||
left: var(--layers-w);
|
||||
height: 245px;
|
||||
background-color: var(--main-back);
|
||||
width: calc(100% - 375px);
|
||||
width: calc(100% - var(--layers-w));
|
||||
border-top: 1px solid var(--panel-stroke);
|
||||
z-index: 999;
|
||||
}
|
||||
@@ -1846,10 +1877,108 @@ layer:nth-child(even) .properties {
|
||||
cursor: ns-resize;
|
||||
background-color: var(--panel-stroke);
|
||||
}
|
||||
/* Side panel resize handles. Each one straddles the border between a side
|
||||
panel and the canvas, and carries the hide/show button for that panel.
|
||||
Their height is kept in sync with the panels by resetHeight(). */
|
||||
.panel-handle {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
height: calc(100% - 340px);
|
||||
width: 7px;
|
||||
z-index: 9999999;
|
||||
box-sizing: border-box;
|
||||
touch-action: none;
|
||||
-webkit-user-drag: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
#browser-handle {
|
||||
left: calc(var(--rail-w) + var(--browser-w) - 3px);
|
||||
}
|
||||
#properties-handle {
|
||||
right: calc(var(--props-w) - 3px);
|
||||
}
|
||||
#properties-handle.panel-hidden {
|
||||
right: 0px;
|
||||
}
|
||||
.panel-handle:not(.panel-hidden):hover,
|
||||
.panel-handle.handle-dragging {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.panel-handle:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 0px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background-color: var(--accent-color);
|
||||
opacity: 0;
|
||||
}
|
||||
.panel-handle:not(.panel-hidden):hover:before,
|
||||
.panel-handle.handle-dragging:before {
|
||||
opacity: 1;
|
||||
}
|
||||
.panel-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -18px;
|
||||
width: 18px;
|
||||
height: 36px;
|
||||
background-color: var(--input-color);
|
||||
border: 1px solid var(--panel-stroke);
|
||||
box-sizing: border-box;
|
||||
/* Always on screen, dimmed. Revealing it on hover meant having to find the
|
||||
3px resize line first, then travel onto the button. */
|
||||
opacity: 0.4;
|
||||
}
|
||||
.panel-toggle:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
.panel-toggle:hover,
|
||||
.panel-handle:hover .panel-toggle,
|
||||
.panel-handle.handle-dragging .panel-toggle,
|
||||
.panel-handle.panel-hidden .panel-toggle {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Chevron, pointing at the edge the panel would collapse towards */
|
||||
.panel-toggle:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border-left: 1.5px solid var(--secondary-text-color);
|
||||
border-bottom: 1.5px solid var(--secondary-text-color);
|
||||
}
|
||||
#browser-handle .panel-toggle {
|
||||
left: 5px;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
#properties-handle .panel-toggle {
|
||||
right: 5px;
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
}
|
||||
#properties-handle.panel-hidden .panel-toggle {
|
||||
right: 4px;
|
||||
}
|
||||
#browser-handle .panel-toggle:after,
|
||||
#properties-handle.panel-hidden .panel-toggle:after {
|
||||
margin: -4px 0px 0px -2px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
#properties-handle .panel-toggle:after,
|
||||
#browser-handle.panel-hidden .panel-toggle:after {
|
||||
margin: -4px 0px 0px -5px;
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
#seekarea {
|
||||
height: 100%;
|
||||
z-index: 99999;
|
||||
width: calc(100% - 375px);
|
||||
/* Fixed positioning - the percentage is the viewport, not #timearea. */
|
||||
width: calc(100% - var(--layers-w));
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user