547 lines
16 KiB
JavaScript
547 lines
16 KiB
JavaScript
const fs = require("fs");
|
|
const http = require("http");
|
|
const https = require("https");
|
|
const path = require("path");
|
|
const { execFileSync } = require("child_process");
|
|
|
|
const electronRoot = path.join(__dirname, "..");
|
|
const packageJson = JSON.parse(
|
|
fs.readFileSync(path.join(electronRoot, "package.json"), "utf8"),
|
|
);
|
|
|
|
const targetRoot = path.join(electronRoot, "resources", "export");
|
|
const targetPyDir = path.join(targetRoot, "py");
|
|
const targetIndex = path.join(targetRoot, "index.js");
|
|
const cacheDir = path.join(electronRoot, ".cache", "export-runtime");
|
|
const exportRepoBase = "https://github.com/presenton/presenton-export/releases/download";
|
|
const exportVersion = packageJson.exportVersion || "v0.1.0";
|
|
|
|
const cliArgs = new Set(process.argv.slice(2));
|
|
const forceDownload = cliArgs.has("--force");
|
|
const checkOnly = cliArgs.has("--check-only");
|
|
|
|
async function getTargetVersion() {
|
|
const requestedVersion = process.env.EXPORT_RUNTIME_VERSION || exportVersion;
|
|
if (requestedVersion !== "latest") {
|
|
return requestedVersion;
|
|
}
|
|
|
|
const apiUrl = "https://api.github.com/repos/presenton/presenton-export/releases/latest";
|
|
const latest = await requestJson(apiUrl);
|
|
if (!latest.tag_name) {
|
|
throw new Error(`Could not resolve latest release tag from ${apiUrl}`);
|
|
}
|
|
|
|
return latest.tag_name;
|
|
}
|
|
|
|
function getPlatformAssetName() {
|
|
const platformArch = `${process.platform}-${process.arch}`;
|
|
if (platformArch === "linux-arm64") return "export-Linux-ARM64.zip";
|
|
if (platformArch === "linux-x64") return "export-Linux-X64.zip";
|
|
if (platformArch === "darwin-arm64") return "export-macOS-ARM64.zip";
|
|
if (platformArch === "darwin-x64") return "export-macOS-X64.zip";
|
|
if (platformArch === "win32-x64") return "export-Windows-X64.zip";
|
|
|
|
throw new Error(
|
|
`Unsupported export runtime platform: ${platformArch}. Supported: linux-arm64, linux-x64, darwin-arm64, darwin-x64, win32-x64`
|
|
);
|
|
}
|
|
|
|
function getConverterCandidates() {
|
|
const platformAliases = {
|
|
linux: ["linux"],
|
|
darwin: ["darwin", "macos", "mac"],
|
|
win32: ["win32", "windows", "win"],
|
|
};
|
|
const archAliases = {
|
|
x64: ["x64", "amd64"],
|
|
arm64: ["arm64", "aarch64"],
|
|
};
|
|
|
|
const candidates = [];
|
|
const platforms = platformAliases[process.platform] || [process.platform];
|
|
const archs = archAliases[process.arch] || [process.arch];
|
|
const windows = process.platform === "win32";
|
|
|
|
for (const p of platforms) {
|
|
for (const a of archs) {
|
|
candidates.push(path.join(targetPyDir, `convert-${p}-${a}`));
|
|
candidates.push(path.join(targetPyDir, `convert-${p}-${a}.exe`));
|
|
}
|
|
candidates.push(path.join(targetPyDir, `convert-${p}`));
|
|
candidates.push(path.join(targetPyDir, `convert-${p}.exe`));
|
|
}
|
|
|
|
if (windows) {
|
|
candidates.push(path.join(targetPyDir, "convert.exe"));
|
|
}
|
|
candidates.push(path.join(targetPyDir, "convert"));
|
|
|
|
return [...new Set(candidates)];
|
|
}
|
|
|
|
function ensureDir(dirPath) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
|
|
function chmodIfPossible(filePath) {
|
|
if (process.platform !== "win32") {
|
|
fs.chmodSync(filePath, 0o755);
|
|
}
|
|
}
|
|
|
|
function detectBinaryFormat(filePath) {
|
|
const fd = fs.openSync(filePath, "r");
|
|
try {
|
|
const header = Buffer.alloc(4);
|
|
fs.readSync(fd, header, 0, 4, 0);
|
|
|
|
if (header[0] === 0x7f && header[1] === 0x45 && header[2] === 0x4c && header[3] === 0x46) {
|
|
return "elf";
|
|
}
|
|
|
|
if (header[0] === 0x4d && header[1] === 0x5a) {
|
|
return "pe";
|
|
}
|
|
|
|
const magic = header.readUInt32BE(0);
|
|
if (
|
|
magic === 0xfeedface ||
|
|
magic === 0xcefaedfe ||
|
|
magic === 0xfeedfacf ||
|
|
magic === 0xcffaedfe ||
|
|
magic === 0xcafebabe ||
|
|
magic === 0xbebafeca
|
|
) {
|
|
return "mach-o";
|
|
}
|
|
|
|
return "unknown";
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
}
|
|
|
|
function isFormatCompatible(format) {
|
|
if (process.platform === "darwin") return format === "mach-o";
|
|
if (process.platform === "linux") return format === "elf";
|
|
if (process.platform === "win32") return format === "pe";
|
|
return true;
|
|
}
|
|
|
|
function validateExistingRuntime() {
|
|
if (!fs.existsSync(targetIndex)) {
|
|
return { ok: false, reason: `Missing runtime bundle: ${targetIndex}` };
|
|
}
|
|
|
|
const converterCandidates = getConverterCandidates();
|
|
const converterPath = converterCandidates.find((candidate) => fs.existsSync(candidate));
|
|
|
|
if (!converterPath) {
|
|
return {
|
|
ok: false,
|
|
reason: [
|
|
"No converter binary found in electron/resources/export/py.",
|
|
"Expected one of:",
|
|
...converterCandidates.map((candidate) => ` - ${candidate}`),
|
|
].join("\n"),
|
|
};
|
|
}
|
|
|
|
const binaryFormat = detectBinaryFormat(converterPath);
|
|
if (!isFormatCompatible(binaryFormat)) {
|
|
return {
|
|
ok: false,
|
|
reason: [
|
|
`Converter binary is not valid for ${process.platform}/${process.arch}.`,
|
|
`Selected converter: ${converterPath}`,
|
|
`Detected format: ${binaryFormat}`,
|
|
].join("\n"),
|
|
};
|
|
}
|
|
|
|
chmodIfPossible(converterPath);
|
|
return { ok: true, converterPath };
|
|
}
|
|
|
|
function patchHtmlToImageRuntime() {
|
|
if (!fs.existsSync(targetIndex)) {
|
|
return false;
|
|
}
|
|
|
|
const original = fs.readFileSync(targetIndex, "utf8");
|
|
let patched = original.replace(
|
|
'await C.setContent(a.html,{waitUntil:"networkidle0",timeout:12e4})',
|
|
'await C.setContent(a.html,{waitUntil:"domcontentloaded",timeout:12e4})',
|
|
);
|
|
patched = patched.replace(
|
|
'catch(C){throw C instanceof ig?C:new ig("Failed to render HTML to image",500)}',
|
|
'catch(C){console.error("[html-to-image]",C);throw C instanceof ig?C:new ig("Failed to render HTML to image",500)}',
|
|
);
|
|
|
|
if (patched === original) {
|
|
return false;
|
|
}
|
|
fs.writeFileSync(targetIndex, patched);
|
|
console.log("[export-runtime] Patched HTML-to-image readiness and error logging.");
|
|
return true;
|
|
}
|
|
|
|
function hasExportDirectoryContent() {
|
|
if (!fs.existsSync(targetRoot)) return false;
|
|
return fs.readdirSync(targetRoot).length > 0;
|
|
}
|
|
|
|
function request(url) {
|
|
const client = url.startsWith("https:") ? https : http;
|
|
return client;
|
|
}
|
|
|
|
function requestJson(url, redirects = 5) {
|
|
return new Promise((resolve, reject) => {
|
|
const client = request(url);
|
|
const req = client.get(
|
|
url,
|
|
{
|
|
headers: {
|
|
"User-Agent": "presenton-export-runtime-sync",
|
|
Accept: "application/vnd.github+json",
|
|
},
|
|
},
|
|
(res) => {
|
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
if (redirects <= 0) {
|
|
reject(new Error(`Too many redirects for JSON request: ${url}`));
|
|
return;
|
|
}
|
|
requestJson(res.headers.location, redirects - 1).then(resolve).catch(reject);
|
|
return;
|
|
}
|
|
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new Error(`Failed to fetch ${url}. HTTP ${res.statusCode}`));
|
|
return;
|
|
}
|
|
|
|
let payload = "";
|
|
res.setEncoding("utf8");
|
|
res.on("data", (chunk) => {
|
|
payload += chunk;
|
|
});
|
|
res.on("end", () => {
|
|
try {
|
|
resolve(JSON.parse(payload));
|
|
} catch (error) {
|
|
reject(new Error(`Invalid JSON received from ${url}: ${error.message}`));
|
|
}
|
|
});
|
|
}
|
|
);
|
|
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function downloadFile(url, outputPath, redirects = 5) {
|
|
return new Promise((resolve, reject) => {
|
|
const client = request(url);
|
|
const req = client.get(
|
|
url,
|
|
{
|
|
headers: {
|
|
"User-Agent": "presenton-export-runtime-sync",
|
|
Accept: "application/octet-stream",
|
|
},
|
|
},
|
|
(res) => {
|
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
if (redirects <= 0) {
|
|
reject(new Error(`Too many redirects while downloading ${url}`));
|
|
return;
|
|
}
|
|
downloadFile(res.headers.location, outputPath, redirects - 1).then(resolve).catch(reject);
|
|
return;
|
|
}
|
|
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new Error(`Failed to download ${url}. HTTP ${res.statusCode}`));
|
|
return;
|
|
}
|
|
|
|
ensureDir(path.dirname(outputPath));
|
|
const fileStream = fs.createWriteStream(outputPath);
|
|
res.pipe(fileStream);
|
|
fileStream.on("finish", () => {
|
|
fileStream.close(resolve);
|
|
});
|
|
fileStream.on("error", reject);
|
|
}
|
|
);
|
|
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
async function downloadFileWithRetries(url, outputPath, attempts = 4) {
|
|
let lastError;
|
|
for (let i = 0; i < attempts; i++) {
|
|
try {
|
|
if (i > 0) {
|
|
const delay = 1500 * Math.pow(2, i - 1);
|
|
console.log(`[export-runtime] Retrying download (attempt ${i + 1}/${attempts}) after ${delay}ms…`);
|
|
await sleep(delay);
|
|
}
|
|
try {
|
|
fs.unlinkSync(outputPath);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
await downloadFile(url, outputPath);
|
|
const st = fs.statSync(outputPath);
|
|
if (st.size < 512) {
|
|
throw new Error(`Downloaded file is too small (${st.size} bytes); likely corrupt or HTML error page`);
|
|
}
|
|
const magic = Buffer.alloc(4);
|
|
const fd = fs.openSync(outputPath, "r");
|
|
try {
|
|
fs.readSync(fd, magic, 0, 4, 0);
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
if (magic[0] !== 0x50 || magic[1] !== 0x4b) {
|
|
throw new Error("Downloaded file is not a ZIP (missing PK header); delete cache and retry");
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
lastError = err;
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
function unzipArchive(zipPath, destDir) {
|
|
ensureDir(destDir);
|
|
if (process.platform === "win32") {
|
|
const psQuote = (p) => p.replace(/'/g, "''");
|
|
execFileSync(
|
|
"powershell.exe",
|
|
[
|
|
"-NoProfile",
|
|
"-Command",
|
|
`Expand-Archive -LiteralPath '${psQuote(zipPath)}' -DestinationPath '${psQuote(destDir)}' -Force`,
|
|
],
|
|
{ stdio: "inherit" }
|
|
);
|
|
return;
|
|
}
|
|
|
|
execFileSync("unzip", ["-o", zipPath, "-d", destDir], { stdio: "inherit" });
|
|
}
|
|
|
|
function hasRuntimeLayout(dir) {
|
|
const indexPath = path.join(dir, "index.js");
|
|
if (!fs.existsSync(indexPath)) return false;
|
|
|
|
const pyPath = path.join(dir, "py");
|
|
if (fs.existsSync(pyPath)) {
|
|
try {
|
|
return fs.statSync(pyPath).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Windows release zips are often flat: index.js + convert-*.exe (no py/ yet).
|
|
try {
|
|
return fs.readdirSync(dir).some((name) => {
|
|
if (!/^convert/i.test(name)) return false;
|
|
const p = path.join(dir, name);
|
|
try {
|
|
return fs.statSync(p).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Flat Windows bundles ship convert-*.exe next to index.js; runtime expects py/. */
|
|
function ensurePyConverterLayout(root) {
|
|
const pyDir = path.join(root, "py");
|
|
let needMoveFromRoot = false;
|
|
|
|
if (fs.existsSync(pyDir)) {
|
|
try {
|
|
if (fs.statSync(pyDir).isDirectory()) {
|
|
const inner = fs.readdirSync(pyDir);
|
|
const hasBin = inner.some(
|
|
(n) =>
|
|
n === "convert" ||
|
|
n === "convert.exe" ||
|
|
/^convert-/i.test(n)
|
|
);
|
|
if (hasBin) return;
|
|
needMoveFromRoot = true;
|
|
}
|
|
} catch {
|
|
needMoveFromRoot = true;
|
|
}
|
|
} else {
|
|
needMoveFromRoot = true;
|
|
}
|
|
|
|
if (!needMoveFromRoot) return;
|
|
|
|
fs.mkdirSync(pyDir, { recursive: true });
|
|
const names = fs.readdirSync(root, { withFileTypes: true });
|
|
for (const ent of names) {
|
|
if (!ent.isFile()) continue;
|
|
const base = ent.name;
|
|
if (!/^convert/i.test(base)) continue;
|
|
const from = path.join(root, base);
|
|
const to = path.join(pyDir, base);
|
|
fs.renameSync(from, to);
|
|
}
|
|
}
|
|
|
|
function describeExtractTree(extractDir, maxEntries = 30) {
|
|
const lines = [];
|
|
function walk(dir, prefix, depth) {
|
|
if (depth > 3 || lines.length >= maxEntries) return;
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const e of entries) {
|
|
if (lines.length >= maxEntries) break;
|
|
const p = path.join(dir, e.name);
|
|
lines.push(`${prefix}${e.name}${e.isDirectory() ? "/" : ""}`);
|
|
if (e.isDirectory()) walk(p, `${prefix} `, depth + 1);
|
|
}
|
|
}
|
|
walk(extractDir, "", 0);
|
|
return lines.length ? lines.join("\n") : "(empty)";
|
|
}
|
|
|
|
function resolveExtractedRoot(extractDir) {
|
|
if (hasRuntimeLayout(extractDir)) {
|
|
return extractDir;
|
|
}
|
|
|
|
const queue = [{ dir: extractDir, depth: 0 }];
|
|
const maxDepth = 8;
|
|
while (queue.length > 0) {
|
|
const { dir, depth } = queue.shift();
|
|
if (depth >= maxDepth) continue;
|
|
let children;
|
|
try {
|
|
children = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of children) {
|
|
if (!entry.isDirectory()) continue;
|
|
const candidate = path.join(dir, entry.name);
|
|
if (hasRuntimeLayout(candidate)) {
|
|
return candidate;
|
|
}
|
|
queue.push({ dir: candidate, depth: depth + 1 });
|
|
}
|
|
}
|
|
|
|
const hint = describeExtractTree(extractDir);
|
|
throw new Error(
|
|
`Unable to locate export runtime root under ${extractDir}\n` +
|
|
`Expected a folder containing index.js and a py/ directory. Extracted layout (partial):\n${hint}`
|
|
);
|
|
}
|
|
|
|
async function downloadAndInstallRuntime() {
|
|
const tag = await getTargetVersion();
|
|
const assetName = getPlatformAssetName();
|
|
const downloadUrl = `${exportRepoBase}/${tag}/${assetName}`;
|
|
|
|
ensureDir(cacheDir);
|
|
const zipPath = path.join(cacheDir, assetName);
|
|
const extractDir = path.join(cacheDir, `extract-${Date.now()}`);
|
|
|
|
console.log(`[export-runtime] Downloading ${downloadUrl}`);
|
|
try {
|
|
await downloadFileWithRetries(downloadUrl, zipPath);
|
|
} catch (err) {
|
|
try {
|
|
fs.unlinkSync(zipPath);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
console.log(`[export-runtime] Extracting ${zipPath}`);
|
|
try {
|
|
unzipArchive(zipPath, extractDir);
|
|
const sourceRoot = resolveExtractedRoot(extractDir);
|
|
ensurePyConverterLayout(sourceRoot);
|
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
ensureDir(targetRoot);
|
|
fs.cpSync(sourceRoot, targetRoot, { recursive: true, force: true });
|
|
} finally {
|
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
}
|
|
|
|
return { tag, downloadUrl };
|
|
}
|
|
|
|
async function main() {
|
|
const existing = validateExistingRuntime();
|
|
|
|
if (checkOnly) {
|
|
if (!existing.ok) {
|
|
throw new Error(existing.reason);
|
|
}
|
|
console.log("[export-runtime] Existing runtime is valid.");
|
|
console.log(` - ${targetIndex}`);
|
|
console.log(` - ${existing.converterPath}`);
|
|
return;
|
|
}
|
|
|
|
if (existing.ok && !forceDownload) {
|
|
patchHtmlToImageRuntime();
|
|
console.log("[export-runtime] Using existing runtime artifacts:");
|
|
console.log(` - ${targetIndex}`);
|
|
console.log(` - ${existing.converterPath}`);
|
|
return;
|
|
}
|
|
|
|
if (!existing.ok && hasExportDirectoryContent()) {
|
|
console.log("[export-runtime] Existing export directory is invalid, re-syncing package.");
|
|
}
|
|
|
|
const { tag, downloadUrl } = await downloadAndInstallRuntime();
|
|
patchHtmlToImageRuntime();
|
|
const installed = validateExistingRuntime();
|
|
if (!installed.ok) {
|
|
throw new Error(installed.reason);
|
|
}
|
|
|
|
console.log("[export-runtime] Runtime synced successfully:");
|
|
console.log(` - release: ${tag}`);
|
|
console.log(` - url: ${downloadUrl}`);
|
|
console.log(` - ${targetIndex}`);
|
|
console.log(` - ${installed.converterPath}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[export-runtime] ${error.message}`);
|
|
process.exit(1);
|
|
});
|