build: add Gitea release scripts and npm entry points

scripts/build-release.ps1 packages the desktop installers; scripts/publish.ps1
pushes the container image to git.azuze.fr and attaches the installers to the
Gitea release for the same tag. The split keeps a local build free of any
credential, and lets a failed upload be retried with -BinariesOnly
-NoBinaryBuild without paying for the build again.

Two details the plain `npm run dist:*` path gets wrong for a release:

- package.json's global artifactName resolves NSIS and portable to the same
  file name, so one silently overwrites the other. The build script passes
  distinct -c.nsis.artifactName / -c.portable.artifactName.
- Artifact names now carry the tag, so publish.ps1 can glob exactly one tag's
  files and never ship a stale one. electron-builder's update metadata
  (.blockmap, latest*.yml, the NSIS .7z payload) is swept out of dist/ so it
  cannot match that glob and end up attached to the release.

The tag defaults to v<package.json version> rather than git describe, because
electron-builder stamps package.json into the app: a SHA-based tag would name
an installer whose About box disagrees with it.

Also brings scripts/server.cjs, vendor.mjs and make-icon.cjs under version
control, which the release scripts and the Docker build both call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 12:56:11 +02:00
co-authored by Claude Opus 5
parent f8f863e7e2
commit f2b9f28d14
7 changed files with 1203 additions and 0 deletions
+34
View File
@@ -1,3 +1,37 @@
# Motionity
This is a fork of the original project aiming to fix issues and add features.
## Running it
```bash
npm run vendor # downloads the third-party libraries into src/vendor/
npm start # http://127.0.0.1:8080
```
Three packaged distributions are available — desktop (Windows `.exe`, Linux
AppImage and Flatpak), a Docker image, and a bare-metal install. Build
instructions for all of them are in [PACKAGING.md](PACKAGING.md).
```bash
npm run dev # desktop app from source
npm run dist:win # Windows installer + portable exe
npm run dist:linux # AppImage + Flatpak
npm run docker:build # container image
```
## Releasing
```powershell
./scripts/build-release.ps1 # installers + SHA256SUMS.txt in dist/
./scripts/publish.ps1 -PublishRelease # + image push and Gitea release upload
```
`build-release.ps1` needs no credentials. `publish.ps1` needs a Gitea token with
package read/write (image push) and `write:repository` (release upload), read
from `$env:GITEA_TOKEN` or prompted for. `-BinariesOnly -NoBinaryBuild` retries a
failed upload without rebuilding.
One rule applies to every target: browsers expose WebCodecs (the fast exporter)
and IndexedDB (project saving) only in a secure context. `http://localhost`
qualifies; a plain-HTTP LAN address does not. Serve it over TLS anywhere else.
+90
View File
@@ -0,0 +1,90 @@
{
"name": "motionity",
"productName": "Motionity",
"version": "1.0.0",
"description": "Web-based motion graphics editor with keyframing, masking, filters and text animations",
"license": "MIT",
"main": "electron/main.js",
"private": true,
"scripts": {
"vendor": "node scripts/vendor.mjs",
"icons": "node scripts/make-icon.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",
"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": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease",
"test": "node --test \"test/**/*.test.js\""
},
"devDependencies": {
"electron": "^40.0.0",
"electron-builder": "^26.0.0"
},
"build": {
"appId": "app.motionity.desktop",
"productName": "Motionity",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"electron/**/*",
"scripts/server.cjs",
"src/**/*",
"!src/**/*.map"
],
"win": {
"target": [
{ "target": "nsis", "arch": ["x64"] },
{ "target": "portable", "arch": ["x64"] }
],
"icon": "build/icon.png"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false,
"createDesktopShortcut": true,
"shortcutName": "Motionity"
},
"linux": {
"target": ["AppImage", "flatpak"],
"icon": "build/icon.png",
"category": "Graphics",
"synopsis": "Motion graphics editor",
"desktop": {
"entry": {
"Name": "Motionity",
"Categories": "Graphics;AudioVideo;VideoEditor;"
}
}
},
"appImage": {
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"flatpak": {
"runtimeVersion": "23.08",
"baseVersion": "23.08",
"finishArgs": [
"--share=ipc",
"--socket=x11",
"--socket=wayland",
"--socket=pulseaudio",
"--device=dri",
"--share=network",
"--filesystem=xdg-download",
"--filesystem=xdg-documents",
"--filesystem=xdg-pictures",
"--filesystem=xdg-videos",
"--filesystem=xdg-music"
]
}
}
}
+222
View File
@@ -0,0 +1,222 @@
#requires -Version 5.1
<#
.SYNOPSIS
Build the Motionity desktop installers with electron-builder.
.DESCRIPTION
Produces, in dist/:
motionity-<tag>-win-x64-setup.exe NSIS installer
motionity-<tag>-win-x64-portable.exe portable exe
motionity-<tag>-linux-x86_64.AppImage AppImage
motionity-<tag>-linux-x86_64.flatpak Flatpak bundle
SHA256SUMS.txt
The PowerShell equivalent of `npm run dist:win` / `npm run dist:linux`, with
two differences that matter for a release:
* every artifact name carries the tag, so publish.ps1 can glob exactly this
tag's files and never ship a stale one from an earlier build;
* NSIS and portable get distinct names. package.json's global artifactName
(`${productName}-${version}-${arch}.${ext}`) resolves to the same file for
both, so one silently overwrites the other.
The vendor step runs first: index.html references only src/vendor/, which is
gitignored, so a package built without it ships an app whose every script tag
404s.
Windows builds the .exe targets; AppImage and Flatpak need a Linux host or
WSL (see PACKAGING.md). Nothing here cross-builds.
.EXAMPLE
./scripts/build-release.ps1
Build every target at v<package.json version>.
.EXAMPLE
./scripts/build-release.ps1 -Targets win -Tag v1.1.0
Windows installers only, named v1.1.0.
.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" is AppImage + Flatpak.
[ValidateSet("win", "linux")]
[string[]]$Targets = @("win", "linux"),
# Version used in the artifact names. Defaults to v<package.json version>,
# because electron-builder stamps that same version into the app itself — a
# git-describe tag here would disagree with what the installed app reports.
[string]$Tag,
# Skip `npm run vendor` (the ~20 MB third-party download into src/vendor/).
[switch]$SkipVendor,
# Skip the npm install even when node_modules is missing.
[switch]$SkipDeps,
# Remove dist/ before building.
[switch]$Clean
)
$ErrorActionPreference = "Stop"
function Invoke-Checked {
param([Parameter(Mandatory)][string]$Exe, [Parameter(Mandatory)][string[]]$Args)
Write-Host " > $Exe $($Args -join ' ')" -ForegroundColor DarkGray
& $Exe @Args
if ($LASTEXITCODE -ne 0) {
throw "'$Exe $($Args -join ' ')' failed with exit code $LASTEXITCODE."
}
}
function Get-ArtifactName {
<#
electron-builder rejects an artifactName that has no ${ext} macro, and
PowerShell would read `${ext}` inside a double-quoted string as a variable,
so the macro is appended from a single-quoted literal.
#>
param([Parameter(Mandatory)][string]$Stem)
return $Stem + '.${ext}'
}
$repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot
try {
$pkg = Get-Content (Join-Path $repoRoot "package.json") -Raw | ConvertFrom-Json
if (-not $Tag) { $Tag = "v$($pkg.version)" }
if ($Tag.TrimStart("v") -ne $pkg.version) {
Write-Warning "-Tag '$Tag' does not match package.json version '$($pkg.version)'. electron-builder stamps package.json into the app, so the file names and the app's own About version would disagree — bump package.json first."
}
$prefix = "motionity-$Tag"
$distDir = Join-Path $repoRoot "dist"
Write-Host "Motionity release build" -ForegroundColor Cyan
Write-Host " tag : $Tag"
Write-Host " targets : $($Targets -join ', ')"
Write-Host " output : $distDir"
Write-Host ""
# 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 ($Targets -contains "linux" -and $env:OS -eq "Windows_NT") {
Write-Warning "the linux target needs a Linux host or WSL — electron-builder cannot produce AppImage or Flatpak on Windows (PACKAGING.md has the WSL setup)."
}
if ($Clean) {
Write-Host "Cleaning dist/..." -ForegroundColor Cyan
if (Test-Path $distDir) { Remove-Item -Recurse -Force $distDir }
}
New-Item -ItemType Directory -Force -Path $distDir | Out-Null
# --- Dependencies ---------------------------------------------------------
# Only when node_modules is absent: `npm ci` deletes the tree and re-extracts
# ~250 MB of Electron every time, which turns a 2-minute rebuild into a
# 10-minute one for no gain.
if (-not $SkipDeps -and -not (Test-Path (Join-Path $repoRoot "node_modules"))) {
Write-Host "Installing dependencies..." -ForegroundColor Cyan
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
throw "npm not found — install Node 18+, or pass -SkipDeps if node_modules is provided some other way."
}
Invoke-Checked npm @("ci", "--no-audit", "--no-fund")
Write-Host ""
}
# --- Vendored assets ------------------------------------------------------
# index.html points only at src/vendor/, which is gitignored, so this has to
# run before electron-builder copies src/ into the package — not after.
if (-not $SkipVendor) {
Write-Host "Vendoring third-party assets..." -ForegroundColor Cyan
Invoke-Checked node @("scripts/vendor.mjs")
Write-Host ""
}
# A missing src/vendor/ yields an installer that opens to a blank editor and
# only fails at run time, so check one file that must be there.
$vendorProbe = Join-Path $repoRoot "src/vendor/fabric.min.js"
if (-not (Test-Path $vendorProbe)) {
throw "src/vendor/fabric.min.js is missing — the package would ship an app whose scripts all 404. Run without -SkipVendor."
}
# --- Icon -----------------------------------------------------------------
# build/icon.png is gitignored and generated; electron-builder derives the
# Windows .ico and the Linux icon set from it and fails without it.
Write-Host "Rendering icon..." -ForegroundColor Cyan
Invoke-Checked node @("scripts/make-icon.cjs")
Write-Host ""
# --- Package --------------------------------------------------------------
# The local binary rather than npx: npx renamed --no-install to --no in npm 10,
# and a version-dependent flag inside a release script is a trap.
$builder = Join-Path $repoRoot "node_modules/.bin/electron-builder.cmd"
if (-not (Test-Path $builder)) {
$builder = Join-Path $repoRoot "node_modules/.bin/electron-builder"
}
if (-not (Test-Path $builder)) {
throw 'electron-builder not found in node_modules — run "npm ci" (or drop -SkipDeps).'
}
foreach ($target in $Targets) {
Write-Host "Packaging $target..." -ForegroundColor Cyan
# --publish never: electron-builder otherwise tries to upload to whatever
# provider it infers from the repo URL as soon as the tag looks like a
# release. Publishing is publish.ps1's job, against Gitea.
switch ($target) {
"win" {
$builderArgs = @(
"--win", "--publish", "never",
"-c.nsis.artifactName=$(Get-ArtifactName "$prefix-win-x64-setup")",
"-c.portable.artifactName=$(Get-ArtifactName "$prefix-win-x64-portable")"
)
}
"linux" {
$builderArgs = @(
"--linux", "AppImage", "flatpak", "--publish", "never",
"-c.appImage.artifactName=$(Get-ArtifactName "$prefix-linux-x86_64")",
"-c.flatpak.artifactName=$(Get-ArtifactName "$prefix-linux-x86_64")"
)
}
}
Invoke-Checked $builder $builderArgs
Write-Host ""
}
# electron-builder drops auto-update metadata and the NSIS payload beside the
# installers. publish.ps1 uploads everything matching the tag prefix, so clear
# them out here instead of attaching 80 MB of intermediates to the release.
Get-ChildItem $distDir -File | Where-Object {
$_.Name -like "*.blockmap" -or $_.Name -like "latest*.yml" -or
$_.Name -like "*.nsis.7z" -or $_.Name -eq "builder-debug.yml"
} | 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."
}
# --- 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)"
}
# 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 ""
Write-Host "Done. Built:" -ForegroundColor Green
foreach ($f in $built) {
Write-Host " $($f.FullName) ($([math]::Round($f.Length / 1MB, 1)) MB)" -ForegroundColor Green
}
Write-Host " $sumsPath" -ForegroundColor Green
}
finally {
Pop-Location
}
+111
View File
@@ -0,0 +1,111 @@
#!/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.
//
// 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.
const { deflateSync } = require('node:zlib');
const { mkdirSync, writeFileSync } = require('node:fs');
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 };
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 },
];
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 };
// Raw RGBA scanlines, each prefixed with filter type 0.
const raw = Buffer.alloc(SIZE * (1 + SIZE * 4));
for (let py = 0; py < SIZE; py++) {
const rowStart = py * (1 + SIZE * 4);
raw[rowStart] = 0;
for (let px = 0; px < SIZE; px++) {
let plateHits = 0;
let barHits = 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;
if (BARS.some((bar) => insideRoundedRect(lx, ly, bar))) barHits++;
}
}
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 + 3] = alpha;
}
}
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const out = Buffer.alloc(8 + data.length + 4);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, 'ascii');
data.copy(out, 8);
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
return out;
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(SIZE, 0);
ihdr.writeUInt32BE(SIZE, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type: RGBA
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
const buildDir = resolve(__dirname, '..', 'build');
mkdirSync(buildDir, { recursive: true });
const dest = join(buildDir, 'icon.png');
writeFileSync(dest, png);
console.log(`Wrote ${dest} (${SIZE}x${SIZE}, ${Math.round(png.length / 1024)} KB)`);
+456
View File
@@ -0,0 +1,456 @@
#requires -Version 5.1
<#
.SYNOPSIS
Build the Motionity container image + desktop installers; push the image to the
Gitea registry and attach the installers to a Gitea release.
.DESCRIPTION
Builds the Docker image from the repo Dockerfile, tags it for the Gitea
registry (git.azuze.fr by default), logs in, and pushes one or more tags.
It also builds the desktop installers (scripts/build-release.ps1) from the same
commit, so both carry the same -Tag. Installers cannot live in a container
registry, so -PublishRelease attaches them to the Gitea release for that tag
instead (creating the release if it does not exist).
-BinariesOnly ships just the installers: no docker build, no docker login, no
image push, and the release upload is implied. That is also the mode to use on
a Linux host or in WSL, where the AppImage and Flatpak targets actually build.
Credentials are read, in order of precedence:
1. -Username / -Password parameters
2. $env:GITEA_USER / $env:GITEA_TOKEN
3. Interactive prompt (token is read as a SecureString)
Use a Gitea access token (Settings -> Applications) as the password, not your
account password. The image push needs package read/write scope; the release
upload needs repository write scope (`write:repository`).
.EXAMPLE
./scripts/publish.ps1
Build and push :latest plus v<package.json version>; build installers locally.
.EXAMPLE
./scripts/publish.ps1 -Tag v1.1.0 -PublishRelease
Full release: push the image and attach every dist/ installer to release v1.1.0.
.EXAMPLE
./scripts/publish.ps1 -BinariesOnly -Targets win -Tag v1.1.0
Windows installers only — build them and attach them to release v1.1.0. Docker
is never invoked, so this works with Docker Desktop stopped.
.EXAMPLE
./scripts/publish.ps1 -BinariesOnly -NoBinaryBuild -Tag v1.1.0
Retry a failed upload: attach the installers already in dist/ without rebuilding.
.EXAMPLE
./scripts/publish.ps1 -NoBinaries
Container only — no installer build, so no Node toolchain needed.
.EXAMPLE
$env:GITEA_USER = "kawa"; $env:GITEA_TOKEN = "xxxx"; ./scripts/publish.ps1 -SkipLogin:$false
#>
[CmdletBinding()]
param(
# Registry host (Gitea instance).
[string]$Registry = "git.azuze.fr",
# Owner / organisation that holds the package and the repo.
[string]$Owner = "kawa",
# Image name.
[string]$Image = "motionity",
# Repository name holding the releases. The image and the repo are not named
# the same here (motionity vs Motionity), so this is separate from -Image.
[string]$Repo = "Motionity",
# Primary tag. Defaults to v<package.json version>.
[string]$Tag,
# Also push :latest. On by default.
[switch]$NoLatest,
# Registry username. Falls back to $env:GITEA_USER then a prompt.
[string]$Username,
# Registry token/password. Falls back to $env:GITEA_TOKEN then a prompt.
[string]$Password,
# Ship the image without the 18.5 MB asm.js ffmpeg build: "0" makes MP4/GIF
# export fetch it from archive.org on first use instead of working offline.
# The Dockerfile declares this ARG; it has no ARG VERSION.
[ValidateSet("0", "1")]
[string]$WithFfmpeg = "1",
# Skip the image build and only push existing local tags.
[switch]$NoBuild,
# Skip docker login (assume already authenticated).
[switch]$SkipLogin,
# Skip building the desktop installers.
[switch]$NoBinaries,
# Forwarded to build-release.ps1.
[ValidateSet("win", "linux")]
[string[]]$Targets = @("win", "linux"),
[switch]$SkipVendor,
# 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,
# Ship only the installers: no docker build, login or push. Implies
# -PublishRelease, since building alone is what build-release.ps1 already does.
[switch]$BinariesOnly,
# Attach the installers to the Gitea release for $Tag, creating the release if
# it is missing.
[switch]$PublishRelease,
# owner/repo holding the release. Defaults to $Owner/$Repo.
[string]$ReleaseRepo,
# Gitea base URL for the API. Defaults to https://<Registry>.
[string]$ApiBase,
# Replace release attachments that already exist under the same name.
[switch]$Force
)
$ErrorActionPreference = "Stop"
function Invoke-Checked {
param([Parameter(Mandatory)][string]$Exe, [Parameter(Mandatory)][string[]]$Args)
Write-Host " > $Exe $($Args -join ' ')" -ForegroundColor DarkGray
& $Exe @Args
if ($LASTEXITCODE -ne 0) {
throw "'$Exe $($Args -join ' ')' failed with exit code $LASTEXITCODE."
}
}
function Resolve-Token {
<#
The token for both the registry push and the release API: parameter, then
env, then an interactive SecureString prompt. Read once and reused, so a
run that does both does not prompt twice.
#>
param([string]$Provided, [Parameter(Mandatory)][string]$Purpose)
if ($Provided) { return $Provided }
if ($env:GITEA_TOKEN) { return $env:GITEA_TOKEN }
$secure = Read-Host "Gitea token ($Purpose)" -AsSecureString
return [System.Net.NetworkCredential]::new("", $secure).Password
}
function Invoke-GiteaApi {
<#
JSON call against the Gitea API. Returns $null on 404 instead of throwing,
because "does this release exist yet?" is a 404 in the normal case and
Invoke-RestMethod treats any 4xx as terminating.
#>
param(
[Parameter(Mandatory)][string]$Method,
[Parameter(Mandatory)][string]$Uri,
[Parameter(Mandatory)][string]$Token,
$Body
)
$params = @{
Method = $Method
Uri = $Uri
Headers = @{ Authorization = "token $Token"; Accept = "application/json" }
}
if ($null -ne $Body) {
$params.Body = ($Body | ConvertTo-Json -Depth 5)
$params.ContentType = "application/json"
}
try {
return Invoke-RestMethod @params
}
catch {
$status = $_.Exception.Response.StatusCode.value__
if ($status -eq 404) { return $null }
if ($status -eq 401) {
throw "Gitea API $Method $Uri returned 401 — the token was rejected. Check GITEA_TOKEN (a registry-only token works for docker push but not for the API)."
}
if ($status -eq 403) {
throw "Gitea API $Method $Uri returned 403 — the token is valid but lacks repository write scope (write:repository)."
}
throw "Gitea API $Method $Uri failed: $($_.Exception.Message)"
}
}
function Send-ReleaseAsset {
<#
Upload one file as a release attachment.
curl.exe rather than Invoke-RestMethod -Form: -Form needs PowerShell 6+,
and hand-rolling a multipart body in 5.1 means loading the whole binary
into a string — these installers are 80-200 MB. The token goes in a
--config file, never in the argument list, so it stays out of the process
table and the shell history.
#>
param(
[Parameter(Mandatory)][string]$Uri,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][string]$Path
)
$curl = (Get-Command curl.exe -ErrorAction SilentlyContinue).Source
if (-not $curl) { $curl = (Get-Command curl -ErrorAction SilentlyContinue).Source }
if (-not $curl) { throw "curl not found — needed to upload release attachments." }
$configFile = [System.IO.Path]::GetTempFileName()
try {
# curl --config syntax: one option per line, `name = "value"`, and a value
# may not span lines. Only the header belongs here — everything else goes
# on the command line, where a stray escape can't silently split a line.
Set-Content -Path $configFile -Encoding ASCII -Value @(
"header = `"Authorization: token $Token`"",
"silent",
"show-error",
"fail-with-body"
)
Write-Host " > curl --config <temp> -F attachment=@$(Split-Path -Leaf $Path) `"$Uri`"" -ForegroundColor DarkGray
# Single-quoted: the \n is curl's own escape in -w, not PowerShell's.
& $curl "--config" $configFile `
"--write-out" ' http %{http_code}, %{size_upload} bytes uploaded\n' `
"-F" "attachment=@$Path" $Uri
if ($LASTEXITCODE -ne 0) { throw "upload of '$Path' failed (curl exit $LASTEXITCODE)." }
}
finally {
Remove-Item -Force $configFile -ErrorAction SilentlyContinue
}
}
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.
#>
param(
[Parameter(Mandatory)][string]$ApiRoot,
[Parameter(Mandatory)][string]$RepoPath,
[Parameter(Mandatory)][string]$Tag,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][string[]]$Artifacts,
[switch]$Force
)
$releasesUri = "$ApiRoot/repos/$RepoPath/releases"
$release = Invoke-GiteaApi -Method GET -Uri "$releasesUri/tags/$Tag" -Token $Token
if (-not $release) {
Write-Host " creating release $Tag in $RepoPath..." -ForegroundColor DarkGray
$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"
draft = $false
}
if (-not $release) { throw "could not create release $Tag in $RepoPath (does the repo exist?)." }
}
else {
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."
}
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"
}
return "$ApiRoot/repos/$RepoPath/releases/tags/$Tag"
}
# Resolve repo root (parent of this script's folder) so the script works from anywhere.
$repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot
try {
# --- Mode resolution ------------------------------------------------------
if ($BinariesOnly -and $NoBinaries) {
throw "-BinariesOnly and -NoBinaries cancel each other out — pick one."
}
if ($NoBinaryBuild -and $NoBinaries) {
throw "-NoBinaryBuild reuses the build that -NoBinaries skips entirely — pick one."
}
if ($BinariesOnly) {
# 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").
$NoBuild = $true
$SkipLogin = $true
$PublishRelease = $true
}
$pushImage = -not $BinariesOnly
if (-not $ReleaseRepo) { $ReleaseRepo = "$Owner/$Repo" }
if (-not $ApiBase) { $ApiBase = "https://$Registry" }
$apiRoot = "$($ApiBase.TrimEnd('/'))/api/v1"
# --- Tag resolution -------------------------------------------------------
# Same default as build-release.ps1, so the image tag, the installer names and
# the version the app reports in its own window all agree.
if (-not $Tag) {
$pkg = Get-Content (Join-Path $repoRoot "package.json") -Raw | ConvertFrom-Json
$Tag = "v$($pkg.version)"
}
# A published tag nobody can check out again is worth naming out loud. The tag
# comes from package.json rather than git describe, so the dirty state has to
# be asked for separately.
$dirty = $false
try { $dirty = [bool](git status --porcelain 2>$null) } catch { }
if ($dirty -or $Tag -like "*-dirty") {
Write-Warning "the worktree is dirty — the artifacts published as '$Tag' won't match any commit. Commit first."
}
$base = "$Registry/$Owner/$Image"
$tags = @("$base`:$Tag")
if (-not $NoLatest -and $Tag -ne "latest") { $tags += "$base`:latest" }
Write-Host "Motionity publish" -ForegroundColor Cyan
Write-Host " registry : $Registry"
if ($pushImage) {
Write-Host " image : $base"
Write-Host " tags : $($tags -join ', ')"
Write-Host " ffmpeg : $(if ($WithFfmpeg -eq '1') { 'bundled' } else { 'fetched at run time (WITH_FFMPEG=0)' })"
}
else {
Write-Host " image : skipped (-BinariesOnly)"
}
Write-Host " binaries : $(if ($NoBinaries) { 'skipped' } elseif ($NoBinaryBuild) { 'dist/ (reused, not rebuilt)' } else { $Targets -join ', ' })"
Write-Host " release : $(if ($PublishRelease) { "$ReleaseRepo @ $Tag" } else { 'not uploaded' })"
Write-Host ""
# PowerShell 5.1 still defaults to TLS 1.0 on some hosts, which every current
# Gitea rejects — the API call would fail with an opaque connection error.
if ($PublishRelease -and [Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') {
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
}
# --- Build ----------------------------------------------------------------
if (-not $NoBuild) {
Write-Host "Building image..." -ForegroundColor Cyan
# The Dockerfile has no ARG VERSION — the image is a static file server and
# carries no version string of its own, so the tag is the only marker.
$buildArgs = @("build") + @("--build-arg", "WITH_FFMPEG=$WithFfmpeg")
foreach ($t in $tags) { $buildArgs += @("-t", $t) }
$buildArgs += "."
Invoke-Checked docker $buildArgs
Write-Host ""
}
# --- 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 = @()
if (-not $NoBinaries) {
$distDir = Join-Path $repoRoot "dist"
if ($NoBinaryBuild) {
Write-Host "Reusing existing build..." -ForegroundColor Cyan
if (-not (Test-Path $distDir)) {
throw "-NoBinaryBuild was set but $distDir does not exist — build first (drop the flag, or run scripts/build-release.ps1)."
}
# 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.
$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."
}
# 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 |
Where-Object {
$_.FullName -notlike "$distDir*" -and
$_.FullName -notlike "*\node_modules\*" -and
$_.FullName -notlike "*/node_modules/*" -and
$_.LastWriteTime -gt $oldest.LastWriteTime
}
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))."
}
}
else {
Write-Host "Building desktop installers..." -ForegroundColor Cyan
# build-release.ps1 throws on any failure and $ErrorActionPreference=Stop
# propagates it, so there is nothing to test an exit code against —
# `& 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
}
$artifacts = @(Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File | ForEach-Object FullName)
if (-not $artifacts.Count) { throw "no installers for $Tag found in $distDir." }
$sums = Join-Path $distDir "SHA256SUMS.txt"
if (Test-Path $sums) { $artifacts += $sums }
Write-Host ""
}
# --- Login ----------------------------------------------------------------
if (-not $SkipLogin) {
if (-not $Username) { $Username = $env:GITEA_USER }
if (-not $Username) { $Username = Read-Host "Gitea username for $Registry" }
$Password = Resolve-Token -Provided $Password -Purpose "registry push as $Username"
Write-Host "Logging in to $Registry as $Username..." -ForegroundColor Cyan
# Pass the token via stdin so it never lands in process args or history.
$Password | docker login $Registry --username $Username --password-stdin
if ($LASTEXITCODE -ne 0) { throw "docker login failed (exit $LASTEXITCODE)." }
Write-Host ""
}
# --- Push -----------------------------------------------------------------
if ($pushImage) {
Write-Host "Pushing image..." -ForegroundColor Cyan
foreach ($t in $tags) { Invoke-Checked docker @("push", $t) }
Write-Host ""
}
# --- Release attachments --------------------------------------------------
$releaseUrl = $null
if ($PublishRelease) {
if (-not $artifacts.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
Write-Host ""
}
Write-Host "Done." -ForegroundColor Green
if ($pushImage) {
Write-Host "Pushed:" -ForegroundColor Green
foreach ($t in $tags) { Write-Host " $t" -ForegroundColor Green }
}
if ($artifacts.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 }
if ($releaseUrl) { Write-Host " $ApiBase/$ReleaseRepo/releases/tag/$Tag" -ForegroundColor Green }
}
}
finally {
Pop-Location
}
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
// Zero-dependency static file server for src/. Shared by the bare-metal target
// (npm start) and by the Electron build, which runs it on 127.0.0.1 so the
// renderer gets a secure context — WebCodecs (VideoEncoder) and IndexedDB are
// both unavailable over file://.
//
// CommonJS on purpose: the Electron main process requires it straight out of
// the asar archive, where ESM loading is not guaranteed.
//
// Range requests matter here: the audio/video panels seek in media files.
const { createReadStream, statSync } = require('node:fs');
const { createServer } = require('node:http');
const { extname, join, normalize, resolve, sep } = require('node:path');
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.wasm': 'application/wasm',
'.map': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
};
function send(res, status, body, headers = {}) {
res.writeHead(status, {
'content-type': 'text/plain; charset=utf-8',
...headers,
});
res.end(body);
}
function startServer({ root, host = '127.0.0.1', port = 0 } = {}) {
const rootDir = resolve(root);
const server = createServer((req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' });
}
const url = new URL(req.url, 'http://localhost');
let pathname;
try {
pathname = decodeURIComponent(url.pathname);
} catch {
return send(res, 400, 'Bad request');
}
if (pathname.endsWith('/')) pathname += 'index.html';
// normalize() collapses ../ before we compare, so nothing outside rootDir
// can be reached even with encoded traversal sequences.
const filePath = join(rootDir, normalize(pathname));
if (filePath !== rootDir && !filePath.startsWith(rootDir + sep)) {
return send(res, 403, 'Forbidden');
}
let stat;
try {
stat = statSync(filePath);
if (stat.isDirectory()) throw new Error('directory');
} catch {
return send(res, 404, 'Not found');
}
const ext = extname(filePath).toLowerCase();
const headers = {
'content-type': TYPES[ext] || 'application/octet-stream',
'accept-ranges': 'bytes',
// The HTML entry point must never be cached or a rebuild ships stale
// script tags; everything else is safe to keep for a session.
'cache-control': ext === '.html' ? 'no-cache' : 'public, max-age=3600',
'x-content-type-options': 'nosniff',
};
const range = req.headers.range;
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
if (match) {
const size = stat.size;
let start = match[1] === '' ? null : Number(match[1]);
let end = match[2] === '' ? null : Number(match[2]);
if (start === null) {
// Suffix form: "bytes=-500" means the last 500 bytes.
start = Math.max(0, size - (end || 0));
end = size - 1;
} else {
end = end === null ? size - 1 : Math.min(end, size - 1);
}
if (start > end || start >= size) {
return send(res, 416, 'Range not satisfiable', {
'content-range': `bytes */${size}`,
});
}
res.writeHead(206, {
...headers,
'content-range': `bytes ${start}-${end}/${size}`,
'content-length': end - start + 1,
});
if (req.method === 'HEAD') return res.end();
return createReadStream(filePath, { start, end }).pipe(res);
}
}
res.writeHead(200, { ...headers, 'content-length': stat.size });
if (req.method === 'HEAD') return res.end();
createReadStream(filePath).pipe(res);
});
return new Promise((ok, fail) => {
server.on('error', fail);
server.listen(port, host, () => {
const bound = server.address().port;
ok({ server, port: bound, url: `http://${host}:${bound}/` });
});
});
}
module.exports = { startServer };
// Direct invocation: npm start / node scripts/server.cjs
if (require.main === module) {
const root = resolve(__dirname, '..', 'src');
const host = process.env.HOST || '127.0.0.1';
const port = Number(process.env.PORT || 8080);
startServer({ root, host, port }).then(({ url }) => {
console.log(`Motionity serving ${root}`);
console.log(` ${url}`);
if (host !== '127.0.0.1' && host !== 'localhost') {
console.log(
`\nNOTE: browsers expose WebCodecs and IndexedDB only in a secure\n` +
`context. Reached over plain http:// from another machine this\n` +
`disables the fast exporter and project saving. Use TLS for LAN or\n` +
`remote access.`
);
}
});
}
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
// Downloads every third-party asset that index.html used to pull from a CDN
// into src/vendor/, so the app runs with no network access. Run once before
// packaging (npm run vendor); the directory is gitignored.
//
// The only runtime network dependency left after this is the Google Fonts
// family the user picks in the text panel (WebFont.load), which degrades to a
// fallback font when offline.
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { existsSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const vendorDir = join(root, 'src', 'vendor');
const fontsDir = join(vendorDir, 'fonts');
// A desktop UA is required for the Google Fonts API to answer with woff2
// instead of the ancient truetype payload.
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
const assets = [
{
url: 'https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.9.6/lottie.min.js',
file: 'lottie.min.js',
},
{
url: 'https://cdn.jsdelivr.net/npm/@simonwep/selection-js/lib/selection.min.js',
file: 'selection.min.js',
},
{
url: 'https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js',
file: 'jquery.min.js',
},
{
url: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js',
file: 'pickr.min.js',
},
{
url: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css',
file: 'pickr-nano.min.css',
},
{
url: 'https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js',
file: 'fabric.min.js',
},
{
url: 'https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js',
file: 'webfont.js',
},
{
// ~18.5 MB asm.js build of ffmpeg, used by converter.js for MP4/GIF export.
url: 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js',
file: 'ffmpeg_asm.js',
optional: true,
},
];
const fontCss =
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
async function fetchBuffer(url) {
const res = await fetch(url, { headers: { 'user-agent': UA } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
return Buffer.from(await res.arrayBuffer());
}
function human(bytes) {
return bytes > 1e6
? `${(bytes / 1e6).toFixed(1)} MB`
: `${Math.round(bytes / 1024)} KB`;
}
async function download(url, dest, { force }) {
if (!force && existsSync(dest) && statSync(dest).size > 0) {
console.log(` skip ${dest.slice(root.length + 1)} (already vendored)`);
return;
}
const buf = await fetchBuffer(url);
await writeFile(dest, buf);
console.log(` get ${dest.slice(root.length + 1)} (${human(buf.length)})`);
}
// Rewrites the remote font files referenced by the Google Fonts stylesheet to
// local copies so no request leaves the machine at startup.
async function vendorFonts({ force }) {
const dest = join(vendorDir, 'inter.css');
if (!force && existsSync(dest) && statSync(dest).size > 0) {
console.log(` skip src/vendor/inter.css (already vendored)`);
return;
}
let css = (await fetchBuffer(fontCss)).toString('utf8');
const urls = [...new Set([...css.matchAll(/url\((https:[^)]+)\)/g)].map((m) => m[1]))];
for (const url of urls) {
const ext = url.split('.').pop().split('?')[0];
const name = `inter-${createHash('sha1').update(url).digest('hex').slice(0, 10)}.${ext}`;
await writeFile(join(fontsDir, name), await fetchBuffer(url));
css = css.split(url).join(`fonts/${name}`);
}
await writeFile(dest, css);
console.log(` get src/vendor/inter.css (+${urls.length} font files)`);
}
const force = process.argv.includes('--force');
// Docker builds can drop the 18.5 MB ffmpeg blob; MP4/GIF export then falls
// back to fetching it from the public mirror at conversion time.
const skipOptional = process.argv.includes('--skip-ffmpeg');
await mkdir(fontsDir, { recursive: true });
console.log(`Vendoring third-party assets into src/vendor/`);
for (const asset of assets) {
if (asset.optional && skipOptional) {
console.log(` omit src/vendor/${asset.file} (--skip-ffmpeg)`);
continue;
}
await download(asset.url, join(vendorDir, asset.file), { force });
}
await vendorFonts({ force });
// Sanity check: index.html must not have regained a CDN reference.
const html = await readFile(join(root, 'src', 'index.html'), 'utf8');
const remote = [...html.matchAll(/(?:src|href)="(https?:\/\/[^"]+)"/g)]
.map((m) => m[1])
.filter((u) => !/github\.com|motionity\.app|twitter\.com/.test(u));
if (remote.length) {
console.warn(`\nWARNING: index.html still loads remote assets:`);
for (const u of remote) console.warn(` ${u}`);
process.exitCode = 1;
} else {
console.log(`\nDone. index.html loads no remote assets.`);
}