diff --git a/electron/app/ipc/libreoffice_install_handlers.ts b/electron/app/ipc/libreoffice_install_handlers.ts new file mode 100644 index 00000000..0b10baa9 --- /dev/null +++ b/electron/app/ipc/libreoffice_install_handlers.ts @@ -0,0 +1,474 @@ +import { ipcMain, WebContents } from "electron"; +import { spawn } from "child_process"; +import * as fs from "fs"; +import * as https from "https"; +import * as http from "http"; +import { IncomingMessage } from "http"; +import * as path from "path"; +import { app } from "electron"; +import { LIBREOFFICE_DOWNLOAD_URLS, LIBREOFFICE_VERSION } from "../utils/libreoffice-urls"; +import { getLinuxInstallCommand } from "../utils/libreoffice-check"; + +// --------------------------------------------------------------------------- +// IPC helpers +// --------------------------------------------------------------------------- + +function sendProgress( + wc: WebContents, + phase: "downloading" | "installing" | "done" | "error", + percent?: number, + message?: string +) { + if (!wc.isDestroyed()) { + wc.send("lo:progress", { phase, percent, message }); + } +} + +function sendLog( + wc: WebContents, + level: "info" | "warn" | "error" | "ok" | "cmd", + text: string +) { + if (!wc.isDestroyed()) { + // Split multi-line output into individual log entries + const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0); + for (const line of lines) { + wc.send("lo:log", { level, text: line }); + } + } +} + +// --------------------------------------------------------------------------- +// Download with progress +// --------------------------------------------------------------------------- + +/** Minimum expected size (bytes). LibreOffice installers are ~280–350 MB; HTML/redirect pages are ~30 KB. */ +const MIN_INSTALLER_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB + +/** + * Known approximate installer sizes used as fallback when the download server + * does not send a Content-Length header (e.g. some CDN mirrors strip it). + * These are intentionally conservative estimates so the progress bar never + * jumps backward if the actual file is slightly smaller. + */ +const KNOWN_INSTALLER_SIZES = { + win64: 370 * 1024 * 1024, // ~350–360 MB MSI + macX64: 400 * 1024 * 1024, // ~370–390 MB DMG + macArm64: 400 * 1024 * 1024, // ~370–390 MB DMG +}; + +function downloadWithProgress( + url: string, + dest: string, + filename: string, + wc: WebContents, + minSizeBytes: number = MIN_INSTALLER_SIZE_BYTES, + knownTotalBytes?: number +): Promise { + return new Promise((resolve, reject) => { + const fmtBytes = (bytes: number) => { + if (bytes <= 0) return "0 B"; + const mb = bytes / 1024 / 1024; + if (mb >= 1) return `${mb.toFixed(1)} MB`; + const kb = bytes / 1024; + return kb >= 1 ? `${kb.toFixed(0)} KB` : `${bytes} B`; + }; + + const fmtSpeed = (bytesPerSec: number) => { + const mbps = bytesPerSec / 1024 / 1024; + return mbps >= 1 ? `${mbps.toFixed(1)} MB/s` : `${(bytesPerSec / 1024).toFixed(0)} KB/s`; + }; + + const fmtEta = (seconds: number) => { + if (seconds <= 0 || !isFinite(seconds)) return ""; + if (seconds < 60) return `~${Math.ceil(seconds)}s left`; + return `~${Math.ceil(seconds / 60)}m left`; + }; + + sendLog(wc, "cmd", `GET ${url}`); + sendLog(wc, "info", `Connecting to ${new URL(url).hostname}…`); + + const doRequest = (requestUrl: string) => { + const requester = requestUrl.startsWith("https") ? https.get : http.get; + requester(requestUrl, (res: IncomingMessage) => { + if ( + (res.statusCode === 301 || res.statusCode === 302) && + res.headers.location + ) { + sendLog(wc, "info", `HTTP ${res.statusCode} → Redirecting to ${res.headers.location}`); + doRequest(res.headers.location); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`Download failed: HTTP ${res.statusCode}`)); + return; + } + + const contentLength = parseInt(res.headers["content-length"] ?? "0", 10); + // Use Content-Length when available; fall back to the caller-supplied + // known size so the progress bar shows something meaningful even when + // the CDN mirror omits the header. + const totalBytes = contentLength > 0 ? contentLength : (knownTotalBytes ?? 0); + sendLog(wc, "ok", `HTTP 200 OK — ${totalBytes > 0 ? fmtBytes(totalBytes) : "size unknown"}`); + sendLog(wc, "info", `Saving to: ${dest}`); + sendLog(wc, "info", `Starting download of ${filename}…`); + + let downloaded = 0; + const startTime = Date.now(); + let lastLogTime = startTime; + // Log interval: every 2 seconds or every 5% — whichever fires first + const LOG_INTERVAL_MS = 2000; + let lastLoggedPct = 0; + + const file = fs.createWriteStream(dest); + + res.on("data", (chunk: Buffer) => { + downloaded += chunk.length; + const now = Date.now(); + const elapsedMs = now - startTime; + // Cap at 99 while still downloading so 100% only fires on completion + const percent = totalBytes > 0 ? Math.min(Math.floor((downloaded / totalBytes) * 100), 99) : 0; + const sizeLabel = totalBytes > 0 + ? `${fmtBytes(downloaded)} / ${fmtBytes(totalBytes)}` + : fmtBytes(downloaded); + + // Update the progress bar UI on every chunk + sendProgress(wc, "downloading", percent, `${filename}|${sizeLabel}`); + + // Log every 2 s OR every 5% progress + const pctBucket = Math.floor(percent / 5) * 5; + const timeSinceLastLog = now - lastLogTime; + if ( + (timeSinceLastLog >= LOG_INTERVAL_MS || pctBucket > lastLoggedPct) + && elapsedMs > 0 + ) { + lastLogTime = now; + lastLoggedPct = pctBucket; + const speed = downloaded / (elapsedMs / 1000); + const remaining = totalBytes > 0 ? (totalBytes - downloaded) / speed : 0; + const etaStr = totalBytes > 0 ? ` ${fmtEta(remaining)}` : ""; + const pctStr = totalBytes > 0 ? `${percent}% ` : ""; + sendLog( + wc, + "info", + `${pctStr}${fmtBytes(downloaded)} downloaded @ ${fmtSpeed(speed)}${etaStr}` + ); + } + }); + + res.pipe(file); + file.on("finish", () => + file.close(() => { + const elapsedSec = (Date.now() - startTime) / 1000; + const avgSpeed = downloaded / elapsedSec; + sendLog(wc, "ok", `Download complete — ${fmtBytes(downloaded)} in ${elapsedSec.toFixed(1)}s (avg ${fmtSpeed(avgSpeed)})`); + if (downloaded < minSizeBytes) { + fs.unlink(dest, () => {}); + reject( + new Error( + `Download failed: received ${fmtBytes(downloaded)} (expected > 50 MB). The server may have returned an HTML page instead of the installer.` + ) + ); + return; + } + resolve(); + }) + ); + file.on("error", (err) => { + fs.unlink(dest, () => {}); + reject(err); + }); + }).on("error", (err) => { + fs.unlink(dest, () => {}); + reject(err); + }); + }; + + doRequest(url); + }); +} + +// --------------------------------------------------------------------------- +// Platform installers +// --------------------------------------------------------------------------- + +async function installWindows(wc: WebContents): Promise { + const url = LIBREOFFICE_DOWNLOAD_URLS.win64; + const filename = `LibreOffice_${LIBREOFFICE_VERSION}_Win_x86-64.msi`; + const dest = path.join(app.getPath("temp"), filename); + + sendProgress(wc, "downloading", 0, `${filename}|`); + await downloadWithProgress(url, dest, filename, wc, MIN_INSTALLER_SIZE_BYTES, KNOWN_INSTALLER_SIZES.win64); + + sendProgress(wc, "installing"); + sendLog(wc, "info", "Requesting administrator rights (UAC prompt may appear)…"); + sendLog(wc, "cmd", `Running: msiexec /i "${filename}" /qn /norestart`); + + await new Promise((resolve, reject) => { + // Run msiexec elevated via PowerShell; error 1603 often means installer needs admin rights + const destEscaped = dest.replace(/'/g, "''"); + const ps = `$p = Start-Process -FilePath "msiexec" -ArgumentList "/i", '${destEscaped}', "/qn", "/norestart" -Verb RunAs -Wait -PassThru; if ($p) { exit $p.ExitCode } else { exit 1 }`; + const child = spawn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps], { + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (d: Buffer) => sendLog(wc, "info", d.toString())); + child.stderr?.on("data", (d: Buffer) => sendLog(wc, "warn", d.toString())); + child.on("close", (code) => { + fs.unlink(dest, () => {}); + if (code === 0 || code === 3010) { + sendLog(wc, "ok", `msiexec exited with code ${code} (success)`); + resolve(); + } else { + const hint = + code === 1603 + ? " — Try closing other apps, freeing disk space, or install LibreOffice manually from libreoffice.org" + : code === 1 + ? " — Did you cancel the administrator prompt?" + : ""; + reject(new Error(`msiexec exited with code ${code}${hint}`)); + } + }); + child.on("error", (err) => { + fs.unlink(dest, () => {}); + reject(err); + }); + }); +} + +async function installMac(wc: WebContents): Promise { + const brewPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]; + const brew = brewPaths.find((p) => fs.existsSync(p)); + + if (brew) { + sendProgress(wc, "installing"); + sendLog(wc, "cmd", `Running: ${brew} install --cask libreoffice`); + + await new Promise((resolve, reject) => { + const child = spawn(brew, ["install", "--cask", "libreoffice"], { + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (d: Buffer) => sendLog(wc, "info", d.toString())); + child.stderr?.on("data", (d: Buffer) => { + const text = d.toString(); + // brew writes normal output to stderr too + sendLog(wc, text.toLowerCase().includes("error") ? "error" : "info", text); + }); + child.on("close", (code) => { + if (code === 0) { + sendLog(wc, "ok", "Homebrew install succeeded"); + resolve(); + } else { + reject(new Error(`brew exit ${code}`)); + } + }); + child.on("error", reject); + }); + return; + } + + // Fallback: download DMG + const isArm64 = process.arch === "arm64"; + const url = isArm64 + ? LIBREOFFICE_DOWNLOAD_URLS.macArm64 + : LIBREOFFICE_DOWNLOAD_URLS.macX64; + const filename = `LibreOffice_${LIBREOFFICE_VERSION}_MacOS_${isArm64 ? "aarch64" : "x86-64"}.dmg`; + const dmgPath = path.join(app.getPath("temp"), filename); + const mountPoint = path.join(app.getPath("temp"), "LibreOfficeMount"); + + sendProgress(wc, "downloading", 0, `${filename}|`); + await downloadWithProgress( + url, dmgPath, filename, wc, + MIN_INSTALLER_SIZE_BYTES, + isArm64 ? KNOWN_INSTALLER_SIZES.macArm64 : KNOWN_INSTALLER_SIZES.macX64 + ); + + sendProgress(wc, "installing"); + fs.mkdirSync(mountPoint, { recursive: true }); + + sendLog(wc, "cmd", `Mounting DMG at ${mountPoint}`); + await new Promise((resolve, reject) => { + const child = spawn( + "hdiutil", + ["attach", dmgPath, "-nobrowse", "-quiet", "-mountpoint", mountPoint], + { stdio: ["ignore", "pipe", "pipe"] } + ); + child.stdout?.on("data", (d: Buffer) => sendLog(wc, "info", d.toString())); + child.stderr?.on("data", (d: Buffer) => sendLog(wc, "warn", d.toString())); + child.on("close", (code) => + code === 0 ? resolve() : reject(new Error("hdiutil attach failed")) + ); + child.on("error", reject); + }); + + try { + const entries = fs.readdirSync(mountPoint); + const bundle = entries.find((e) => /^LibreOffice[\s\d.]*\.app$/i.test(e)); + if (!bundle) throw new Error("LibreOffice.app not found in DMG"); + + const src = path.join(mountPoint, bundle); + const applicationsDir = path.join(process.env.HOME ?? "", "Applications"); + const dest = path.join(applicationsDir, bundle); + fs.mkdirSync(applicationsDir, { recursive: true }); + sendLog(wc, "cmd", `Copying ${bundle} to ~/Applications…`); + fs.cpSync(src, dest, { recursive: true }); + sendLog(wc, "ok", `Installed to ~/Applications/${bundle}`); + } finally { + sendLog(wc, "info", "Unmounting DMG…"); + spawn("hdiutil", ["detach", mountPoint, "-quiet"], { stdio: "ignore" }); + fs.unlink(dmgPath, () => {}); + try { fs.rmdirSync(mountPoint); } catch { /* ignore */ } + } +} + +async function installLinux(wc: WebContents): Promise { + const installCmd = getLinuxInstallCommand(); + if (!installCmd) { + throw new Error( + "Unsupported Linux distribution. Please install LibreOffice manually:\n sudo apt install libreoffice" + ); + } + + const isApt = installCmd.cmd === "apt" || installCmd.cmd === "apt-get"; + + if (isApt) { + // apt-get supports APT::Status-Fd which writes machine-readable progress + // lines to the specified file descriptor. We route them to stdout (fd=1) + // so the piped child.stdout stream delivers them without mixing with the + // regular log output that apt sends to stderr. + // + // Status line formats: + // dlstatus::: — download progress + // pmstatus::: — dpkg install progress + sendProgress(wc, "downloading", 0, "libreoffice|Resolving packages…"); + sendLog(wc, "cmd", "Running: pkexec apt-get install -y libreoffice"); + sendLog(wc, "info", "A system dialog will prompt for your password…"); + + await new Promise((resolve, reject) => { + const child = spawn( + "pkexec", + ["apt-get", "install", "-y", "-o", "APT::Status-Fd=1", "libreoffice"], + { stdio: ["ignore", "pipe", "pipe"] } + ); + + let stdoutBuf = ""; + + child.stdout?.on("data", (d: Buffer) => { + stdoutBuf += d.toString(); + const lines = stdoutBuf.split("\n"); + stdoutBuf = lines.pop() ?? ""; // keep any incomplete trailing line + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith("dlstatus:")) { + // dlstatus::: + const parts = trimmed.split(":"); + const pct = parseFloat(parts[2] ?? "0"); + if (!isNaN(pct)) { + const msg = parts.slice(3).join(":").trim() || "Downloading packages…"; + sendProgress(wc, "downloading", Math.min(Math.floor(pct), 99), `libreoffice|${msg}`); + } + } else if (trimmed.startsWith("pmstatus:")) { + // pmstatus::: + const parts = trimmed.split(":"); + const pct = parseFloat(parts[2] ?? "0"); + if (!isNaN(pct)) { + sendProgress(wc, "installing", Math.min(Math.floor(pct), 99)); + } + } else { + sendLog(wc, "info", trimmed); + } + } + }); + + child.stderr?.on("data", (d: Buffer) => { + const text = d.toString(); + sendLog(wc, text.toLowerCase().includes("error") ? "error" : "info", text); + }); + + child.on("close", (code) => { + if (code === 0) { + sendLog(wc, "ok", "apt-get exited successfully"); + resolve(); + } else { + reject(new Error(`apt-get exited with code ${code}`)); + } + }); + child.on("error", reject); + }); + return; + } + + // For dnf, pacman, zypper — use a simple regex to extract any percentage + // printed to stdout so we can at least animate the progress bar forward. + sendProgress(wc, "installing"); + const fullCmd = `pkexec ${installCmd.cmd} ${installCmd.args.join(" ")}`; + sendLog(wc, "cmd", `Running: ${fullCmd}`); + sendLog(wc, "info", "A system dialog will prompt for your password…"); + + const pctRegex = /(\d+)\s*%/; + + await new Promise((resolve, reject) => { + const child = spawn("pkexec", [installCmd.cmd, ...installCmd.args], { + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.on("data", (d: Buffer) => { + const text = d.toString(); + const match = pctRegex.exec(text); + if (match) { + const pct = parseInt(match[1], 10); + if (pct >= 0 && pct <= 100) { + sendProgress(wc, "installing", pct); + } + } + sendLog(wc, "info", text); + }); + + child.stderr?.on("data", (d: Buffer) => { + const text = d.toString(); + sendLog(wc, text.toLowerCase().includes("error") ? "error" : "info", text); + }); + + child.on("close", (code) => { + if (code === 0) { + sendLog(wc, "ok", `${installCmd.cmd} exited successfully`); + resolve(); + } else { + reject(new Error(`${installCmd.cmd} exited with code ${code}`)); + } + }); + child.on("error", reject); + }); +} + +// --------------------------------------------------------------------------- +// IPC registration +// --------------------------------------------------------------------------- + +export function setupLibreOfficeInstallHandlers() { + ipcMain.handle("lo:start-install", async (event) => { + const wc = event.sender; + try { + const platform = process.platform; + sendLog(wc, "info", `Platform: ${platform} (${process.arch})`); + if (platform === "win32") { + await installWindows(wc); + } else if (platform === "darwin") { + await installMac(wc); + } else { + await installLinux(wc); + } + sendProgress(wc, "done"); + } catch (err: unknown) { + const message = + err instanceof Error + ? err.message + : "An unexpected error occurred. You can install LibreOffice manually later."; + sendLog(wc, "error", message); + sendProgress(wc, "error", undefined, message); + } + }); +} diff --git a/electron/app/main.ts b/electron/app/main.ts index 993f00ac..e86b9f24 100644 --- a/electron/app/main.ts +++ b/electron/app/main.ts @@ -1,151 +1,188 @@ -require("dotenv").config(); -import { app, BrowserWindow } from "electron"; -import path from "path"; -import { findUnusedPorts, killProcess, setupEnv, setUserConfig } from "./utils"; -import { startFastApiServer, startNextJsServer } from "./utils/servers"; -import { ChildProcessByStdio } from "child_process"; -import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localhost, nextjsDir, tempDir, userConfigPath, userDataDir } from "./utils/constants"; -import { setupIpcHandlers } from "./ipc"; - - -var win: BrowserWindow | undefined; -var fastApiProcess: ChildProcessByStdio | undefined; -var nextjsProcess: any; - -app.commandLine.appendSwitch('gtk-version', '3'); - -const createWindow = () => { - win = new BrowserWindow({ - width: 1280, - height: 720, - icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"), - webPreferences: { - webSecurity: false, - preload: path.join(__dirname, 'preloads/index.js'), - }, - }); -}; - -async function startServers(fastApiPort: number, nextjsPort: number) { - try { - fastApiProcess = await startFastApiServer( - fastapiDir, - fastApiPort, - { - DEBUG: isDev ? "True" : "False", - CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS, - LLM: process.env.LLM, - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - OPENAI_MODEL: process.env.OPENAI_MODEL, - GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, - GOOGLE_MODEL: process.env.GOOGLE_MODEL, - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, - OLLAMA_URL: process.env.OLLAMA_URL, - OLLAMA_MODEL: process.env.OLLAMA_MODEL, - CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL, - CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY, - CUSTOM_MODEL: process.env.CUSTOM_MODEL, - PEXELS_API_KEY: process.env.PEXELS_API_KEY, - PIXABAY_API_KEY: process.env.PIXABAY_API_KEY, - IMAGE_PROVIDER: process.env.IMAGE_PROVIDER, - DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION, - EXTENDED_REASONING: process.env.EXTENDED_REASONING, - TOOL_CALLS: process.env.TOOL_CALLS, - DISABLE_THINKING: process.env.DISABLE_THINKING, - WEB_GROUNDING: process.env.WEB_GROUNDING, - DATABASE_URL: process.env.DATABASE_URL, - DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING, - COMFYUI_URL: process.env.COMFYUI_URL, - COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW, - DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY, - GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY, - APP_DATA_DIRECTORY: appDataDir, - TEMP_DIRECTORY: tempDir, - USER_CONFIG_PATH: userConfigPath, - }, - isDev, - ); - nextjsProcess = await startNextJsServer( - nextjsDir, - nextjsPort, - { - NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API, - TEMP_DIRECTORY: process.env.TEMP_DIRECTORY, - NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL, - NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH, - USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH, - APP_DATA_DIRECTORY: appDataDir, - }, - isDev, - ) - } catch (error) { - console.error("Server startup error:", error); - } -} - -async function stopServers() { - if (fastApiProcess?.pid) { - await killProcess(fastApiProcess.pid); - } - if (nextjsProcess) { - if (isDev) { - await killProcess(nextjsProcess.pid); - } else { - nextjsProcess.close(); - } - } -} - -app.whenReady().then(async () => { - // Ensure all required directories exist before starting - ensureDirectoriesExist(); - - createWindow(); - win?.loadFile(path.join(baseDir, "resources/ui/homepage/index.html")); - - setUserConfig({ - CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS, - LLM: process.env.LLM, - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - OPENAI_MODEL: process.env.OPENAI_MODEL, - GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, - GOOGLE_MODEL: process.env.GOOGLE_MODEL, - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, - OLLAMA_URL: process.env.OLLAMA_URL, - OLLAMA_MODEL: process.env.OLLAMA_MODEL, - CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL, - CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY, - CUSTOM_MODEL: process.env.CUSTOM_MODEL, - PEXELS_API_KEY: process.env.PEXELS_API_KEY, - PIXABAY_API_KEY: process.env.PIXABAY_API_KEY, - IMAGE_PROVIDER: process.env.IMAGE_PROVIDER, - DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION, - EXTENDED_REASONING: process.env.EXTENDED_REASONING, - TOOL_CALLS: process.env.TOOL_CALLS, - DISABLE_THINKING: process.env.DISABLE_THINKING, - WEB_GROUNDING: process.env.WEB_GROUNDING, - DATABASE_URL: process.env.DATABASE_URL, - DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING, - COMFYUI_URL: process.env.COMFYUI_URL, - COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW, - DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY, - GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY, - }) - - const [fastApiPort, nextjsPort] = await findUnusedPorts(); - console.log(`FastAPI port: ${fastApiPort}, NextJS port: ${nextjsPort}`); - - //? Setup environment variables to be used in the preloads - setupEnv(fastApiPort, nextjsPort); - setupIpcHandlers(); - - await startServers(fastApiPort, nextjsPort); - win?.loadURL(`${localhost}:${nextjsPort}`); -}); - -app.on("window-all-closed", async () => { - await stopServers(); - app.quit(); -}); +require("dotenv").config(); +import { app, BrowserWindow } from "electron"; +import path from "path"; +import fs from "fs"; +import { findUnusedPorts, killProcess, setupEnv, setUserConfig } from "./utils"; +import { startFastApiServer, startNextJsServer } from "./utils/servers"; +import { ChildProcessByStdio } from "child_process"; +import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localhost, nextjsDir, tempDir, userConfigPath, userDataDir } from "./utils/constants"; +import { setupIpcHandlers } from "./ipc"; +import { setupLibreOfficeInstallHandlers } from "./ipc/libreoffice_install_handlers"; +import { checkLibreOfficeBeforeWindow, getSofficePath } from "./utils/libreoffice-check"; + + +var win: BrowserWindow | undefined; +var fastApiProcess: ChildProcessByStdio | undefined; +var nextjsProcess: any; + +app.commandLine.appendSwitch('gtk-version', '3'); + +// Mitigate "Unable to move the cache: Access is denied" on Windows (Chromium disk cache). +// Use explicit cache paths and remove stale old_* dirs that cause move failures. +if (process.platform === "win32") { + const ud = app.getPath("userData"); + const cacheBase = path.join(ud, "Cache"); + const gpuCacheBase = path.join(ud, "GPUCache"); + app.setPath("cache", cacheBase); + app.commandLine.appendSwitch("disk-cache-dir", cacheBase); + try { + [cacheBase, gpuCacheBase].forEach((dir) => { + if (fs.existsSync(dir)) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory() && e.name.startsWith("old_")) { + fs.rmSync(path.join(dir, e.name), { recursive: true, force: true }); + } + } + } + }); + } catch { + /* ignore cleanup errors */ + } +} + +const createWindow = () => { + win = new BrowserWindow({ + width: 1280, + height: 720, + icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"), + webPreferences: { + webSecurity: false, + preload: path.join(__dirname, 'preloads/index.js'), + }, + }); +}; + +async function startServers(fastApiPort: number, nextjsPort: number) { + try { + fastApiProcess = await startFastApiServer( + fastapiDir, + fastApiPort, + { + DEBUG: isDev ? "True" : "False", + CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS, + LLM: process.env.LLM, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_MODEL: process.env.OPENAI_MODEL, + GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, + GOOGLE_MODEL: process.env.GOOGLE_MODEL, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, + OLLAMA_URL: process.env.OLLAMA_URL, + OLLAMA_MODEL: process.env.OLLAMA_MODEL, + CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL, + CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY, + CUSTOM_MODEL: process.env.CUSTOM_MODEL, + PEXELS_API_KEY: process.env.PEXELS_API_KEY, + PIXABAY_API_KEY: process.env.PIXABAY_API_KEY, + IMAGE_PROVIDER: process.env.IMAGE_PROVIDER, + DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION, + EXTENDED_REASONING: process.env.EXTENDED_REASONING, + TOOL_CALLS: process.env.TOOL_CALLS, + DISABLE_THINKING: process.env.DISABLE_THINKING, + WEB_GROUNDING: process.env.WEB_GROUNDING, + DATABASE_URL: process.env.DATABASE_URL, + DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING, + COMFYUI_URL: process.env.COMFYUI_URL, + COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW, + DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY, + GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY, + APP_DATA_DIRECTORY: appDataDir, + TEMP_DIRECTORY: tempDir, + USER_CONFIG_PATH: userConfigPath, + // Resolved by libreoffice-check.ts at startup; lets Python invoke the + // exact binary path instead of relying on the system PATH. + SOFFICE_PATH: getSofficePath(), + }, + isDev, + ); + nextjsProcess = await startNextJsServer( + nextjsDir, + nextjsPort, + { + NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API, + TEMP_DIRECTORY: process.env.TEMP_DIRECTORY, + NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL, + NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH, + USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH, + APP_DATA_DIRECTORY: appDataDir, + }, + isDev, + ) + } catch (error) { + console.error("Server startup error:", error); + } +} + +async function stopServers() { + if (fastApiProcess?.pid) { + await killProcess(fastApiProcess.pid); + } + if (nextjsProcess) { + if (isDev) { + await killProcess(nextjsProcess.pid); + } else { + nextjsProcess.close(); + } + } +} + +app.whenReady().then(async () => { + // Ensure all required directories exist before starting + ensureDirectoriesExist(); + + // Register LibreOffice install handlers early so the installer window can use them + setupLibreOfficeInstallHandlers(); + + // Check for LibreOffice (required for custom template from PPTX). Shows installer + // window if missing. Never blocks; always proceeds. + await checkLibreOfficeBeforeWindow(); + + createWindow(); + win?.loadFile(path.join(baseDir, "resources/ui/homepage/index.html")); + + setUserConfig({ + CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS, + LLM: process.env.LLM, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_MODEL: process.env.OPENAI_MODEL, + GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, + GOOGLE_MODEL: process.env.GOOGLE_MODEL, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, + OLLAMA_URL: process.env.OLLAMA_URL, + OLLAMA_MODEL: process.env.OLLAMA_MODEL, + CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL, + CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY, + CUSTOM_MODEL: process.env.CUSTOM_MODEL, + PEXELS_API_KEY: process.env.PEXELS_API_KEY, + PIXABAY_API_KEY: process.env.PIXABAY_API_KEY, + IMAGE_PROVIDER: process.env.IMAGE_PROVIDER, + DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION, + EXTENDED_REASONING: process.env.EXTENDED_REASONING, + TOOL_CALLS: process.env.TOOL_CALLS, + DISABLE_THINKING: process.env.DISABLE_THINKING, + WEB_GROUNDING: process.env.WEB_GROUNDING, + DATABASE_URL: process.env.DATABASE_URL, + DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING, + COMFYUI_URL: process.env.COMFYUI_URL, + COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW, + DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY, + GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY, + }) + + const [fastApiPort, nextjsPort] = await findUnusedPorts(); + console.log(`FastAPI port: ${fastApiPort}, NextJS port: ${nextjsPort}`); + + //? Setup environment variables to be used in the preloads + setupEnv(fastApiPort, nextjsPort); + setupIpcHandlers(); + + await startServers(fastApiPort, nextjsPort); + win?.loadURL(`${localhost}:${nextjsPort}`); +}); + +app.on("window-all-closed", async () => { + await stopServers(); + app.quit(); +}); diff --git a/electron/app/preloads/libreoffice-installer.ts b/electron/app/preloads/libreoffice-installer.ts new file mode 100644 index 00000000..99bd0c68 --- /dev/null +++ b/electron/app/preloads/libreoffice-installer.ts @@ -0,0 +1,12 @@ +import { contextBridge, ipcRenderer } from "electron"; + +contextBridge.exposeInMainWorld("loInstaller", { + startInstall: () => ipcRenderer.invoke("lo:start-install"), + skip: () => ipcRenderer.send("lo:skip"), + onProgress: (cb: (data: { phase: string; percent?: number; message?: string }) => void) => { + ipcRenderer.on("lo:progress", (_event, data) => cb(data)); + }, + onLog: (cb: (data: { level: string; text: string }) => void) => { + ipcRenderer.on("lo:log", (_event, data) => cb(data)); + }, +}); diff --git a/electron/app/types/index.d.ts b/electron/app/types/index.d.ts index d405562d..bf2ea930 100644 --- a/electron/app/types/index.d.ts +++ b/electron/app/types/index.d.ts @@ -30,6 +30,8 @@ interface FastApiEnv { APP_DATA_DIRECTORY?: string, TEMP_DIRECTORY?: string, USER_CONFIG_PATH?: string, + /** Absolute path to the soffice binary resolved at startup by libreoffice-check.ts. */ + SOFFICE_PATH?: string, } interface NextJsEnv { diff --git a/electron/app/utils/libreoffice-check.ts b/electron/app/utils/libreoffice-check.ts new file mode 100644 index 00000000..ae70a6b6 --- /dev/null +++ b/electron/app/utils/libreoffice-check.ts @@ -0,0 +1,386 @@ +/** + * libreoffice-check.ts + * + * Checks whether LibreOffice is available on the host machine before the + * main BrowserWindow is created. LibreOffice is required for creating custom + * templates from uploaded PPTX files. + * + * If not found, shows a branded installer window that lets the user download + * and install LibreOffice with a real-time progress UI. + */ + +import { BrowserWindow, ipcMain, app } from "electron"; +import { exec } from "child_process"; +import * as util from "util"; +import * as fs from "fs"; +import * as path from "path"; +import { baseDir } from "./constants"; + +const execAsync = util.promisify(exec); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Result returned by {@link isLibreOfficeInstalled}. */ +interface LibreOfficeCheckResult { + installed: boolean; + /** The raw version string from `soffice --version`, when available. */ + version?: string; + /** The resolved absolute path (or bare command name) of the soffice binary. */ + path?: string; +} + +// --------------------------------------------------------------------------- +// Platform helpers +// --------------------------------------------------------------------------- + +/** + * Reads a directory and returns the names of all entries whose names match + * `pattern`. Returns an empty array if the directory cannot be read. + */ +function scanDir(dir: string, pattern: RegExp): string[] { + try { + return fs.readdirSync(dir).filter((entry) => pattern.test(entry)); + } catch { + return []; + } +} + +/** + * Returns an ordered list of absolute paths to try for the `soffice` binary + * on the current platform. + * + * Instead of hard-coding version numbers, parent directories are scanned with + * a regex so any past or future LibreOffice version is automatically found. + * Fixed (non-versioned) paths are still included first so the common case + * resolves instantly. + * + * Detection strategy per platform: + * Windows – scan Program Files (64-bit & 32-bit) for /^LibreOffice(\s[\d.]+)?$/i, + * plus per-user LOCALAPPDATA / APPDATA locations. + * macOS – scan /Applications and ~/Applications for /^LibreOffice[\s\d.]*\.app$/i, + * plus Homebrew (Intel & Apple Silicon) and MacPorts fixed paths. + * Linux – fixed distro/local/snap/flatpak paths, then scan /opt for + * /^libreoffice[\d.]*$/i, and ~/.local for user installs. + */ +function getCandidatePaths(): string[] { + const platform = process.platform; + + // ------------------------------------------------------------------------- + // Windows + // ------------------------------------------------------------------------- + if (platform === "win32") { + const pf = process.env["ProgramFiles"] ?? "C:\\Program Files"; + const pf86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; + const local = process.env["LOCALAPPDATA"] ?? ""; + const appData = process.env["APPDATA"] ?? ""; + + // Matches "LibreOffice", "LibreOffice 7", "LibreOffice 24.8", etc. + const loPattern = /^LibreOffice(\s[\d.]+)?$/i; + + const paths: string[] = []; + + // Scan both Program Files roots for any LibreOffice installation folder. + for (const root of [pf, pf86]) { + for (const entry of scanDir(root, loPattern)) { + paths.push(`${root}\\${entry}\\program\\soffice.exe`); + } + } + + // Per-user installs + if (local) { + paths.push( + `${local}\\Programs\\LibreOffice\\program\\soffice.exe`, + `${local}\\LibreOffice\\program\\soffice.exe`, + ); + } + if (appData) { + paths.push(`${appData}\\LibreOffice\\program\\soffice.exe`); + } + + return paths; + } + + // ------------------------------------------------------------------------- + // macOS + // ------------------------------------------------------------------------- + if (platform === "darwin") { + const home = process.env["HOME"] ?? ""; + + // Matches "LibreOffice.app", "LibreOffice 7.app", "LibreOffice 24.8.app", etc. + const bundlePattern = /^LibreOffice[\s\d.]*\.app$/i; + const macosRelative = "Contents/MacOS/soffice"; + + const paths: string[] = []; + + // Scan /Applications and ~/Applications for any LibreOffice bundle. + const appDirs = ["/Applications"]; + if (home) appDirs.push(`${home}/Applications`); + + for (const appDir of appDirs) { + for (const bundle of scanDir(appDir, bundlePattern)) { + paths.push(`${appDir}/${bundle}/${macosRelative}`); + } + } + + // Homebrew – Intel Macs + paths.push( + "/usr/local/bin/soffice", + "/usr/local/lib/libreoffice/program/soffice", + ); + + // Homebrew – Apple Silicon (M-series) + paths.push( + "/opt/homebrew/bin/soffice", + "/opt/homebrew/lib/libreoffice/program/soffice", + ); + + // MacPorts + paths.push("/opt/local/bin/soffice"); + + return paths; + } + + // ------------------------------------------------------------------------- + // Linux + // ------------------------------------------------------------------------- + const home = process.env["HOME"] ?? ""; + + const paths: string[] = [ + // Distro packages (Debian/Ubuntu, Fedora, Arch, openSUSE, …) + "/usr/bin/soffice", + "/usr/bin/libreoffice", + "/usr/lib/libreoffice/program/soffice", + "/usr/lib64/libreoffice/program/soffice", + + // Manual / local installs + "/usr/local/bin/soffice", + "/usr/local/lib/libreoffice/program/soffice", + + // Snap – classic and strict confinement + "/snap/bin/soffice", + "/snap/bin/libreoffice", + "/var/lib/snapd/snap/bin/soffice", + "/var/lib/snapd/snap/bin/libreoffice", + + // Flatpak – system-wide + "/var/lib/flatpak/exports/bin/org.libreoffice.LibreOffice", + "/var/lib/flatpak/app/org.libreoffice.LibreOffice/current/active/export/bin/libreoffice", + ]; + + // Scan /opt for any versioned tarball directory, e.g. libreoffice7.6, + // libreoffice24.8, libreoffice (plain symlink), etc. + // Matches "libreoffice", "libreoffice7", "libreoffice7.6", "libreoffice24.2", … + const optPattern = /^libreoffice[\d.]*$/i; + for (const entry of scanDir("/opt", optPattern)) { + paths.push(`/opt/${entry}/program/soffice`); + } + + // Flatpak – per-user and ~/.local installs + if (home) { + paths.push( + `${home}/.local/share/flatpak/exports/bin/org.libreoffice.LibreOffice`, + `${home}/.local/share/flatpak/app/org.libreoffice.LibreOffice/current/active/export/bin/libreoffice`, + `${home}/.local/bin/soffice`, + `${home}/.local/lib/libreoffice/program/soffice`, + ); + } + + return paths; +} + +/** + * Detects the Linux distro from /etc/os-release and returns the install + * command for LibreOffice, or null if the distro is not supported. + * + * Exported so that libreoffice_install_handlers.ts can reuse it. + */ +export function getLinuxInstallCommand(): { cmd: string; args: string[] } | null { + const osReleasePaths = ["/etc/os-release", "/usr/lib/os-release"]; + let id = ""; + let idLike = ""; + + for (const p of osReleasePaths) { + try { + const content = fs.readFileSync(p, "utf-8"); + for (const line of content.split("\n")) { + const m = line.match(/^ID=(.+)$/); + if (m) id = m[1].replace(/^["']|["']$/g, "").trim().toLowerCase(); + const m2 = line.match(/^ID_LIKE=(.+)$/); + if (m2) idLike = m2[1].replace(/^["']|["']$/g, "").trim().toLowerCase(); + } + if (id) break; + } catch { + continue; + } + } + + const ids = `${id} ${idLike}`; + if (ids.includes("ubuntu") || ids.includes("debian") || ids.includes("pop") || ids.includes("linuxmint")) { + return { cmd: "apt", args: ["install", "-y", "libreoffice"] }; + } + if (ids.includes("fedora") || ids.includes("rhel") || ids.includes("centos") || ids.includes("rocky")) { + return { cmd: "dnf", args: ["install", "-y", "libreoffice"] }; + } + if (ids.includes("arch")) { + return { cmd: "pacman", args: ["-S", "--noconfirm", "libreoffice-still"] }; + } + if (ids.includes("opensuse") || ids.includes("suse")) { + return { cmd: "zypper", args: ["install", "-y", "libreoffice"] }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Resolved path – set once by checkLibreOfficeBeforeWindow() +// --------------------------------------------------------------------------- + +/** + * The resolved soffice binary path discovered at startup. + * Defaults to the bare command name so callers always get a usable string + * even if the check has not run yet (e.g. in non-Electron environments). + */ +let resolvedSofficePath: string = "soffice"; + +/** + * Returns the resolved soffice binary path found during startup detection. + * + * Pass as the `SOFFICE_PATH` env var to the FastAPI subprocess so Python + * code can invoke the exact binary rather than relying on `PATH`. + */ +export function getSofficePath(): string { + return resolvedSofficePath; +} + +// --------------------------------------------------------------------------- +// Core detection logic +// --------------------------------------------------------------------------- + +/** + * Attempts to detect LibreOffice by: + * 1. Checking well-known installation paths for the binary (fast, no shell). + * 2. Falling back to `soffice --version` via the shell (catches PATH installs). + * + * Returns an object indicating whether LibreOffice was found and, when it + * was, the version string reported by the binary. + */ +async function isLibreOfficeInstalled(): Promise { + // --- Step 1: check well-known paths synchronously (no exec overhead) --- + for (const candidate of getCandidatePaths()) { + if (fs.existsSync(candidate)) { + // Binary found at a known location – try to get the version string. + try { + const quoted = `"${candidate}"`; + const { stdout } = await execAsync(`${quoted} --version`, { + timeout: 8_000, + }); + return { installed: true, version: stdout.trim(), path: candidate }; + } catch { + // Binary exists but failed to execute – still treat as installed. + return { installed: true, path: candidate }; + } + } + } + + // --- Step 2: try the PATH-based command --- + try { + const { stdout } = await execAsync("soffice --version", { + timeout: 8_000, + }); + // Found via PATH – record the bare command name as the path so callers + // can pass it directly to subprocess invocations. + return { installed: true, version: stdout.trim(), path: "soffice" }; + } catch { + // Command not found or timed out – LibreOffice is not available. + return { installed: false }; + } +} + +// --------------------------------------------------------------------------- +// Installer window +// --------------------------------------------------------------------------- + +/** + * Opens a branded 520×400 installer window that lets the user download and + * install LibreOffice with a live progress UI. + * + * Returns a Promise that resolves once the window is closed (either by the + * user skipping or after a successful install). + */ +async function showLibreOfficeInstallerWindow(): Promise { + return new Promise((resolve) => { + const win = new BrowserWindow({ + width: 520, + height: 560, + resizable: false, + center: true, + title: "Presenton – Install LibreOffice", + icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"), + webPreferences: { + webSecurity: false, + preload: path.join(__dirname, "../preloads/libreoffice-installer.js"), + }, + }); + + win.setMenuBarVisibility(false); + + const htmlPath = path.join( + baseDir, + "resources/ui/libreoffice-installer/index.html" + ); + win.loadFile(htmlPath); + + // lo:skip is sent by the renderer when the user clicks "Skip" or after + // a successful install (the success state auto-sends skip after 2 s). + const onSkip = () => { + if (!win.isDestroyed()) win.close(); + }; + ipcMain.once("lo:skip", onSkip); + + win.on("closed", () => { + // Remove the listener in case the window was closed by the OS (title-bar X) + ipcMain.removeListener("lo:skip", onSkip); + resolve(); + }); + }); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Checks for LibreOffice. When absent, shows a branded installer window that + * lets the user install it. Never blocks app startup — always returns `true`. + * + * Call this function **before** creating the main `BrowserWindow`. + * + * @returns Always `true` – the application should always proceed. + */ +export async function checkLibreOfficeBeforeWindow(): Promise { + let result = await isLibreOfficeInstalled(); + + if (result.installed) { + if (result.path) { + resolvedSofficePath = result.path; + } + console.log( + `[LibreOffice] Detected: ${result.version ?? "(version unknown)"} at ${resolvedSofficePath}` + ); + return true; + } + + console.warn("[LibreOffice] Not found – showing installer window."); + await showLibreOfficeInstallerWindow(); + + // Re-detect after the window closes (install may have succeeded) + result = await isLibreOfficeInstalled(); + if (result.installed && result.path) { + resolvedSofficePath = result.path; + console.log(`[LibreOffice] Detected after install: ${resolvedSofficePath}`); + } + + // Always proceed – never block the app + return true; +} diff --git a/electron/app/utils/libreoffice-urls.ts b/electron/app/utils/libreoffice-urls.ts new file mode 100644 index 00000000..b614510f --- /dev/null +++ b/electron/app/utils/libreoffice-urls.ts @@ -0,0 +1,16 @@ +/** + * LibreOffice download URLs for automated installation. + * Uses direct CDN URLs (download.documentfoundation.org) — the donate URLs + * return HTML pages instead of the actual installer on Windows. + * Update the version when upgrading to a newer LibreOffice release. + * See https://www.libreoffice.org/download/download-libreoffice/ + */ +export const LIBREOFFICE_VERSION = "25.8.5"; + +const CDN_BASE = "https://download.documentfoundation.org/libreoffice/stable"; + +export const LIBREOFFICE_DOWNLOAD_URLS = { + win64: `${CDN_BASE}/${LIBREOFFICE_VERSION}/win/x86_64/LibreOffice_${LIBREOFFICE_VERSION}_Win_x86-64.msi`, + macX64: `${CDN_BASE}/${LIBREOFFICE_VERSION}/mac/x86_64/LibreOffice_${LIBREOFFICE_VERSION}_MacOS_x86-64.dmg`, + macArm64: `${CDN_BASE}/${LIBREOFFICE_VERSION}/mac/aarch64/LibreOffice_${LIBREOFFICE_VERSION}_MacOS_aarch64.dmg`, +} as const; diff --git a/electron/app/utils/servers.ts b/electron/app/utils/servers.ts index 166aaffa..b5739cb3 100644 --- a/electron/app/utils/servers.ts +++ b/electron/app/utils/servers.ts @@ -26,6 +26,15 @@ export async function startFastApiServer( args = ["--port", port.toString()]; } + const safeLog = (data: Buffer | string, logPath: string) => { + try { + fs.appendFileSync(logPath, data); + } catch { + /* ignore if logs dir not writable */ + } + }; + const fastapiLogPath = path.join(logsDir, "fastapi-server.log"); + const fastApiProcess = spawn( command, args, @@ -36,13 +45,16 @@ export async function startFastApiServer( } ); fastApiProcess.stdout.on("data", (data: any) => { - fs.appendFileSync(path.join(logsDir, "fastapi-server.log"), data); + safeLog(data, fastapiLogPath); console.log(`FastAPI: ${data}`); }); fastApiProcess.stderr.on("data", (data: any) => { - fs.appendFileSync(path.join(logsDir, "fastapi-server.log"), data); + safeLog(data, fastapiLogPath); console.error(`FastAPI: ${data}`); }); + fastApiProcess.on("error", (err) => { + safeLog(`Spawn error: ${err.message}\n`, fastapiLogPath); + }); // Wait for FastAPI server to start await waitForServer(`${localhost}:${port}/docs`); return fastApiProcess; @@ -67,17 +79,25 @@ export async function startNextJsServer( env: { ...process.env, ...env }, } ); + const nextjsLogPath = path.join(logsDir, "nextjs-server.log"); + const safeNextLog = (d: Buffer | string) => { + try { + fs.appendFileSync(nextjsLogPath, d); + } catch { + /* ignore */ + } + }; nextjsProcess.stdout.on("data", (data: any) => { - fs.appendFileSync(path.join(logsDir, "nextjs-server.log"), data); + safeNextLog(data); console.log(`NextJS: ${data}`); }); nextjsProcess.stderr.on("data", (data: any) => { - fs.appendFileSync(path.join(logsDir, "nextjs-server.log"), data); + safeNextLog(data); console.error(`NextJS: ${data}`); }); } else { // Start NextJS build server - nextjsProcess = startNextjsBuildServer(directory, port); + nextjsProcess = await startNextjsBuildServer(directory, port); } // Wait for NextJS server to start @@ -85,16 +105,20 @@ export async function startNextJsServer( return nextjsProcess; } -async function startNextjsBuildServer(directory: string, port: number) { - const server = http.createServer((req, res) => { - return handler(req, res, { - public: directory, - cleanUrls: true, +function startNextjsBuildServer(directory: string, port: number): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + return handler(req, res, { + public: directory, + cleanUrls: true, + }); + }); + server.on("error", reject); + server.listen(port, () => { + server.off("error", reject); + resolve(server); }); }); - - server.listen(port); - return server; } diff --git a/electron/build.js b/electron/build.js index 2f2d14e2..45cca82b 100644 --- a/electron/build.js +++ b/electron/build.js @@ -48,8 +48,12 @@ const config = { }, linux: { artifactName: "Presenton-${version}.${ext}", - target: ["AppImage"], - icon: "resources/ui/assets/images/presenton_short_filled.png", + target: ["AppImage", "deb"], + icon: "build/icons", + }, + deb: { + afterInstall: "build/after-install.tpl", + recommends: ["libreoffice"], }, win: { target: ["nsis", "appx"], diff --git a/electron/build/after-install.tpl b/electron/build/after-install.tpl new file mode 100644 index 00000000..b4c0b0e3 --- /dev/null +++ b/electron/build/after-install.tpl @@ -0,0 +1,56 @@ +#!/bin/bash + +if type update-alternatives 2>/dev/null >&1; then + # Remove previous link if it doesn't use update-alternatives + if [ -L '/usr/bin/${executable}' -a -e '/usr/bin/${executable}' -a "`readlink '/usr/bin/${executable}'`" != '/etc/alternatives/${executable}' ]; then + rm -f '/usr/bin/${executable}' + fi + update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 || ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +else + ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +fi + +# Check if user namespaces are supported by the kernel and working with a quick test: +if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then + # Use SUID chrome-sandbox only on systems without user namespaces: + chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || true +else + chmod 0755 '/opt/${sanitizedProductName}/chrome-sandbox' || true +fi + +if hash update-mime-database 2>/dev/null; then + update-mime-database /usr/share/mime || true +fi + +if hash update-desktop-database 2>/dev/null; then + update-desktop-database /usr/share/applications || true +fi + +# Install apparmor profile. (Ubuntu 24+) +# First check if the version of AppArmor running on the device supports our profile. +# This is in order to keep backwards compatibility with Ubuntu 22.04 which does not support abi/4.0. +# +# Those apparmor_parser flags are akin to performing a dry run of loading a profile. +# https://wiki.debian.org/AppArmor/HowToUse#Dumping_profiles +# +# Unfortunately, at the moment AppArmor doesn't have a good story for backwards compatibility. +# https://askubuntu.com/questions/1517272/writing-a-backwards-compatible-apparmor-profile +if apparmor_status --enabled > /dev/null 2>&1; then + APPARMOR_PROFILE_SOURCE='/opt/${sanitizedProductName}/resources/apparmor-profile' + APPARMOR_PROFILE_TARGET='/etc/apparmor.d/${executable}' + if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then + cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET" + + # Updating the current AppArmor profile is not possible and probably not meaningful in a chroot'ed environment. + # Use cases are for example environments where images for clients are maintained. + # There, AppArmor might correctly be installed, but live updating makes no sense. + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + # Extra flags taken from dh_apparmor: + # > By using '-W -T' we ensure that any abstraction updates are also pulled in. + # https://wiki.debian.org/AppArmor/Contribute/FirstTimeProfileImport + apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET" + fi + else + echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile" + fi +fi diff --git a/electron/build/icons/128x128.png b/electron/build/icons/128x128.png new file mode 100644 index 00000000..f8533d9a Binary files /dev/null and b/electron/build/icons/128x128.png differ diff --git a/electron/build/icons/16x16.png b/electron/build/icons/16x16.png new file mode 100644 index 00000000..25cee61b Binary files /dev/null and b/electron/build/icons/16x16.png differ diff --git a/electron/build/icons/256x256.png b/electron/build/icons/256x256.png new file mode 100644 index 00000000..4c724cb2 Binary files /dev/null and b/electron/build/icons/256x256.png differ diff --git a/electron/build/icons/32x32.png b/electron/build/icons/32x32.png new file mode 100644 index 00000000..e35721cc Binary files /dev/null and b/electron/build/icons/32x32.png differ diff --git a/electron/build/icons/48x48.png b/electron/build/icons/48x48.png new file mode 100644 index 00000000..72c06d26 Binary files /dev/null and b/electron/build/icons/48x48.png differ diff --git a/electron/build/icons/512x512.png b/electron/build/icons/512x512.png new file mode 100644 index 00000000..1ed41d64 Binary files /dev/null and b/electron/build/icons/512x512.png differ diff --git a/electron/build/icons/64x64.png b/electron/build/icons/64x64.png new file mode 100644 index 00000000..876e5748 Binary files /dev/null and b/electron/build/icons/64x64.png differ diff --git a/electron/build/installer.nsh b/electron/build/installer.nsh new file mode 100644 index 00000000..032c104d --- /dev/null +++ b/electron/build/installer.nsh @@ -0,0 +1,5 @@ +; Custom NSIS include for Presenton installer. +; LibreOffice installation is handled by the in-app installer UI on first launch. + +!macro customInstall +!macroend diff --git a/electron/resources/ui/libreoffice-installer/index.html b/electron/resources/ui/libreoffice-installer/index.html new file mode 100644 index 00000000..dd76928b --- /dev/null +++ b/electron/resources/ui/libreoffice-installer/index.html @@ -0,0 +1,669 @@ + + + + + + Presenton – Install LibreOffice + + + +
+ + +
+ Presenton + +
+ + +
+
+ + +
+
📦
+

