From 016cd44cb960d53daa095ff4d99e7111597f270c Mon Sep 17 00:00:00 2001 From: sudipnext Date: Fri, 6 Mar 2026 17:41:15 +0545 Subject: [PATCH] feat: implement in-app LibreOffice installer with progress UI and update build configurations for Linux and Windows --- .../app/ipc/libreoffice_install_handlers.ts | 340 ++++++++++ electron/app/main.ts | 323 ++++----- .../app/preloads/libreoffice-installer.ts | 12 + electron/app/utils/libreoffice-check.ts | 184 ++--- electron/app/utils/libreoffice-urls.ts | 12 + electron/build.js | 8 +- electron/build/after-install.tpl | 56 ++ electron/build/icons/128x128.png | Bin 0 -> 3021 bytes electron/build/icons/16x16.png | Bin 0 -> 453 bytes electron/build/icons/256x256.png | Bin 0 -> 6549 bytes electron/build/icons/32x32.png | Bin 0 -> 815 bytes electron/build/icons/48x48.png | Bin 0 -> 1183 bytes electron/build/icons/512x512.png | Bin 0 -> 17305 bytes electron/build/icons/64x64.png | Bin 0 -> 1534 bytes electron/build/installer.nsh | 5 + .../ui/libreoffice-installer/index.html | 639 ++++++++++++++++++ 16 files changed, 1328 insertions(+), 251 deletions(-) create mode 100644 electron/app/ipc/libreoffice_install_handlers.ts create mode 100644 electron/app/preloads/libreoffice-installer.ts create mode 100644 electron/app/utils/libreoffice-urls.ts create mode 100644 electron/build/after-install.tpl create mode 100644 electron/build/icons/128x128.png create mode 100644 electron/build/icons/16x16.png create mode 100644 electron/build/icons/256x256.png create mode 100644 electron/build/icons/32x32.png create mode 100644 electron/build/icons/48x48.png create mode 100644 electron/build/icons/512x512.png create mode 100644 electron/build/icons/64x64.png create mode 100644 electron/build/installer.nsh create mode 100644 electron/resources/ui/libreoffice-installer/index.html 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 0000000000000000000000000000000000000000..f8533d9a2b61a8567291c8dae2748fc9260d1317 GIT binary patch literal 3021 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 E0k7SQ8~^|S literal 0 HcmV?d00001 diff --git a/electron/build/icons/16x16.png b/electron/build/icons/16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..25cee61b71d3205e15221c35b2103c6442867cef GIT binary patch literal 453 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 literal 0 HcmV?d00001 diff --git a/electron/build/icons/32x32.png b/electron/build/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..e35721ccadbf0c4ea09baf93b639135664cb790e GIT binary patch literal 815 zcmV+~1JL}5P)LIstGcBa#ZZRr$fZRhcsdoBx1pv*8O#Dtribne`H zdw=(QXU@5TuQr7JG~oci_diJBcbc<30q`D^;HffaY_8vB4Mr$5c)Ui!xu!6#o{yrZ zBeJ8NZj|9F6A8vFgs|N}Q(Xv-5)m^KX|R^b=ulN-Tg&GSq*gS%np;O#dj#$Z2`5}O z0cuJ_JelSIYpXhv2@NaBtu2+(ps5DUWgxxXQMfC2h7fiFn4RPRQ=@5kj!J0r%4qS( zIO*lq+Q9oQ^)jwqjAA~JrTI%=b7*ph(C8=}6M|WQv0()@hei4`Z#5rd60yKz^Vvqu zGLhB=m-Dn{us;D;Sz#{-W&y@W6xx7AIXm%-XKR=oQ3%Mnu}P~ZgxXROGvj;>Tk9z0 zQ1$_a6*>)*p|?8*P2+kmeaqwStvKo`Bs5e@1UWIH(Ad+NRq|5N0dDlfpymNx2MmK- z;7tZsIwPpBlCbbOM{Dj4B?+iF5)A+A0oJ6Tis>;0>9kILWktuw4}a)68qv`1mw9q> z?h(N9k397|n!^aZ&ComM4JOavoj6?OMf?oMw*UzKRH=XAR2Yk2xZ7h<4Y&J#!&S}$ z)^;MAEdam*aIim435vCaPdS2d9x1H5Er5n<34NDiqq7f-X_7jw9DHp2C`;Bw0VkEb}m`#=`5q|)>2m+NSDhp5_F(c4xz)-;1M zAFBy07IvezWdO$}4=*0DSu_!Cy1npAzsvCRqRg002ovPDHLkV1jv7Z4dwe literal 0 HcmV?d00001 diff --git a/electron/build/icons/48x48.png b/electron/build/icons/48x48.png new file mode 100644 index 0000000000000000000000000000000000000000..72c06d263daed22a3c9b0828834ea01dcbf159e1 GIT binary patch literal 1183 zcmV;Q1YrA#P)zg*50kxdz_FGl`}DNg3_;M=%(4 zE(0V1Q;%>PfWEZXr=V+-3~94-@o$l_bDP2-!tW9Q8NXwjf@cpVaQ%lIzMovd)o-hp zuP2sq?(6~%495{@l@anq&>wICfV@Rt_{vBMbc#hdI)-7s=JQ2-`FR#YJ<;-QxB)PF z(gY~1I)_;6fIog)#r~lfLpZq!$2UMn2^aA0nFYK*mcjXV{+*0}yog`#X{Hkj=5^fp zS!4ZuT`mJm-^gR@LtzBlW%yewCD5v@`;0`|BBN)!f{)H+P}B*+)b-(e8BXqML#ZZ! zThl5h_O5R8ISG|YN_g@nea@vbu>N_+!(ohHSh97^{aL_ZclEbW6Tr;0ieP&=0m(4( zF4g+s!5Dj-$}SnbRu{#3s8hkCB8t?!ZimycLkV=R(dQ}v86WD5U{5HDnW;RoOGSKg zaS=nkQTR8@whkM@AHmKJ1vh@o+xkwuoZ>z~4sacSB-p0lg=5Kzav6^m@b>Fzz8QLM z!E#^Os^I#yGQb<7DRg-W5NK2I;_-RAOzL_Ws2CIF1G@kXiUQ=Cs;S;iB4<7j&zj5DvLZC%OxI`;KPIcKVwlj;uek_A9b z>i1P#xx8%Gd#iaZq;-6HaS4h0W&+c>d@0LG?`j$SCjbf?`k$kR68xTyt{e8v=q0fE zgpU2YW1J_|PN@wAxI3$HLQ=1wW_SA46dO$dnr0fG%NaPyv0uIS8xC;iw#GexJWH=% zO)Qsg!F;aF6?nRJYJ9^1ewo$SxW{(KaPO{GVLa9Uy?s#x+vIhPZo>e~OsRPO>7<>k z6#sNCjc2)u_4v%L?bP`C1GH8GWHLIk+49Vt&lz~@RGJ4m(%E0bBGV86dT#-g3XSdc zs8Ya@ClltV;Kfcn@HYV5Rd+<-dql>_(PTv@rgQTr75fHa9AdS^yQn+BlnFq_Qxkf8 zPuyNsEYDATmPN>?pr^z6+9(>m50LRE4kt06EO%B_H86HM&9e{9`1J}IQBQ!IQ!3gY z4CCyZ3((YZho!1>;+X`bP0s7PaO(yxe6Uzi-)C=WJQmR3);r!sJppLKB%{}k%-?*m z!r?+SzR{i+P60+s0JMzLQqJ#J4V)j#a3ARFtlYdd><#FpDnmOhZDI6tqqS+dWxR0m z)8gR+30%9H!*`P_yqp{yj&o;qBi=H)Bk xq{V9kL=!;r-ix9cLX!8M6wM@-y!WOP`3wIp?A|yFewzRQ002ovPDHLkV1n`%KHdNT literal 0 HcmV?d00001 diff --git a/electron/build/icons/512x512.png b/electron/build/icons/512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..1ed41d647636a5da84c7cdfb16008c96e78b850b GIT binary patch literal 17305 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 XZJ7NP-dn}{8FT>B%E7YwynoU^(|78n literal 0 HcmV?d00001 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.

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