diff --git a/README.md b/README.md index 104e491..671e91f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..09315ac --- /dev/null +++ b/package.json @@ -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" + ] + } + } +} diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 new file mode 100644 index 0000000..b51fba0 --- /dev/null +++ b/scripts/build-release.ps1 @@ -0,0 +1,222 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Build the Motionity desktop installers with electron-builder. + +.DESCRIPTION + Produces, in dist/: + + motionity--win-x64-setup.exe NSIS installer + motionity--win-x64-portable.exe portable exe + motionity--linux-x86_64.AppImage AppImage + motionity--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. + +.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, + # 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 +} diff --git a/scripts/make-icon.cjs b/scripts/make-icon.cjs new file mode 100644 index 0000000..ae2f174 --- /dev/null +++ b/scripts/make-icon.cjs @@ -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)`); diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 new file mode 100644 index 0000000..e100d0e --- /dev/null +++ b/scripts/publish.ps1 @@ -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; 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. + [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://. + [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 -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 +} diff --git a/scripts/server.cjs b/scripts/server.cjs new file mode 100644 index 0000000..6d8c6b5 --- /dev/null +++ b/scripts/server.cjs @@ -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.` + ); + } + }); +} diff --git a/scripts/vendor.mjs b/scripts/vendor.mjs new file mode 100644 index 0000000..e69f461 --- /dev/null +++ b/scripts/vendor.mjs @@ -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.`); +}