Merge pull request #451 from presenton/feat/fixcheckexport
Feat/fixcheckexport
This commit is contained in:
commit
9e3492ce1b
12 changed files with 586 additions and 1143 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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://"):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
362
electron/sync_export_runtime.js
Normal file
362
electron/sync_export_runtime.js
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue