Merge pull request #433 from presenton/feat/sofficefixes

Feat/sofficefixes
This commit is contained in:
Sudip Parajuli 2026-03-07 19:46:51 +05:45 committed by GitHub
commit 8871b4d0ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1863 additions and 167 deletions

View file

@ -0,0 +1,474 @@
import { ipcMain, WebContents } from "electron";
import { spawn } from "child_process";
import * as fs from "fs";
import * as https from "https";
import * as http from "http";
import { IncomingMessage } from "http";
import * as path from "path";
import { app } from "electron";
import { LIBREOFFICE_DOWNLOAD_URLS, LIBREOFFICE_VERSION } from "../utils/libreoffice-urls";
import { getLinuxInstallCommand } from "../utils/libreoffice-check";
// ---------------------------------------------------------------------------
// IPC helpers
// ---------------------------------------------------------------------------
function sendProgress(
wc: WebContents,
phase: "downloading" | "installing" | "done" | "error",
percent?: number,
message?: string
) {
if (!wc.isDestroyed()) {
wc.send("lo:progress", { phase, percent, message });
}
}
function sendLog(
wc: WebContents,
level: "info" | "warn" | "error" | "ok" | "cmd",
text: string
) {
if (!wc.isDestroyed()) {
// Split multi-line output into individual log entries
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
for (const line of lines) {
wc.send("lo:log", { level, text: line });
}
}
}
// ---------------------------------------------------------------------------
// Download with progress
// ---------------------------------------------------------------------------
/** Minimum expected size (bytes). LibreOffice installers are ~280350 MB; HTML/redirect pages are ~30 KB. */
const MIN_INSTALLER_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
/**
* Known approximate installer sizes used as fallback when the download server
* does not send a Content-Length header (e.g. some CDN mirrors strip it).
* These are intentionally conservative estimates so the progress bar never
* jumps backward if the actual file is slightly smaller.
*/
const KNOWN_INSTALLER_SIZES = {
win64: 370 * 1024 * 1024, // ~350360 MB MSI
macX64: 400 * 1024 * 1024, // ~370390 MB DMG
macArm64: 400 * 1024 * 1024, // ~370390 MB DMG
};
function downloadWithProgress(
url: string,
dest: string,
filename: string,
wc: WebContents,
minSizeBytes: number = MIN_INSTALLER_SIZE_BYTES,
knownTotalBytes?: number
): 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 contentLength = parseInt(res.headers["content-length"] ?? "0", 10);
// Use Content-Length when available; fall back to the caller-supplied
// known size so the progress bar shows something meaningful even when
// the CDN mirror omits the header.
const totalBytes = contentLength > 0 ? contentLength : (knownTotalBytes ?? 0);
sendLog(wc, "ok", `HTTP 200 OK — ${totalBytes > 0 ? fmtBytes(totalBytes) : "size unknown"}`);
sendLog(wc, "info", `Saving to: ${dest}`);
sendLog(wc, "info", `Starting download of ${filename}`);
let downloaded = 0;
const startTime = Date.now();
let lastLogTime = startTime;
// Log interval: every 2 seconds or every 5% — whichever fires first
const LOG_INTERVAL_MS = 2000;
let lastLoggedPct = 0;
const file = fs.createWriteStream(dest);
res.on("data", (chunk: Buffer) => {
downloaded += chunk.length;
const now = Date.now();
const elapsedMs = now - startTime;
// Cap at 99 while still downloading so 100% only fires on completion
const percent = totalBytes > 0 ? Math.min(Math.floor((downloaded / totalBytes) * 100), 99) : 0;
const sizeLabel = totalBytes > 0
? `${fmtBytes(downloaded)} / ${fmtBytes(totalBytes)}`
: fmtBytes(downloaded);
// Update the progress bar UI on every chunk
sendProgress(wc, "downloading", percent, `${filename}|${sizeLabel}`);
// Log every 2 s OR every 5% progress
const pctBucket = Math.floor(percent / 5) * 5;
const timeSinceLastLog = now - lastLogTime;
if (
(timeSinceLastLog >= LOG_INTERVAL_MS || pctBucket > lastLoggedPct)
&& elapsedMs > 0
) {
lastLogTime = now;
lastLoggedPct = pctBucket;
const speed = downloaded / (elapsedMs / 1000);
const remaining = totalBytes > 0 ? (totalBytes - downloaded) / speed : 0;
const etaStr = totalBytes > 0 ? ` ${fmtEta(remaining)}` : "";
const pctStr = totalBytes > 0 ? `${percent}% ` : "";
sendLog(
wc,
"info",
`${pctStr}${fmtBytes(downloaded)} downloaded @ ${fmtSpeed(speed)}${etaStr}`
);
}
});
res.pipe(file);
file.on("finish", () =>
file.close(() => {
const elapsedSec = (Date.now() - startTime) / 1000;
const avgSpeed = downloaded / elapsedSec;
sendLog(wc, "ok", `Download complete — ${fmtBytes(downloaded)} in ${elapsedSec.toFixed(1)}s (avg ${fmtSpeed(avgSpeed)})`);
if (downloaded < minSizeBytes) {
fs.unlink(dest, () => {});
reject(
new Error(
`Download failed: received ${fmtBytes(downloaded)} (expected > 50 MB). The server may have returned an HTML page instead of the installer.`
)
);
return;
}
resolve();
})
);
file.on("error", (err) => {
fs.unlink(dest, () => {});
reject(err);
});
}).on("error", (err) => {
fs.unlink(dest, () => {});
reject(err);
});
};
doRequest(url);
});
}
// ---------------------------------------------------------------------------
// Platform installers
// ---------------------------------------------------------------------------
async function installWindows(wc: WebContents): Promise<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, MIN_INSTALLER_SIZE_BYTES, KNOWN_INSTALLER_SIZES.win64);
sendProgress(wc, "installing");
sendLog(wc, "info", "Requesting administrator rights (UAC prompt may appear)…");
sendLog(wc, "cmd", `Running: msiexec /i "${filename}" /qn /norestart`);
await new Promise<void>((resolve, reject) => {
// Run msiexec elevated via PowerShell; error 1603 often means installer needs admin rights
const destEscaped = dest.replace(/'/g, "''");
const ps = `$p = Start-Process -FilePath "msiexec" -ArgumentList "/i", '${destEscaped}', "/qn", "/norestart" -Verb RunAs -Wait -PassThru; if ($p) { exit $p.ExitCode } else { exit 1 }`;
const child = spawn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps], {
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (d: Buffer) => sendLog(wc, "info", d.toString()));
child.stderr?.on("data", (d: Buffer) => sendLog(wc, "warn", d.toString()));
child.on("close", (code) => {
fs.unlink(dest, () => {});
if (code === 0 || code === 3010) {
sendLog(wc, "ok", `msiexec exited with code ${code} (success)`);
resolve();
} else {
const hint =
code === 1603
? " — Try closing other apps, freeing disk space, or install LibreOffice manually from libreoffice.org"
: code === 1
? " — Did you cancel the administrator prompt?"
: "";
reject(new Error(`msiexec exited with code ${code}${hint}`));
}
});
child.on("error", (err) => {
fs.unlink(dest, () => {});
reject(err);
});
});
}
async function installMac(wc: WebContents): Promise<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,
MIN_INSTALLER_SIZE_BYTES,
isArm64 ? KNOWN_INSTALLER_SIZES.macArm64 : KNOWN_INSTALLER_SIZES.macX64
);
sendProgress(wc, "installing");
fs.mkdirSync(mountPoint, { recursive: true });
sendLog(wc, "cmd", `Mounting DMG at ${mountPoint}`);
await new Promise<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"
);
}
const isApt = installCmd.cmd === "apt" || installCmd.cmd === "apt-get";
if (isApt) {
// apt-get supports APT::Status-Fd which writes machine-readable progress
// lines to the specified file descriptor. We route them to stdout (fd=1)
// so the piped child.stdout stream delivers them without mixing with the
// regular log output that apt sends to stderr.
//
// Status line formats:
// dlstatus:<id>:<percent>:<message> — download progress
// pmstatus:<pkg>:<percent>:<message> — dpkg install progress
sendProgress(wc, "downloading", 0, "libreoffice|Resolving packages…");
sendLog(wc, "cmd", "Running: pkexec apt-get install -y libreoffice");
sendLog(wc, "info", "A system dialog will prompt for your password…");
await new Promise<void>((resolve, reject) => {
const child = spawn(
"pkexec",
["apt-get", "install", "-y", "-o", "APT::Status-Fd=1", "libreoffice"],
{ stdio: ["ignore", "pipe", "pipe"] }
);
let stdoutBuf = "";
child.stdout?.on("data", (d: Buffer) => {
stdoutBuf += d.toString();
const lines = stdoutBuf.split("\n");
stdoutBuf = lines.pop() ?? ""; // keep any incomplete trailing line
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("dlstatus:")) {
// dlstatus:<numeric-id>:<percent>:<human-readable-msg>
const parts = trimmed.split(":");
const pct = parseFloat(parts[2] ?? "0");
if (!isNaN(pct)) {
const msg = parts.slice(3).join(":").trim() || "Downloading packages…";
sendProgress(wc, "downloading", Math.min(Math.floor(pct), 99), `libreoffice|${msg}`);
}
} else if (trimmed.startsWith("pmstatus:")) {
// pmstatus:<pkg-name>:<percent>:<human-readable-msg>
const parts = trimmed.split(":");
const pct = parseFloat(parts[2] ?? "0");
if (!isNaN(pct)) {
sendProgress(wc, "installing", Math.min(Math.floor(pct), 99));
}
} else {
sendLog(wc, "info", trimmed);
}
}
});
child.stderr?.on("data", (d: Buffer) => {
const text = d.toString();
sendLog(wc, text.toLowerCase().includes("error") ? "error" : "info", text);
});
child.on("close", (code) => {
if (code === 0) {
sendLog(wc, "ok", "apt-get exited successfully");
resolve();
} else {
reject(new Error(`apt-get exited with code ${code}`));
}
});
child.on("error", reject);
});
return;
}
// For dnf, pacman, zypper — use a simple regex to extract any percentage
// printed to stdout so we can at least animate the progress bar forward.
sendProgress(wc, "installing");
const fullCmd = `pkexec ${installCmd.cmd} ${installCmd.args.join(" ")}`;
sendLog(wc, "cmd", `Running: ${fullCmd}`);
sendLog(wc, "info", "A system dialog will prompt for your password…");
const pctRegex = /(\d+)\s*%/;
await new Promise<void>((resolve, reject) => {
const child = spawn("pkexec", [installCmd.cmd, ...installCmd.args], {
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (d: Buffer) => {
const text = d.toString();
const match = pctRegex.exec(text);
if (match) {
const pct = parseInt(match[1], 10);
if (pct >= 0 && pct <= 100) {
sendProgress(wc, "installing", pct);
}
}
sendLog(wc, "info", text);
});
child.stderr?.on("data", (d: Buffer) => {
const text = d.toString();
sendLog(wc, text.toLowerCase().includes("error") ? "error" : "info", text);
});
child.on("close", (code) => {
if (code === 0) {
sendLog(wc, "ok", `${installCmd.cmd} exited successfully`);
resolve();
} else {
reject(new Error(`${installCmd.cmd} exited with code ${code}`));
}
});
child.on("error", reject);
});
}
// ---------------------------------------------------------------------------
// IPC registration
// ---------------------------------------------------------------------------
export function setupLibreOfficeInstallHandlers() {
ipcMain.handle("lo:start-install", async (event) => {
const wc = event.sender;
try {
const platform = process.platform;
sendLog(wc, "info", `Platform: ${platform} (${process.arch})`);
if (platform === "win32") {
await installWindows(wc);
} else if (platform === "darwin") {
await installMac(wc);
} else {
await installLinux(wc);
}
sendProgress(wc, "done");
} catch (err: unknown) {
const message =
err instanceof Error
? err.message
: "An unexpected error occurred. You can install LibreOffice manually later.";
sendLog(wc, "error", message);
sendProgress(wc, "error", undefined, message);
}
});
}

View file

@ -1,151 +1,188 @@
require("dotenv").config();
import { app, BrowserWindow } from "electron";
import path from "path";
import { findUnusedPorts, killProcess, setupEnv, setUserConfig } from "./utils";
import { startFastApiServer, startNextJsServer } from "./utils/servers";
import { ChildProcessByStdio } from "child_process";
import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localhost, nextjsDir, tempDir, userConfigPath, userDataDir } from "./utils/constants";
import { setupIpcHandlers } from "./ipc";
var win: BrowserWindow | undefined;
var fastApiProcess: ChildProcessByStdio<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,
},
isDev,
);
nextjsProcess = await startNextJsServer(
nextjsDir,
nextjsPort,
{
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API,
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY,
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
APP_DATA_DIRECTORY: appDataDir,
},
isDev,
)
} catch (error) {
console.error("Server startup error:", error);
}
}
async function stopServers() {
if (fastApiProcess?.pid) {
await killProcess(fastApiProcess.pid);
}
if (nextjsProcess) {
if (isDev) {
await killProcess(nextjsProcess.pid);
} else {
nextjsProcess.close();
}
}
}
app.whenReady().then(async () => {
// Ensure all required directories exist before starting
ensureDirectoriesExist();
createWindow();
win?.loadFile(path.join(baseDir, "resources/ui/homepage/index.html"));
setUserConfig({
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
LLM: process.env.LLM,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
OLLAMA_URL: process.env.OLLAMA_URL,
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
TOOL_CALLS: process.env.TOOL_CALLS,
DISABLE_THINKING: process.env.DISABLE_THINKING,
WEB_GROUNDING: process.env.WEB_GROUNDING,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
COMFYUI_URL: process.env.COMFYUI_URL,
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
})
const [fastApiPort, nextjsPort] = await findUnusedPorts();
console.log(`FastAPI port: ${fastApiPort}, NextJS port: ${nextjsPort}`);
//? Setup environment variables to be used in the preloads
setupEnv(fastApiPort, nextjsPort);
setupIpcHandlers();
await startServers(fastApiPort, nextjsPort);
win?.loadURL(`${localhost}:${nextjsPort}`);
});
app.on("window-all-closed", async () => {
await stopServers();
app.quit();
});
require("dotenv").config();
import { app, BrowserWindow } from "electron";
import path from "path";
import fs from "fs";
import { findUnusedPorts, killProcess, setupEnv, setUserConfig } from "./utils";
import { startFastApiServer, startNextJsServer } from "./utils/servers";
import { ChildProcessByStdio } from "child_process";
import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localhost, nextjsDir, tempDir, userConfigPath, userDataDir } from "./utils/constants";
import { setupIpcHandlers } from "./ipc";
import { setupLibreOfficeInstallHandlers } from "./ipc/libreoffice_install_handlers";
import { checkLibreOfficeBeforeWindow, getSofficePath } from "./utils/libreoffice-check";
var win: BrowserWindow | undefined;
var fastApiProcess: ChildProcessByStdio<any, any, any> | undefined;
var nextjsProcess: any;
app.commandLine.appendSwitch('gtk-version', '3');
// Mitigate "Unable to move the cache: Access is denied" on Windows (Chromium disk cache).
// Use explicit cache paths and remove stale old_* dirs that cause move failures.
if (process.platform === "win32") {
const ud = app.getPath("userData");
const cacheBase = path.join(ud, "Cache");
const gpuCacheBase = path.join(ud, "GPUCache");
app.setPath("cache", cacheBase);
app.commandLine.appendSwitch("disk-cache-dir", cacheBase);
try {
[cacheBase, gpuCacheBase].forEach((dir) => {
if (fs.existsSync(dir)) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && e.name.startsWith("old_")) {
fs.rmSync(path.join(dir, e.name), { recursive: true, force: true });
}
}
}
});
} catch {
/* ignore cleanup errors */
}
}
const createWindow = () => {
win = new BrowserWindow({
width: 1280,
height: 720,
icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"),
webPreferences: {
webSecurity: false,
preload: path.join(__dirname, 'preloads/index.js'),
},
});
};
async function startServers(fastApiPort: number, nextjsPort: number) {
try {
fastApiProcess = await startFastApiServer(
fastapiDir,
fastApiPort,
{
DEBUG: isDev ? "True" : "False",
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
LLM: process.env.LLM,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
OLLAMA_URL: process.env.OLLAMA_URL,
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
TOOL_CALLS: process.env.TOOL_CALLS,
DISABLE_THINKING: process.env.DISABLE_THINKING,
WEB_GROUNDING: process.env.WEB_GROUNDING,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
COMFYUI_URL: process.env.COMFYUI_URL,
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
APP_DATA_DIRECTORY: appDataDir,
TEMP_DIRECTORY: tempDir,
USER_CONFIG_PATH: userConfigPath,
// Resolved by libreoffice-check.ts at startup; lets Python invoke the
// exact binary path instead of relying on the system PATH.
SOFFICE_PATH: getSofficePath(),
},
isDev,
);
nextjsProcess = await startNextJsServer(
nextjsDir,
nextjsPort,
{
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API,
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY,
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
APP_DATA_DIRECTORY: appDataDir,
},
isDev,
)
} catch (error) {
console.error("Server startup error:", error);
}
}
async function stopServers() {
if (fastApiProcess?.pid) {
await killProcess(fastApiProcess.pid);
}
if (nextjsProcess) {
if (isDev) {
await killProcess(nextjsProcess.pid);
} else {
nextjsProcess.close();
}
}
}
app.whenReady().then(async () => {
// Ensure all required directories exist before starting
ensureDirectoriesExist();
// Register LibreOffice install handlers early so the installer window can use them
setupLibreOfficeInstallHandlers();
// Check for LibreOffice (required for custom template from PPTX). Shows installer
// window if missing. Never blocks; always proceeds.
await checkLibreOfficeBeforeWindow();
createWindow();
win?.loadFile(path.join(baseDir, "resources/ui/homepage/index.html"));
setUserConfig({
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
LLM: process.env.LLM,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
OLLAMA_URL: process.env.OLLAMA_URL,
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
TOOL_CALLS: process.env.TOOL_CALLS,
DISABLE_THINKING: process.env.DISABLE_THINKING,
WEB_GROUNDING: process.env.WEB_GROUNDING,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
COMFYUI_URL: process.env.COMFYUI_URL,
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
})
const [fastApiPort, nextjsPort] = await findUnusedPorts();
console.log(`FastAPI port: ${fastApiPort}, NextJS port: ${nextjsPort}`);
//? Setup environment variables to be used in the preloads
setupEnv(fastApiPort, nextjsPort);
setupIpcHandlers();
await startServers(fastApiPort, nextjsPort);
win?.loadURL(`${localhost}:${nextjsPort}`);
});
app.on("window-all-closed", async () => {
await stopServers();
app.quit();
});

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

