Merge pull request #433 from presenton/feat/sofficefixes
Feat/sofficefixes
474
electron/app/ipc/libreoffice_install_handlers.ts
Normal 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 ~280–350 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, // ~350–360 MB MSI
|
||||
macX64: 400 * 1024 * 1024, // ~370–390 MB DMG
|
||||
macArm64: 400 * 1024 * 1024, // ~370–390 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
12
electron/app/preloads/libreoffice-installer.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
2
electron/app/types/index.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
386
electron/app/utils/libreoffice-check.ts
Normal 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;
|
||||
}
|
||||
16
electron/app/utils/libreoffice-urls.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
56
electron/build/after-install.tpl
Normal 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
|
||||
BIN
electron/build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
electron/build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 453 B |
BIN
electron/build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
electron/build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 815 B |
BIN
electron/build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
electron/build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
electron/build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
5
electron/build/installer.nsh
Normal 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
|
||||
669
electron/resources/ui/libreoffice-installer/index.html
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||