feat: introduce local-first SVG workbench

This commit is contained in:
2026-08-02 16:31:49 +02:00
commit 39d9802daa
97 changed files with 20702 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
import { lstat, readFile, writeFile } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { format } from "prettier";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
const outputPath = join(root, "public", "toolbox-app.json");
const packagePath = join(root, "package.json");
const applicationVersionPath = join(root, "src", "version.ts");
const publicPath = join(root, "public");
const checkOnly = process.argv.includes("--check");
const source = JSON.parse(await readFile(sourcePath, "utf8"));
const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
const applicationVersionSource = await readFile(applicationVersionPath, "utf8");
const applicationVersion =
/^export const APPLICATION_VERSION = "([^"]+)";$/mu.exec(
applicationVersionSource,
)?.[1];
if (
source.version !== packageJson.version ||
applicationVersion !== packageJson.version
) {
throw new Error(
`Version drift: manifest ${source.version}, application ${String(applicationVersion)}, package ${packageJson.version}`,
);
}
if (
source.source?.repository !== "https://git.add-ideas.de/lotobo/svg-tools" ||
source.source?.license !== "GPL-3.0-or-later"
) {
throw new Error("Manifest source identity is incomplete or inconsistent");
}
for (const asset of source.assets ?? []) {
if (
typeof asset !== "string" ||
!asset.startsWith("./") ||
asset.includes("\\") ||
asset.split("/").includes("..")
) {
throw new Error(`Unsafe manifest asset path: ${JSON.stringify(asset)}`);
}
const details = await lstat(join(publicPath, asset.slice(2))).catch(
() => null,
);
if (!details?.isFile() || details.isSymbolicLink()) {
throw new Error(`Manifest asset is missing or unsafe: ${asset}`);
}
}
const serialized = await format(JSON.stringify(source), {
filepath: outputPath,
});
if (checkOnly) {
const current = await readFile(outputPath, "utf8").catch(() => "");
if (current !== serialized) {
throw new Error(
`${relative(root, outputPath)} is stale; run npm run manifest:generate`,
);
}
console.log("Toolbox manifest is synchronized");
} else {
await writeFile(outputPath, serialized);
console.log(`Generated ${relative(root, outputPath)}`);
}
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import {
access,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { deflateRawSync } from "node:zlib";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const input = path.join(root, "dist");
const argument = (name, fallback) => {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
};
const output = path.resolve(
root,
argument("--output", `release/svg-tools-${packageJson.version}.zip`),
);
const checksumOutput = `${output}.sha256`;
const force = process.argv.includes("--force");
if (path.extname(output).toLowerCase() !== ".zip") {
throw new Error("Release output must use a .zip extension");
}
if (output === path.parse(output).root || output === root) {
throw new Error("Release output is not a safe file target");
}
const exists = (file) =>
access(file).then(
() => true,
() => false,
);
if (!force && (await exists(output))) {
throw new Error(`Release already exists (use --force): ${output}`);
}
if (!force && (await exists(checksumOutput))) {
throw new Error(`Checksum already exists (use --force): ${checksumOutput}`);
}
const required = [
"index.html",
"toolbox-app.json",
"favicon.svg",
"canvas-frame-controller.js",
"README.md",
"CHANGELOG.md",
"LICENSE",
"SOURCE.md",
"THIRD_PARTY_NOTICES.md",
"THIRD_PARTY_LICENSES.txt",
"LICENSES/README.md",
"LICENSES/npm-runtime-licenses.txt",
];
for (const name of required) {
const details = await lstat(path.join(input, name)).catch(() => null);
if (!details?.isFile() || details.isSymbolicLink()) {
throw new Error(`Release input is missing a regular file: ${name}`);
}
}
const assets = await lstat(path.join(input, "assets")).catch(() => null);
if (!assets?.isDirectory() || assets.isSymbolicLink()) {
throw new Error("Release input is missing its assets directory");
}
const manifest = JSON.parse(
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
);
if (
manifest.id !== "de.add-ideas.svg-tools" ||
manifest.version !== packageJson.version ||
manifest.entry !== "./" ||
manifest.icon !== "./favicon.svg" ||
!manifest.assets?.includes("./canvas-frame-controller.js")
) {
throw new Error("Packaged Toolbox manifest identity/assets are invalid");
}
async function collect(directory, prefix = "") {
const files = [];
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
(left, right) =>
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
)) {
const absolute = path.join(directory, entry.name);
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) {
throw new Error(`Release input contains a symbolic link: ${relative}`);
}
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
else if (entry.isFile()) files.push({ absolute, relative });
else throw new Error(`Unsupported release entry: ${relative}`);
}
return files;
}
const files = await collect(input);
if (files.length > 65_535) throw new Error("Release contains too many files");
for (const { relative } of files) {
if (
relative.endsWith(".map") ||
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
relative,
)
) {
throw new Error(`Forbidden release entry: ${relative}`);
}
if (relative.startsWith("/") || relative.split("/").includes("..")) {
throw new Error(`Unsafe release entry: ${relative}`);
}
}
const indexHtml = await readFile(path.join(input, "index.html"), "utf8");
if (/\b(?:src|href)=["']\//iu.test(indexHtml)) {
throw new Error("index.html contains a root-absolute asset reference");
}
const crcTable = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
crcTable[index] = value >>> 0;
}
function crc32(bytes) {
let value = 0xffffffff;
for (const byte of bytes)
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
return (value ^ 0xffffffff) >>> 0;
}
function header(length) {
return Buffer.alloc(length);
}
const localParts = [];
const centralParts = [];
let offset = 0;
for (const file of files) {
const source = await readFile(file.absolute);
const compressed = deflateRawSync(source, { level: 9 });
const name = Buffer.from(file.relative, "utf8");
const checksum = crc32(source);
if (
source.byteLength > 0xffffffff ||
compressed.byteLength > 0xffffffff ||
offset > 0xffffffff
) {
throw new Error("ZIP64 releases are not supported");
}
const local = header(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(0, 10);
local.writeUInt16LE(0x0021, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(compressed.byteLength, 18);
local.writeUInt32LE(source.byteLength, 22);
local.writeUInt16LE(name.byteLength, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, name, compressed);
const central = header(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(0x0314, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(0, 12);
central.writeUInt16LE(0x0021, 14);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(compressed.byteLength, 20);
central.writeUInt32LE(source.byteLength, 24);
central.writeUInt16LE(name.byteLength, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
central.writeUInt32LE(offset, 42);
centralParts.push(central, name);
offset += local.byteLength + name.byteLength + compressed.byteLength;
}
const centralOffset = offset;
const centralSize = centralParts.reduce(
(sum, part) => sum + part.byteLength,
0,
);
if (centralOffset + centralSize > 0xffffffff) {
throw new Error("ZIP64 releases are not supported");
}
const end = header(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(centralSize, 12);
end.writeUInt32LE(centralOffset, 16);
end.writeUInt16LE(0, 20);
const archive = Buffer.concat([...localParts, ...centralParts, end]);
await mkdir(path.dirname(output), { recursive: true });
const staging = await mkdtemp(path.join(path.dirname(output), ".svg-release-"));
const stagedArchive = path.join(staging, path.basename(output));
const stagedChecksum = `${stagedArchive}.sha256`;
try {
await writeFile(stagedArchive, archive, { flag: "wx", mode: 0o644 });
const digest = createHash("sha256").update(archive).digest("hex");
await writeFile(stagedChecksum, `${digest} ${path.basename(output)}\n`, {
flag: "wx",
mode: 0o644,
});
if (force) {
await rm(output, { force: true });
await rm(checksumOutput, { force: true });
}
await rename(stagedArchive, output);
await rename(stagedChecksum, checksumOutput);
process.stdout.write(
`Created ${path.relative(root, output)} (${archive.byteLength} bytes, ${files.length} files)\nSHA-256 ${digest}\n`,
);
} finally {
await rm(staging, { recursive: true, force: true });
}
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import {
access,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const workspace = path.dirname(root);
const portal = path.resolve(
process.env.TOOLBOX_PORTAL_DIR ?? path.join(workspace, "toolbox-portal"),
);
const appPackage = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const portalPackage = JSON.parse(
await readFile(path.join(portal, "package.json"), "utf8"),
);
const artifact = path.join(
root,
"release",
`svg-tools-${appPackage.version}.zip`,
);
async function run(command, arguments_, cwd) {
await new Promise((resolve, reject) => {
const child = spawn(command, arguments_, {
cwd,
stdio: "inherit",
env: { ...process.env, CI: "1" },
});
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) resolve();
else
reject(
new Error(
`${command} exited with ${code ?? `signal ${signal ?? "unknown"}`}`,
),
);
});
});
}
const details = await stat(artifact).catch(() => null);
if (!details?.isFile()) {
throw new Error(`Build the app release first: ${artifact}`);
}
const digest = createHash("sha256")
.update(await readFile(artifact))
.digest("hex");
const sidecar = await readFile(`${artifact}.sha256`, "utf8");
if (!sidecar.startsWith(`${digest} ${path.basename(artifact)}`)) {
throw new Error("The app release checksum sidecar does not match the ZIP");
}
await access(path.join(portal, "scripts", "assemble.mjs"));
await run(
process.platform === "win32" ? "npm.cmd" : "npm",
["run", "build"],
portal,
);
const temporary = await mkdtemp(
path.join(os.tmpdir(), "svg-tools-portal-smoke-"),
);
try {
const example = JSON.parse(
await readFile(
path.join(portal, "release", "toolbox.lock.example.json"),
"utf8",
),
);
const lock = {
...example,
releaseVersion: "0.10.0-smoke.0",
portalVersion: portalPackage.version,
apps: [
{
id: "de.add-ideas.svg-tools",
version: appPackage.version,
artifact: pathToFileURL(artifact).href,
sha256: digest,
target: "svg",
},
],
};
const lockFile = path.join(temporary, "toolbox.lock.json");
const output = path.join(temporary, "assembled", "toolbox");
const archive = path.join(temporary, "assembled", "toolbox.zip");
await writeFile(lockFile, `${JSON.stringify(lock, null, 2)}\n`, {
flag: "wx",
});
await run(
process.execPath,
[
path.join(portal, "scripts", "assemble.mjs"),
"--lock",
lockFile,
"--portal-dist",
path.join(portal, "dist"),
"--output",
output,
"--archive",
archive,
],
portal,
);
const catalogue = JSON.parse(
await readFile(path.join(output, "toolbox.catalog.json"), "utf8"),
);
const manifest = JSON.parse(
await readFile(
path.join(output, "apps", "svg", "toolbox-app.json"),
"utf8",
),
);
const appIndex = await readFile(
path.join(output, "apps", "svg", "index.html"),
"utf8",
);
if (
catalogue.apps.length !== 1 ||
catalogue.apps[0]?.manifest !== "./apps/svg/toolbox-app.json" ||
manifest.id !== "de.add-ideas.svg-tools" ||
manifest.version !== appPackage.version ||
!manifest.assets?.includes("./canvas-frame-controller.js") ||
/\b(?:src|href)=["']\//iu.test(appIndex)
) {
throw new Error(
"Assembled SVG Tools identity or relocatable assets changed",
);
}
for (const relative of [
"apps/svg/favicon.svg",
"apps/svg/canvas-frame-controller.js",
"apps/svg/LICENSE",
"apps/svg/SOURCE.md",
"toolbox.release.json",
]) {
const file = await stat(path.join(output, relative)).catch(() => null);
if (!file?.isFile()) throw new Error(`Assembly is missing ${relative}`);
}
process.stdout.write(
`Portal assembly smoke passed for SVG Tools ${appPackage.version} (${digest})\n`,
);
} finally {
await rm(temporary, { recursive: true, force: true });
}
+95
View File
@@ -0,0 +1,95 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const publicDirectory = path.join(root, "public");
const required = [
"LICENSE",
"README.md",
"CHANGELOG.md",
"SOURCE.md",
"THIRD_PARTY_NOTICES.md",
];
await mkdir(publicDirectory, { recursive: true });
for (const name of required) {
const source = path.join(root, name);
await readFile(source);
await cp(source, path.join(publicDirectory, name));
}
const publicLicenses = path.join(publicDirectory, "LICENSES");
await rm(publicLicenses, { recursive: true, force: true });
await cp(path.join(root, "LICENSES"), publicLicenses, { recursive: true });
const publicDocs = path.join(publicDirectory, "docs");
await rm(publicDocs, { recursive: true, force: true });
await cp(path.join(root, "docs"), publicDocs, { recursive: true });
const lock = JSON.parse(
await readFile(path.join(root, "package-lock.json"), "utf8"),
);
const runtimeLicenseSections = [];
for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
([left], [right]) => (left < right ? -1 : left > right ? 1 : 0),
)) {
if (!location.includes("node_modules/") || locked.dev === true) continue;
const packageDirectory = path.join(root, location);
const details = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
);
const candidates = (await readdir(packageDirectory))
.filter((name) => /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(name))
.sort();
const texts = [];
for (const candidate of candidates) {
try {
texts.push(
`--- ${candidate} ---\n${await readFile(path.join(packageDirectory, candidate), "utf8")}`,
);
} catch {
// Ignore directories or non-text aliases; another matching file may exist.
}
}
runtimeLicenseSections.push(
[
"=".repeat(78),
`${details.name}@${details.version}`,
`Declared licence: ${details.license ?? locked.license ?? "See upstream package"}`,
`Installed from: ${location}`,
"=".repeat(78),
texts.length
? texts.join("\n\n")
: "No package-local licence file was present; see THIRD_PARTY_NOTICES.md and the upstream source.",
].join("\n"),
);
}
await writeFile(
path.join(publicLicenses, "npm-runtime-licenses.txt"),
`${runtimeLicenseSections.join("\n\n")}\n`,
);
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const rows = [
"# Runtime dependency licences",
"",
"Generated from the exact lock used for this build.",
"",
"| Package | Version | Licence |",
"| --- | --- | --- |",
];
for (const name of Object.keys(packageJson.dependencies).sort()) {
const manifestPath = path.join(root, "node_modules", name, "package.json");
const details = JSON.parse(await readFile(manifestPath, "utf8"));
rows.push(
`| \`${details.name}\` | ${details.version} | ${details.license ?? "See packaged notices"} |`,
);
}
await writeFile(
path.join(publicDirectory, "THIRD_PARTY_LICENSES.txt"),
`${rows.join("\n")}\n`,
);
console.log("Prepared static release notices");
+97
View File
@@ -0,0 +1,97 @@
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"dist",
);
const nestedPrefix = "/deep/nested/svg/";
const mediaTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".svg", "image/svg+xml"],
[".wasm", "application/wasm"],
]);
const catalogue = {
schemaVersion: 1,
id: "de.add-ideas.svg-tools.browser-test",
name: "SVG Tools browser-test Toolbox",
home: "./",
theme: { mode: "system", brand: "add·ideas" },
apps: [{ manifest: "./toolbox-app.json", enabled: true }],
};
const headers = {
"Content-Security-Policy":
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'self' blob:; manifest-src 'self'",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"X-Content-Type-Options": "nosniff",
};
function safeFile(requestPath) {
const decoded = decodeURIComponent(requestPath);
const relative = decoded.startsWith(nestedPrefix)
? decoded.slice(nestedPrefix.length)
: decoded.replace(/^\/+/, "");
const normalized = path.posix.normalize(relative || "index.html");
if (
normalized === ".." ||
normalized.startsWith("../") ||
path.isAbsolute(normalized)
) {
return null;
}
return path.join(root, normalized);
}
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/toolbox.catalog.json") {
response.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-cache",
...headers,
});
response.end(JSON.stringify(catalogue));
return;
}
let file = safeFile(url.pathname);
if (!file) {
response.writeHead(400).end("Bad request");
return;
}
if ((await stat(file).catch(() => null))?.isDirectory()) {
file = path.join(file, "index.html");
}
const content = await readFile(file);
const isCanvasController =
path.basename(file) === "canvas-frame-controller.js";
response.writeHead(200, {
"Content-Type":
mediaTypes.get(path.extname(file)) ?? "application/octet-stream",
"Cache-Control": "no-cache",
...headers,
...(isCanvasController
? {
"Access-Control-Allow-Origin": "*",
"Cross-Origin-Resource-Policy": "cross-origin",
}
: {}),
});
response.end(content);
} catch {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
}
});
server.listen(4173, "127.0.0.1", () => {
console.log("SVG Tools test server listening on http://127.0.0.1:4173");
});