diff --git a/electron/app/main.ts b/electron/app/main.ts index e86b9f24..03d269c5 100644 --- a/electron/app/main.ts +++ b/electron/app/main.ts @@ -7,13 +7,20 @@ 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 { ipcMain } from "electron"; import { setupLibreOfficeInstallHandlers } from "./ipc/libreoffice_install_handlers"; import { checkLibreOfficeBeforeWindow, getSofficePath } from "./utils/libreoffice-check"; +import { checkPuppeteerChromiumBeforeWindow } from "./utils/puppeteer-check"; var win: BrowserWindow | undefined; var fastApiProcess: ChildProcessByStdio | undefined; var nextjsProcess: any; +let isStopping = false; +const startupStatus: Record = { + libreoffice: "checking", + puppeteer: "checking", +}; app.commandLine.appendSwitch('gtk-version', '3'); @@ -55,7 +62,7 @@ const createWindow = () => { async function startServers(fastApiPort: number, nextjsPort: number) { try { - fastApiProcess = await startFastApiServer( + const fastApi = await startFastApiServer( fastapiDir, fastApiPort, { @@ -96,7 +103,10 @@ async function startServers(fastApiPort: number, nextjsPort: number) { }, isDev, ); - nextjsProcess = await startNextJsServer( + fastApiProcess = fastApi.process; + await fastApi.ready; + + const nextjs = await startNextJsServer( nextjsDir, nextjsPort, { @@ -109,6 +119,8 @@ async function startServers(fastApiPort: number, nextjsPort: number) { }, isDev, ) + nextjsProcess = nextjs.process; + await nextjs.ready; } catch (error) { console.error("Server startup error:", error); } @@ -116,12 +128,23 @@ async function startServers(fastApiPort: number, nextjsPort: number) { async function stopServers() { if (fastApiProcess?.pid) { - await killProcess(fastApiProcess.pid); + console.log("Closing FastAPI..."); + try { + await killProcess(fastApiProcess.pid); + } catch { + await killProcess(fastApiProcess.pid, "SIGKILL"); + } } if (nextjsProcess) { if (isDev) { - await killProcess(nextjsProcess.pid); + console.log("Closing NextJS..."); + try { + await killProcess(nextjsProcess.pid); + } catch { + await killProcess(nextjsProcess.pid, "SIGKILL"); + } } else { + console.log("Closing NextJS..."); nextjsProcess.close(); } } @@ -134,13 +157,30 @@ app.whenReady().then(async () => { // 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")); + const sendStartupStatus = (name: string, status: string) => { + startupStatus[name] = status; + win?.webContents.send("startup:status", { name, status }); + }; + + win?.webContents.once("did-finish-load", async () => { + // Emit initial status so the UI doesn't remain in "Checking..." if it + // registers late. + sendStartupStatus("libreoffice", startupStatus.libreoffice); + sendStartupStatus("puppeteer", startupStatus.puppeteer); + // Check for LibreOffice (required for custom template from PPTX). Shows installer + // window if missing. Never blocks; always proceeds. + await checkLibreOfficeBeforeWindow((status) => + sendStartupStatus("libreoffice", status) + ); + // Check Puppeteer Chromium (used for export & template rendering). + await checkPuppeteerChromiumBeforeWindow((status) => + sendStartupStatus("puppeteer", status) + ); + }); + setUserConfig({ CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS, LLM: process.env.LLM, @@ -177,12 +217,34 @@ app.whenReady().then(async () => { //? Setup environment variables to be used in the preloads setupEnv(fastApiPort, nextjsPort); setupIpcHandlers(); + ipcMain.handle("startup:get-status", () => startupStatus); await startServers(fastApiPort, nextjsPort); win?.loadURL(`${localhost}:${nextjsPort}`); }); app.on("window-all-closed", async () => { - await stopServers(); app.quit(); }); + +app.on("before-quit", async (event) => { + if (isStopping) return; + isStopping = true; + event.preventDefault(); + try { + await stopServers(); + } finally { + app.quit(); + } +}); + +app.on("will-quit", async (event) => { + if (isStopping) return; + isStopping = true; + event.preventDefault(); + try { + await stopServers(); + } finally { + app.quit(); + } +}); diff --git a/electron/app/preloads/index.ts b/electron/app/preloads/index.ts index 353b02db..9de2f097 100644 --- a/electron/app/preloads/index.ts +++ b/electron/app/preloads/index.ts @@ -29,4 +29,7 @@ contextBridge.exposeInMainWorld('electron', { telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"), getTemplates: () => ipcRenderer.invoke("api:templates"), getPresentationPptxModel: (presentationId: string) => ipcRenderer.invoke("presentation-to-pptx-model", presentationId), + onStartupStatus: (callback: (payload: { name: string; status: string }) => void) => + ipcRenderer.on("startup:status", (_event, payload) => callback(payload)), + getStartupStatus: () => ipcRenderer.invoke("startup:get-status"), }); diff --git a/electron/app/utils/index.ts b/electron/app/utils/index.ts index 8e307b64..4add319d 100644 --- a/electron/app/utils/index.ts +++ b/electron/app/utils/index.ts @@ -70,14 +70,14 @@ export function setupEnv(fastApiPort: number, nextjsPort: number) { } -export function killProcess(pid: number) { +export function killProcess(pid: number, signal: NodeJS.Signals = "SIGTERM") { return new Promise((resolve, reject) => { - treeKill(pid, "SIGTERM", (err: any) => { + treeKill(pid, signal, (err: any) => { if (err) { console.error(`Error killing process ${pid}:`, err) reject(err) } else { - console.log(`Process ${pid} killed`) + console.log(`Process ${pid} killed (${signal})`) resolve(true) } }) diff --git a/electron/app/utils/libreoffice-check.ts b/electron/app/utils/libreoffice-check.ts index ae70a6b6..73a5d132 100644 --- a/electron/app/utils/libreoffice-check.ts +++ b/electron/app/utils/libreoffice-check.ts @@ -31,6 +31,12 @@ interface LibreOfficeCheckResult { path?: string; } +export type LibreOfficeStatus = + | "checking" + | "installed" + | "missing" + | "installing"; + // --------------------------------------------------------------------------- // Platform helpers // --------------------------------------------------------------------------- @@ -358,7 +364,10 @@ async function showLibreOfficeInstallerWindow(): Promise { * * @returns Always `true` – the application should always proceed. */ -export async function checkLibreOfficeBeforeWindow(): Promise { +export async function checkLibreOfficeBeforeWindow( + onStatus?: (status: LibreOfficeStatus) => void +): Promise { + onStatus?.("checking"); let result = await isLibreOfficeInstalled(); if (result.installed) { @@ -368,10 +377,13 @@ export async function checkLibreOfficeBeforeWindow(): Promise { console.log( `[LibreOffice] Detected: ${result.version ?? "(version unknown)"} at ${resolvedSofficePath}` ); + onStatus?.("installed"); return true; } console.warn("[LibreOffice] Not found – showing installer window."); + onStatus?.("missing"); + onStatus?.("installing"); await showLibreOfficeInstallerWindow(); // Re-detect after the window closes (install may have succeeded) @@ -379,6 +391,9 @@ export async function checkLibreOfficeBeforeWindow(): Promise { if (result.installed && result.path) { resolvedSofficePath = result.path; console.log(`[LibreOffice] Detected after install: ${resolvedSofficePath}`); + onStatus?.("installed"); + } else { + onStatus?.("missing"); } // Always proceed – never block the app diff --git a/electron/app/utils/puppeteer-check.ts b/electron/app/utils/puppeteer-check.ts new file mode 100644 index 00000000..e9e7aa3b --- /dev/null +++ b/electron/app/utils/puppeteer-check.ts @@ -0,0 +1,100 @@ +/** + * puppeteer-check.ts + * + * Ensures Puppeteer's Chromium/Chrome-for-Testing binary is downloaded + * before the main BrowserWindow is created. + */ +import fs from "fs"; +import os from "os"; +import path from "path"; +import puppeteer from "puppeteer"; +import { Browser, detectBrowserPlatform, install } from "@puppeteer/browsers"; + +function getPuppeteerCacheDir(): string { + const configCache = + (puppeteer as any).configuration?.cacheDirectory ?? + (puppeteer as any).defaultDownloadPath; + return configCache ?? path.join(os.homedir(), ".cache", "puppeteer"); +} + +function shouldSkipDownload(): boolean { + if (process.env.PUPPETEER_SKIP_DOWNLOAD) { + const value = process.env.PUPPETEER_SKIP_DOWNLOAD.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; + } + return Boolean((puppeteer as any).configuration?.skipDownload); +} + +/** + * Ensures Puppeteer has its browser binary available. + * Never blocks app startup — always returns `true`. + */ +export type PuppeteerStatus = + | "checking" + | "installed" + | "missing" + | "downloading" + | "downloaded" + | "skipped" + | "failed"; + +export async function checkPuppeteerChromiumBeforeWindow( + onStatus?: (status: PuppeteerStatus) => void +): Promise { + onStatus?.("checking"); + if (shouldSkipDownload()) { + console.log("[Puppeteer] Skip download enabled."); + onStatus?.("skipped"); + return true; + } + + const executablePath = puppeteer.executablePath(); + if (executablePath && fs.existsSync(executablePath)) { + console.log(`[Puppeteer] Chromium found at ${executablePath}`); + onStatus?.("installed"); + return true; + } + + onStatus?.("missing"); + const cacheDir = getPuppeteerCacheDir(); + const platform = detectBrowserPlatform(); + if (!platform) { + console.warn("[Puppeteer] Unable to detect platform; skipping download."); + onStatus?.("failed"); + return true; + } + + const buildId = + (puppeteer as any).browserVersion ?? + (puppeteer as any).defaultBrowserRevision; + + if (!buildId) { + console.warn("[Puppeteer] Unable to resolve browser build; skipping download."); + onStatus?.("failed"); + return true; + } + + console.warn("[Puppeteer] Chromium missing – downloading now..."); + onStatus?.("downloading"); + try { + await install({ + cacheDir, + platform, + browser: Browser.CHROME, + buildId, + }); + const downloadedPath = puppeteer.executablePath(); + if (downloadedPath && fs.existsSync(downloadedPath)) { + console.log(`[Puppeteer] Chromium downloaded to ${downloadedPath}`); + onStatus?.("downloaded"); + } else { + console.log("[Puppeteer] Chromium download finished."); + onStatus?.("downloaded"); + } + } catch (error) { + console.warn("[Puppeteer] Chromium download failed:", error); + onStatus?.("failed"); + } + + return true; +} diff --git a/electron/app/utils/servers.ts b/electron/app/utils/servers.ts index b5739cb3..077f1fc7 100644 --- a/electron/app/utils/servers.ts +++ b/electron/app/utils/servers.ts @@ -55,9 +55,10 @@ export async function startFastApiServer( fastApiProcess.on("error", (err) => { safeLog(`Spawn error: ${err.message}\n`, fastapiLogPath); }); - // Wait for FastAPI server to start - await waitForServer(`${localhost}:${port}/docs`); - return fastApiProcess; + return { + process: fastApiProcess, + ready: waitForServer(`${localhost}:${port}/docs`), + }; } export async function startNextJsServer( @@ -100,9 +101,10 @@ export async function startNextJsServer( nextjsProcess = await startNextjsBuildServer(directory, port); } - // Wait for NextJS server to start - await waitForServer(`${localhost}:${port}`); - return nextjsProcess; + return { + process: nextjsProcess, + ready: waitForServer(`${localhost}:${port}`), + }; } function startNextjsBuildServer(directory: string, port: number): Promise { diff --git a/electron/resources/ui/homepage/index.html b/electron/resources/ui/homepage/index.html index 0c79f133..1bca9ddb 100644 --- a/electron/resources/ui/homepage/index.html +++ b/electron/resources/ui/homepage/index.html @@ -20,13 +20,163 @@ transform: rotate(360deg); } } + + .status-panel { + position: fixed; + top: 16px; + right: 16px; + background: rgba(17, 24, 39, 0.9); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 10px 12px; + font-size: 12px; + line-height: 1.4; + display: inline-flex; + width: fit-content; + max-width: 80vw; + } + + .status-row { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0; + } + + .status-name { + flex: 1; + color: #e5e7eb; + font-weight: 600; + } + + .status-text { + color: #d1d5db; + } + + .status-icon { + width: 10px; + height: 10px; + display: inline-block; + border-radius: 9999px; + } + + .status-icon.loading { + background: #3b82f6; + animation: pulse 1s ease-in-out infinite; + } + + .status-icon.ok { + background: #10b981; + } + + .status-icon.error { + background: #ef4444; + } + + @keyframes pulse { + 0% { + opacity: 0.35; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.35; + } + } + + .status-tooltip { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: rgba(17, 24, 39, 0.95); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 8px 10px; + font-size: 11px; + color: #e5e7eb; + white-space: normal; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 200px; + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + transition: opacity 120ms ease, transform 120ms ease; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.28); + } + + .status-panel:hover .status-tooltip { + opacity: 1; + transform: translateY(0); + } + + .status-tooltip-line { + display: flex; + justify-content: space-between; + gap: 12px; + } + + .status-tooltip-line span { + white-space: nowrap; + } + + .status-tooltip-label { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .status-tooltip-label::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 9999px; + background: #6b7280; + box-shadow: 0 0 0 2px rgba(107, 114, 128, 0.2); + } + + .status-tooltip-label.loading::before { + background: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25); + animation: pulse 1s ease-in-out infinite; + } + + .status-tooltip-label.ok::before { + background: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25); + } + + .status-tooltip-label.error::before { + background: #ef4444; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25); + } Presenton Logo -

Just a moment...

-
+

Just a moment...

+
+ +
+
+ + Dependencies +
+
+
+ LibreOffice + Checking... +
+
+ Chromium + Checking... +
+
+
\ No newline at end of file diff --git a/electron/resources/ui/homepage/script.js b/electron/resources/ui/homepage/script.js index e69de29b..04ba9044 100644 --- a/electron/resources/ui/homepage/script.js +++ b/electron/resources/ui/homepage/script.js @@ -0,0 +1,88 @@ +window.addEventListener("DOMContentLoaded", () => { + const statusMap = { + checking: "Checking...", + installed: "Installed", + missing: "Missing", + installing: "Installing...", + downloading: "Downloading...", + downloaded: "Downloaded", + skipped: "Skipped", + failed: "Failed", + }; + const labelMap = { + libreoffice: "LibreOffice", + puppeteer: "Chromium", + }; + + const dependenciesEl = document.getElementById("status-dependencies"); + const dependenciesIcon = document.getElementById("icon-dependencies"); + const dependenciesTooltip = document.getElementById("dependencies-tooltip"); + const libreofficeTooltip = document.getElementById("tooltip-libreoffice"); + const puppeteerTooltip = document.getElementById("tooltip-puppeteer"); + const libreofficeLabel = document.getElementById("tooltip-label-libreoffice"); + const puppeteerLabel = document.getElementById("tooltip-label-puppeteer"); + const currentStatus = { + libreoffice: "checking", + puppeteer: "checking", + }; + + function setStatus(name, status) { + if (currentStatus[name] !== undefined) { + currentStatus[name] = status; + } + if (dependenciesEl) dependenciesEl.textContent = "Dependencies"; + + const statuses = Object.values(currentStatus); + const hasError = statuses.some((s) => s === "missing" || s === "failed"); + const isBusy = statuses.some((s) => s === "checking" || s === "installing" || s === "downloading"); + const isDone = statuses.every((s) => s === "installed" || s === "downloaded" || s === "skipped"); + + let iconClass = "loading"; + let iconText = ""; + if (hasError) { + iconClass = "error"; + iconText = "×"; + } else if (isDone && !isBusy) { + iconClass = "ok"; + iconText = "✓"; + } else { + iconClass = "loading"; + iconText = ""; + } + + if (dependenciesIcon) { + dependenciesIcon.className = `status-icon ${iconClass}`; + } + + const libreofficeStatus = currentStatus.libreoffice; + const puppeteerStatus = currentStatus.puppeteer; + const libreofficeText = statusMap[libreofficeStatus] || libreofficeStatus; + const puppeteerText = statusMap[puppeteerStatus] || puppeteerStatus; + + const toDotClass = (value) => { + if (value === "missing" || value === "failed") return "error"; + if (value === "installed" || value === "downloaded" || value === "skipped") return "ok"; + return "loading"; + }; + + if (libreofficeTooltip) libreofficeTooltip.textContent = libreofficeText; + if (puppeteerTooltip) puppeteerTooltip.textContent = puppeteerText; + if (libreofficeLabel) libreofficeLabel.className = `status-tooltip-label ${toDotClass(libreofficeStatus)}`; + if (puppeteerLabel) puppeteerLabel.className = `status-tooltip-label ${toDotClass(puppeteerStatus)}`; + if (dependenciesTooltip) dependenciesTooltip.setAttribute("aria-live", "polite"); + } + + if (window.electron?.onStartupStatus) { + window.electron.onStartupStatus((payload) => { + if (!payload) return; + setStatus(payload.name, payload.status); + }); + } + if (window.electron?.getStartupStatus) { + window.electron.getStartupStatus().then((statusMap) => { + if (!statusMap) return; + if (statusMap.libreoffice) setStatus("libreoffice", statusMap.libreoffice); + if (statusMap.puppeteer) setStatus("puppeteer", statusMap.puppeteer); + }); + } +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..a92e236e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "presenton", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "presenton", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^25.3.5" + } + }, + "node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +}