/** * 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; } export type LibreOfficeStatus = | "checking" | "installed" | "missing" | "installing"; // --------------------------------------------------------------------------- // 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. */ export async function isLibreOfficeInstalled(): Promise { // --- Step 1: check well-known paths synchronously (no exec overhead) --- for (const candidate of getCandidatePaths()) { if (fs.existsSync(candidate)) { // On Windows, avoid probing with "--version" because some LibreOffice // builds open a transient console window for this command. if (process.platform === "win32") { return { installed: true, path: 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, windowsHide: (process.platform as string) === "win32", }); 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 --- if (process.platform === "win32") { try { // Use "where" for PATH detection without launching LibreOffice itself. const { stdout } = await execAsync("where soffice.exe", { timeout: 8_000, windowsHide: true, }); const firstPath = stdout .split(/\r?\n/) .map((line) => line.trim()) .find((line) => line.length > 0); if (firstPath) { return { installed: true, path: firstPath }; } } catch { // Keep behavior: if PATH lookup fails, report not installed. } return { installed: false }; } try { const { stdout } = await execAsync("soffice --version", { timeout: 8_000, windowsHide: (process.platform as string) === "win32", }); // 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 { return new Promise((resolve) => { const win = new BrowserWindow({ width: 520, height: 560, resizable: false, center: true, title: "Presenton – Install LibreOffice", icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"), webPreferences: { webSecurity: false, preload: path.join(__dirname, "../preloads/libreoffice-installer.js"), }, }); 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( onStatus?: (status: LibreOfficeStatus) => void ): Promise { onStatus?.("checking"); let result = await isLibreOfficeInstalled(); if (result.installed) { if (result.path) { resolvedSofficePath = result.path; } console.log( `[LibreOffice] Detected: ${result.version ?? "(version unknown)"} at ${resolvedSofficePath}` ); onStatus?.("installed"); return true; } console.warn("[LibreOffice] Not found – showing installer window."); onStatus?.("missing"); onStatus?.("installing"); await showLibreOfficeInstallerWindow(); // Re-detect after the window closes (install may have succeeded) result = await isLibreOfficeInstalled(); if (result.installed && result.path) { resolvedSofficePath = result.path; console.log(`[LibreOffice] Detected after install: ${resolvedSofficePath}`); onStatus?.("installed"); } else { onStatus?.("missing"); } // Always proceed – never block the app return true; }