feat: enhance ImageMagick installation process with direct download and improved logging
This commit is contained in:
parent
3d0539b89a
commit
49579a0f97
5 changed files with 382 additions and 24 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
2
electron/app/types/index.d.ts
vendored
2
electron/app/types/index.d.ts
vendored
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue