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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)`);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
Reference in New Issue
Block a user