feat: implement in-app LibreOffice installer with progress UI and update build configurations for Linux and Windows
340
electron/app/ipc/libreoffice_install_handlers.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { ipcMain, WebContents } from "electron";
|
||||
import { spawn } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as https from "https";
|
||||
import * as http from "http";
|
||||
import { IncomingMessage } from "http";
|
||||
import * as path from "path";
|
||||
import { app } from "electron";
|
||||
import { LIBREOFFICE_DOWNLOAD_URLS, LIBREOFFICE_VERSION } from "../utils/libreoffice-urls";
|
||||
import { getLinuxInstallCommand } from "../utils/libreoffice-check";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sendProgress(
|
||||
wc: WebContents,
|
||||
phase: "downloading" | "installing" | "done" | "error",
|
||||
percent?: number,
|
||||
message?: string
|
||||
) {
|
||||
if (!wc.isDestroyed()) {
|
||||
wc.send("lo:progress", { phase, percent, message });
|
||||
}
|
||||
}
|
||||
|
||||
function sendLog(
|
||||
wc: WebContents,
|
||||
level: "info" | "warn" | "error" | "ok" | "cmd",
|
||||
text: string
|
||||
) {
|
||||
if (!wc.isDestroyed()) {
|
||||
// Split multi-line output into individual log entries
|
||||
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||
for (const line of lines) {
|
||||
wc.send("lo:log", { level, text: line });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download with progress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function downloadWithProgress(
|
||||
url: string,
|
||||
dest: string,
|
||||
filename: string,
|
||||
wc: WebContents
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fmtBytes = (bytes: number) => {
|
||||
if (bytes <= 0) return "0 B";
|
||||
const mb = bytes / 1024 / 1024;
|
||||
if (mb >= 1) return `${mb.toFixed(1)} MB`;
|
||||
const kb = bytes / 1024;
|
||||
return kb >= 1 ? `${kb.toFixed(0)} KB` : `${bytes} B`;
|
||||
};
|
||||
|
||||
const fmtSpeed = (bytesPerSec: number) => {
|
||||
const mbps = bytesPerSec / 1024 / 1024;
|
||||
return mbps >= 1 ? `${mbps.toFixed(1)} MB/s` : `${(bytesPerSec / 1024).toFixed(0)} KB/s`;
|
||||
};
|
||||
|
||||
const fmtEta = (seconds: number) => {
|
||||
if (seconds <= 0 || !isFinite(seconds)) return "";
|
||||
if (seconds < 60) return `~${Math.ceil(seconds)}s left`;
|
||||
return `~${Math.ceil(seconds / 60)}m left`;
|
||||
};
|
||||
|
||||
sendLog(wc, "cmd", `GET ${url}`);
|
||||
sendLog(wc, "info", `Connecting to ${new URL(url).hostname}…`);
|
||||
|
||||
const doRequest = (requestUrl: string) => {
|
||||
const requester = requestUrl.startsWith("https") ? https.get : http.get;
|
||||
requester(requestUrl, (res: IncomingMessage) => {
|
||||
if (
|
||||
(res.statusCode === 301 || res.statusCode === 302) &&
|
||||
res.headers.location
|
||||
) {
|
||||
sendLog(wc, "info", `HTTP ${res.statusCode} → Redirecting to ${res.headers.location}`);
|
||||
doRequest(res.headers.location);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalBytes = parseInt(res.headers["content-length"] ?? "0", 10);
|
||||
sendLog(wc, "ok", `HTTP 200 OK — ${totalBytes > 0 ? fmtBytes(totalBytes) : "size unknown"}`);
|
||||
sendLog(wc, "info", `Saving to: ${dest}`);
|
||||
sendLog(wc, "info", `Starting download of ${filename}…`);
|
||||
|
||||
let downloaded = 0;
|
||||
const startTime = Date.now();
|
||||
let lastLogTime = startTime;
|
||||
// Log interval: every 2 seconds or every 5% — whichever fires first
|
||||
const LOG_INTERVAL_MS = 2000;
|
||||
let lastLoggedPct = 0;
|
||||
|
||||
const file = fs.createWriteStream(dest);
|
||||
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
downloaded += chunk.length;
|
||||
const now = Date.now();
|
||||
const elapsedMs = now - startTime;
|
||||
const percent = totalBytes > 0 ? Math.floor((downloaded / totalBytes) * 100) : 0;
|
||||
const sizeLabel = totalBytes > 0
|
||||
? `${fmtBytes(downloaded)} / ${fmtBytes(totalBytes)}`
|
||||
: fmtBytes(downloaded);
|
||||
|
||||
// Update the progress bar UI on every chunk
|
||||
sendProgress(wc, "downloading", percent, `${filename}|${sizeLabel}`);
|
||||
|
||||
// Log every 2 s OR every 5% progress
|
||||
const pctBucket = Math.floor(percent / 5) * 5;
|
||||
const timeSinceLastLog = now - lastLogTime;
|
||||
if (
|
||||
(timeSinceLastLog >= LOG_INTERVAL_MS || pctBucket > lastLoggedPct)
|
||||
&& elapsedMs > 0
|
||||
) {
|
||||
lastLogTime = now;
|
||||
lastLoggedPct = pctBucket;
|
||||
const speed = downloaded / (elapsedMs / 1000);
|
||||
const remaining = totalBytes > 0 ? (totalBytes - downloaded) / speed : 0;
|
||||
const etaStr = totalBytes > 0 ? ` ${fmtEta(remaining)}` : "";
|
||||
const pctStr = totalBytes > 0 ? `${percent}% ` : "";
|
||||
sendLog(
|
||||
wc,
|
||||
"info",
|
||||
`${pctStr}${fmtBytes(downloaded)} downloaded @ ${fmtSpeed(speed)}${etaStr}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
res.pipe(file);
|
||||
file.on("finish", () =>
|
||||
file.close(() => {
|
||||
const elapsedSec = (Date.now() - startTime) / 1000;
|
||||
const avgSpeed = downloaded / elapsedSec;
|
||||
sendLog(wc, "ok", `Download complete — ${fmtBytes(downloaded)} in ${elapsedSec.toFixed(1)}s (avg ${fmtSpeed(avgSpeed)})`);
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
file.on("error", (err) => {
|
||||
fs.unlink(dest, () => {});
|
||||
reject(err);
|
||||
});
|
||||
}).on("error", (err) => {
|
||||
fs.unlink(dest, () => {});
|
||||
reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
doRequest(url);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform installers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function installWindows(wc: WebContents): Promise<void> {
|
||||
const url = LIBREOFFICE_DOWNLOAD_URLS.win64;
|
||||
const filename = `LibreOffice_${LIBREOFFICE_VERSION}_Win_x86-64.msi`;
|
||||
const dest = path.join(app.getPath("temp"), filename);
|
||||
|
||||
sendProgress(wc, "downloading", 0, `${filename}|`);
|
||||
await downloadWithProgress(url, dest, filename, wc);
|
||||
|
||||
sendProgress(wc, "installing");
|
||||
sendLog(wc, "cmd", `Running: msiexec /i "${filename}" /qn /norestart`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("msiexec", ["/i", dest, "/qn", "/norestart"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout?.on("data", (d: Buffer) => sendLog(wc, "info", d.toString()));
|
||||
child.stderr?.on("data", (d: Buffer) => sendLog(wc, "warn", d.toString()));
|
||||
child.on("close", (code) => {
|
||||
fs.unlink(dest, () => {});
|
||||
if (code === 0 || code === 3010) {
|
||||
sendLog(wc, "ok", `msiexec exited with code ${code} (success)`);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`msiexec exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
fs.unlink(dest, () => {});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function installMac(wc: WebContents): Promise<void> {
|
||||
const brewPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
|
||||
const brew = brewPaths.find((p) => fs.existsSync(p));
|
||||
|
||||
if (brew) {
|
||||
sendProgress(wc, "installing");
|
||||
sendLog(wc, "cmd", `Running: ${brew} install --cask libreoffice`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(brew, ["install", "--cask", "libreoffice"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout?.on("data", (d: Buffer) => sendLog(wc, "info", d.toString()));
|
||||
child.stderr?.on("data", (d: Buffer) => {
|
||||
const text = d.toString();
|
||||
// brew writes normal output to stderr too
|
||||
sendLog(wc, text.toLowerCase().includes("error") ? "error" : "info", text);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
sendLog(wc, "ok", "Homebrew install succeeded");
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`brew exit ${code}`));
|
||||
}
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: download DMG
|
||||
const isArm64 = process.arch === "arm64";
|
||||
const url = isArm64
|
||||
? LIBREOFFICE_DOWNLOAD_URLS.macArm64
|
||||
: LIBREOFFICE_DOWNLOAD_URLS.macX64;
|
||||
const filename = `LibreOffice_${LIBREOFFICE_VERSION}_MacOS_${isArm64 ? "aarch64" : "x86-64"}.dmg`;
|
||||
const dmgPath = path.join(app.getPath("temp"), filename);
|
||||
const mountPoint = path.join(app.getPath("temp"), "LibreOfficeMount");
|
||||
|
||||
sendProgress(wc, "downloading", 0, `${filename}|`);
|
||||
await downloadWithProgress(url, dmgPath, filename, wc);
|
||||
|
||||
sendProgress(wc, "installing");
|
||||
fs.mkdirSync(mountPoint, { recursive: true });
|
||||
|
||||
sendLog(wc, "cmd", `Mounting DMG at ${mountPoint}`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"hdiutil",
|
||||
["attach", dmgPath, "-nobrowse", "-quiet", "-mountpoint", mountPoint],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
child.stdout?.on("data", (d: Buffer) => sendLog(wc, "info", d.toString()));
|
||||
child.stderr?.on("data", (d: Buffer) => sendLog(wc, "warn", d.toString()));
|
||||
child.on("close", (code) =>
|
||||
code === 0 ? resolve() : reject(new Error("hdiutil attach failed"))
|
||||
);
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(mountPoint);
|
||||
const bundle = entries.find((e) => /^LibreOffice[\s\d.]*\.app$/i.test(e));
|
||||
if (!bundle) throw new Error("LibreOffice.app not found in DMG");
|
||||
|
||||
const src = path.join(mountPoint, bundle);
|
||||
const applicationsDir = path.join(process.env.HOME ?? "", "Applications");
|
||||
const dest = path.join(applicationsDir, bundle);
|
||||
fs.mkdirSync(applicationsDir, { recursive: true });
|
||||
sendLog(wc, "cmd", `Copying ${bundle} to ~/Applications…`);
|
||||
fs.cpSync(src, dest, { recursive: true });
|
||||
sendLog(wc, "ok", `Installed to ~/Applications/${bundle}`);
|
||||
} finally {
|
||||
sendLog(wc, "info", "Unmounting DMG…");
|
||||
spawn("hdiutil", ["detach", mountPoint, "-quiet"], { stdio: "ignore" });
|
||||
fs.unlink(dmgPath, () => {});
|
||||
try { fs.rmdirSync(mountPoint); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function installLinux(wc: WebContents): Promise<void> {
|
||||
const installCmd = getLinuxInstallCommand();
|
||||
if (!installCmd) {
|
||||
throw new Error(
|
||||
"Unsupported Linux distribution. Please install LibreOffice manually:\n sudo apt install libreoffice"
|
||||
);
|
||||
}
|
||||
|
||||
sendProgress(wc, "installing");
|
||||
const fullCmd = `pkexec ${installCmd.cmd} ${installCmd.args.join(" ")}`;
|
||||
sendLog(wc, "cmd", `Running: ${fullCmd}`);
|
||||
sendLog(wc, "info", "A system dialog will prompt for your password…");
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("pkexec", [installCmd.cmd, ...installCmd.args], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout?.on("data", (d: Buffer) => sendLog(wc, "info", d.toString()));
|
||||
child.stderr?.on("data", (d: Buffer) => {
|
||||
const text = d.toString();
|
||||
sendLog(wc, text.toLowerCase().includes("error") ? "error" : "info", text);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
sendLog(wc, "ok", `${installCmd.cmd} exited successfully`);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${installCmd.cmd} exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setupLibreOfficeInstallHandlers() {
|
||||
ipcMain.handle("lo:start-install", async (event) => {
|
||||
const wc = event.sender;
|
||||
try {
|
||||
const platform = process.platform;
|
||||
sendLog(wc, "info", `Platform: ${platform} (${process.arch})`);
|
||||
if (platform === "win32") {
|
||||
await installWindows(wc);
|
||||
} else if (platform === "darwin") {
|
||||
await installMac(wc);
|
||||
} else {
|
||||
await installLinux(wc);
|
||||
}
|
||||
sendProgress(wc, "done");
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "An unexpected error occurred. You can install LibreOffice manually later.";
|
||||
sendLog(wc, "error", message);
|
||||
sendProgress(wc, "error", undefined, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,160 +1,163 @@
|
|||
require("dotenv").config();
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "path";
|
||||
import { findUnusedPorts, killProcess, setupEnv, setUserConfig } from "./utils";
|
||||
import { startFastApiServer, startNextJsServer } from "./utils/servers";
|
||||
import { ChildProcessByStdio } from "child_process";
|
||||
import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localhost, nextjsDir, tempDir, userConfigPath, userDataDir } from "./utils/constants";
|
||||
import { setupIpcHandlers } from "./ipc";
|
||||
import { checkLibreOfficeBeforeWindow, getSofficePath } from "./utils/libreoffice-check";
|
||||
|
||||
|
||||
var win: BrowserWindow | undefined;
|
||||
var fastApiProcess: ChildProcessByStdio<any, any, any> | undefined;
|
||||
var nextjsProcess: any;
|
||||
|
||||
app.commandLine.appendSwitch('gtk-version', '3');
|
||||
|
||||
const createWindow = () => {
|
||||
win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"),
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
preload: path.join(__dirname, 'preloads/index.js'),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
async function startServers(fastApiPort: number, nextjsPort: number) {
|
||||
try {
|
||||
fastApiProcess = await startFastApiServer(
|
||||
fastapiDir,
|
||||
fastApiPort,
|
||||
{
|
||||
DEBUG: isDev ? "True" : "False",
|
||||
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
|
||||
LLM: process.env.LLM,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
||||
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
OLLAMA_URL: process.env.OLLAMA_URL,
|
||||
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
|
||||
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
|
||||
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
|
||||
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
|
||||
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
|
||||
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
|
||||
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
|
||||
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
|
||||
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
|
||||
TOOL_CALLS: process.env.TOOL_CALLS,
|
||||
DISABLE_THINKING: process.env.DISABLE_THINKING,
|
||||
WEB_GROUNDING: process.env.WEB_GROUNDING,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
|
||||
COMFYUI_URL: process.env.COMFYUI_URL,
|
||||
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
|
||||
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
|
||||
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
|
||||
APP_DATA_DIRECTORY: appDataDir,
|
||||
TEMP_DIRECTORY: tempDir,
|
||||
USER_CONFIG_PATH: userConfigPath,
|
||||
// Resolved by libreoffice-check.ts at startup; lets Python invoke the
|
||||
// exact binary path instead of relying on the system PATH.
|
||||
SOFFICE_PATH: getSofficePath(),
|
||||
},
|
||||
isDev,
|
||||
);
|
||||
nextjsProcess = await startNextJsServer(
|
||||
nextjsDir,
|
||||
nextjsPort,
|
||||
{
|
||||
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API,
|
||||
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY,
|
||||
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
|
||||
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
|
||||
USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
|
||||
APP_DATA_DIRECTORY: appDataDir,
|
||||
},
|
||||
isDev,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Server startup error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServers() {
|
||||
if (fastApiProcess?.pid) {
|
||||
await killProcess(fastApiProcess.pid);
|
||||
}
|
||||
if (nextjsProcess) {
|
||||
if (isDev) {
|
||||
await killProcess(nextjsProcess.pid);
|
||||
} else {
|
||||
nextjsProcess.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Ensure all required directories exist before starting
|
||||
ensureDirectoriesExist();
|
||||
|
||||
// Guard: verify LibreOffice is available before showing the main window.
|
||||
// If it is missing, the user is prompted to download it or exit.
|
||||
const shouldContinue = await checkLibreOfficeBeforeWindow();
|
||||
if (!shouldContinue) return;
|
||||
|
||||
createWindow();
|
||||
win?.loadFile(path.join(baseDir, "resources/ui/homepage/index.html"));
|
||||
|
||||
setUserConfig({
|
||||
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
|
||||
LLM: process.env.LLM,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
||||
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
OLLAMA_URL: process.env.OLLAMA_URL,
|
||||
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
|
||||
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
|
||||
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
|
||||
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
|
||||
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
|
||||
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
|
||||
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
|
||||
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
|
||||
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
|
||||
TOOL_CALLS: process.env.TOOL_CALLS,
|
||||
DISABLE_THINKING: process.env.DISABLE_THINKING,
|
||||
WEB_GROUNDING: process.env.WEB_GROUNDING,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
|
||||
COMFYUI_URL: process.env.COMFYUI_URL,
|
||||
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
|
||||
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
|
||||
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
|
||||
})
|
||||
|
||||
const [fastApiPort, nextjsPort] = await findUnusedPorts();
|
||||
console.log(`FastAPI port: ${fastApiPort}, NextJS port: ${nextjsPort}`);
|
||||
|
||||
//? Setup environment variables to be used in the preloads
|
||||
setupEnv(fastApiPort, nextjsPort);
|
||||
setupIpcHandlers();
|
||||
|
||||
await startServers(fastApiPort, nextjsPort);
|
||||
win?.loadURL(`${localhost}:${nextjsPort}`);
|
||||
});
|
||||
|
||||
app.on("window-all-closed", async () => {
|
||||
await stopServers();
|
||||
app.quit();
|
||||
});
|
||||
require("dotenv").config();
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "path";
|
||||
import { findUnusedPorts, killProcess, setupEnv, setUserConfig } from "./utils";
|
||||
import { startFastApiServer, startNextJsServer } from "./utils/servers";
|
||||
import { ChildProcessByStdio } from "child_process";
|
||||
import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localhost, nextjsDir, tempDir, userConfigPath, userDataDir } from "./utils/constants";
|
||||
import { setupIpcHandlers } from "./ipc";
|
||||
import { setupLibreOfficeInstallHandlers } from "./ipc/libreoffice_install_handlers";
|
||||
import { checkLibreOfficeBeforeWindow, getSofficePath } from "./utils/libreoffice-check";
|
||||
|
||||
|
||||
var win: BrowserWindow | undefined;
|
||||
var fastApiProcess: ChildProcessByStdio<any, any, any> | undefined;
|
||||
var nextjsProcess: any;
|
||||
|
||||
app.commandLine.appendSwitch('gtk-version', '3');
|
||||
|
||||
const createWindow = () => {
|
||||
win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"),
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
preload: path.join(__dirname, 'preloads/index.js'),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
async function startServers(fastApiPort: number, nextjsPort: number) {
|
||||
try {
|
||||
fastApiProcess = await startFastApiServer(
|
||||
fastapiDir,
|
||||
fastApiPort,
|
||||
{
|
||||
DEBUG: isDev ? "True" : "False",
|
||||
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
|
||||
LLM: process.env.LLM,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
||||
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
OLLAMA_URL: process.env.OLLAMA_URL,
|
||||
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
|
||||
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
|
||||
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
|
||||
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
|
||||
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
|
||||
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
|
||||
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
|
||||
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
|
||||
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
|
||||
TOOL_CALLS: process.env.TOOL_CALLS,
|
||||
DISABLE_THINKING: process.env.DISABLE_THINKING,
|
||||
WEB_GROUNDING: process.env.WEB_GROUNDING,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
|
||||
COMFYUI_URL: process.env.COMFYUI_URL,
|
||||
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
|
||||
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
|
||||
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
|
||||
APP_DATA_DIRECTORY: appDataDir,
|
||||
TEMP_DIRECTORY: tempDir,
|
||||
USER_CONFIG_PATH: userConfigPath,
|
||||
// Resolved by libreoffice-check.ts at startup; lets Python invoke the
|
||||
// exact binary path instead of relying on the system PATH.
|
||||
SOFFICE_PATH: getSofficePath(),
|
||||
},
|
||||
isDev,
|
||||
);
|
||||
nextjsProcess = await startNextJsServer(
|
||||
nextjsDir,
|
||||
nextjsPort,
|
||||
{
|
||||
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API,
|
||||
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY,
|
||||
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
|
||||
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
|
||||
USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
|
||||
APP_DATA_DIRECTORY: appDataDir,
|
||||
},
|
||||
isDev,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Server startup error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServers() {
|
||||
if (fastApiProcess?.pid) {
|
||||
await killProcess(fastApiProcess.pid);
|
||||
}
|
||||
if (nextjsProcess) {
|
||||
if (isDev) {
|
||||
await killProcess(nextjsProcess.pid);
|
||||
} else {
|
||||
nextjsProcess.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Ensure all required directories exist before starting
|
||||
ensureDirectoriesExist();
|
||||
|
||||
// Register LibreOffice install handlers early so the installer window can use them
|
||||
setupLibreOfficeInstallHandlers();
|
||||
|
||||
// Check for LibreOffice (required for custom template from PPTX). Shows installer
|
||||
// window if missing. Never blocks; always proceeds.
|
||||
await checkLibreOfficeBeforeWindow();
|
||||
|
||||
createWindow();
|
||||
win?.loadFile(path.join(baseDir, "resources/ui/homepage/index.html"));
|
||||
|
||||
setUserConfig({
|
||||
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
|
||||
LLM: process.env.LLM,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
||||
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
OLLAMA_URL: process.env.OLLAMA_URL,
|
||||
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
|
||||
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
|
||||
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
|
||||
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
|
||||
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
|
||||
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
|
||||
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
|
||||
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
|
||||
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
|
||||
TOOL_CALLS: process.env.TOOL_CALLS,
|
||||
DISABLE_THINKING: process.env.DISABLE_THINKING,
|
||||
WEB_GROUNDING: process.env.WEB_GROUNDING,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
|
||||
COMFYUI_URL: process.env.COMFYUI_URL,
|
||||
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
|
||||
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
|
||||
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
|
||||
})
|
||||
|
||||
const [fastApiPort, nextjsPort] = await findUnusedPorts();
|
||||
console.log(`FastAPI port: ${fastApiPort}, NextJS port: ${nextjsPort}`);
|
||||
|
||||
//? Setup environment variables to be used in the preloads
|
||||
setupEnv(fastApiPort, nextjsPort);
|
||||
setupIpcHandlers();
|
||||
|
||||
await startServers(fastApiPort, nextjsPort);
|
||||
win?.loadURL(`${localhost}:${nextjsPort}`);
|
||||
});
|
||||
|
||||
app.on("window-all-closed", async () => {
|
||||
await stopServers();
|
||||
app.quit();
|
||||
});
|
||||
|
|
|
|||
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,14 +2,19 @@
|
|||
* libreoffice-check.ts
|
||||
*
|
||||
* Checks whether LibreOffice is available on the host machine before the
|
||||
* main BrowserWindow is created. If it is not found, an Electron dialog is
|
||||
* shown that lets the user download LibreOffice, skip the check, or quit.
|
||||
* main BrowserWindow is created. LibreOffice is required for creating custom
|
||||
* templates from uploaded PPTX files.
|
||||
*
|
||||
* If not found, shows a branded installer window that lets the user download
|
||||
* and install LibreOffice with a real-time progress UI.
|
||||
*/
|
||||
|
||||
import { app, dialog, shell } from "electron";
|
||||
import { BrowserWindow, ipcMain, app } from "electron";
|
||||
import { exec } from "child_process";
|
||||
import * as util from "util";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { baseDir } from "./constants";
|
||||
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
|
|
@ -186,33 +191,45 @@ function getCandidatePaths(): string[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable, OS-specific install instruction string.
|
||||
* Detects the Linux distro from /etc/os-release and returns the install
|
||||
* command for LibreOffice, or null if the distro is not supported.
|
||||
*
|
||||
* Exported so that libreoffice_install_handlers.ts can reuse it.
|
||||
*/
|
||||
function getInstallInstructions(): string {
|
||||
const platform = process.platform;
|
||||
export function getLinuxInstallCommand(): { cmd: string; args: string[] } | null {
|
||||
const osReleasePaths = ["/etc/os-release", "/usr/lib/os-release"];
|
||||
let id = "";
|
||||
let idLike = "";
|
||||
|
||||
if (platform === "win32") {
|
||||
return (
|
||||
"Download the Windows installer from https://www.libreoffice.org/download/ " +
|
||||
"and run it. Both the 64-bit and 32-bit editions are supported."
|
||||
);
|
||||
for (const p of osReleasePaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(p, "utf-8");
|
||||
for (const line of content.split("\n")) {
|
||||
const m = line.match(/^ID=(.+)$/);
|
||||
if (m) id = m[1].replace(/^["']|["']$/g, "").trim().toLowerCase();
|
||||
const m2 = line.match(/^ID_LIKE=(.+)$/);
|
||||
if (m2) idLike = m2[1].replace(/^["']|["']$/g, "").trim().toLowerCase();
|
||||
}
|
||||
if (id) break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
return (
|
||||
"Download the macOS disk image from https://www.libreoffice.org/download/ " +
|
||||
"and drag LibreOffice into your Applications folder."
|
||||
);
|
||||
const ids = `${id} ${idLike}`;
|
||||
if (ids.includes("ubuntu") || ids.includes("debian") || ids.includes("pop") || ids.includes("linuxmint")) {
|
||||
return { cmd: "apt", args: ["install", "-y", "libreoffice"] };
|
||||
}
|
||||
|
||||
// Linux
|
||||
return (
|
||||
"Install LibreOffice with your package manager, for example:\n\n" +
|
||||
" Ubuntu / Debian: sudo apt install libreoffice\n" +
|
||||
" Fedora: sudo dnf install libreoffice\n" +
|
||||
" Arch: sudo pacman -S libreoffice-still\n\n" +
|
||||
"Or download it from https://www.libreoffice.org/download/"
|
||||
);
|
||||
if (ids.includes("fedora") || ids.includes("rhel") || ids.includes("centos") || ids.includes("rocky")) {
|
||||
return { cmd: "dnf", args: ["install", "-y", "libreoffice"] };
|
||||
}
|
||||
if (ids.includes("arch")) {
|
||||
return { cmd: "pacman", args: ["-S", "--noconfirm", "libreoffice-still"] };
|
||||
}
|
||||
if (ids.includes("opensuse") || ids.includes("suse")) {
|
||||
return { cmd: "zypper", args: ["install", "-y", "libreoffice"] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -281,68 +298,52 @@ async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult> {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialog
|
||||
// Installer window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shows a modal dialog informing the user that LibreOffice is required.
|
||||
* Opens a branded 520×400 installer window that lets the user download and
|
||||
* install LibreOffice with a live progress UI.
|
||||
*
|
||||
* Button indices:
|
||||
* 0 – "Download LibreOffice" → opens download page, shows a re-launch notice,
|
||||
* then quits the application
|
||||
* 1 – "Install Later" → continues launching without LibreOffice
|
||||
* 2 – "Exit" → quits the application immediately
|
||||
*
|
||||
* @returns `true` if the application should proceed to create its window,
|
||||
* `false` if `app.quit()` has been called.
|
||||
* Returns a Promise that resolves once the window is closed (either by the
|
||||
* user skipping or after a successful install).
|
||||
*/
|
||||
async function showLibreOfficeMissingDialog(): Promise<boolean> {
|
||||
const instructions = getInstallInstructions();
|
||||
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: "warning",
|
||||
title: "LibreOffice Required",
|
||||
message: "LibreOffice is not installed",
|
||||
detail:
|
||||
"Presenton uses LibreOffice to export presentations to PPTX and PDF " +
|
||||
"formats. Without it, export functionality will not work.\n\n" +
|
||||
`How to install LibreOffice on your system:\n\n${instructions}`,
|
||||
buttons: ["Download LibreOffice", "Install Later", "Exit"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
});
|
||||
|
||||
if (response === 0) {
|
||||
// Open the LibreOffice download page in the default browser.
|
||||
await shell.openExternal("https://www.libreoffice.org/download/");
|
||||
|
||||
// Let the user know they need to restart Presenton after installation,
|
||||
// then close the app so they start fresh with LibreOffice on the PATH.
|
||||
await dialog.showMessageBox({
|
||||
type: "info",
|
||||
title: "Restart Required",
|
||||
message: "Please re-launch Presenton after installation",
|
||||
detail:
|
||||
"The LibreOffice download page has been opened in your browser.\n\n" +
|
||||
"Once LibreOffice is installed, re-run Presenton and it will be " +
|
||||
"detected automatically.",
|
||||
buttons: ["OK"],
|
||||
defaultId: 0,
|
||||
async function showLibreOfficeInstallerWindow(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const win = new BrowserWindow({
|
||||
width: 520,
|
||||
height: 560,
|
||||
resizable: false,
|
||||
center: true,
|
||||
title: "Presenton – Install LibreOffice",
|
||||
icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"),
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
preload: path.join(__dirname, "../preloads/libreoffice-installer.js"),
|
||||
},
|
||||
});
|
||||
|
||||
app.quit();
|
||||
return false;
|
||||
}
|
||||
win.setMenuBarVisibility(false);
|
||||
|
||||
if (response === 2) {
|
||||
// User chose to exit immediately.
|
||||
app.quit();
|
||||
return false;
|
||||
}
|
||||
const htmlPath = path.join(
|
||||
baseDir,
|
||||
"resources/ui/libreoffice-installer/index.html"
|
||||
);
|
||||
win.loadFile(htmlPath);
|
||||
|
||||
// response === 1 → "Install Later" – continue launching without LibreOffice.
|
||||
return true;
|
||||
// lo:skip is sent by the renderer when the user clicks "Skip" or after
|
||||
// a successful install (the success state auto-sends skip after 2 s).
|
||||
const onSkip = () => {
|
||||
if (!win.isDestroyed()) win.close();
|
||||
};
|
||||
ipcMain.once("lo:skip", onSkip);
|
||||
|
||||
win.on("closed", () => {
|
||||
// Remove the listener in case the window was closed by the OS (title-bar X)
|
||||
ipcMain.removeListener("lo:skip", onSkip);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -350,20 +351,17 @@ async function showLibreOfficeMissingDialog(): Promise<boolean> {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Checks for LibreOffice and, when it is absent, presents the user with the
|
||||
* "LibreOffice Required" dialog.
|
||||
* Checks for LibreOffice. When absent, shows a branded installer window that
|
||||
* lets the user install it. Never blocks app startup — always returns `true`.
|
||||
*
|
||||
* Call this function **before** creating the main `BrowserWindow`.
|
||||
*
|
||||
* @returns `true` if the application should proceed to create its window,
|
||||
* `false` if the user chose to exit and `app.quit()` has been called.
|
||||
* @returns Always `true` – the application should always proceed.
|
||||
*/
|
||||
export async function checkLibreOfficeBeforeWindow(): Promise<boolean> {
|
||||
const result = await isLibreOfficeInstalled();
|
||||
let result = await isLibreOfficeInstalled();
|
||||
|
||||
if (result.installed) {
|
||||
// Persist the resolved path so getSofficePath() returns it for the
|
||||
// lifetime of this Electron process.
|
||||
if (result.path) {
|
||||
resolvedSofficePath = result.path;
|
||||
}
|
||||
|
|
@ -373,8 +371,16 @@ export async function checkLibreOfficeBeforeWindow(): Promise<boolean> {
|
|||
return true;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"[LibreOffice] Not found on this system – showing installation dialog."
|
||||
);
|
||||
return showLibreOfficeMissingDialog();
|
||||
console.warn("[LibreOffice] Not found – showing installer window.");
|
||||
await showLibreOfficeInstallerWindow();
|
||||
|
||||
// Re-detect after the window closes (install may have succeeded)
|
||||
result = await isLibreOfficeInstalled();
|
||||
if (result.installed && result.path) {
|
||||
resolvedSofficePath = result.path;
|
||||
console.log(`[LibreOffice] Detected after install: ${resolvedSofficePath}`);
|
||||
}
|
||||
|
||||
// Always proceed – never block the app
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
12
electron/app/utils/libreoffice-urls.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* LibreOffice download URLs for automated installation.
|
||||
* Update the version when upgrading to a newer LibreOffice release.
|
||||
* See https://www.libreoffice.org/download/download-libreoffice/
|
||||
*/
|
||||
export const LIBREOFFICE_VERSION = "24.8.7";
|
||||
|
||||
export const LIBREOFFICE_DOWNLOAD_URLS = {
|
||||
win64: `https://www.libreoffice.org/donate/dl/win-x86_64/${LIBREOFFICE_VERSION}/en-US/LibreOffice_${LIBREOFFICE_VERSION}_Win_x86-64.msi`,
|
||||
macX64: `https://www.libreoffice.org/donate/dl/mac-x86_64/${LIBREOFFICE_VERSION}/en-US/LibreOffice_${LIBREOFFICE_VERSION}_MacOS_x86-64.dmg`,
|
||||
macArm64: `https://www.libreoffice.org/donate/dl/mac-aarch64/${LIBREOFFICE_VERSION}/en-US/LibreOffice_${LIBREOFFICE_VERSION}_MacOS_aarch64.dmg`,
|
||||
} as const;
|
||||
|
|
@ -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
|
||||
639
electron/resources/ui/libreoffice-installer/index.html
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Presenton – Install LibreOffice</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0d0d14;
|
||||
--surface: #16162a;
|
||||
--surface2: #1e1e38;
|
||||
--border: rgba(145, 52, 234, 0.25);
|
||||
--grad-a: #9034EA;
|
||||
--grad-b: #5146E5;
|
||||
--text: #f0eeff;
|
||||
--text-muted: #9b96c4;
|
||||
--text-dim: #5e5a88;
|
||||
--track: #1e1e38;
|
||||
--log-bg: #0a0a12;
|
||||
--log-border: rgba(145,52,234,0.15);
|
||||
--radius: 14px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Outer layout ── */
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 24px 32px 0;
|
||||
}
|
||||
|
||||
/* ── Logo ── */
|
||||
.logo-wrap {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-wrap img {
|
||||
height: 36px;
|
||||
display: inline-block;
|
||||
}
|
||||
.logo-fallback {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.card {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px 28px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius);
|
||||
background: radial-gradient(ellipse 60% 40% at 50% -10%, rgba(145,52,234,0.18) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── State panels ── */
|
||||
.state { display: none; flex-direction: column; align-items: center; gap: 14px; width: 100%; }
|
||||
.state.active { display: flex; }
|
||||
|
||||
/* ── Icons ── */
|
||||
.icon-wrap {
|
||||
width: 52px; height: 52px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
.icon-wrap.purple {
|
||||
background: linear-gradient(135deg, rgba(145,52,234,0.2), rgba(81,70,229,0.2));
|
||||
border: 1px solid rgba(145,52,234,0.35);
|
||||
}
|
||||
.icon-wrap.green {
|
||||
background: rgba(34,197,94,0.12);
|
||||
border: 1px solid rgba(34,197,94,0.3);
|
||||
}
|
||||
.icon-wrap.red {
|
||||
background: rgba(239,68,68,0.12);
|
||||
border: 1px solid rgba(239,68,68,0.3);
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
.heading {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
.sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
max-width: 340px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.sub strong { color: var(--text); font-weight: 500; }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 20px;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
outline: none;
|
||||
}
|
||||
button:active { transform: scale(0.97); }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
|
||||
color: #fff;
|
||||
min-width: 150px;
|
||||
box-shadow: 0 4px 20px rgba(145,52,234,0.35);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(155,150,196,0.25);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: rgba(155,150,196,0.5); }
|
||||
|
||||
/* ── Progress bar ── */
|
||||
.progress-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--track);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-fill.indeterminate {
|
||||
width: 40% !important;
|
||||
animation: shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(310%); }
|
||||
}
|
||||
|
||||
/* ── Spinner ── */
|
||||
.spinner {
|
||||
width: 28px; height: 28px;
|
||||
border: 3px solid rgba(145,52,234,0.2);
|
||||
border-top-color: var(--grad-a);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.phase-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Dots decoration ── */
|
||||
.dots {
|
||||
position: absolute;
|
||||
bottom: -20px; right: -20px;
|
||||
width: 100px; height: 100px;
|
||||
background-image: radial-gradient(circle, rgba(145,52,234,0.18) 1px, transparent 1px);
|
||||
background-size: 14px 14px;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Log section ── */
|
||||
.log-section {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-top: 10px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.log-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.log-toggle:hover { color: var(--text-muted); }
|
||||
.log-toggle:active { transform: none; }
|
||||
|
||||
.log-toggle-chevron {
|
||||
display: inline-block;
|
||||
width: 14px; height: 14px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-toggle-chevron::before,
|
||||
.log-toggle-chevron::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 6px; height: 1.5px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.log-toggle-chevron::before { transform: translate(-50%, -50%) rotate(-40deg) translateX(-2px); }
|
||||
.log-toggle-chevron::after { transform: translate(-50%, -50%) rotate( 40deg) translateX( 2px); }
|
||||
.log-toggle.open .log-toggle-chevron::before { transform: translate(-50%, -50%) rotate( 40deg) translateX(-2px); }
|
||||
.log-toggle.open .log-toggle-chevron::after { transform: translate(-50%, -50%) rotate(-40deg) translateX( 2px); }
|
||||
|
||||
.log-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 6px;
|
||||
background: var(--log-bg);
|
||||
border: 1px solid var(--log-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.log-panel.open { display: flex; }
|
||||
|
||||
.log-inner {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 10px 12px;
|
||||
font-family: "SF Mono", "Cascadia Code", "Fira Code", "Consolas", "Monaco", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-muted);
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(145,52,234,0.3) transparent;
|
||||
}
|
||||
.log-inner::-webkit-scrollbar { width: 4px; }
|
||||
.log-inner::-webkit-scrollbar-thumb { background: rgba(145,52,234,0.3); border-radius: 2px; }
|
||||
.log-inner::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
.log-time {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.log-text { white-space: pre-wrap; word-break: break-all; flex: 1; }
|
||||
.log-line.info .log-text { color: var(--text-muted); }
|
||||
.log-line.warn .log-text { color: #c9a54a; }
|
||||
.log-line.error .log-text { color: #e05a5a; }
|
||||
.log-line.ok .log-text { color: #5ab870; }
|
||||
.log-line.cmd .log-text { color: #7c8fd8; }
|
||||
|
||||
.log-empty {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* ── Clear log button in panel header ── */
|
||||
.log-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px 5px 12px;
|
||||
border-bottom: 1px solid var(--log-border);
|
||||
}
|
||||
.log-panel-title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.log-clear-btn {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(155,150,196,0.15);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.log-clear-btn:hover { color: var(--text-muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="logo-wrap">
|
||||
<img src="../assets/images/presenton_logo.png" alt="Presenton"
|
||||
onerror="this.style.display='none'; document.getElementById('logo-fb').style.display='block';" />
|
||||
<span id="logo-fb" class="logo-fallback" style="display:none;">Presenton</span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="card">
|
||||
<div class="dots"></div>
|
||||
|
||||
<!-- ── STATE: prompt ── -->
|
||||
<div id="state-prompt" class="state active">
|
||||
<div class="icon-wrap purple">📦</div>
|
||||
<p class="heading">LibreOffice Required</p>
|
||||
<p class="sub">
|
||||
<strong>Presenton</strong> uses LibreOffice to generate custom presentation
|
||||
templates from uploaded PPTX files. Without it, this feature won't work.
|
||||
</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-primary" onclick="handleInstall()">Install LibreOffice</button>
|
||||
<button class="btn-ghost" onclick="handleSkip()">Skip for now</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── STATE: downloading ── -->
|
||||
<div id="state-downloading" class="state">
|
||||
<div class="spinner"></div>
|
||||
<p class="heading">Downloading LibreOffice</p>
|
||||
<p class="sub" id="dl-filename">Preparing download…</p>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-meta">
|
||||
<span id="dl-label">0%</span>
|
||||
<span id="dl-size"></span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="dl-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="phase-label">This may take a few minutes (~300 MB)</p>
|
||||
</div>
|
||||
|
||||
<!-- ── STATE: installing ── -->
|
||||
<div id="state-installing" class="state">
|
||||
<div class="spinner"></div>
|
||||
<p class="heading">Installing LibreOffice</p>
|
||||
<p class="sub">Running the installer — this won't take long…</p>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill indeterminate" id="install-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── STATE: success ── -->
|
||||
<div id="state-success" class="state">
|
||||
<div class="icon-wrap green">✓</div>
|
||||
<p class="heading">LibreOffice Installed</p>
|
||||
<p class="sub">
|
||||
Custom template generation from PPTX is now available.
|
||||
Presenton will continue in a moment…
|
||||
</p>
|
||||
<div class="progress-wrap" style="margin-top:2px;">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="success-bar" style="width:0%;transition:width 2s linear;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── STATE: error ── -->
|
||||
<div id="state-error" class="state">
|
||||
<div class="icon-wrap red">⚠</div>
|
||||
<p class="heading">Installation Failed</p>
|
||||
<p class="sub" id="err-msg">Something went wrong. You can try again or skip and install LibreOffice manually later.</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-primary" onclick="handleInstall()">Try Again</button>
|
||||
<button class="btn-ghost" onclick="handleSkip()">Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /card -->
|
||||
|
||||
<!-- ── Log section ── -->
|
||||
<div class="log-section" id="log-section" style="display:none;">
|
||||
<button class="log-toggle" id="log-toggle" onclick="toggleLog()">
|
||||
<span class="log-toggle-chevron"></span>
|
||||
<span id="log-toggle-label">Show details</span>
|
||||
<span class="log-count" id="log-count"></span>
|
||||
</button>
|
||||
<div class="log-panel" id="log-panel">
|
||||
<div class="log-panel-header">
|
||||
<span class="log-panel-title">Installation Log</span>
|
||||
<button class="log-clear-btn" onclick="clearLog()">Clear</button>
|
||||
</div>
|
||||
<div class="log-inner" id="log-inner">
|
||||
<div class="log-empty" id="log-empty">No output yet…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /shell -->
|
||||
|
||||
<script>
|
||||
// ── State machine ───────────────────────────────────────────
|
||||
const STATES = ['prompt','downloading','installing','success','error'];
|
||||
let logLines = 0;
|
||||
|
||||
function showState(name) {
|
||||
STATES.forEach(s => {
|
||||
const el = document.getElementById('state-' + s);
|
||||
if (el) el.classList.toggle('active', s === name);
|
||||
});
|
||||
// show log section for active phases
|
||||
const logSection = document.getElementById('log-section');
|
||||
if (logSection) {
|
||||
logSection.style.display =
|
||||
(name === 'downloading' || name === 'installing' || name === 'error') ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Log panel ───────────────────────────────────────────────
|
||||
let logOpen = false;
|
||||
|
||||
function toggleLog() {
|
||||
logOpen = !logOpen;
|
||||
const toggle = document.getElementById('log-toggle');
|
||||
const panel = document.getElementById('log-panel');
|
||||
const label = document.getElementById('log-toggle-label');
|
||||
if (toggle) toggle.classList.toggle('open', logOpen);
|
||||
if (panel) panel.classList.toggle('open', logOpen);
|
||||
if (label) label.textContent = logOpen ? 'Hide details' : 'Show details';
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
const inner = document.getElementById('log-inner');
|
||||
if (!inner) return;
|
||||
inner.innerHTML = '<div class="log-empty" id="log-empty">Log cleared.</div>';
|
||||
logLines = 0;
|
||||
updateLogCount();
|
||||
}
|
||||
|
||||
function updateLogCount() {
|
||||
const el = document.getElementById('log-count');
|
||||
if (el) el.textContent = logLines > 0 ? `${logLines} lines` : '';
|
||||
}
|
||||
|
||||
function appendLog(level, text) {
|
||||
const inner = document.getElementById('log-inner');
|
||||
if (!inner) return;
|
||||
|
||||
// remove empty placeholder
|
||||
const placeholder = document.getElementById('log-empty');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const now = new Date();
|
||||
const ts = now.toTimeString().slice(0, 8);
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = `log-line ${level}`;
|
||||
line.innerHTML =
|
||||
`<span class="log-time">${ts}</span>` +
|
||||
`<span class="log-text">${escHtml(text)}</span>`;
|
||||
|
||||
inner.appendChild(line);
|
||||
logLines++;
|
||||
updateLogCount();
|
||||
|
||||
// Auto-scroll to keep the latest log visible (if user is following)
|
||||
const threshold = 80;
|
||||
const nearBottom = inner.scrollHeight - inner.scrollTop - inner.clientHeight < threshold;
|
||||
if (nearBottom) {
|
||||
line.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.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');
|
||||
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>
|
||||