Packaging/release scripts #1
@@ -1,3 +1,37 @@
|
|||||||
# Motionity
|
# Motionity
|
||||||
|
|
||||||
This is a fork of the original project aiming to fix issues and add features.
|
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