feat: implement in-app LibreOffice installer with progress UI and update build configurations for Linux and Windows

This commit is contained in:
sudipnext 2026-03-06 17:41:15 +05:45
parent 73b60ebd8c
commit 016cd44cb9
16 changed files with 1328 additions and 251 deletions

View file

@ -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<void> {
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<void> {
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<void>((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<void> {
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<void>((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<void>((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<void> {
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<void>((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);
}
});
}

View file

@ -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<any, any, any> | 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<any, any, any> | 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();
});

View file

@ -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));
},
});

View file

@ -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<LibreOfficeCheckResult> {
}
// ---------------------------------------------------------------------------
// 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<boolean> {
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<void> {
return new Promise<void>((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<boolean> {
// ---------------------------------------------------------------------------
/**
* 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<boolean> {
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<boolean> {
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;
}

View file

@ -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;

View file

@ -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"],

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -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

View file

@ -0,0 +1,639 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Presenton Install LibreOffice</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d14;
--surface: #16162a;
--surface2: #1e1e38;
--border: rgba(145, 52, 234, 0.25);
--grad-a: #9034EA;
--grad-b: #5146E5;
--text: #f0eeff;
--text-muted: #9b96c4;
--text-dim: #5e5a88;
--track: #1e1e38;
--log-bg: #0a0a12;
--log-border: rgba(145,52,234,0.15);
--radius: 14px;
--radius-sm: 8px;
}
html, body {
width: 100%; height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
}
/* ── Outer layout ── */
.shell {
display: flex;
flex-direction: column;
height: 100vh;
padding: 24px 32px 0;
}
/* ── Logo ── */
.logo-wrap {
text-align: center;
margin-bottom: 20px;
flex-shrink: 0;
}
.logo-wrap img {
height: 36px;
display: inline-block;
}
.logo-fallback {
font-size: 20px;
font-weight: 700;
background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.5px;
}
/* ── Card ── */
.card {
width: 100%;
flex-shrink: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px 28px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius);
background: radial-gradient(ellipse 60% 40% at 50% -10%, rgba(145,52,234,0.18) 0%, transparent 70%);
pointer-events: none;
}
/* ── State panels ── */
.state { display: none; flex-direction: column; align-items: center; gap: 14px; width: 100%; }
.state.active { display: flex; }
/* ── Icons ── */
.icon-wrap {
width: 52px; height: 52px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 24px;
}
.icon-wrap.purple {
background: linear-gradient(135deg, rgba(145,52,234,0.2), rgba(81,70,229,0.2));
border: 1px solid rgba(145,52,234,0.35);
}
.icon-wrap.green {
background: rgba(34,197,94,0.12);
border: 1px solid rgba(34,197,94,0.3);
}
.icon-wrap.red {
background: rgba(239,68,68,0.12);
border: 1px solid rgba(239,68,68,0.3);
}
/* ── Typography ── */
.heading {
font-size: 16px;
font-weight: 600;
color: var(--text);
text-align: center;
letter-spacing: -0.2px;
}
.sub {
font-size: 13px;
color: var(--text-muted);
text-align: center;
max-width: 340px;
line-height: 1.6;
}
.sub strong { color: var(--text); font-weight: 500; }
/* ── Buttons ── */
.btn-row {
display: flex;
gap: 12px;
width: 100%;
justify-content: center;
margin-top: 2px;
}
button {
cursor: pointer;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
font-weight: 500;
padding: 8px 20px;
transition: opacity 0.15s, transform 0.1s;
outline: none;
}
button:active { transform: scale(0.97); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.btn-primary {
background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
color: #fff;
min-width: 150px;
box-shadow: 0 4px 20px rgba(145,52,234,0.35);
}
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid rgba(155,150,196,0.25);
}
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: rgba(155,150,196,0.5); }
/* ── Progress bar ── */
.progress-wrap {
width: 100%;
display: flex;
flex-direction: column;
gap: 7px;
}
.progress-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-muted);
}
.progress-track {
width: 100%;
height: 6px;
background: var(--track);
border-radius: 99px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 99px;
background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
width: 0%;
transition: width 0.3s ease;
}
.progress-fill.indeterminate {
width: 40% !important;
animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes shimmer {
0% { transform: translateX(-120%); }
100% { transform: translateX(310%); }
}
/* ── Spinner ── */
.spinner {
width: 28px; height: 28px;
border: 3px solid rgba(145,52,234,0.2);
border-top-color: var(--grad-a);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.phase-label {
font-size: 12px;
color: var(--text-dim);
text-align: center;
}
/* ── Dots decoration ── */
.dots {
position: absolute;
bottom: -20px; right: -20px;
width: 100px; height: 100px;
background-image: radial-gradient(circle, rgba(145,52,234,0.18) 1px, transparent 1px);
background-size: 14px 14px;
pointer-events: none;
opacity: 0.6;
}
/* ── Log section ── */
.log-section {
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
margin-top: 10px;
padding-bottom: 12px;
}
.log-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 4px 0;
color: var(--text-dim);
font-size: 12px;
font-weight: 500;
background: none;
border: none;
font-family: inherit;
text-align: left;
width: 100%;
transition: color 0.15s;
user-select: none;
}
.log-toggle:hover { color: var(--text-muted); }
.log-toggle:active { transform: none; }
.log-toggle-chevron {
display: inline-block;
width: 14px; height: 14px;
position: relative;
flex-shrink: 0;
}
.log-toggle-chevron::before,
.log-toggle-chevron::after {
content: '';
position: absolute;
top: 50%; left: 50%;
width: 6px; height: 1.5px;
background: currentColor;
border-radius: 1px;
transition: transform 0.2s;
}
.log-toggle-chevron::before { transform: translate(-50%, -50%) rotate(-40deg) translateX(-2px); }
.log-toggle-chevron::after { transform: translate(-50%, -50%) rotate( 40deg) translateX( 2px); }
.log-toggle.open .log-toggle-chevron::before { transform: translate(-50%, -50%) rotate( 40deg) translateX(-2px); }
.log-toggle.open .log-toggle-chevron::after { transform: translate(-50%, -50%) rotate(-40deg) translateX( 2px); }
.log-count {
margin-left: auto;
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.log-panel {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
margin-top: 6px;
background: var(--log-bg);
border: 1px solid var(--log-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.log-panel.open { display: flex; }
.log-inner {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 10px 12px;
font-family: "SF Mono", "Cascadia Code", "Fira Code", "Consolas", "Monaco", monospace;
font-size: 11px;
line-height: 1.7;
color: var(--text-muted);
scroll-behavior: smooth;
scrollbar-width: thin;
scrollbar-color: rgba(145,52,234,0.3) transparent;
}
.log-inner::-webkit-scrollbar { width: 4px; }
.log-inner::-webkit-scrollbar-thumb { background: rgba(145,52,234,0.3); border-radius: 2px; }
.log-inner::-webkit-scrollbar-track { background: transparent; }
.log-line {
display: flex;
gap: 8px;
padding: 1px 0;
}
.log-time {
color: var(--text-dim);
flex-shrink: 0;
font-size: 10px;
padding-top: 1px;
}
.log-text { white-space: pre-wrap; word-break: break-all; flex: 1; }
.log-line.info .log-text { color: var(--text-muted); }
.log-line.warn .log-text { color: #c9a54a; }
.log-line.error .log-text { color: #e05a5a; }
.log-line.ok .log-text { color: #5ab870; }
.log-line.cmd .log-text { color: #7c8fd8; }
.log-empty {
color: var(--text-dim);
font-style: italic;
font-size: 11px;
text-align: center;
padding: 12px 0;
}
/* ── Clear log button in panel header ── */
.log-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px 5px 12px;
border-bottom: 1px solid var(--log-border);
}
.log-panel-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.log-clear-btn {
font-size: 10px;
padding: 2px 8px;
color: var(--text-dim);
background: transparent;
border: 1px solid rgba(155,150,196,0.15);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
}
.log-clear-btn:hover { color: var(--text-muted); }
</style>
</head>
<body>
<div class="shell">
<!-- Logo -->
<div class="logo-wrap">
<img src="../assets/images/presenton_logo.png" alt="Presenton"
onerror="this.style.display='none'; document.getElementById('logo-fb').style.display='block';" />
<span id="logo-fb" class="logo-fallback" style="display:none;">Presenton</span>
</div>
<!-- Card -->
<div class="card">
<div class="dots"></div>
<!-- ── STATE: prompt ── -->
<div id="state-prompt" class="state active">
<div class="icon-wrap purple">📦</div>
<p class="heading">LibreOffice Required</p>
<p class="sub">
<strong>Presenton</strong> uses LibreOffice to generate custom presentation
templates from uploaded PPTX files. Without it, this feature won't work.
</p>
<div class="btn-row">
<button class="btn-primary" onclick="handleInstall()">Install LibreOffice</button>
<button class="btn-ghost" onclick="handleSkip()">Skip for now</button>
</div>
</div>
<!-- ── STATE: downloading ── -->
<div id="state-downloading" class="state">
<div class="spinner"></div>
<p class="heading">Downloading LibreOffice</p>
<p class="sub" id="dl-filename">Preparing download…</p>
<div class="progress-wrap">
<div class="progress-meta">
<span id="dl-label">0%</span>
<span id="dl-size"></span>
</div>
<div class="progress-track">
<div class="progress-fill" id="dl-bar"></div>
</div>
</div>
<p class="phase-label">This may take a few minutes (~300 MB)</p>
</div>
<!-- ── STATE: installing ── -->
<div id="state-installing" class="state">
<div class="spinner"></div>
<p class="heading">Installing LibreOffice</p>
<p class="sub">Running the installer — this won't take long…</p>
<div class="progress-wrap">
<div class="progress-track">
<div class="progress-fill indeterminate" id="install-bar"></div>
</div>
</div>
</div>
<!-- ── STATE: success ── -->
<div id="state-success" class="state">
<div class="icon-wrap green"></div>
<p class="heading">LibreOffice Installed</p>
<p class="sub">
Custom template generation from PPTX is now available.
Presenton will continue in a moment…
</p>
<div class="progress-wrap" style="margin-top:2px;">
<div class="progress-track">
<div class="progress-fill" id="success-bar" style="width:0%;transition:width 2s linear;"></div>
</div>
</div>
</div>
<!-- ── STATE: error ── -->
<div id="state-error" class="state">
<div class="icon-wrap red"></div>
<p class="heading">Installation Failed</p>
<p class="sub" id="err-msg">Something went wrong. You can try again or skip and install LibreOffice manually later.</p>
<div class="btn-row">
<button class="btn-primary" onclick="handleInstall()">Try Again</button>
<button class="btn-ghost" onclick="handleSkip()">Skip</button>
</div>
</div>
</div><!-- /card -->
<!-- ── Log section ── -->
<div class="log-section" id="log-section" style="display:none;">
<button class="log-toggle" id="log-toggle" onclick="toggleLog()">
<span class="log-toggle-chevron"></span>
<span id="log-toggle-label">Show details</span>
<span class="log-count" id="log-count"></span>
</button>
<div class="log-panel" id="log-panel">
<div class="log-panel-header">
<span class="log-panel-title">Installation Log</span>
<button class="log-clear-btn" onclick="clearLog()">Clear</button>
</div>
<div class="log-inner" id="log-inner">
<div class="log-empty" id="log-empty">No output yet…</div>
</div>
</div>
</div>
</div><!-- /shell -->
<script>
// ── State machine ───────────────────────────────────────────
const STATES = ['prompt','downloading','installing','success','error'];
let logLines = 0;
function showState(name) {
STATES.forEach(s => {
const el = document.getElementById('state-' + s);
if (el) el.classList.toggle('active', s === name);
});
// show log section for active phases
const logSection = document.getElementById('log-section');
if (logSection) {
logSection.style.display =
(name === 'downloading' || name === 'installing' || name === 'error') ? 'flex' : 'none';
}
}
// ── Log panel ───────────────────────────────────────────────
let logOpen = false;
function toggleLog() {
logOpen = !logOpen;
const toggle = document.getElementById('log-toggle');
const panel = document.getElementById('log-panel');
const label = document.getElementById('log-toggle-label');
if (toggle) toggle.classList.toggle('open', logOpen);
if (panel) panel.classList.toggle('open', logOpen);
if (label) label.textContent = logOpen ? 'Hide details' : 'Show details';
}
function clearLog() {
const inner = document.getElementById('log-inner');
if (!inner) return;
inner.innerHTML = '<div class="log-empty" id="log-empty">Log cleared.</div>';
logLines = 0;
updateLogCount();
}
function updateLogCount() {
const el = document.getElementById('log-count');
if (el) el.textContent = logLines > 0 ? `${logLines} lines` : '';
}
function appendLog(level, text) {
const inner = document.getElementById('log-inner');
if (!inner) return;
// remove empty placeholder
const placeholder = document.getElementById('log-empty');
if (placeholder) placeholder.remove();
const now = new Date();
const ts = now.toTimeString().slice(0, 8);
const line = document.createElement('div');
line.className = `log-line ${level}`;
line.innerHTML =
`<span class="log-time">${ts}</span>` +
`<span class="log-text">${escHtml(text)}</span>`;
inner.appendChild(line);
logLines++;
updateLogCount();
// Auto-scroll to keep the latest log visible (if user is following)
const threshold = 80;
const nearBottom = inner.scrollHeight - inner.scrollTop - inner.clientHeight < threshold;
if (nearBottom) {
line.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
}
function escHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── Button handlers ─────────────────────────────────────────
function handleInstall() {
showState('downloading');
// auto-open log on install start
if (!logOpen) toggleLog();
if (window.loInstaller) {
window.loInstaller.startInstall();
}
}
function handleSkip() {
if (window.loInstaller) {
window.loInstaller.skip();
}
}
// ── Progress handler ─────────────────────────────────────────
function onProgress(data) {
const { phase, percent, message } = data;
if (phase === 'downloading') {
showState('downloading');
const bar = document.getElementById('dl-bar');
const label = document.getElementById('dl-label');
const size = document.getElementById('dl-size');
const fname = document.getElementById('dl-filename');
if (bar) bar.style.width = (percent || 0) + '%';
if (label) label.textContent = (percent || 0) + '%';
if (message) {
const parts = message.split('|');
if (fname && parts[0]) fname.textContent = parts[0];
if (size && parts[1]) size.textContent = parts[1];
}
return;
}
if (phase === 'installing') {
showState('installing');
return;
}
if (phase === 'done') {
showState('success');
appendLog('ok', 'Installation complete.');
setTimeout(() => {
const bar = document.getElementById('success-bar');
if (bar) bar.style.width = '100%';
}, 50);
setTimeout(() => {
if (window.loInstaller) window.loInstaller.skip();
}, 2200);
return;
}
if (phase === 'error') {
showState('error');
const msg = document.getElementById('err-msg');
if (msg && message) msg.textContent = message;
appendLog('error', message || 'Installation failed.');
// auto-open log on error so user can see what happened
if (!logOpen) toggleLog();
return;
}
}
// ── Log handler ──────────────────────────────────────────────
function onLog(data) {
const { level, text } = data;
appendLog(level || 'info', text || '');
}
// ── Wire up IPC ──────────────────────────────────────────────
if (window.loInstaller) {
window.loInstaller.onProgress(onProgress);
window.loInstaller.onLog(onLog);
}
</script>
</body>
</html>