@ -30,6 +30,8 @@ interface FastApiEnv {
APP_DATA_DIRECTORY?: string,
TEMP_DIRECTORY?: string,
USER_CONFIG_PATH?: string,
/** Absolute path to the soffice binary resolved at startup by libreoffice-check.ts. */
SOFFICE_PATH?: string,
}
interface NextJsEnv {

View file

@ -0,0 +1,386 @@
/**
* libreoffice-check.ts
*
* Checks whether LibreOffice is available on the host machine before the
* main BrowserWindow is created. LibreOffice is required for creating custom
* templates from uploaded PPTX files.
*
* If not found, shows a branded installer window that lets the user download
* and install LibreOffice with a real-time progress UI.
*/
import { BrowserWindow, ipcMain, app } from "electron";
import { exec } from "child_process";
import * as util from "util";
import * as fs from "fs";
import * as path from "path";
import { baseDir } from "./constants";
const execAsync = util.promisify(exec);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Result returned by {@link isLibreOfficeInstalled}. */
interface LibreOfficeCheckResult {
installed: boolean;
/** The raw version string from `soffice --version`, when available. */
version?: string;
/** The resolved absolute path (or bare command name) of the soffice binary. */
path?: string;
}
// ---------------------------------------------------------------------------
// Platform helpers
// ---------------------------------------------------------------------------
/**
* Reads a directory and returns the names of all entries whose names match
* `pattern`. Returns an empty array if the directory cannot be read.
*/
function scanDir(dir: string, pattern: RegExp): string[] {
try {
return fs.readdirSync(dir).filter((entry) => pattern.test(entry));
} catch {
return [];
}
}
/**
* Returns an ordered list of absolute paths to try for the `soffice` binary
* on the current platform.
*
* Instead of hard-coding version numbers, parent directories are scanned with
* a regex so any past or future LibreOffice version is automatically found.
* Fixed (non-versioned) paths are still included first so the common case
* resolves instantly.
*
* Detection strategy per platform:
* Windows scan Program Files (64-bit & 32-bit) for /^LibreOffice(\s[\d.]+)?$/i,
* plus per-user LOCALAPPDATA / APPDATA locations.
* macOS scan /Applications and ~/Applications for /^LibreOffice[\s\d.]*\.app$/i,
* plus Homebrew (Intel & Apple Silicon) and MacPorts fixed paths.
* Linux fixed distro/local/snap/flatpak paths, then scan /opt for
* /^libreoffice[\d.]*$/i, and ~/.local for user installs.
*/
function getCandidatePaths(): string[] {
const platform = process.platform;
// -------------------------------------------------------------------------
// Windows
// -------------------------------------------------------------------------
if (platform === "win32") {
const pf = process.env["ProgramFiles"] ?? "C:\\Program Files";
const pf86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const local = process.env["LOCALAPPDATA"] ?? "";
const appData = process.env["APPDATA"] ?? "";
// Matches "LibreOffice", "LibreOffice 7", "LibreOffice 24.8", etc.
const loPattern = /^LibreOffice(\s[\d.]+)?$/i;
const paths: string[] = [];
// Scan both Program Files roots for any LibreOffice installation folder.
for (const root of [pf, pf86]) {
for (const entry of scanDir(root, loPattern)) {
paths.push(`${root}\\${entry}\\program\\soffice.exe`);
}
}
// Per-user installs
if (local) {
paths.push(
`${local}\\Programs\\LibreOffice\\program\\soffice.exe`,
`${local}\\LibreOffice\\program\\soffice.exe`,
);
}
if (appData) {
paths.push(`${appData}\\LibreOffice\\program\\soffice.exe`);
}
return paths;
}
// -------------------------------------------------------------------------
// macOS
// -------------------------------------------------------------------------
if (platform === "darwin") {
const home = process.env["HOME"] ?? "";
// Matches "LibreOffice.app", "LibreOffice 7.app", "LibreOffice 24.8.app", etc.
const bundlePattern = /^LibreOffice[\s\d.]*\.app$/i;
const macosRelative = "Contents/MacOS/soffice";
const paths: string[] = [];
// Scan /Applications and ~/Applications for any LibreOffice bundle.
const appDirs = ["/Applications"];
if (home) appDirs.push(`${home}/Applications`);
for (const appDir of appDirs) {
for (const bundle of scanDir(appDir, bundlePattern)) {
paths.push(`${appDir}/${bundle}/${macosRelative}`);
}
}
// Homebrew Intel Macs
paths.push(
"/usr/local/bin/soffice",
"/usr/local/lib/libreoffice/program/soffice",
);
// Homebrew Apple Silicon (M-series)
paths.push(
"/opt/homebrew/bin/soffice",
"/opt/homebrew/lib/libreoffice/program/soffice",
);
// MacPorts
paths.push("/opt/local/bin/soffice");
return paths;
}
// -------------------------------------------------------------------------
// Linux
// -------------------------------------------------------------------------
const home = process.env["HOME"] ?? "";
const paths: string[] = [
// Distro packages (Debian/Ubuntu, Fedora, Arch, openSUSE, …)
"/usr/bin/soffice",
"/usr/bin/libreoffice",
"/usr/lib/libreoffice/program/soffice",
"/usr/lib64/libreoffice/program/soffice",
// Manual / local installs
"/usr/local/bin/soffice",
"/usr/local/lib/libreoffice/program/soffice",
// Snap classic and strict confinement
"/snap/bin/soffice",
"/snap/bin/libreoffice",
"/var/lib/snapd/snap/bin/soffice",
"/var/lib/snapd/snap/bin/libreoffice",
// Flatpak system-wide
"/var/lib/flatpak/exports/bin/org.libreoffice.LibreOffice",
"/var/lib/flatpak/app/org.libreoffice.LibreOffice/current/active/export/bin/libreoffice",
];
// Scan /opt for any versioned tarball directory, e.g. libreoffice7.6,
// libreoffice24.8, libreoffice (plain symlink), etc.
// Matches "libreoffice", "libreoffice7", "libreoffice7.6", "libreoffice24.2", …
const optPattern = /^libreoffice[\d.]*$/i;
for (const entry of scanDir("/opt", optPattern)) {
paths.push(`/opt/${entry}/program/soffice`);
}
// Flatpak per-user and ~/.local installs
if (home) {
paths.push(
`${home}/.local/share/flatpak/exports/bin/org.libreoffice.LibreOffice`,
`${home}/.local/share/flatpak/app/org.libreoffice.LibreOffice/current/active/export/bin/libreoffice`,
`${home}/.local/bin/soffice`,
`${home}/.local/lib/libreoffice/program/soffice`,
);
}
return paths;
}
/**
* Detects the Linux distro from /etc/os-release and returns the install
* command for LibreOffice, or null if the distro is not supported.
*
* Exported so that libreoffice_install_handlers.ts can reuse it.
*/
export function getLinuxInstallCommand(): { cmd: string; args: string[] } | null {
const osReleasePaths = ["/etc/os-release", "/usr/lib/os-release"];
let id = "";
let idLike = "";
for (const p of osReleasePaths) {
try {
const content = fs.readFileSync(p, "utf-8");
for (const line of content.split("\n")) {
const m = line.match(/^ID=(.+)$/);
if (m) id = m[1].replace(/^["']|["']$/g, "").trim().toLowerCase();
const m2 = line.match(/^ID_LIKE=(.+)$/);
if (m2) idLike = m2[1].replace(/^["']|["']$/g, "").trim().toLowerCase();
}
if (id) break;
} catch {
continue;
}
}
const ids = `${id} ${idLike}`;
if (ids.includes("ubuntu") || ids.includes("debian") || ids.includes("pop") || ids.includes("linuxmint")) {
return { cmd: "apt", args: ["install", "-y", "libreoffice"] };
}
if (ids.includes("fedora") || ids.includes("rhel") || ids.includes("centos") || ids.includes("rocky")) {
return { cmd: "dnf", args: ["install", "-y", "libreoffice"] };
}
if (ids.includes("arch")) {
return { cmd: "pacman", args: ["-S", "--noconfirm", "libreoffice-still"] };
}
if (ids.includes("opensuse") || ids.includes("suse")) {
return { cmd: "zypper", args: ["install", "-y", "libreoffice"] };
}
return null;
}
// ---------------------------------------------------------------------------
// Resolved path set once by checkLibreOfficeBeforeWindow()
// ---------------------------------------------------------------------------
/**
* The resolved soffice binary path discovered at startup.
* Defaults to the bare command name so callers always get a usable string
* even if the check has not run yet (e.g. in non-Electron environments).
*/
let resolvedSofficePath: string = "soffice";
/**
* Returns the resolved soffice binary path found during startup detection.
*
* Pass as the `SOFFICE_PATH` env var to the FastAPI subprocess so Python
* code can invoke the exact binary rather than relying on `PATH`.
*/
export function getSofficePath(): string {
return resolvedSofficePath;
}
// ---------------------------------------------------------------------------
// Core detection logic
// ---------------------------------------------------------------------------
/**
* Attempts to detect LibreOffice by:
* 1. Checking well-known installation paths for the binary (fast, no shell).
* 2. Falling back to `soffice --version` via the shell (catches PATH installs).
*
* Returns an object indicating whether LibreOffice was found and, when it
* was, the version string reported by the binary.
*/
async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult> {
// --- Step 1: check well-known paths synchronously (no exec overhead) ---
for (const candidate of getCandidatePaths()) {
if (fs.existsSync(candidate)) {
// Binary found at a known location try to get the version string.
try {
const quoted = `"${candidate}"`;
const { stdout } = await execAsync(`${quoted} --version`, {
timeout: 8_000,
});
return { installed: true, version: stdout.trim(), path: candidate };
} catch {
// Binary exists but failed to execute still treat as installed.
return { installed: true, path: candidate };
}
}
}
// --- Step 2: try the PATH-based command ---
try {
const { stdout } = await execAsync("soffice --version", {
timeout: 8_000,
});
// Found via PATH record the bare command name as the path so callers
// can pass it directly to subprocess invocations.
return { installed: true, version: stdout.trim(), path: "soffice" };
} catch {
// Command not found or timed out LibreOffice is not available.
return { installed: false };
}
}
// ---------------------------------------------------------------------------
// Installer window
// ---------------------------------------------------------------------------
/**
* Opens a branded 520×400 installer window that lets the user download and
* install LibreOffice with a live progress UI.
*
* Returns a Promise that resolves once the window is closed (either by the
* user skipping or after a successful install).
*/
async function showLibreOfficeInstallerWindow(): Promise<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"),
},
});
win.setMenuBarVisibility(false);
const htmlPath = path.join(
baseDir,
"resources/ui/libreoffice-installer/index.html"
);
win.loadFile(htmlPath);
// lo:skip is sent by the renderer when the user clicks "Skip" or after
// a successful install (the success state auto-sends skip after 2 s).
const onSkip = () => {
if (!win.isDestroyed()) win.close();
};
ipcMain.once("lo:skip", onSkip);
win.on("closed", () => {
// Remove the listener in case the window was closed by the OS (title-bar X)
ipcMain.removeListener("lo:skip", onSkip);
resolve();
});
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Checks for LibreOffice. When absent, shows a branded installer window that
* lets the user install it. Never blocks app startup always returns `true`.
*
* Call this function **before** creating the main `BrowserWindow`.
*
* @returns Always `true` the application should always proceed.
*/
export async function checkLibreOfficeBeforeWindow(): Promise<boolean> {
let result = await isLibreOfficeInstalled();
if (result.installed) {
if (result.path) {
resolvedSofficePath = result.path;
}
console.log(
`[LibreOffice] Detected: ${result.version ?? "(version unknown)"} at ${resolvedSofficePath}`
);
return true;
}
console.warn("[LibreOffice] Not found showing installer window.");
await showLibreOfficeInstallerWindow();
// Re-detect after the window closes (install may have succeeded)
result = await isLibreOfficeInstalled();
if (result.installed && result.path) {
resolvedSofficePath = result.path;
console.log(`[LibreOffice] Detected after install: ${resolvedSofficePath}`);
}
// Always proceed never block the app
return true;
}

View file

@ -0,0 +1,16 @@
/**
* LibreOffice download URLs for automated installation.
* Uses direct CDN URLs (download.documentfoundation.org) the donate URLs
* return HTML pages instead of the actual installer on Windows.
* Update the version when upgrading to a newer LibreOffice release.
* See https://www.libreoffice.org/download/download-libreoffice/
*/
export const LIBREOFFICE_VERSION = "25.8.5";
const CDN_BASE = "https://download.documentfoundation.org/libreoffice/stable";
export const LIBREOFFICE_DOWNLOAD_URLS = {
win64: `${CDN_BASE}/${LIBREOFFICE_VERSION}/win/x86_64/LibreOffice_${LIBREOFFICE_VERSION}_Win_x86-64.msi`,
macX64: `${CDN_BASE}/${LIBREOFFICE_VERSION}/mac/x86_64/LibreOffice_${LIBREOFFICE_VERSION}_MacOS_x86-64.dmg`,
macArm64: `${CDN_BASE}/${LIBREOFFICE_VERSION}/mac/aarch64/LibreOffice_${LIBREOFFICE_VERSION}_MacOS_aarch64.dmg`,
} as const;

View file

@ -26,6 +26,15 @@ export async function startFastApiServer(
args = ["--port", port.toString()];
}
const safeLog = (data: Buffer | string, logPath: string) => {
try {
fs.appendFileSync(logPath, data);
} catch {
/* ignore if logs dir not writable */
}
};
const fastapiLogPath = path.join(logsDir, "fastapi-server.log");
const fastApiProcess = spawn(
command,
args,
@ -36,13 +45,16 @@ export async function startFastApiServer(
}
);
fastApiProcess.stdout.on("data", (data: any) => {
fs.appendFileSync(path.join(logsDir, "fastapi-server.log"), data);
safeLog(data, fastapiLogPath);
console.log(`FastAPI: ${data}`);
});
fastApiProcess.stderr.on("data", (data: any) => {
fs.appendFileSync(path.join(logsDir, "fastapi-server.log"), data);
safeLog(data, fastapiLogPath);
console.error(`FastAPI: ${data}`);
});
fastApiProcess.on("error", (err) => {
safeLog(`Spawn error: ${err.message}\n`, fastapiLogPath);
});
// Wait for FastAPI server to start
await waitForServer(`${localhost}:${port}/docs`);
return fastApiProcess;
@ -67,17 +79,25 @@ export async function startNextJsServer(
env: { ...process.env, ...env },
}
);
const nextjsLogPath = path.join(logsDir, "nextjs-server.log");
const safeNextLog = (d: Buffer | string) => {
try {
fs.appendFileSync(nextjsLogPath, d);
} catch {
/* ignore */
}
};
nextjsProcess.stdout.on("data", (data: any) => {
fs.appendFileSync(path.join(logsDir, "nextjs-server.log"), data);
safeNextLog(data);
console.log(`NextJS: ${data}`);
});
nextjsProcess.stderr.on("data", (data: any) => {
fs.appendFileSync(path.join(logsDir, "nextjs-server.log"), data);
safeNextLog(data);
console.error(`NextJS: ${data}`);
});
} else {
// Start NextJS build server
nextjsProcess = startNextjsBuildServer(directory, port);
nextjsProcess = await startNextjsBuildServer(directory, port);
}
// Wait for NextJS server to start
@ -85,16 +105,20 @@ export async function startNextJsServer(
return nextjsProcess;
}
async function startNextjsBuildServer(directory: string, port: number) {
const server = http.createServer((req, res) => {
return handler(req, res, {
public: directory,
cleanUrls: true,
function startNextjsBuildServer(directory: string, port: number): Promise<http.Server> {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
return handler(req, res, {
public: directory,
cleanUrls: true,
});
});
server.on("error", reject);
server.listen(port, () => {
server.off("error", reject);
resolve(server);
});
});
server.listen(port);
return server;
}

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,669 @@
<!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-meta" id="install-meta" style="display:none;">
<span id="install-label">0%</span>
</div>
<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';
}
// When entering installing state fresh, reset to indeterminate until a
// real percent arrives (e.g. Windows msiexec never sends one).
if (name === 'installing') {
const bar = document.getElementById('install-bar');
const meta = document.getElementById('install-meta');
const label = document.getElementById('install-label');
if (bar && !bar.classList.contains('indeterminate')) {
bar.classList.add('indeterminate');
bar.style.width = '';
}
if (meta) meta.style.display = 'none';
if (label) label.textContent = '0%';
}
}
// ── 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');
const bar = document.getElementById('install-bar');
const meta = document.getElementById('install-meta');
const label = document.getElementById('install-label');
if (bar) {
if (typeof percent === 'number') {
bar.classList.remove('indeterminate');
bar.style.width = Math.min(percent, 100) + '%';
if (meta) meta.style.display = 'flex';
if (label) label.textContent = percent + '%';
} else if (!bar.classList.contains('indeterminate')) {
bar.classList.add('indeterminate');
bar.style.width = '';
}
}
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>

View file

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