429 lines
No EOL
14 KiB
TypeScript
429 lines
No EOL
14 KiB
TypeScript
/**
|
||
* 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<LibreOfficeCheckResult> {
|
||
// --- 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<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(
|
||
onStatus?: (status: LibreOfficeStatus) => void
|
||
): Promise<boolean> {
|
||
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;
|
||
} |