Merge pull request #451 from presenton/feat/fixcheckexport

Feat/fixcheckexport
This commit is contained in:
Sudip Parajuli 2026-03-18 20:30:45 +05:45 committed by GitHub
commit 9e3492ce1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 586 additions and 1143 deletions

View file

@ -8,6 +8,8 @@ import { v4 as uuidv4 } from 'uuid';
import { spawn } from "child_process";
import { getPuppeteerExecutablePath } from "../utils/puppeteer-check";
type BinaryFormat = "elf" | "mach-o" | "pe" | "unknown";
export function setupExportHandlers() {
ipcMain.handle("file-downloaded", async (_, filePath: string): Promise<IPCStatus> => {
const fileName = path.basename(filePath);
@ -38,7 +40,7 @@ export function setupExportHandlers() {
await fs.promises.writeFile(exportTaskPath, JSON.stringify(exportTask));
const exportScriptPath = path.join(baseDir, "resources", "export", "index.js");
const pythonModulePath = path.join(baseDir, "resources", "export", "py", "convert");
const pythonModulePath = await resolveConverterPath(baseDir);
const puppeteerExecutablePath = await getPuppeteerExecutablePath();
console.log("[Export] Spawning export task with config:", {
exportAs,
@ -125,6 +127,94 @@ export function setupExportHandlers() {
}
async function resolveConverterPath(currentBaseDir: string): Promise<string> {
const pyDir = path.join(currentBaseDir, "resources", "export", "py");
const extension = process.platform === "win32" ? ".exe" : "";
const converterCandidates = [
path.join(pyDir, `convert-${process.platform}-${process.arch}${extension}`),
path.join(pyDir, `convert-${process.platform}${extension}`),
...(process.platform === "win32"
? [path.join(pyDir, "convert.exe"), path.join(pyDir, "convert")]
: [path.join(pyDir, "convert")]),
];
const converterPath = await findFirstExistingPath(converterCandidates);
if (!converterPath) {
throw new Error(
[
"No converter binary found for export.",
"Expected one of:",
...converterCandidates.map((candidate) => ` - ${candidate}`),
].join("\n")
);
}
const format = await detectBinaryFormat(converterPath);
if (!isBinaryFormatCompatible(format)) {
throw new Error(
[
`Converter binary is not valid for ${process.platform}/${process.arch}.`,
`Selected converter: ${converterPath}`,
`Detected format: ${format}`,
"Please bundle a platform-correct converter binary (for example convert-darwin-arm64 or convert-darwin-x64).",
].join("\n")
);
}
return converterPath;
}
async function findFirstExistingPath(paths: string[]): Promise<string | null> {
for (const candidate of paths) {
try {
await fs.promises.access(candidate, fs.constants.F_OK);
return candidate;
} catch {
continue;
}
}
return null;
}
async function detectBinaryFormat(binaryPath: string): Promise<BinaryFormat> {
const fd = await fs.promises.open(binaryPath, "r");
try {
const header = Buffer.alloc(4);
await fd.read(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 {
await fd.close();
}
}
function isBinaryFormatCompatible(format: BinaryFormat): boolean {
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 resolveExportedFilePath(responseData: any): string | null {
if (responseData?.path && typeof responseData.path === "string") {
return path.isAbsolute(responseData.path)

View file

@ -10,35 +10,10 @@ export function setUserConfig(userConfig: UserConfig) {
const configData = fs.readFileSync(userConfigPath, 'utf-8')
existingConfig = JSON.parse(configData)
}
const definedIncomingEntries = Object.entries(userConfig).filter(([, value]) => value !== undefined)
const mergedConfig: UserConfig = {
CAN_CHANGE_KEYS: userConfig.CAN_CHANGE_KEYS || existingConfig.CAN_CHANGE_KEYS,
LLM: userConfig.LLM || existingConfig.LLM,
OPENAI_API_KEY: userConfig.OPENAI_API_KEY || existingConfig.OPENAI_API_KEY,
OPENAI_MODEL: userConfig.OPENAI_MODEL || existingConfig.OPENAI_MODEL,
GOOGLE_API_KEY: userConfig.GOOGLE_API_KEY || existingConfig.GOOGLE_API_KEY,
GOOGLE_MODEL: userConfig.GOOGLE_MODEL || existingConfig.GOOGLE_MODEL,
ANTHROPIC_API_KEY: userConfig.ANTHROPIC_API_KEY || existingConfig.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: userConfig.ANTHROPIC_MODEL || existingConfig.ANTHROPIC_MODEL,
OLLAMA_URL: userConfig.OLLAMA_URL || existingConfig.OLLAMA_URL,
OLLAMA_MODEL: userConfig.OLLAMA_MODEL || existingConfig.OLLAMA_MODEL,
CUSTOM_LLM_URL: userConfig.CUSTOM_LLM_URL || existingConfig.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY: userConfig.CUSTOM_LLM_API_KEY || existingConfig.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: userConfig.CUSTOM_MODEL || existingConfig.CUSTOM_MODEL,
PEXELS_API_KEY: userConfig.PEXELS_API_KEY || existingConfig.PEXELS_API_KEY,
PIXABAY_API_KEY: userConfig.PIXABAY_API_KEY || existingConfig.PIXABAY_API_KEY,
IMAGE_PROVIDER: userConfig.IMAGE_PROVIDER || existingConfig.IMAGE_PROVIDER,
DISABLE_IMAGE_GENERATION: userConfig.DISABLE_IMAGE_GENERATION || existingConfig.DISABLE_IMAGE_GENERATION,
EXTENDED_REASONING: userConfig.EXTENDED_REASONING || existingConfig.EXTENDED_REASONING,
TOOL_CALLS: userConfig.TOOL_CALLS || existingConfig.TOOL_CALLS,
DISABLE_THINKING: userConfig.DISABLE_THINKING || existingConfig.DISABLE_THINKING,
WEB_GROUNDING: userConfig.WEB_GROUNDING || existingConfig.WEB_GROUNDING,
DATABASE_URL: userConfig.DATABASE_URL || existingConfig.DATABASE_URL,
DISABLE_ANONYMOUS_TRACKING: userConfig.DISABLE_ANONYMOUS_TRACKING || existingConfig.DISABLE_ANONYMOUS_TRACKING,
COMFYUI_URL: userConfig.COMFYUI_URL || existingConfig.COMFYUI_URL,
COMFYUI_WORKFLOW: userConfig.COMFYUI_WORKFLOW || existingConfig.COMFYUI_WORKFLOW,
DALL_E_3_QUALITY: userConfig.DALL_E_3_QUALITY || existingConfig.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY: userConfig.GPT_IMAGE_1_5_QUALITY || existingConfig.GPT_IMAGE_1_5_QUALITY,
CODEX_MODEL: userConfig.CODEX_MODEL || existingConfig.CODEX_MODEL,
...existingConfig,
...Object.fromEntries(definedIncomingEntries),
CODEX_ACCESS_TOKEN: existingConfig.CODEX_ACCESS_TOKEN,
CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN,
CODEX_TOKEN_EXPIRES: existingConfig.CODEX_TOKEN_EXPIRES,

View file

@ -6,7 +6,22 @@ const path = require("path")
const afterPack = async (context) => {
if (context.electronPlatformName === "darwin") {
const appPath = context.appOutDir
const fastapiPath = path.join(appPath, "Presenton.app/Contents/Resources/app/resources/fastapi/fastapi")
const appBundleName = `${context.packager.appInfo.productFilename}.app`
const resourcesRoot = path.join(
appPath,
appBundleName,
"Contents",
"Resources",
"app",
"resources"
)
const fastapiPath = path.join(resourcesRoot, "fastapi", "fastapi")
const exportPyDir = path.join(resourcesRoot, "export", "py")
const converterCandidates = [
`convert-${process.platform}-${process.arch}`,
`convert-${process.platform}`,
"convert",
]
console.log("Setting executable permissions for FastAPI binary...")
console.log("FastAPI path:", fastapiPath)
@ -18,10 +33,28 @@ const afterPack = async (context) => {
console.warn("⚠ FastAPI binary not found at:", fastapiPath)
}
const fastapiDir = path.join(appPath, "Presenton.app/Contents/Resources/app/resources/fastapi")
console.log("Setting executable permissions for export converter binary...")
let converterFound = false
for (const candidate of converterCandidates) {
const candidatePath = path.join(exportPyDir, candidate)
if (fs.existsSync(candidatePath)) {
fs.chmodSync(candidatePath, 0o755)
console.log("✓ Execute permissions set for converter:", candidatePath)
converterFound = true
}
}
if (!converterFound) {
console.warn("⚠ No converter binary found in:", exportPyDir)
}
const fastapiDir = path.join(resourcesRoot, "fastapi")
if (fs.existsSync(fastapiDir)) {
console.log("FastAPI directory contents:", fs.readdirSync(fastapiDir))
}
if (fs.existsSync(exportPyDir)) {
console.log("Export py directory contents:", fs.readdirSync(exportPyDir))
}
}
}

View file

@ -2,6 +2,7 @@
"name": "presenton",
"productName": "Presenton Open Source",
"version": "0.6.2-beta",
"exportVersion": "v0.1.0",
"main": "app_dist/main.js",
"description": "Open-Source AI Presentation Generator",
"homepage": "https://presenton.ai",
@ -29,15 +30,19 @@
"dist": "electron-builder",
"postinstall": "electron-builder install-app-deps",
"dev": "rm -rf app_dist && tsc && electron .",
"setup:env": "npm install && cd servers/fastapi && uv sync && cd ../../servers/nextjs && npm install",
"setup:env": "npm install && cd servers/fastapi && uv sync && cd ../../servers/nextjs && npm install && cd ../.. && npm run setup:export-runtime",
"install:pyinstaller": "cd servers/fastapi && echo 'pyinstaller already in dependencies'",
"build:ts": "rm -rf app_dist && tsc",
"build:css": "tailwindcss -i ./resources/ui/assets/css/tailwind.import.css -o ./resources/ui/assets/css/tailwind.css --watch",
"build:vectorstore": "cd servers/fastapi && uv run python build_vectorstore.py",
"build:export-runtime": "node sync_export_runtime.js",
"setup:export-runtime": "node sync_export_runtime.js",
"fetch:export-runtime": "node sync_export_runtime.js --force",
"fetch:export-runtime:latest": "EXPORT_RUNTIME_VERSION=latest node sync_export_runtime.js --force",
"build:nextjs": "rm -rf resources/nextjs && cd servers/nextjs && cross-env BUILD_TARGET=electron npm run build && cp -r .next-build ../../resources/nextjs && cp -r app/presentation-templates ../../resources/nextjs/presentation-templates",
"build:fastapi": "rm -rf resources/fastapi && npm run build:vectorstore && (cp ../servers/fastapi/alembic/versions/*.py servers/fastapi/alembic/versions/ 2>/dev/null || true) && cd servers/fastapi && uv run python -m PyInstaller --distpath ../../resources server.spec",
"generate:version": "node generate_update.js",
"build:electron": "npm run generate:version && rm -rf app_dist && tsc && node build.js",
"build:electron": "npm run generate:version && npm run build:export-runtime && rm -rf app_dist && tsc && node build.js",
"build:all": "npm run clean:build && npm run setup:env && npm run build:ts && npm run install:pyinstaller && npm run build:nextjs && npm run build:fastapi && npm run build:electron",
"clean:build": "rm -rf resources/nextjs && rm -rf resources/fastapi && rm -rf app_dist"
},

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -34,6 +34,17 @@ if alembic_config.config_file_name is not None:
target_metadata = SQLModel.metadata
def _to_sync_database_url(database_url: str) -> str:
# Preserve slash counts for sqlite URLs so Windows paths stay valid.
if database_url.startswith("sqlite+aiosqlite:///"):
return "sqlite:///" + database_url[len("sqlite+aiosqlite:///") :]
if database_url.startswith("postgresql+asyncpg://"):
return "postgresql://" + database_url[len("postgresql+asyncpg://") :]
if database_url.startswith("mysql+aiomysql://"):
return "mysql://" + database_url[len("mysql+aiomysql://") :]
return database_url
def _get_url() -> str:
"""
Prefer the URL injected by migrations.py via config.set_main_option,
@ -46,12 +57,7 @@ def _get_url() -> str:
from utils.db_utils import get_database_url_and_connect_args
url, _ = get_database_url_and_connect_args()
return (
url
.replace("sqlite+aiosqlite://", "sqlite:///")
.replace("postgresql+asyncpg://", "postgresql://")
.replace("mysql+aiomysql://", "mysql://")
)
return _to_sync_database_url(url)
def run_migrations_offline() -> None:

View file

@ -8,6 +8,17 @@ from utils.db_utils import get_database_url_and_connect_args
from utils.get_env import get_migrate_database_on_startup_env
def _to_sync_database_url(database_url: str) -> str:
# Preserve slash counts for sqlite URLs so Windows paths stay valid.
if database_url.startswith("sqlite+aiosqlite:///"):
return "sqlite:///" + database_url[len("sqlite+aiosqlite:///") :]
if database_url.startswith("postgresql+asyncpg://"):
return "postgresql://" + database_url[len("postgresql+asyncpg://") :]
if database_url.startswith("mysql+aiomysql://"):
return "mysql://" + database_url[len("mysql+aiomysql://") :]
return database_url
async def migrate_database_on_startup() -> None:
if get_migrate_database_on_startup_env() not in ["true", "True"]:
return
@ -29,12 +40,7 @@ def _run_migrations() -> None:
database_url, _ = get_database_url_and_connect_args()
# Alembic uses synchronous engines; strip async driver prefixes.
database_url = (
database_url
.replace("sqlite+aiosqlite://", "sqlite:///")
.replace("postgresql+asyncpg://", "postgresql://")
.replace("mysql+aiomysql://", "mysql://")
)
database_url = _to_sync_database_url(database_url)
config.set_main_option("sqlalchemy.url", database_url)
command.upgrade(config, "head")

View file

@ -4,11 +4,31 @@ from urllib.parse import urlsplit, urlunsplit, parse_qsl
import ssl
def _ensure_sqlite_parent_dir(database_url: str) -> None:
if not database_url.startswith("sqlite://"):
return
split_result = urlsplit(database_url)
db_path = split_result.path
if not db_path:
return
# sqlite URLs on Windows can start with /C:/..., normalize that for os.path.
if os.name == "nt" and len(db_path) >= 3 and db_path[0] == "/" and db_path[2] == ":":
db_path = db_path[1:]
parent = os.path.dirname(db_path)
if parent:
os.makedirs(parent, exist_ok=True)
def get_database_url_and_connect_args() -> tuple[str, dict]:
database_url = get_database_url_env() or "sqlite:///" + os.path.join(
get_app_data_directory_env() or "/tmp/presenton", "fastapi.db"
)
_ensure_sqlite_parent_dir(database_url)
if database_url.startswith("sqlite://"):
database_url = database_url.replace("sqlite://", "sqlite+aiosqlite://", 1)
elif database_url.startswith("postgresql://"):

View file

@ -40,54 +40,12 @@ export async function POST(request: Request) {
const configData = fs.readFileSync(userConfigPath, "utf-8");
existingConfig = JSON.parse(configData);
}
const definedIncomingEntries = Object.entries(userConfig).filter(
([, value]) => value !== undefined
);
const mergedConfig: LLMConfig = {
LLM: userConfig.LLM || existingConfig.LLM,
OPENAI_API_KEY: userConfig.OPENAI_API_KEY || existingConfig.OPENAI_API_KEY,
OPENAI_MODEL: userConfig.OPENAI_MODEL || existingConfig.OPENAI_MODEL,
GOOGLE_API_KEY: userConfig.GOOGLE_API_KEY || existingConfig.GOOGLE_API_KEY,
GOOGLE_MODEL: userConfig.GOOGLE_MODEL || existingConfig.GOOGLE_MODEL,
ANTHROPIC_API_KEY:
userConfig.ANTHROPIC_API_KEY || existingConfig.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL:
userConfig.ANTHROPIC_MODEL || existingConfig.ANTHROPIC_MODEL,
OLLAMA_URL: userConfig.OLLAMA_URL || existingConfig.OLLAMA_URL,
OLLAMA_MODEL: userConfig.OLLAMA_MODEL || existingConfig.OLLAMA_MODEL,
CUSTOM_LLM_URL: userConfig.CUSTOM_LLM_URL || existingConfig.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY:
userConfig.CUSTOM_LLM_API_KEY || existingConfig.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: userConfig.CUSTOM_MODEL || existingConfig.CUSTOM_MODEL,
DISABLE_IMAGE_GENERATION:
userConfig.DISABLE_IMAGE_GENERATION === undefined
? existingConfig.DISABLE_IMAGE_GENERATION
: userConfig.DISABLE_IMAGE_GENERATION,
PIXABAY_API_KEY:
userConfig.PIXABAY_API_KEY || existingConfig.PIXABAY_API_KEY,
IMAGE_PROVIDER: userConfig.IMAGE_PROVIDER || existingConfig.IMAGE_PROVIDER,
PEXELS_API_KEY: userConfig.PEXELS_API_KEY || existingConfig.PEXELS_API_KEY,
COMFYUI_URL: userConfig.COMFYUI_URL || existingConfig.COMFYUI_URL,
COMFYUI_WORKFLOW:
userConfig.COMFYUI_WORKFLOW || existingConfig.COMFYUI_WORKFLOW,
DALL_E_3_QUALITY:
userConfig.DALL_E_3_QUALITY || existingConfig.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY:
userConfig.GPT_IMAGE_1_5_QUALITY || existingConfig.GPT_IMAGE_1_5_QUALITY,
TOOL_CALLS:
userConfig.TOOL_CALLS === undefined
? existingConfig.TOOL_CALLS
: userConfig.TOOL_CALLS,
DISABLE_THINKING:
userConfig.DISABLE_THINKING === undefined
? existingConfig.DISABLE_THINKING
: userConfig.DISABLE_THINKING,
EXTENDED_REASONING:
userConfig.EXTENDED_REASONING === undefined
? existingConfig.EXTENDED_REASONING
: userConfig.EXTENDED_REASONING,
WEB_GROUNDING:
userConfig.WEB_GROUNDING === undefined
? existingConfig.WEB_GROUNDING
: userConfig.WEB_GROUNDING,
CODEX_MODEL: userConfig.CODEX_MODEL || existingConfig.CODEX_MODEL,
...existingConfig,
...Object.fromEntries(definedIncomingEntries),
CODEX_ACCESS_TOKEN: existingConfig.CODEX_ACCESS_TOKEN,
CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN,
CODEX_TOKEN_EXPIRES: existingConfig.CODEX_TOKEN_EXPIRES,

View file

@ -1,23 +1,45 @@
import { getFastAPIUrl } from "./api";
function normalizePathSeparators(value: string): string {
return value.replace(/\\/g, "/");
}
function toServedPath(rawPath: string): string {
const normalized = normalizePathSeparators(decodeURIComponent(rawPath));
// Never rewrite Next.js bundled/static assets.
// Example: /_next/static/media/*.svg should stay unchanged.
if (normalized.startsWith("/_next/static/")) {
return normalized;
}
// Prefer canonical FastAPI-mounted roots when present.
const appDataIdx = normalized.indexOf("/app_data/");
if (appDataIdx !== -1) {
return normalized.slice(appDataIdx);
}
const staticIdx = normalized.indexOf("/static/");
if (staticIdx !== -1) {
return normalized.slice(staticIdx);
}
// Windows absolute path in URL form: /C:/Users/.../images/foo.png
// Map anything under an images folder to FastAPI app_data mount.
const imagesIdx = normalized.lastIndexOf("/images/");
if (imagesIdx !== -1) {
return `/app_data${normalized.slice(imagesIdx)}`;
}
return normalized;
}
function toFastApiStaticUrl(fileSrc: string): string {
try {
const baseUrl = getFastAPIUrl();
const url = new URL(fileSrc);
const path = url.pathname;
// Prefer subpath starting at /app_data or /static if present
const appDataIdx = path.indexOf("/app_data/");
const staticIdx = path.indexOf("/static/");
let relPath = path;
if (appDataIdx !== -1) {
relPath = path.slice(appDataIdx);
} else if (staticIdx !== -1) {
relPath = path.slice(staticIdx);
}
return `${baseUrl}${relPath}`;
const servedPath = toServedPath(url.pathname);
return `${baseUrl}${servedPath}`;
} catch {
// If URL parsing fails, leave as-is
return fileSrc;
@ -29,8 +51,9 @@ function normalizeImageSrc(src: string): string {
if (/^https?:\/\//.test(src)) {
try {
const url = new URL(src);
if (url.pathname.startsWith("/app_data/") || url.pathname.startsWith("/static/")) {
return `${getFastAPIUrl()}${url.pathname}`;
const servedPath = toServedPath(url.pathname);
if (servedPath.startsWith("/app_data/") || servedPath.startsWith("/static/")) {
return `${getFastAPIUrl()}${servedPath}`;
}
return src;
} catch {

View file

@ -0,0 +1,362 @@
const fs = require("fs");
const http = require("http");
const https = require("https");
const path = require("path");
const { execFileSync } = require("child_process");
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"));
const targetRoot = path.join(__dirname, "resources", "export");
const targetPyDir = path.join(targetRoot, "py");
const targetIndex = path.join(targetRoot, "index.js");
const cacheDir = path.join(__dirname, ".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-x64") return "export-Linux-X64.zip";
if (platformArch === "darwin-arm64") return "export-macOS-ARM64.zip";
if (platformArch === "win32-x64") return "export-Windows-X64.zip";
throw new Error(
`Unsupported export runtime platform: ${platformArch}. Supported: linux-x64, darwin-arm64, 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 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 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);
});
}
function unzipArchive(zipPath, destDir) {
ensureDir(destDir);
if (process.platform === "win32") {
execFileSync(
"powershell",
[
"-NoProfile",
"-Command",
`Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force`,
],
{ stdio: "inherit" }
);
return;
}
execFileSync("unzip", ["-o", zipPath, "-d", destDir], { stdio: "inherit" });
}
function resolveExtractedRoot(extractDir) {
const directIndex = path.join(extractDir, "index.js");
const directPy = path.join(extractDir, "py");
if (fs.existsSync(directIndex) && fs.existsSync(directPy)) {
return extractDir;
}
const children = fs.readdirSync(extractDir, { withFileTypes: true });
for (const entry of children) {
if (!entry.isDirectory()) continue;
const candidate = path.join(extractDir, entry.name);
const candidateIndex = path.join(candidate, "index.js");
const candidatePy = path.join(candidate, "py");
if (fs.existsSync(candidateIndex) && fs.existsSync(candidatePy)) {
return candidate;
}
}
throw new Error(`Unable to locate export runtime root under ${extractDir}`);
}
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}`);
await downloadFile(downloadUrl, zipPath);
console.log(`[export-runtime] Extracting ${zipPath}`);
unzipArchive(zipPath, extractDir);
const sourceRoot = resolveExtractedRoot(extractDir);
fs.rmSync(targetRoot, { recursive: true, force: true });
ensureDir(targetRoot);
fs.cpSync(sourceRoot, targetRoot, { recursive: true, force: true });
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) {
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();
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);
});