feat: enhance ImageMagick installation process with direct download and improved logging

This commit is contained in:
sudipnext 2026-03-31 10:45:13 +05:45
parent 3d0539b89a
commit 49579a0f97
5 changed files with 382 additions and 24 deletions

View file

@ -4,11 +4,14 @@
* - setup:install-chrome download Chromium (browser-snapshots) with progress
*/
import { ipcMain, WebContents, shell } from "electron";
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,
@ -19,8 +22,10 @@ import {
} from "@puppeteer/browsers";
import { getSetupStatus } from "../utils/setup-dependencies";
import {
getImageMagickBinaryPath,
getImageMagickDownloadUrl,
getImageMagickManualInstallCommands,
getWindowsImageMagickInstallDir,
isImageMagickInstalled,
} from "../utils/imagemagick-check";
@ -50,7 +55,7 @@ function sendChromeLog(wc: WebContents, level: string, text: string) {
function sendImageMagickProgress(
wc: WebContents,
phase: "installing" | "done" | "error",
phase: "downloading" | "installing" | "done" | "error",
percent?: number,
message?: string
) {
@ -100,6 +105,156 @@ function logManualImageMagickCommands(wc: WebContents) {
}
}
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,
@ -282,23 +437,61 @@ export function setupSetupInstallHandlers() {
await runInstallCommand(wc, brewCommand, ["install", "imagemagick"]);
} else if (process.platform === "win32") {
if (commandExists("choco", ["-v"])) {
await runInstallCommand(wc, "choco", [
"install",
"imagemagick.app",
"-y",
]);
} else {
throw new Error(
"Chocolatey is not installed. Falling back to direct installer download."
);
}
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) {
@ -310,9 +503,8 @@ export function setupSetupInstallHandlers() {
sendImageMagickLog(
wc,
"info",
`Opening manual install link: ${downloadUrl}`
`Manual install URL: ${downloadUrl}`
);
await shell.openExternal(downloadUrl);
sendImageMagickProgress(
wc,
"error",
@ -331,7 +523,11 @@ export function setupSetupInstallHandlers() {
const installed = isImageMagickInstalled();
if (installed) {
sendImageMagickProgress(wc, "done", 100, "ImageMagick detected");
sendImageMagickLog(wc, "ok", "ImageMagick is installed and ready.");
sendImageMagickLog(
wc,
"ok",
`ImageMagick is installed and ready (${getImageMagickBinaryPath()}).`
);
return { ok: true };
}
const message =

View file

@ -14,7 +14,7 @@ import { checkDependenciesBeforeWindow } from "./utils/setup-dependencies";
import { getSofficePath, isLibreOfficeInstalled } from "./utils/libreoffice-check";
import { getPuppeteerExecutablePath, isChromeInstalled } from "./utils/puppeteer-check";
import { getLiteParseRunnerPath } from "./utils/liteparse-check";
import { isImageMagickInstalled } from "./utils/imagemagick-check";
import { getImageMagickBinaryPath, isImageMagickInstalled } from "./utils/imagemagick-check";
import { startUpdateChecker, stopUpdateChecker } from "./utils/update-checker";
@ -125,6 +125,7 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
// Resolved by libreoffice-check.ts at startup; lets Python invoke the
// exact binary path instead of relying on the system PATH.
SOFFICE_PATH: getSofficePath(),
IMAGEMAGICK_BINARY: getImageMagickBinaryPath(),
LITEPARSE_RUNNER_PATH: getLiteParseRunnerPath(),
},
isDev,

View file

@ -33,6 +33,8 @@ interface FastApiEnv {
MIGRATE_DATABASE_ON_STARTUP?: string,
/** Absolute path to the soffice binary resolved at startup by libreoffice-check.ts. */
SOFFICE_PATH?: string,
/** Absolute path to the ImageMagick binary resolved at startup by imagemagick-check.ts. */
IMAGEMAGICK_BINARY?: string,
/** Absolute path to the bundled LiteParse runner script. */
LITEPARSE_RUNNER_PATH?: string,
}

View file

@ -1,5 +1,10 @@
import fs from "fs";
import os from "os";
import path from "path";
import { spawnSync } from "child_process";
let resolvedImageMagickBinaryPath = process.platform === "win32" ? "magick" : "convert";
function canExecute(command: string, args: string[]): boolean {
const result = spawnSync(command, args, {
stdio: "pipe",
@ -8,12 +13,162 @@ function canExecute(command: string, args: string[]): boolean {
return result.status === 0;
}
function runCommand(command: string, args: string[]): string | null {
const result = spawnSync(command, args, {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
windowsHide: true,
});
if (result.status !== 0) return null;
const stdout = (result.stdout ?? "").trim();
return stdout.length > 0 ? stdout : null;
}
function getWindowsInstallRootCandidates(): string[] {
const roots = new Set<string>();
if (process.env.LOCALAPPDATA) roots.add(process.env.LOCALAPPDATA);
if (process.env.ProgramFiles) roots.add(process.env.ProgramFiles);
if (process.env["ProgramFiles(x86)"]) {
roots.add(process.env["ProgramFiles(x86)"] as string);
}
roots.add(path.join(os.homedir(), "AppData", "Local"));
return Array.from(roots);
}
export function getWindowsImageMagickInstallDir(): string {
const localAppData =
process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
return path.join(localAppData, "Presenton", "runtime", "imagemagick");
}
function collectWindowsImageMagickBinaryCandidates(): string[] {
const candidates: string[] = [
path.join(getWindowsImageMagickInstallDir(), "magick.exe"),
];
for (const root of getWindowsInstallRootCandidates()) {
try {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !/^ImageMagick/i.test(entry.name)) {
continue;
}
candidates.push(path.join(root, entry.name, "magick.exe"));
}
} catch {
continue;
}
}
return candidates;
}
function resolveBrewCommandPath(): string | null {
const candidates = ["brew", "/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
for (const candidate of candidates) {
if (canExecute(candidate, ["--version"])) {
return candidate;
}
}
return null;
}
function collectDarwinBrewImageMagickCandidates(): string[] {
const candidates: string[] = [
"/opt/homebrew/bin/magick",
"/usr/local/bin/magick",
"/opt/homebrew/opt/imagemagick/bin/magick",
"/usr/local/opt/imagemagick/bin/magick",
];
const brewCommand = resolveBrewCommandPath();
if (!brewCommand) {
return candidates;
}
const brewPrefix = runCommand(brewCommand, ["--prefix", "imagemagick"]);
if (brewPrefix) {
candidates.push(path.join(brewPrefix, "bin", "magick"));
}
const brewCellar = runCommand(brewCommand, ["--cellar", "imagemagick"]);
if (brewCellar && fs.existsSync(brewCellar)) {
try {
const versions = fs
.readdirSync(brewCellar, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((a, b) =>
b.localeCompare(a, undefined, { numeric: true, sensitivity: "base" })
);
for (const version of versions) {
candidates.push(path.join(brewCellar, version, "bin", "magick"));
}
} catch {
// Ignore cellar enumeration errors and continue with other candidates.
}
}
return candidates;
}
function resolveImageMagickBinaryPath(): string | null {
const commandCandidates = process.platform === "win32" ? ["magick"] : ["magick", "convert"];
for (const candidate of commandCandidates) {
if (canExecute(candidate, ["-version"])) {
return candidate;
}
}
if (process.platform === "win32") {
for (const candidate of collectWindowsImageMagickBinaryCandidates()) {
if (fs.existsSync(candidate) && canExecute(candidate, ["-version"])) {
return candidate;
}
}
return null;
}
if (process.platform === "darwin") {
for (const candidate of collectDarwinBrewImageMagickCandidates()) {
if (fs.existsSync(candidate) && canExecute(candidate, ["-version"])) {
return candidate;
}
}
}
const unixCandidates = [
"/opt/homebrew/bin/magick",
"/usr/local/bin/magick",
"/opt/local/bin/magick",
"/usr/bin/magick",
"/usr/local/bin/convert",
"/usr/bin/convert",
];
for (const candidate of unixCandidates) {
if (fs.existsSync(candidate) && canExecute(candidate, ["-version"])) {
return candidate;
}
}
return null;
}
export function isImageMagickInstalled(): boolean {
// ImageMagick 7+ command
if (canExecute("magick", ["-version"])) return true;
// Legacy command on Linux/macOS packages
if (canExecute("convert", ["-version"])) return true;
return false;
const resolved = resolveImageMagickBinaryPath();
if (!resolved) return false;
resolvedImageMagickBinaryPath = resolved;
return true;
}
export function getImageMagickBinaryPath(): string {
return resolvedImageMagickBinaryPath;
}
export function getImageMagickDownloadUrl(): string {
@ -31,6 +186,8 @@ export function getImageMagickManualInstallCommands(): string[] {
return [
"Download and run the installer:",
getImageMagickDownloadUrl(),
"Recommended install path:",
getWindowsImageMagickInstallDir(),
];
}
@ -40,6 +197,8 @@ export function getImageMagickManualInstallCommands(): string[] {
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
"Install ImageMagick:",
"brew install imagemagick",
"Verify detected binary path:",
"brew --prefix imagemagick",
];
}

View file

@ -288,7 +288,7 @@
? '<strong>Presenton</strong> uses LibreOffice to generate custom templates from PPTX files.'
: step === 'chrome'
? '<strong>Presenton</strong> uses Chromium for export and slide rendering. Download it now (~150 MB).'
: '<strong>Presenton</strong> uses ImageMagick for OCR/document conversion support. Linux uses apt, macOS installs Homebrew first (if needed) and then runs brew install imagemagick, and Windows uses Chocolatey with a direct installer fallback.';
: '<strong>Presenton</strong> uses ImageMagick for OCR/document conversion support. Linux uses apt, macOS installs Homebrew first (if needed) and then runs brew install imagemagick, and Windows downloads and installs it directly into the Presenton runtime.';
document.getElementById('btn-install').onclick = () => startInstall(step);
document.getElementById('btn-skip').onclick = () => handleSkip();
showState('prompt');
@ -315,7 +315,7 @@
});
} else {
document.getElementById('dl-heading').textContent = 'Installing ImageMagick';
document.getElementById('dl-phase').textContent = 'Linux: apt-get | macOS: Homebrew + brew install | Windows: choco or direct installer';
document.getElementById('dl-phase').textContent = 'Linux: apt-get | macOS: Homebrew + brew install | Windows: direct installer (Presenton runtime)';
window.setupInstaller.installImageMagick().then((installResult) => {
if (!installResult || !installResult.ok) {
if (currentStep !== 'imagemagick') return;