From 49579a0f978af770d5ad3cddaa2036000ae43dee Mon Sep 17 00:00:00 2001 From: sudipnext Date: Tue, 31 Mar 2026 10:45:13 +0545 Subject: [PATCH] feat: enhance ImageMagick installation process with direct download and improved logging --- electron/app/ipc/setup_install_handlers.ts | 228 ++++++++++++++++-- electron/app/main.ts | 3 +- electron/app/types/index.d.ts | 2 + electron/app/utils/imagemagick-check.ts | 169 ++++++++++++- .../resources/ui/setup-installer/index.html | 4 +- 5 files changed, 382 insertions(+), 24 deletions(-) diff --git a/electron/app/ipc/setup_install_handlers.ts b/electron/app/ipc/setup_install_handlers.ts index 3ffa345b..a59cf8aa 100644 --- a/electron/app/ipc/setup_install_handlers.ts +++ b/electron/app/ipc/setup_install_handlers.ts @@ -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 { + 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 { + 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 = diff --git a/electron/app/main.ts b/electron/app/main.ts index b478b7ca..97e9e144 100644 --- a/electron/app/main.ts +++ b/electron/app/main.ts @@ -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, diff --git a/electron/app/types/index.d.ts b/electron/app/types/index.d.ts index 10807ecf..2489385c 100644 --- a/electron/app/types/index.d.ts +++ b/electron/app/types/index.d.ts @@ -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, } diff --git a/electron/app/utils/imagemagick-check.ts b/electron/app/utils/imagemagick-check.ts index 38d01be5..34985a74 100644 --- a/electron/app/utils/imagemagick-check.ts +++ b/electron/app/utils/imagemagick-check.ts @@ -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(); + + 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", ]; } diff --git a/electron/resources/ui/setup-installer/index.html b/electron/resources/ui/setup-installer/index.html index 2d871226..aa398fe1 100644 --- a/electron/resources/ui/setup-installer/index.html +++ b/electron/resources/ui/setup-installer/index.html @@ -288,7 +288,7 @@ ? 'Presenton uses LibreOffice to generate custom templates from PPTX files.' : step === 'chrome' ? 'Presenton uses Chromium for export and slide rendering. Download it now (~150 MB).' - : 'Presenton 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.'; + : 'Presenton 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;