diff --git a/electron/app/ipc/libreoffice_install_handlers.ts b/electron/app/ipc/libreoffice_install_handlers.ts new file mode 100644 index 00000000..300da6f6 --- /dev/null +++ b/electron/app/ipc/libreoffice_install_handlers.ts @@ -0,0 +1,340 @@ +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 +// --------------------------------------------------------------------------- + +function downloadWithProgress( + url: string, + dest: string, + filename: string, + wc: WebContents +): 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 totalBytes = parseInt(res.headers["content-length"] ?? "0", 10); + 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; + const percent = totalBytes > 0 ? Math.floor((downloaded / totalBytes) * 100) : 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)})`); + 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); + + sendProgress(wc, "installing"); + sendLog(wc, "cmd", `Running: msiexec /i "${filename}" /qn /norestart`); + + await new Promise((resolve, reject) => { + const child = spawn("msiexec", ["/i", dest, "/qn", "/norestart"], { + 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 { + reject(new Error(`msiexec exited with code ${code}`)); + } + }); + 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); + + 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" + ); + } + + 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…"); + + await new Promise((resolve, reject) => { + const child = spawn("pkexec", [installCmd.cmd, ...installCmd.args], { + 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(); + 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 958cf679..61d0fd2c 100644 --- a/electron/app/main.ts +++ b/electron/app/main.ts @@ -1,160 +1,163 @@ -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"; -import { checkLibreOfficeBeforeWindow, getSofficePath } from "./utils/libreoffice-check"; - - -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, - // 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(); - - // Guard: verify LibreOffice is available before showing the main window. - // If it is missing, the user is prompted to download it or exit. - const shouldContinue = await checkLibreOfficeBeforeWindow(); - if (!shouldContinue) return; - - 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 { 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'); + +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/utils/libreoffice-check.ts b/electron/app/utils/libreoffice-check.ts index daf33619..ae70a6b6 100644 --- a/electron/app/utils/libreoffice-check.ts +++ b/electron/app/utils/libreoffice-check.ts @@ -2,14 +2,19 @@ * libreoffice-check.ts * * Checks whether LibreOffice is available on the host machine before the - * main BrowserWindow is created. If it is not found, an Electron dialog is - * shown that lets the user download LibreOffice, skip the check, or quit. + * 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 { app, dialog, shell } from "electron"; +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); @@ -186,33 +191,45 @@ function getCandidatePaths(): string[] { } /** - * Returns a human-readable, OS-specific install instruction string. + * 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. */ -function getInstallInstructions(): string { - const platform = process.platform; +export function getLinuxInstallCommand(): { cmd: string; args: string[] } | null { + const osReleasePaths = ["/etc/os-release", "/usr/lib/os-release"]; + let id = ""; + let idLike = ""; - if (platform === "win32") { - return ( - "Download the Windows installer from https://www.libreoffice.org/download/ " + - "and run it. Both the 64-bit and 32-bit editions are supported." - ); + 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; + } } - if (platform === "darwin") { - return ( - "Download the macOS disk image from https://www.libreoffice.org/download/ " + - "and drag LibreOffice into your Applications folder." - ); + const ids = `${id} ${idLike}`; + if (ids.includes("ubuntu") || ids.includes("debian") || ids.includes("pop") || ids.includes("linuxmint")) { + return { cmd: "apt", args: ["install", "-y", "libreoffice"] }; } - - // Linux - return ( - "Install LibreOffice with your package manager, for example:\n\n" + - " Ubuntu / Debian: sudo apt install libreoffice\n" + - " Fedora: sudo dnf install libreoffice\n" + - " Arch: sudo pacman -S libreoffice-still\n\n" + - "Or download it from https://www.libreoffice.org/download/" - ); + 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; } // --------------------------------------------------------------------------- @@ -281,68 +298,52 @@ async function isLibreOfficeInstalled(): Promise { } // --------------------------------------------------------------------------- -// Dialog +// Installer window // --------------------------------------------------------------------------- /** - * Shows a modal dialog informing the user that LibreOffice is required. + * Opens a branded 520×400 installer window that lets the user download and + * install LibreOffice with a live progress UI. * - * Button indices: - * 0 – "Download LibreOffice" → opens download page, shows a re-launch notice, - * then quits the application - * 1 – "Install Later" → continues launching without LibreOffice - * 2 – "Exit" → quits the application immediately - * - * @returns `true` if the application should proceed to create its window, - * `false` if `app.quit()` has been called. + * Returns a Promise that resolves once the window is closed (either by the + * user skipping or after a successful install). */ -async function showLibreOfficeMissingDialog(): Promise { - const instructions = getInstallInstructions(); - - const { response } = await dialog.showMessageBox({ - type: "warning", - title: "LibreOffice Required", - message: "LibreOffice is not installed", - detail: - "Presenton uses LibreOffice to export presentations to PPTX and PDF " + - "formats. Without it, export functionality will not work.\n\n" + - `How to install LibreOffice on your system:\n\n${instructions}`, - buttons: ["Download LibreOffice", "Install Later", "Exit"], - defaultId: 0, - cancelId: 1, - noLink: true, - }); - - if (response === 0) { - // Open the LibreOffice download page in the default browser. - await shell.openExternal("https://www.libreoffice.org/download/"); - - // Let the user know they need to restart Presenton after installation, - // then close the app so they start fresh with LibreOffice on the PATH. - await dialog.showMessageBox({ - type: "info", - title: "Restart Required", - message: "Please re-launch Presenton after installation", - detail: - "The LibreOffice download page has been opened in your browser.\n\n" + - "Once LibreOffice is installed, re-run Presenton and it will be " + - "detected automatically.", - buttons: ["OK"], - defaultId: 0, +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"), + }, }); - app.quit(); - return false; - } + win.setMenuBarVisibility(false); - if (response === 2) { - // User chose to exit immediately. - app.quit(); - return false; - } + const htmlPath = path.join( + baseDir, + "resources/ui/libreoffice-installer/index.html" + ); + win.loadFile(htmlPath); - // response === 1 → "Install Later" – continue launching without LibreOffice. - return true; + // 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(); + }); + }); } // --------------------------------------------------------------------------- @@ -350,20 +351,17 @@ async function showLibreOfficeMissingDialog(): Promise { // --------------------------------------------------------------------------- /** - * Checks for LibreOffice and, when it is absent, presents the user with the - * "LibreOffice Required" dialog. + * 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 `true` if the application should proceed to create its window, - * `false` if the user chose to exit and `app.quit()` has been called. + * @returns Always `true` – the application should always proceed. */ export async function checkLibreOfficeBeforeWindow(): Promise { - const result = await isLibreOfficeInstalled(); + let result = await isLibreOfficeInstalled(); if (result.installed) { - // Persist the resolved path so getSofficePath() returns it for the - // lifetime of this Electron process. if (result.path) { resolvedSofficePath = result.path; } @@ -373,8 +371,16 @@ export async function checkLibreOfficeBeforeWindow(): Promise { return true; } - console.warn( - "[LibreOffice] Not found on this system – showing installation dialog." - ); - return showLibreOfficeMissingDialog(); + 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..f073d0ac --- /dev/null +++ b/electron/app/utils/libreoffice-urls.ts @@ -0,0 +1,12 @@ +/** + * LibreOffice download URLs for automated installation. + * Update the version when upgrading to a newer LibreOffice release. + * See https://www.libreoffice.org/download/download-libreoffice/ + */ +export const LIBREOFFICE_VERSION = "24.8.7"; + +export const LIBREOFFICE_DOWNLOAD_URLS = { + win64: `https://www.libreoffice.org/donate/dl/win-x86_64/${LIBREOFFICE_VERSION}/en-US/LibreOffice_${LIBREOFFICE_VERSION}_Win_x86-64.msi`, + macX64: `https://www.libreoffice.org/donate/dl/mac-x86_64/${LIBREOFFICE_VERSION}/en-US/LibreOffice_${LIBREOFFICE_VERSION}_MacOS_x86-64.dmg`, + macArm64: `https://www.libreoffice.org/donate/dl/mac-aarch64/${LIBREOFFICE_VERSION}/en-US/LibreOffice_${LIBREOFFICE_VERSION}_MacOS_aarch64.dmg`, +} as const; 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..dd7c4d3f --- /dev/null +++ b/electron/resources/ui/libreoffice-installer/index.html @@ -0,0 +1,639 @@ + + + + + + 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.

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