From debf6802cecf441a2981123d48ded368a1338d33 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 3 Aug 2026 22:44:46 +0100 Subject: [PATCH] Update Windows installer to support `.msi` format and enhance installation process - Changed the installer extension from `.exe` to `.msi` for better compatibility with Windows Installer. - Refactored validation functions to ensure proper handling of MSI packages. - Improved logging and progress tracking during installation, providing clearer feedback on the installation status. - Updated the installer script to manage previous installations more effectively, allowing for smoother upgrades. --- packaging/windows/msi-wrapped.wxs | 102 ++++++--- public/appupdate.js | 312 ++++++++++++++------------- public/winappupdate.js | 341 +++++++++++++++++++++++++++--- scripts/build-windows-msi.ps1 | 5 +- src/desktop/appupdate.js | 10 +- src/desktop/winappupdate.js | 337 ++++++++++++++++++++++++++--- 6 files changed, 852 insertions(+), 255 deletions(-) diff --git a/packaging/windows/msi-wrapped.wxs b/packaging/windows/msi-wrapped.wxs index 6dd3a51..8092190 100644 --- a/packaging/windows/msi-wrapped.wxs +++ b/packaging/windows/msi-wrapped.wxs @@ -1,7 +1,13 @@ + = 601]]> @@ -24,24 +30,46 @@ + + + - - - + + + + + + + - - - + + + + + + @@ -70,15 +98,19 @@ Return="check" /> - - - - - + + + - - + + + + + + NOT Installed NOT Installed + NOT Installed AND PREVIOUS_UNINSTALL_CMD - NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND PREVIOUS_INSTALL_DIR - NOT Installed AND (PREVIOUS_UNINSTALL_CMD OR PREVIOUS_INSTALL_DIR) + NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND PREVIOUS_UNINSTALL_CMD_LEGACY + NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND NOT PREVIOUS_UNINSTALL_CMD_LEGACY AND PREVIOUS_INSTALL_DIR + NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND NOT PREVIOUS_UNINSTALL_CMD_LEGACY AND NOT PREVIOUS_INSTALL_DIR AND PREVIOUS_INSTALL_DIR_LEGACY + + NOT Installed AND (PREVIOUS_UNINSTALL_CMD OR PREVIOUS_UNINSTALL_CMD_LEGACY OR PREVIOUS_INSTALL_DIR OR PREVIOUS_INSTALL_DIR_LEGACY) NOT Installed diff --git a/public/appupdate.js b/public/appupdate.js index 57ed27c..214115b 100644 --- a/public/appupdate.js +++ b/public/appupdate.js @@ -1,262 +1,260 @@ -import { ipcMain } from 'electron' -import { createWriteStream, promises as fs } from 'fs' -import http from 'http' -import https from 'https' -import os from 'os' -import path from 'path' -import process from 'process' -import { launchMacInstaller } from './macappupdate.js' -import { launchWindowsInstaller } from './winappupdate.js' +import { createWriteStream, promises as fs } from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { Utils } from "electrobun/bun"; +import { launchMacInstaller } from "./macappupdate.js"; +import { launchWindowsInstaller } from "./winappupdate.js"; +import { scheduleAppRestart } from "./updater-runner.js"; -const UPDATE_PROGRESS_CHANNEL = 'app-update-progress' const SUPPORTED_TARGETS = { darwin: { - extension: '.pkg', - osMatchers: ['darwin', 'mac', 'macos', 'osx'] + extension: ".pkg", + osMatchers: ["darwin", "mac", "macos", "osx"], }, win32: { - extension: '.exe', - osMatchers: ['win32', 'win', 'windows'] - } -} + extension: ".msi", + osMatchers: ["win32", "win", "windows"], + }, +}; -let runningUpdate = null +let runningUpdate = null; const getArtifactName = (artifact) => - String(artifact?.fileName || artifact?.relativePath || artifact?.url || '') + String(artifact?.fileName || artifact?.relativePath || artifact?.url || ""); const normalizeArch = (arch) => { - if (arch === 'x64' || arch === 'amd64') return 'x64' - if (arch === 'arm64' || arch === 'aarch64') return 'arm64' - return arch -} + if (arch === "x64" || arch === "amd64") return "x64"; + if (arch === "arm64" || arch === "aarch64") return "arm64"; + return arch; +}; const artifactMatchesPlatform = (artifact, target, platform, arch) => { - const name = getArtifactName(artifact).toLowerCase() - const normalizedArch = normalizeArch(arch) - const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase()) + const name = getArtifactName(artifact).toLowerCase(); + const normalizedArch = normalizeArch(arch); + const artifactArch = normalizeArch(String(artifact?.arch || "").toLowerCase()); const artifactPlatform = String( - artifact?.platform || artifact?.os || artifact?.target || '' - ).toLowerCase() + artifact?.platform || artifact?.os || artifact?.target || "", + ).toLowerCase(); - if (!name.endsWith(target.extension)) return false - if (!artifact?.url) return false + if (!name.endsWith(target.extension)) return false; + if (!artifact?.url) return false; const matchesArch = artifactArch === normalizedArch || name.includes(`-${normalizedArch}`) || name.includes(`_${normalizedArch}`) || name.includes(`.${normalizedArch}.`) || - name.includes(normalizedArch) + name.includes(normalizedArch); const matchesOs = !artifactPlatform || target.osMatchers.includes(artifactPlatform) || target.osMatchers.some((matcher) => name.includes(matcher)) || - (platform === 'darwin' && name.includes('mac')) || - (platform === 'win32' && name.includes('win')) + (platform === "darwin" && name.includes("mac")) || + (platform === "win32" && name.includes("win")); - return matchesArch && matchesOs -} + return matchesArch && matchesOs; +}; const selectUpdateArtifact = ( update, platform = process.platform, - arch = process.arch + arch = process.arch, ) => { - const target = SUPPORTED_TARGETS[platform] + const target = SUPPORTED_TARGETS[platform]; if (!target) { - throw new Error(`App updates are not supported on ${platform}.`) + throw new Error(`App updates are not supported on ${platform}.`); } - const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : [] + const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []; const matchingArtifact = artifacts.find((artifact) => - artifactMatchesPlatform(artifact, target, platform, arch) - ) + artifactMatchesPlatform(artifact, target, platform, arch), + ); const fallbackArtifact = artifacts.find((artifact) => { - const name = getArtifactName(artifact).toLowerCase() - return artifact?.url && name.endsWith(target.extension) - }) + const name = getArtifactName(artifact).toLowerCase(); + return artifact?.url && name.endsWith(target.extension); + }); if (!matchingArtifact && !fallbackArtifact) { throw new Error( - `No ${target.extension} update artifact found for ${platform}/${arch}.` - ) + `No ${target.extension} update artifact found for ${platform}/${arch}.`, + ); } - return matchingArtifact || fallbackArtifact -} + return matchingArtifact || fallbackArtifact; +}; -const sendProgress = (webContents, payload) => { - if (!webContents || webContents.isDestroyed()) return - webContents.send(UPDATE_PROGRESS_CHANNEL, { - timestamp: new Date().toISOString(), - ...payload - }) -} - -const getInstallErrorMessage = (error, output = '') => { - const combined = `${output}\n${error?.message || ''}`.trim() +const getInstallErrorMessage = (error, output = "") => { + const combined = `${output}\n${error?.message || ""}`.trim(); if ( /cancel/i.test(combined) || /did not grant permission/i.test(combined) || /user canceled/i.test(combined) ) { - return 'Update installation was cancelled.' + return "Update installation was cancelled."; } if (/incorrect/i.test(combined)) { - return 'The administrator password was incorrect.' + return "The administrator password was incorrect."; } - return combined || 'Failed to install update.' -} + if ( + /1625/.test(combined) || + /forbidden by system policy/i.test(combined) || + /Non-assigned apps are disabled/i.test(combined) + ) { + return "Update installation was blocked by Windows Installer policy."; + } -const installerHelpers = { sendProgress, getInstallErrorMessage } + return combined || "Failed to install update."; +}; const getDownloadUrl = (url, redirectCount = 0) => new Promise((resolve, reject) => { if (redirectCount > 5) { - reject(new Error('Too many redirects while downloading update.')) - return + reject(new Error("Too many redirects while downloading update.")); + return; } - const parsedUrl = new URL(url) - const client = parsedUrl.protocol === 'https:' ? https : http + const parsedUrl = new URL(url); + const client = parsedUrl.protocol === "https:" ? https : http; const request = client.get(parsedUrl, (response) => { - const location = response.headers.location + const location = response.headers.location; if (response.statusCode >= 300 && response.statusCode < 400 && location) { - response.resume() + response.resume(); resolve( getDownloadUrl( new URL(location, parsedUrl).toString(), - redirectCount + 1 - ) - ) - return + redirectCount + 1, + ), + ); + return; } - resolve({ response, url: parsedUrl.toString() }) - }) + resolve({ response, url: parsedUrl.toString() }); + }); - request.on('error', reject) - }) + request.on("error", reject); + }); -const downloadArtifact = async (artifact, destinationPath, webContents) => { - const { response } = await getDownloadUrl(artifact.url) +const downloadArtifact = async (artifact, destinationPath, sendProgress) => { + const { response } = await getDownloadUrl(artifact.url); if (response.statusCode < 200 || response.statusCode >= 300) { - response.resume() - throw new Error(`Update download failed with HTTP ${response.statusCode}.`) + response.resume(); + throw new Error(`Update download failed with HTTP ${response.statusCode}.`); } const totalBytes = - Number.parseInt(response.headers['content-length'], 10) || 0 - let downloadedBytes = 0 + Number.parseInt(response.headers["content-length"], 10) || 0; + let downloadedBytes = 0; await new Promise((resolve, reject) => { - const output = createWriteStream(destinationPath) + const output = createWriteStream(destinationPath); - response.on('data', (chunk) => { - downloadedBytes += chunk.length + response.on("data", (chunk) => { + downloadedBytes += chunk.length; const percent = totalBytes ? Math.round((downloadedBytes / totalBytes) * 100) - : null + : null; - sendProgress(webContents, { - phase: 'downloading', + sendProgress({ + phase: "downloading", percent, downloadedBytes, totalBytes, message: totalBytes ? `Downloading update (${percent}%)` - : 'Downloading update' - }) - }) + : "Downloading update", + }); + }); - response.on('error', reject) - output.on('error', reject) - output.on('finish', resolve) - response.pipe(output) - }) -} + response.on("error", reject); + output.on("error", reject); + output.on("finish", resolve); + response.pipe(output); + }); +}; -const restartApp = (app) => { - app.relaunch() - app.exit(0) -} +const launchInstallerAndRestart = async ( + mainWindow, + installerPath, + sendProgress, +) => { + const installerHelpers = { sendProgress, getInstallErrorMessage }; -const launchInstallerAndQuit = async (app, installerPath, webContents) => { - if (process.platform === 'darwin') { - await launchMacInstaller(app, installerPath, webContents, installerHelpers) - restartApp(app) - return - } - - if (process.platform === 'win32') { - await launchWindowsInstaller( - app, + if (process.platform === "darwin") { + await launchMacInstaller( + mainWindow, installerPath, - webContents, - installerHelpers - ) - restartApp(app) - return + sendProgress, + installerHelpers, + ); + } else if (process.platform === "win32") { + await launchWindowsInstaller( + mainWindow, + installerPath, + sendProgress, + installerHelpers, + ); + } else { + throw new Error(`App updates are not supported on ${process.platform}.`); } - throw new Error(`App updates are not supported on ${process.platform}.`) -} + scheduleAppRestart(); + Utils.quit(); +}; -const runAppUpdate = async (app, update, webContents) => { - const artifact = selectUpdateArtifact(update) +const runAppUpdate = async (mainWindow, update, sendProgress) => { + const artifact = selectUpdateArtifact(update); const tempDirectory = await fs.mkdtemp( - path.join(os.tmpdir(), 'farmcontrol-update-') - ) - const artifactName = path.basename(getArtifactName(artifact)) - const installerPath = path.join(tempDirectory, artifactName) + path.join(os.tmpdir(), "farmcontrol-update-"), + ); + const artifactName = path.basename(getArtifactName(artifact)); + const installerPath = path.join(tempDirectory, artifactName); - sendProgress(webContents, { - phase: 'preparing', + sendProgress({ + phase: "preparing", percent: 0, artifact, - message: 'Preparing update download' - }) + message: "Preparing update download", + }); - await downloadArtifact(artifact, installerPath, webContents) + await downloadArtifact(artifact, installerPath, sendProgress); - sendProgress(webContents, { - phase: 'downloaded', + sendProgress({ + phase: "downloaded", percent: 100, downloadedBytes: null, totalBytes: null, artifact, - message: 'Update downloaded' - }) + message: "Update downloaded", + }); - await launchInstallerAndQuit(app, installerPath, webContents) -} - -export function setupAppUpdateIPC(app) { - ipcMain.handle('app-update-start', async (event, update) => { - if (runningUpdate) return runningUpdate - - const webContents = event.sender - runningUpdate = runAppUpdate(app, update, webContents) - .then(() => ({ ok: true })) - .catch((error) => { - sendProgress(webContents, { - phase: 'error', - percent: null, - message: error?.message || 'Failed to update app.' - }) - throw error - }) - .finally(() => { - runningUpdate = null - }) - - return runningUpdate - }) + await launchInstallerAndRestart(mainWindow, installerPath, sendProgress); +}; + +export function startAppUpdate(mainWindow, update, sendProgress) { + if (runningUpdate) return runningUpdate; + + runningUpdate = runAppUpdate(mainWindow, update, sendProgress) + .then(() => ({ ok: true })) + .catch((error) => { + sendProgress({ + phase: "error", + percent: null, + message: error?.message || "Failed to update app.", + }); + throw error; + }) + .finally(() => { + runningUpdate = null; + }); + + return runningUpdate; } diff --git a/public/winappupdate.js b/public/winappupdate.js index 8a64fc1..8b7e908 100644 --- a/public/winappupdate.js +++ b/public/winappupdate.js @@ -2,26 +2,149 @@ import { spawn } from 'child_process' import { promises as fs } from 'fs' import os from 'os' import path from 'path' +import process from 'process' -const MZ_HEADER = Buffer.from([0x4d, 0x5a]) +const MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) const DEBUG_PREFIX = '[app-update][win-progress]' const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const debugLog = () => {} -const isValidWindowsExecutable = async (filePath) => { +const decodeMsiLogBuffer = (buffer) => { + if (!buffer?.length) return '' + + if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { + debugLog('decoded MSI log as UTF-16 LE (BOM)') + return buffer.subarray(2).toString('utf16le') + } + + const sample = buffer.subarray(0, Math.min(buffer.length, 64)) + const looksUtf16 = + sample.length >= 4 && + sample.filter((byte) => byte === 0).length > sample.length / 4 + + if (looksUtf16) { + debugLog('decoded MSI log as UTF-16 LE (heuristic)') + return buffer.toString('utf16le') + } + + debugLog('decoded MSI log as UTF-8') + return buffer.toString('utf8') +} + +const formatMsiActionName = (actionName) => { + const humanized = String(actionName) + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .toLowerCase() + .trim() + + if (!humanized) return 'Installing update...' + + return `${humanized.charAt(0).toUpperCase()}${humanized.slice(1)}...` +} + +const parseWindowsInstallerProgress = (output) => { + const lines = String(output || '').split(/\r?\n/) + let percent = null + let message = 'Installing update...' + let totalTicks = 0 + let currentTicks = 0 + let actionStarts = 0 + let actionEnds = 0 + const matchedLines = [] + + for (const line of lines) { + const actionStart = line.match(/^Action start \d{2}:\d{2}:\d{2}: (.+?)\./) + if (actionStart) { + actionStarts += 1 + message = formatMsiActionName(actionStart[1]) + matchedLines.push(`action-start:${actionStart[1]}`) + } + + const doingAction = line.match(/Doing action:\s*(.+)$/) + if (doingAction && !actionStart) { + message = formatMsiActionName(doingAction[1]) + matchedLines.push(`doing-action:${doingAction[1]}`) + } + + if (/^Action ended \d{2}:\d{2}:\d{2}: .+?\. Return value \d+\./.test(line)) { + actionEnds += 1 + matchedLines.push('action-ended') + } + + const progressReset = line.match(/^\s*0\s+(\d+)\s+0(?:\s+\d+)?\s*$/) + if (progressReset) { + totalTicks = Number.parseInt(progressReset[1], 10) || 0 + currentTicks = 0 + matchedLines.push(`progress-reset:${totalTicks}`) + } + + const progressIncrement = line.match(/^\s*2\s+(\d+)\s*$/) + if (progressIncrement) { + currentTicks += Number.parseInt(progressIncrement[1], 10) || 0 + matchedLines.push(`progress-increment:${progressIncrement[1]}`) + } + + const progressAddition = line.match(/^\s*3\s+(\d+)\s*$/) + if (progressAddition) { + totalTicks += Number.parseInt(progressAddition[1], 10) || 0 + matchedLines.push(`progress-addition:${progressAddition[1]}`) + } + + if (/Installation success or error status:\s*0\b/.test(line)) { + percent = 100 + message = 'Installation complete. Restarting Farm Control...' + matchedLines.push('install-success') + } + } + + if (percent !== 100) { + if (totalTicks > 0) { + percent = Math.min(99, Math.round((currentTicks / totalTicks) * 100)) + } else if (actionStarts > 0) { + percent = Math.min( + 95, + Math.max(5, Math.round((actionEnds / actionStarts) * 90)) + ) + } + } + + return { + percent, + message, + stats: { + lineCount: lines.length, + actionStarts, + actionEnds, + totalTicks, + currentTicks, + matchedLines: matchedLines.slice(-8) + } + } +} + +const isWindowsInstallSuccessful = (output) => + /Installation success or error status:\s*0\b/.test(output) || + /MainEngineThread is returning 0\b/.test(output) + +const isWindowsInstallFailed = (output) => + /Installation success or error status:\s*[1-9]\d*\b/.test(output) || + /MainEngineThread is returning [1-9]\d*\b/.test(output) + +const isValidMsiPackage = async (filePath) => { const handle = await fs.open(filePath, 'r') try { - const header = Buffer.alloc(MZ_HEADER.length) + const header = Buffer.alloc(MSI_OLE_HEADER.length) await handle.read(header, 0, header.length, 0) - return header.equals(MZ_HEADER) + return header.equals(MSI_OLE_HEADER) } finally { await handle.close() } } -const prepareInstallerPath = async (installerPath) => { +export const prepareInstallerPath = async (installerPath) => { const fileName = path.basename(installerPath) const updateDir = path.join( os.homedir(), @@ -35,6 +158,7 @@ const prepareInstallerPath = async (installerPath) => { const stablePath = path.join(updateDir, fileName) await fs.copyFile(installerPath, stablePath) + // Resolve to a canonical long path. Short 8.3 paths (e.g. ADMINI~1) break msiexec. const resolvedPath = await fs.realpath(stablePath) const stats = await fs.stat(resolvedPath) @@ -42,25 +166,119 @@ const prepareInstallerPath = async (installerPath) => { throw new Error('Update installer file is missing or empty.') } - if (!(await isValidWindowsExecutable(resolvedPath))) { + if (!(await isValidMsiPackage(resolvedPath))) { throw new Error( - 'Downloaded update is not a valid Windows installer. The file may be corrupted or incomplete.' + 'Downloaded update is not a valid Windows Installer package. The file may be corrupted or incomplete.' ) } return resolvedPath } -const readInstallerLog = async (logPath) => { - try { - return await fs.readFile(logPath, 'utf8') - } catch { - return '' +const startWindowsInstallerProgressWatch = (logPath, sendProgress) => { + let installerOutput = '' + let lastLogSize = 0 + let lastPercent = null + let lastMessage = null + let pollCount = 0 + + const poll = async () => { + pollCount += 1 + + try { + const stat = await fs.stat(logPath) + if (stat.size === 0) { + debugLog(`poll #${pollCount}: log exists but is empty`, { logPath }) + return + } + + if (stat.size === lastLogSize) { + debugLog(`poll #${pollCount}: no new log data`, { + logPath, + size: stat.size + }) + return + } + + const buffer = Buffer.alloc(stat.size) + const handle = await fs.open(logPath, 'r') + try { + await handle.read(buffer, 0, stat.size, 0) + } finally { + await handle.close() + } + + lastLogSize = stat.size + installerOutput = decodeMsiLogBuffer(buffer) + + const { percent, message, stats } = + parseWindowsInstallerProgress(installerOutput) + const resolvedPercent = percent ?? lastPercent ?? 0 + const resolvedMessage = message || 'Installing update...' + + debugLog(`poll #${pollCount}: parsed installer log`, { + logPath, + size: stat.size, + textLength: installerOutput.length, + preview: installerOutput.slice(0, 240).replace(/\s+/g, ' '), + parsed: stats, + resolvedPercent, + resolvedMessage + }) + + if ( + resolvedPercent !== lastPercent || + resolvedMessage !== lastMessage + ) { + debugLog(`poll #${pollCount}: sending progress update`, { + percent: resolvedPercent, + message: resolvedMessage + }) + + lastPercent = resolvedPercent + lastMessage = resolvedMessage + sendProgress( { + phase: 'installing', + percent: resolvedPercent, + message: resolvedMessage + }) + } else { + debugLog(`poll #${pollCount}: progress unchanged, skipping UI update`, { + percent: resolvedPercent, + message: resolvedMessage + }) + } + } catch (error) { + if (error?.code === 'ENOENT') { + debugLog(`poll #${pollCount}: log file not created yet`, { logPath }) + return + } + + console.error(`${DEBUG_PREFIX} installer log poll error:`, error) + } + } + + const intervalId = setInterval(() => { + poll().catch((error) => { + console.error(`${DEBUG_PREFIX} installer log poll error:`, error) + }) + }, 300) + + return async () => { + clearInterval(intervalId) + await poll() + debugLog('stopped progress watch', { + logPath, + finalSize: lastLogSize, + textLength: installerOutput.length, + pollCount + }) + return installerOutput } } export const launchWindowsInstaller = async ( - app, + mainWindow, installerPath, webContents, { sendProgress, getInstallErrorMessage } @@ -74,7 +292,7 @@ export const launchWindowsInstaller = async ( logPath }) - sendProgress(webContents, { + sendProgress( { phase: 'installing', percent: 0, message: 'Installing update...' @@ -82,45 +300,76 @@ export const launchWindowsInstaller = async ( await fs.unlink(logPath).catch(() => {}) + // Allow file handles from the download/copy to settle before msiexec opens the MSI. await sleep(2000) + const stopProgressWatch = startWindowsInstallerProgressWatch( + logPath, + sendProgress + ) + return new Promise((resolve, reject) => { let processOutput = '' const startedAt = Date.now() - const installerArgs = ['/S', `/LOG=${logPath}`] + const installerArgs = [ + '/i', + resolvedPath, + '/qn', + '/norestart', + 'ALLUSERS=2', + 'MSIINSTALLPERUSER=1', + 'REBOOT=ReallySuppress', + '/L*v!', + logPath + ] - debugLog('spawning NSIS installer', { - installerPath: resolvedPath, + debugLog('spawning msiexec', { args: installerArgs, elapsedMs: Date.now() - startedAt }) - const installerProcess = spawn(resolvedPath, installerArgs, { + const installerProcess = spawn('msiexec.exe', installerArgs, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) installerProcess.stdout?.on('data', (data) => { - processOutput += data.toString('utf8') + const text = data.toString('utf16le') + processOutput += text + debugLog('msiexec stdout chunk', { + length: text.length, + preview: text.slice(0, 200) + }) }) installerProcess.stderr?.on('data', (data) => { - processOutput += data.toString('utf8') + const text = data.toString('utf16le') + processOutput += text + debugLog('msiexec stderr chunk', { + length: text.length, + preview: text.slice(0, 200) + }) }) installerProcess.on('spawn', () => { - debugLog('installer spawned', { + debugLog('msiexec spawned', { pid: installerProcess.pid, elapsedMs: Date.now() - startedAt }) }) - installerProcess.on('error', (error) => { + installerProcess.on('error', async (error) => { console.error(`${DEBUG_PREFIX} installer spawn error:`, error) + const watchedOutput = await stopProgressWatch() + + debugLog('installer spawn failed', { + watchedOutputLength: watchedOutput.length, + processOutputLength: processOutput.length + }) const message = error?.message || 'Failed to start update installer.' - sendProgress(webContents, { + sendProgress( { phase: 'error', percent: null, message @@ -129,25 +378,25 @@ export const launchWindowsInstaller = async ( }) installerProcess.on('exit', async (code, signal) => { - const logOutput = await readInstallerLog(logPath) - const output = [processOutput, logOutput].filter(Boolean).join('\n') + const watchedOutput = await stopProgressWatch() + const output = watchedOutput || processOutput + const finalParse = parseWindowsInstallerProgress(output) - debugLog('installer exited', { + debugLog('msiexec exited', { code, signal, elapsedMs: Date.now() - startedAt, - logOutputLength: logOutput.length, + watchedOutputLength: watchedOutput.length, processOutputLength: processOutput.length, + parsed: finalParse.stats, outputPreview: output.slice(0, 500).replace(/\s+/g, ' ') }) debugLog('keeping install log', { logPath }) if (code !== 0) { - const message = - getInstallErrorMessage(null, output) || - `Update installer failed with exit code ${code ?? 'unknown'}.` - sendProgress(webContents, { + const message = getInstallErrorMessage(null, output) + sendProgress( { phase: 'error', percent: null, message @@ -156,10 +405,34 @@ export const launchWindowsInstaller = async ( return } - sendProgress(webContents, { + const succeeded = + isWindowsInstallSuccessful(output) || + (code === 0 && !isWindowsInstallFailed(output)) + + debugLog('install success evaluation', { + succeeded, + isSuccessful: isWindowsInstallSuccessful(output), + isFailed: isWindowsInstallFailed(output), + exitCode: code + }) + + if (!succeeded) { + const message = getInstallErrorMessage(null, output) + sendProgress( { + phase: 'error', + percent: null, + message + }) + reject(new Error(message)) + return + } + + const { percent, message } = finalParse + + sendProgress( { phase: 'installing', - percent: 100, - message: 'Installation complete. Restarting Farm Control...' + percent: percent ?? 100, + message: message || 'Installation complete. Restarting Farm Control...' }) debugLog('installer completed successfully') diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1 index 4c6c364..34d69cc 100644 --- a/scripts/build-windows-msi.ps1 +++ b/scripts/build-windows-msi.ps1 @@ -8,9 +8,7 @@ param( [Parameter(Mandatory = $true)] [string]$Version, - [string]$UpgradeCode = "735812DB-E33B-57A0-8FBC-5FC3155925AA", - - [string]$ProductId = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + [string]$UpgradeCode = "735812DB-E33B-57A0-8FBC-5FC3155925AA" ) $ErrorActionPreference = "Stop" @@ -99,7 +97,6 @@ Copy-Item -LiteralPath $installerIconPath -Destination $installerIconWorkPath -F $msiVersion = Get-MsiVersion $Version $wxsContent = Get-Content -LiteralPath $templatePath -Raw -$wxsContent = $wxsContent.Replace("__PRODUCT_ID__", $ProductId) $wxsContent = $wxsContent.Replace("__UPGRADE_CODE__", $UpgradeCode) $wxsContent = $wxsContent.Replace("__VERSION__", $msiVersion) $wxsContent = $wxsContent.Replace("__SETUP_EXE__", (Escape-WixSourcePath $setupExePath)) diff --git a/src/desktop/appupdate.js b/src/desktop/appupdate.js index edf7025..214115b 100644 --- a/src/desktop/appupdate.js +++ b/src/desktop/appupdate.js @@ -15,7 +15,7 @@ const SUPPORTED_TARGETS = { osMatchers: ["darwin", "mac", "macos", "osx"], }, win32: { - extension: ".exe", + extension: ".msi", osMatchers: ["win32", "win", "windows"], }, }; @@ -102,6 +102,14 @@ const getInstallErrorMessage = (error, output = "") => { return "The administrator password was incorrect."; } + if ( + /1625/.test(combined) || + /forbidden by system policy/i.test(combined) || + /Non-assigned apps are disabled/i.test(combined) + ) { + return "Update installation was blocked by Windows Installer policy."; + } + return combined || "Failed to install update."; }; diff --git a/src/desktop/winappupdate.js b/src/desktop/winappupdate.js index 8e8f942..8b7e908 100644 --- a/src/desktop/winappupdate.js +++ b/src/desktop/winappupdate.js @@ -2,20 +2,143 @@ import { spawn } from 'child_process' import { promises as fs } from 'fs' import os from 'os' import path from 'path' +import process from 'process' -const MZ_HEADER = Buffer.from([0x4d, 0x5a]) +const MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) const DEBUG_PREFIX = '[app-update][win-progress]' const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const debugLog = () => {} -const isValidWindowsExecutable = async (filePath) => { +const decodeMsiLogBuffer = (buffer) => { + if (!buffer?.length) return '' + + if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { + debugLog('decoded MSI log as UTF-16 LE (BOM)') + return buffer.subarray(2).toString('utf16le') + } + + const sample = buffer.subarray(0, Math.min(buffer.length, 64)) + const looksUtf16 = + sample.length >= 4 && + sample.filter((byte) => byte === 0).length > sample.length / 4 + + if (looksUtf16) { + debugLog('decoded MSI log as UTF-16 LE (heuristic)') + return buffer.toString('utf16le') + } + + debugLog('decoded MSI log as UTF-8') + return buffer.toString('utf8') +} + +const formatMsiActionName = (actionName) => { + const humanized = String(actionName) + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .toLowerCase() + .trim() + + if (!humanized) return 'Installing update...' + + return `${humanized.charAt(0).toUpperCase()}${humanized.slice(1)}...` +} + +const parseWindowsInstallerProgress = (output) => { + const lines = String(output || '').split(/\r?\n/) + let percent = null + let message = 'Installing update...' + let totalTicks = 0 + let currentTicks = 0 + let actionStarts = 0 + let actionEnds = 0 + const matchedLines = [] + + for (const line of lines) { + const actionStart = line.match(/^Action start \d{2}:\d{2}:\d{2}: (.+?)\./) + if (actionStart) { + actionStarts += 1 + message = formatMsiActionName(actionStart[1]) + matchedLines.push(`action-start:${actionStart[1]}`) + } + + const doingAction = line.match(/Doing action:\s*(.+)$/) + if (doingAction && !actionStart) { + message = formatMsiActionName(doingAction[1]) + matchedLines.push(`doing-action:${doingAction[1]}`) + } + + if (/^Action ended \d{2}:\d{2}:\d{2}: .+?\. Return value \d+\./.test(line)) { + actionEnds += 1 + matchedLines.push('action-ended') + } + + const progressReset = line.match(/^\s*0\s+(\d+)\s+0(?:\s+\d+)?\s*$/) + if (progressReset) { + totalTicks = Number.parseInt(progressReset[1], 10) || 0 + currentTicks = 0 + matchedLines.push(`progress-reset:${totalTicks}`) + } + + const progressIncrement = line.match(/^\s*2\s+(\d+)\s*$/) + if (progressIncrement) { + currentTicks += Number.parseInt(progressIncrement[1], 10) || 0 + matchedLines.push(`progress-increment:${progressIncrement[1]}`) + } + + const progressAddition = line.match(/^\s*3\s+(\d+)\s*$/) + if (progressAddition) { + totalTicks += Number.parseInt(progressAddition[1], 10) || 0 + matchedLines.push(`progress-addition:${progressAddition[1]}`) + } + + if (/Installation success or error status:\s*0\b/.test(line)) { + percent = 100 + message = 'Installation complete. Restarting Farm Control...' + matchedLines.push('install-success') + } + } + + if (percent !== 100) { + if (totalTicks > 0) { + percent = Math.min(99, Math.round((currentTicks / totalTicks) * 100)) + } else if (actionStarts > 0) { + percent = Math.min( + 95, + Math.max(5, Math.round((actionEnds / actionStarts) * 90)) + ) + } + } + + return { + percent, + message, + stats: { + lineCount: lines.length, + actionStarts, + actionEnds, + totalTicks, + currentTicks, + matchedLines: matchedLines.slice(-8) + } + } +} + +const isWindowsInstallSuccessful = (output) => + /Installation success or error status:\s*0\b/.test(output) || + /MainEngineThread is returning 0\b/.test(output) + +const isWindowsInstallFailed = (output) => + /Installation success or error status:\s*[1-9]\d*\b/.test(output) || + /MainEngineThread is returning [1-9]\d*\b/.test(output) + +const isValidMsiPackage = async (filePath) => { const handle = await fs.open(filePath, 'r') try { - const header = Buffer.alloc(MZ_HEADER.length) + const header = Buffer.alloc(MSI_OLE_HEADER.length) await handle.read(header, 0, header.length, 0) - return header.equals(MZ_HEADER) + return header.equals(MSI_OLE_HEADER) } finally { await handle.close() } @@ -35,6 +158,7 @@ export const prepareInstallerPath = async (installerPath) => { const stablePath = path.join(updateDir, fileName) await fs.copyFile(installerPath, stablePath) + // Resolve to a canonical long path. Short 8.3 paths (e.g. ADMINI~1) break msiexec. const resolvedPath = await fs.realpath(stablePath) const stats = await fs.stat(resolvedPath) @@ -42,20 +166,114 @@ export const prepareInstallerPath = async (installerPath) => { throw new Error('Update installer file is missing or empty.') } - if (!(await isValidWindowsExecutable(resolvedPath))) { + if (!(await isValidMsiPackage(resolvedPath))) { throw new Error( - 'Downloaded update is not a valid Windows installer. The file may be corrupted or incomplete.' + 'Downloaded update is not a valid Windows Installer package. The file may be corrupted or incomplete.' ) } return resolvedPath } -const readInstallerLog = async (logPath) => { - try { - return await fs.readFile(logPath, 'utf8') - } catch { - return '' +const startWindowsInstallerProgressWatch = (logPath, sendProgress) => { + let installerOutput = '' + let lastLogSize = 0 + let lastPercent = null + let lastMessage = null + let pollCount = 0 + + const poll = async () => { + pollCount += 1 + + try { + const stat = await fs.stat(logPath) + if (stat.size === 0) { + debugLog(`poll #${pollCount}: log exists but is empty`, { logPath }) + return + } + + if (stat.size === lastLogSize) { + debugLog(`poll #${pollCount}: no new log data`, { + logPath, + size: stat.size + }) + return + } + + const buffer = Buffer.alloc(stat.size) + const handle = await fs.open(logPath, 'r') + try { + await handle.read(buffer, 0, stat.size, 0) + } finally { + await handle.close() + } + + lastLogSize = stat.size + installerOutput = decodeMsiLogBuffer(buffer) + + const { percent, message, stats } = + parseWindowsInstallerProgress(installerOutput) + const resolvedPercent = percent ?? lastPercent ?? 0 + const resolvedMessage = message || 'Installing update...' + + debugLog(`poll #${pollCount}: parsed installer log`, { + logPath, + size: stat.size, + textLength: installerOutput.length, + preview: installerOutput.slice(0, 240).replace(/\s+/g, ' '), + parsed: stats, + resolvedPercent, + resolvedMessage + }) + + if ( + resolvedPercent !== lastPercent || + resolvedMessage !== lastMessage + ) { + debugLog(`poll #${pollCount}: sending progress update`, { + percent: resolvedPercent, + message: resolvedMessage + }) + + lastPercent = resolvedPercent + lastMessage = resolvedMessage + sendProgress( { + phase: 'installing', + percent: resolvedPercent, + message: resolvedMessage + }) + } else { + debugLog(`poll #${pollCount}: progress unchanged, skipping UI update`, { + percent: resolvedPercent, + message: resolvedMessage + }) + } + } catch (error) { + if (error?.code === 'ENOENT') { + debugLog(`poll #${pollCount}: log file not created yet`, { logPath }) + return + } + + console.error(`${DEBUG_PREFIX} installer log poll error:`, error) + } + } + + const intervalId = setInterval(() => { + poll().catch((error) => { + console.error(`${DEBUG_PREFIX} installer log poll error:`, error) + }) + }, 300) + + return async () => { + clearInterval(intervalId) + await poll() + debugLog('stopped progress watch', { + logPath, + finalSize: lastLogSize, + textLength: installerOutput.length, + pollCount + }) + return installerOutput } } @@ -74,7 +292,7 @@ export const launchWindowsInstaller = async ( logPath }) - sendProgress({ + sendProgress( { phase: 'installing', percent: 0, message: 'Installing update...' @@ -82,45 +300,76 @@ export const launchWindowsInstaller = async ( await fs.unlink(logPath).catch(() => {}) + // Allow file handles from the download/copy to settle before msiexec opens the MSI. await sleep(2000) + const stopProgressWatch = startWindowsInstallerProgressWatch( + logPath, + sendProgress + ) + return new Promise((resolve, reject) => { let processOutput = '' const startedAt = Date.now() - const installerArgs = ['/S', `/LOG=${logPath}`] + const installerArgs = [ + '/i', + resolvedPath, + '/qn', + '/norestart', + 'ALLUSERS=2', + 'MSIINSTALLPERUSER=1', + 'REBOOT=ReallySuppress', + '/L*v!', + logPath + ] - debugLog('spawning NSIS installer', { - installerPath: resolvedPath, + debugLog('spawning msiexec', { args: installerArgs, elapsedMs: Date.now() - startedAt }) - const installerProcess = spawn(resolvedPath, installerArgs, { + const installerProcess = spawn('msiexec.exe', installerArgs, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) installerProcess.stdout?.on('data', (data) => { - processOutput += data.toString('utf8') + const text = data.toString('utf16le') + processOutput += text + debugLog('msiexec stdout chunk', { + length: text.length, + preview: text.slice(0, 200) + }) }) installerProcess.stderr?.on('data', (data) => { - processOutput += data.toString('utf8') + const text = data.toString('utf16le') + processOutput += text + debugLog('msiexec stderr chunk', { + length: text.length, + preview: text.slice(0, 200) + }) }) installerProcess.on('spawn', () => { - debugLog('installer spawned', { + debugLog('msiexec spawned', { pid: installerProcess.pid, elapsedMs: Date.now() - startedAt }) }) - installerProcess.on('error', (error) => { + installerProcess.on('error', async (error) => { console.error(`${DEBUG_PREFIX} installer spawn error:`, error) + const watchedOutput = await stopProgressWatch() + + debugLog('installer spawn failed', { + watchedOutputLength: watchedOutput.length, + processOutputLength: processOutput.length + }) const message = error?.message || 'Failed to start update installer.' - sendProgress({ + sendProgress( { phase: 'error', percent: null, message @@ -129,25 +378,25 @@ export const launchWindowsInstaller = async ( }) installerProcess.on('exit', async (code, signal) => { - const logOutput = await readInstallerLog(logPath) - const output = [processOutput, logOutput].filter(Boolean).join('\n') + const watchedOutput = await stopProgressWatch() + const output = watchedOutput || processOutput + const finalParse = parseWindowsInstallerProgress(output) - debugLog('installer exited', { + debugLog('msiexec exited', { code, signal, elapsedMs: Date.now() - startedAt, - logOutputLength: logOutput.length, + watchedOutputLength: watchedOutput.length, processOutputLength: processOutput.length, + parsed: finalParse.stats, outputPreview: output.slice(0, 500).replace(/\s+/g, ' ') }) debugLog('keeping install log', { logPath }) if (code !== 0) { - const message = - getInstallErrorMessage(null, output) || - `Update installer failed with exit code ${code ?? 'unknown'}.` - sendProgress({ + const message = getInstallErrorMessage(null, output) + sendProgress( { phase: 'error', percent: null, message @@ -156,10 +405,34 @@ export const launchWindowsInstaller = async ( return } - sendProgress({ + const succeeded = + isWindowsInstallSuccessful(output) || + (code === 0 && !isWindowsInstallFailed(output)) + + debugLog('install success evaluation', { + succeeded, + isSuccessful: isWindowsInstallSuccessful(output), + isFailed: isWindowsInstallFailed(output), + exitCode: code + }) + + if (!succeeded) { + const message = getInstallErrorMessage(null, output) + sendProgress( { + phase: 'error', + percent: null, + message + }) + reject(new Error(message)) + return + } + + const { percent, message } = finalParse + + sendProgress( { phase: 'installing', - percent: 100, - message: 'Installation complete. Restarting Farm Control...' + percent: percent ?? 100, + message: message || 'Installation complete. Restarting Farm Control...' }) debugLog('installer completed successfully')