LibreOffice Required

+

+ Presenton uses LibreOffice to generate custom presentation + templates from uploaded PPTX files. Without it, this feature won't work. +

+
+ + +
+
+ + +
+
+

Downloading LibreOffice

+

Preparing download…

+
+
+ 0% + +
+
+
+
+
+

This may take a few minutes (~300 MB)

+
+ + +
+
+

Installing LibreOffice

+

Running the installer — this won't take long…

+
+ +
+
+
+
+
+ + +
+
+

LibreOffice Installed

+

+ Custom template generation from PPTX is now available. + Presenton will continue in a moment… +

+
+
+
+
+
+
+ + +
+
+

Installation Failed

+

Something went wrong. You can try again or skip and install LibreOffice manually later.

+
+ + +
+
+ +
+ + + + +
+ + + + diff --git a/electron/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py b/electron/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py index b4c4acae..c57004a3 100644 --- a/electron/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py +++ b/electron/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py @@ -18,6 +18,17 @@ import uuid from constants.documents import POWERPOINT_TYPES +def _get_soffice_binary() -> str: + """Return the soffice binary to use for LibreOffice subprocess calls. + + When running inside the Electron desktop app, the main process resolves the + exact soffice binary path at startup and forwards it via the ``SOFFICE_PATH`` + environment variable. Falling back to the bare ``"soffice"`` command keeps + Docker / server deployments working unchanged. + """ + return os.environ.get("SOFFICE_PATH") or "soffice" + + PPTX_SLIDES_ROUTER = APIRouter(prefix="/pptx-slides", tags=["PPTX Slides"]) @@ -572,7 +583,7 @@ async def _convert_pptx_to_pdf(pptx_path: str, temp_dir: str) -> str: try: result = subprocess.run( [ - "libreoffice", + _get_soffice_binary(), "--headless", "--convert-to", "pdf",