From b4323c30345e57621e01ad1f2b92d44fa9c50bef Mon Sep 17 00:00:00 2001 From: sudipnext Date: Sat, 7 Mar 2026 10:04:43 +0545 Subject: [PATCH] feat: enhance LibreOffice installation process with improved progress tracking and fallback size handling --- .../app/ipc/libreoffice_install_handlers.ts | 123 +++++++++++++++++- electron/app/utils/servers.ts | 5 - .../ui/libreoffice-installer/index.html | 30 +++++ 3 files changed, 147 insertions(+), 11 deletions(-) diff --git a/electron/app/ipc/libreoffice_install_handlers.ts b/electron/app/ipc/libreoffice_install_handlers.ts index 756d0886..0b10baa9 100644 --- a/electron/app/ipc/libreoffice_install_handlers.ts +++ b/electron/app/ipc/libreoffice_install_handlers.ts @@ -45,12 +45,25 @@ function sendLog( /** Minimum expected size (bytes). LibreOffice installers are ~280–350 MB; HTML/redirect pages are ~30 KB. */ const MIN_INSTALLER_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB +/** + * Known approximate installer sizes used as fallback when the download server + * does not send a Content-Length header (e.g. some CDN mirrors strip it). + * These are intentionally conservative estimates so the progress bar never + * jumps backward if the actual file is slightly smaller. + */ +const KNOWN_INSTALLER_SIZES = { + win64: 370 * 1024 * 1024, // ~350–360 MB MSI + macX64: 400 * 1024 * 1024, // ~370–390 MB DMG + macArm64: 400 * 1024 * 1024, // ~370–390 MB DMG +}; + function downloadWithProgress( url: string, dest: string, filename: string, wc: WebContents, - minSizeBytes: number = MIN_INSTALLER_SIZE_BYTES + minSizeBytes: number = MIN_INSTALLER_SIZE_BYTES, + knownTotalBytes?: number ): Promise { return new Promise((resolve, reject) => { const fmtBytes = (bytes: number) => { @@ -92,7 +105,11 @@ function downloadWithProgress( return; } - const totalBytes = parseInt(res.headers["content-length"] ?? "0", 10); + const contentLength = parseInt(res.headers["content-length"] ?? "0", 10); + // Use Content-Length when available; fall back to the caller-supplied + // known size so the progress bar shows something meaningful even when + // the CDN mirror omits the header. + const totalBytes = contentLength > 0 ? contentLength : (knownTotalBytes ?? 0); 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}…`); @@ -110,7 +127,8 @@ function downloadWithProgress( downloaded += chunk.length; const now = Date.now(); const elapsedMs = now - startTime; - const percent = totalBytes > 0 ? Math.floor((downloaded / totalBytes) * 100) : 0; + // Cap at 99 while still downloading so 100% only fires on completion + const percent = totalBytes > 0 ? Math.min(Math.floor((downloaded / totalBytes) * 100), 99) : 0; const sizeLabel = totalBytes > 0 ? `${fmtBytes(downloaded)} / ${fmtBytes(totalBytes)}` : fmtBytes(downloaded); @@ -181,7 +199,7 @@ async function installWindows(wc: WebContents): Promise { const dest = path.join(app.getPath("temp"), filename); sendProgress(wc, "downloading", 0, `${filename}|`); - await downloadWithProgress(url, dest, filename, wc); + await downloadWithProgress(url, dest, filename, wc, MIN_INSTALLER_SIZE_BYTES, KNOWN_INSTALLER_SIZES.win64); sendProgress(wc, "installing"); sendLog(wc, "info", "Requesting administrator rights (UAC prompt may appear)…"); @@ -259,7 +277,11 @@ async function installMac(wc: WebContents): Promise { const mountPoint = path.join(app.getPath("temp"), "LibreOfficeMount"); sendProgress(wc, "downloading", 0, `${filename}|`); - await downloadWithProgress(url, dmgPath, filename, wc); + await downloadWithProgress( + url, dmgPath, filename, wc, + MIN_INSTALLER_SIZE_BYTES, + isArm64 ? KNOWN_INSTALLER_SIZES.macArm64 : KNOWN_INSTALLER_SIZES.macX64 + ); sendProgress(wc, "installing"); fs.mkdirSync(mountPoint, { recursive: true }); @@ -307,20 +329,109 @@ async function installLinux(wc: WebContents): Promise { ); } + const isApt = installCmd.cmd === "apt" || installCmd.cmd === "apt-get"; + + if (isApt) { + // apt-get supports APT::Status-Fd which writes machine-readable progress + // lines to the specified file descriptor. We route them to stdout (fd=1) + // so the piped child.stdout stream delivers them without mixing with the + // regular log output that apt sends to stderr. + // + // Status line formats: + // dlstatus::: — download progress + // pmstatus::: — dpkg install progress + sendProgress(wc, "downloading", 0, "libreoffice|Resolving packages…"); + sendLog(wc, "cmd", "Running: pkexec apt-get install -y libreoffice"); + sendLog(wc, "info", "A system dialog will prompt for your password…"); + + await new Promise((resolve, reject) => { + const child = spawn( + "pkexec", + ["apt-get", "install", "-y", "-o", "APT::Status-Fd=1", "libreoffice"], + { stdio: ["ignore", "pipe", "pipe"] } + ); + + let stdoutBuf = ""; + + child.stdout?.on("data", (d: Buffer) => { + stdoutBuf += d.toString(); + const lines = stdoutBuf.split("\n"); + stdoutBuf = lines.pop() ?? ""; // keep any incomplete trailing line + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith("dlstatus:")) { + // dlstatus::: + const parts = trimmed.split(":"); + const pct = parseFloat(parts[2] ?? "0"); + if (!isNaN(pct)) { + const msg = parts.slice(3).join(":").trim() || "Downloading packages…"; + sendProgress(wc, "downloading", Math.min(Math.floor(pct), 99), `libreoffice|${msg}`); + } + } else if (trimmed.startsWith("pmstatus:")) { + // pmstatus::: + const parts = trimmed.split(":"); + const pct = parseFloat(parts[2] ?? "0"); + if (!isNaN(pct)) { + sendProgress(wc, "installing", Math.min(Math.floor(pct), 99)); + } + } else { + sendLog(wc, "info", trimmed); + } + } + }); + + 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", "apt-get exited successfully"); + resolve(); + } else { + reject(new Error(`apt-get exited with code ${code}`)); + } + }); + child.on("error", reject); + }); + return; + } + + // For dnf, pacman, zypper — use a simple regex to extract any percentage + // printed to stdout so we can at least animate the progress bar forward. 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…"); + const pctRegex = /(\d+)\s*%/; + await new Promise((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.stdout?.on("data", (d: Buffer) => { + const text = d.toString(); + const match = pctRegex.exec(text); + if (match) { + const pct = parseInt(match[1], 10); + if (pct >= 0 && pct <= 100) { + sendProgress(wc, "installing", pct); + } + } + sendLog(wc, "info", text); + }); + 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`); diff --git a/electron/app/utils/servers.ts b/electron/app/utils/servers.ts index 5a3895c1..b5739cb3 100644 --- a/electron/app/utils/servers.ts +++ b/electron/app/utils/servers.ts @@ -24,11 +24,6 @@ export async function startFastApiServer( const binary = process.platform === "win32" ? "fastapi.exe" : "fastapi"; command = path.join(directory, binary); args = ["--port", port.toString()]; - if (!fs.existsSync(command)) { - throw new Error( - `FastAPI binary not found at ${command}. Rebuild the app for ${process.platform} or run in dev mode.` - ); - } } const safeLog = (data: Buffer | string, logPath: string) => { diff --git a/electron/resources/ui/libreoffice-installer/index.html b/electron/resources/ui/libreoffice-installer/index.html index dd7c4d3f..dd76928b 100644 --- a/electron/resources/ui/libreoffice-installer/index.html +++ b/electron/resources/ui/libreoffice-installer/index.html @@ -423,6 +423,9 @@

Installing LibreOffice

Running the installer — this won't take long…

+
@@ -493,6 +496,19 @@ logSection.style.display = (name === 'downloading' || name === 'installing' || name === 'error') ? 'flex' : 'none'; } + // When entering installing state fresh, reset to indeterminate until a + // real percent arrives (e.g. Windows msiexec never sends one). + if (name === 'installing') { + const bar = document.getElementById('install-bar'); + const meta = document.getElementById('install-meta'); + const label = document.getElementById('install-label'); + if (bar && !bar.classList.contains('indeterminate')) { + bar.classList.add('indeterminate'); + bar.style.width = ''; + } + if (meta) meta.style.display = 'none'; + if (label) label.textContent = '0%'; + } } // ── Log panel ─────────────────────────────────────────────── @@ -596,6 +612,20 @@ if (phase === 'installing') { showState('installing'); + const bar = document.getElementById('install-bar'); + const meta = document.getElementById('install-meta'); + const label = document.getElementById('install-label'); + if (bar) { + if (typeof percent === 'number') { + bar.classList.remove('indeterminate'); + bar.style.width = Math.min(percent, 100) + '%'; + if (meta) meta.style.display = 'flex'; + if (label) label.textContent = percent + '%'; + } else if (!bar.classList.contains('indeterminate')) { + bar.classList.add('indeterminate'); + bar.style.width = ''; + } + } return; }