presenton/electron/app/ipc/setup_install_handlers.ts

540 lines
16 KiB
TypeScript

/**
* IPC handlers for the unified setup installer (LibreOffice + Chromium + ImageMagick).
* - setup:get-status — which dependencies are missing
* - setup:install-chrome — download Chromium (browser-snapshots) with progress
*/
import { ipcMain, WebContents } from "electron";
import fs from "fs";
import path from "path";
import os from "os";
import { spawn, spawnSync } from "child_process";
import * as https from "https";
import * as http from "http";
import { IncomingMessage } from "http";
import puppeteer from "puppeteer";
import {
Browser,
detectBrowserPlatform,
getInstalledBrowsers,
install,
resolveBuildId,
} from "@puppeteer/browsers";
import { getSetupStatus } from "../utils/setup-dependencies";
import {
getImageMagickBinaryPath,
getImageMagickDownloadUrl,
getImageMagickManualInstallCommands,
getWindowsImageMagickInstallDir,
isImageMagickInstalled,
} from "../utils/imagemagick-check";
function getPuppeteerCacheDir(): string {
const configCache =
(puppeteer as any).configuration?.cacheDirectory ??
(puppeteer as any).defaultDownloadPath;
return configCache ?? path.join(os.homedir(), ".cache", "puppeteer");
}
function sendChromeProgress(
wc: WebContents,
phase: "downloading" | "extracting" | "done" | "error",
percent?: number,
message?: string
) {
if (!wc.isDestroyed()) {
wc.send("setup:chrome-progress", { phase, percent, message });
}
}
function sendChromeLog(wc: WebContents, level: string, text: string) {
if (!wc.isDestroyed()) {
wc.send("setup:chrome-log", { level, text });
}
}
function sendImageMagickProgress(
wc: WebContents,
phase: "downloading" | "installing" | "done" | "error",
percent?: number,
message?: string
) {
if (!wc.isDestroyed()) {
wc.send("setup:imagemagick-progress", { phase, percent, message });
}
}
function sendImageMagickLog(wc: WebContents, level: string, text: string) {
if (!wc.isDestroyed()) {
wc.send("setup:imagemagick-log", { level, text });
}
}
function commandExists(command: string, versionArgs: string[] = ["--version"]): boolean {
const result = spawnSync(command, versionArgs, {
stdio: "pipe",
windowsHide: true,
});
return result.status === 0;
}
function resolveBrewCommand(): string | null {
if (commandExists("brew")) {
return "brew";
}
const candidates = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function resolveLinuxEscalationCommand(): string | null {
if (commandExists("pkexec", ["--version"])) return "pkexec";
if (commandExists("sudo", ["-V"])) return "sudo";
return null;
}
function logManualImageMagickCommands(wc: WebContents) {
for (const line of getImageMagickManualInstallCommands()) {
const level = line.endsWith(":") ? "info" : "cmd";
sendImageMagickLog(wc, level, line);
}
}
const MAX_DOWNLOAD_REDIRECTS = 5;
const MIN_IMAGEMAGICK_INSTALLER_SIZE_BYTES = 5 * 1024 * 1024;
function formatBytes(bytes: number): string {
if (bytes <= 0) return "0 B";
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(1)} MB`;
const kb = bytes / 1024;
if (kb >= 1) return `${kb.toFixed(0)} KB`;
return `${bytes} B`;
}
function escapePowerShellSingleQuoted(value: string): string {
return value.replace(/'/g, "''");
}
function getFilenameFromUrl(url: string, fallback: string): string {
try {
const parsed = new URL(url);
const name = path.basename(parsed.pathname);
return name || fallback;
} catch {
return fallback;
}
}
function downloadFileWithProgress(
wc: WebContents,
url: string,
destinationPath: string
): Promise<void> {
return new Promise((resolve, reject) => {
const requestDownload = (requestUrl: string, redirects: number) => {
const requester = requestUrl.startsWith("https") ? https.get : http.get;
sendImageMagickLog(wc, "cmd", `GET ${requestUrl}`);
requester(requestUrl, (res: IncomingMessage) => {
const statusCode = res.statusCode ?? 0;
if (
[301, 302, 303, 307, 308].includes(statusCode) &&
res.headers.location
) {
if (redirects >= MAX_DOWNLOAD_REDIRECTS) {
reject(new Error("Too many redirects while downloading installer."));
return;
}
const redirectUrl = new URL(res.headers.location, requestUrl).toString();
sendImageMagickLog(wc, "info", `Redirecting to ${redirectUrl}`);
requestDownload(redirectUrl, redirects + 1);
return;
}
if (statusCode !== 200) {
reject(new Error(`Download failed with HTTP ${statusCode}.`));
return;
}
const totalBytes = Number.parseInt(
String(res.headers["content-length"] ?? "0"),
10
);
let downloadedBytes = 0;
const file = fs.createWriteStream(destinationPath);
res.on("data", (chunk: Buffer) => {
downloadedBytes += chunk.length;
const percent =
totalBytes > 0
? Math.min(99, Math.floor((downloadedBytes / totalBytes) * 100))
: undefined;
const sizeLabel =
totalBytes > 0
? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`
: `${formatBytes(downloadedBytes)} downloaded`;
sendImageMagickProgress(wc, "downloading", percent, sizeLabel);
});
res.pipe(file);
file.on("finish", () => {
file.close(() => {
if (downloadedBytes < MIN_IMAGEMAGICK_INSTALLER_SIZE_BYTES) {
fs.unlink(destinationPath, () => {});
reject(
new Error(
`Downloaded file is too small (${formatBytes(downloadedBytes)}).`
)
);
return;
}
sendImageMagickLog(
wc,
"ok",
`Download complete (${formatBytes(downloadedBytes)}).`
);
resolve();
});
});
file.on("error", (err) => {
fs.unlink(destinationPath, () => {});
reject(err);
});
}).on("error", (err) => {
fs.unlink(destinationPath, () => {});
reject(err);
});
};
requestDownload(url, 0);
});
}
async function runWindowsExecutableInstaller(
wc: WebContents,
installerPath: string,
installerArgs: string[]
): Promise<void> {
const escapedInstallerPath = escapePowerShellSingleQuoted(installerPath);
const argList = installerArgs
.map((arg) => `'${escapePowerShellSingleQuoted(arg)}'`)
.join(", ");
const runViaPowerShell = async (runAsAdmin: boolean) => {
const verb = runAsAdmin ? " -Verb RunAs" : "";
const script = `$p = Start-Process -FilePath '${escapedInstallerPath}' -ArgumentList ${argList}${verb} -Wait -PassThru; if ($p) { exit $p.ExitCode } else { exit 1 }`;
await runInstallCommand(wc, "powershell", [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
]);
};
try {
sendImageMagickLog(wc, "info", "Running installer in user mode...");
await runViaPowerShell(false);
} catch {
sendImageMagickLog(
wc,
"warn",
"User-mode install failed. Retrying with administrator rights..."
);
await runViaPowerShell(true);
}
}
function runInstallCommand(
wc: WebContents,
command: string,
args: string[]
): Promise<void> {
sendImageMagickLog(wc, "info", `Running: ${command} ${args.join(" ")}`);
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: process.platform === "win32",
});
child.stdout.on("data", (data) => {
const text = String(data).trim();
if (text) sendImageMagickLog(wc, "info", text);
});
child.stderr.on("data", (data) => {
const text = String(data).trim();
if (text) {
sendImageMagickLog(
wc,
text.toLowerCase().includes("error") ? "error" : "info",
text
);
}
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} exited with code ${code}`));
});
});
}
export function setupSetupInstallHandlers() {
ipcMain.handle("setup:get-status", () => {
return (
getSetupStatus() ?? {
needsLibreOffice: false,
needsChrome: false,
needsImageMagick: false,
}
);
});
ipcMain.handle(
"setup:install-chrome",
async (event): Promise<{ ok: boolean; error?: string }> => {
const wc = event.sender;
const cacheDir = getPuppeteerCacheDir();
const platform = detectBrowserPlatform();
if (!platform) {
const msg = "Unable to detect platform.";
sendChromeLog(wc, "error", msg);
sendChromeProgress(wc, "error", undefined, msg);
return { ok: false, error: msg };
}
let buildId: string;
try {
buildId = await resolveBuildId(
Browser.CHROMIUM,
platform,
"latest" as "latest"
);
} catch (err) {
const msg =
err instanceof Error
? err.message
: "Unable to resolve Chromium revision.";
sendChromeLog(wc, "error", msg);
sendChromeProgress(wc, "error", undefined, msg);
return { ok: false, error: msg };
}
sendChromeLog(wc, "info", `Downloading Chromium r${buildId}`);
sendChromeProgress(wc, "downloading", 0, "Connecting…");
try {
await install({
cacheDir,
platform,
browser: Browser.CHROMIUM,
buildId,
downloadProgressCallback: (downloadedBytes, totalBytes) => {
if (totalBytes > 0 && !wc.isDestroyed()) {
const percent = Math.min(
99,
Math.round((downloadedBytes / totalBytes) * 100)
);
const mb = (n: number) => (n / 1024 / 1024).toFixed(1);
sendChromeProgress(
wc,
"downloading",
percent,
`${mb(downloadedBytes)} / ${mb(totalBytes)} MB`
);
}
},
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Chromium download failed.";
sendChromeLog(wc, "error", message);
sendChromeProgress(wc, "error", undefined, message);
return { ok: false, error: message };
}
sendChromeProgress(wc, "extracting", 100, "Extracting…");
const browsers = await getInstalledBrowsers({ cacheDir });
const chromium = browsers.find((b) => b.browser === Browser.CHROMIUM);
if (chromium?.executablePath && fs.existsSync(chromium.executablePath)) {
sendChromeLog(wc, "ok", `Chromium ready at ${chromium.executablePath}`);
}
sendChromeProgress(wc, "done", 100);
return { ok: true };
}
);
ipcMain.handle(
"setup:install-imagemagick",
async (event): Promise<{ ok: boolean; error?: string }> => {
const wc = event.sender;
try {
sendImageMagickProgress(
wc,
"installing",
undefined,
"Installing ImageMagick..."
);
if (process.platform === "linux") {
if (commandExists("apt-get")) {
const escalator = resolveLinuxEscalationCommand();
if (!escalator) {
throw new Error(
"Neither pkexec nor sudo is available to run apt-get install."
);
}
await runInstallCommand(wc, escalator, [
"apt-get",
"update",
]);
await runInstallCommand(wc, escalator, [
"apt-get",
"install",
"-y",
"imagemagick",
]);
} else {
throw new Error(
"apt-get is unavailable. Install ImageMagick manually using your package manager."
);
}
} else if (process.platform === "darwin") {
let brewCommand = resolveBrewCommand();
if (!brewCommand) {
sendImageMagickLog(
wc,
"info",
"Homebrew not found. Installing Homebrew first..."
);
const installHomebrewCommand =
'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"';
await runInstallCommand(wc, "/bin/bash", ["-c", installHomebrewCommand]);
brewCommand = resolveBrewCommand();
}
if (!brewCommand) {
throw new Error(
"Homebrew installation completed, but brew was not found on PATH."
);
}
await runInstallCommand(wc, brewCommand, ["install", "imagemagick"]);
} else if (process.platform === "win32") {
const installerUrl = getImageMagickDownloadUrl();
const installerFilename = getFilenameFromUrl(
installerUrl,
"ImageMagick-installer.exe"
);
const installerPath = path.join(os.tmpdir(), installerFilename);
const installDir = getWindowsImageMagickInstallDir();
fs.mkdirSync(installDir, { recursive: true });
sendImageMagickLog(
wc,
"info",
`Downloading ImageMagick installer (${installerFilename})...`
);
sendImageMagickLog(wc, "cmd", `Install directory: ${installDir}`);
sendImageMagickProgress(wc, "downloading", 0, "Connecting...");
await downloadFileWithProgress(wc, installerUrl, installerPath);
sendImageMagickProgress(
wc,
"installing",
undefined,
"Running installer..."
);
await runWindowsExecutableInstaller(wc, installerPath, [
"/SP-",
"/VERYSILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
`/DIR=${installDir}`,
]);
fs.unlink(installerPath, () => {});
sendImageMagickLog(wc, "ok", "ImageMagick installer completed.");
} else {
throw new Error(
"Unsupported platform for automatic install. Use manual install from the official download page."
);
}
if (!isImageMagickInstalled()) {
throw new Error(
"ImageMagick installation command finished, but the binary was not detected."
);
}
sendImageMagickLog(
wc,
"ok",
`ImageMagick detected at ${getImageMagickBinaryPath()}`
);
sendImageMagickProgress(wc, "done", 100, "ImageMagick install finished");
return { ok: true };
} catch (error) {
const message =
error instanceof Error ? error.message : "ImageMagick install failed";
sendImageMagickLog(wc, "error", message);
logManualImageMagickCommands(wc);
const downloadUrl = getImageMagickDownloadUrl();
sendImageMagickLog(
wc,
"info",
`Manual install URL: ${downloadUrl}`
);
sendImageMagickProgress(
wc,
"error",
undefined,
"Finish manual installation, then click Retry."
);
return { ok: false, error: message };
}
}
);
ipcMain.handle(
"setup:check-imagemagick",
async (event): Promise<{ ok: boolean; error?: string }> => {
const wc = event.sender;
const installed = isImageMagickInstalled();
if (installed) {
sendImageMagickProgress(wc, "done", 100, "ImageMagick detected");
sendImageMagickLog(
wc,
"ok",
`ImageMagick is installed and ready (${getImageMagickBinaryPath()}).`
);
return { ok: true };
}
const message =
"ImageMagick is not detected yet. Install it, then click Retry.";
sendImageMagickProgress(wc, "error", undefined, message);
sendImageMagickLog(wc, "error", message);
return { ok: false, error: message };
}
);
}