From 98ee99ae69bcf4676c22640197b93b7ed4aa1eef Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 3 Aug 2026 18:03:22 +0100 Subject: [PATCH 1/4] Update Windows installer to use `.exe` extension instead of `.msi` for compatibility with new executable format. Refactor validation function to check for Windows executables, enhancing installer integrity checks. --- public/appupdate.js | 2 +- public/winappupdate.js | 331 +++-------------------------------- src/desktop/appupdate.js | 2 +- src/desktop/winappupdate.js | 334 ++++-------------------------------- 4 files changed, 62 insertions(+), 607 deletions(-) diff --git a/public/appupdate.js b/public/appupdate.js index 52201bc..57ed27c 100644 --- a/public/appupdate.js +++ b/public/appupdate.js @@ -15,7 +15,7 @@ const SUPPORTED_TARGETS = { osMatchers: ['darwin', 'mac', 'macos', 'osx'] }, win32: { - extension: '.msi', + extension: '.exe', osMatchers: ['win32', 'win', 'windows'] } } diff --git a/public/winappupdate.js b/public/winappupdate.js index db12610..8a64fc1 100644 --- a/public/winappupdate.js +++ b/public/winappupdate.js @@ -2,143 +2,20 @@ import { spawn } from 'child_process' import { promises as fs } from 'fs' import os from 'os' import path from 'path' -import process from 'process' -const MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) +const MZ_HEADER = Buffer.from([0x4d, 0x5a]) const DEBUG_PREFIX = '[app-update][win-progress]' const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const debugLog = () => {} -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 isValidWindowsExecutable = async (filePath) => { const handle = await fs.open(filePath, 'r') try { - const header = Buffer.alloc(MSI_OLE_HEADER.length) + const header = Buffer.alloc(MZ_HEADER.length) await handle.read(header, 0, header.length, 0) - return header.equals(MSI_OLE_HEADER) + return header.equals(MZ_HEADER) } finally { await handle.close() } @@ -158,7 +35,6 @@ 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) @@ -166,118 +42,20 @@ const prepareInstallerPath = async (installerPath) => { throw new Error('Update installer file is missing or empty.') } - if (!(await isValidMsiPackage(resolvedPath))) { + if (!(await isValidWindowsExecutable(resolvedPath))) { throw new Error( - 'Downloaded update is not a valid Windows Installer package. The file may be corrupted or incomplete.' + 'Downloaded update is not a valid Windows installer. The file may be corrupted or incomplete.' ) } return resolvedPath } -const startWindowsInstallerProgressWatch = ( - logPath, - webContents, - 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(webContents, { - 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 +const readInstallerLog = async (logPath) => { + try { + return await fs.readFile(logPath, 'utf8') + } catch { + return '' } } @@ -304,71 +82,42 @@ 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, - webContents, - sendProgress - ) - return new Promise((resolve, reject) => { let processOutput = '' const startedAt = Date.now() - const installerArgs = [ - '/i', - resolvedPath, - '/qn', - '/norestart', - '/L*v!', - logPath - ] + const installerArgs = ['/S', `/LOG=${logPath}`] - debugLog('spawning msiexec', { + debugLog('spawning NSIS installer', { + installerPath: resolvedPath, args: installerArgs, elapsedMs: Date.now() - startedAt }) - const installerProcess = spawn('msiexec.exe', installerArgs, { + const installerProcess = spawn(resolvedPath, installerArgs, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) installerProcess.stdout?.on('data', (data) => { - const text = data.toString('utf16le') - processOutput += text - debugLog('msiexec stdout chunk', { - length: text.length, - preview: text.slice(0, 200) - }) + processOutput += data.toString('utf8') }) installerProcess.stderr?.on('data', (data) => { - const text = data.toString('utf16le') - processOutput += text - debugLog('msiexec stderr chunk', { - length: text.length, - preview: text.slice(0, 200) - }) + processOutput += data.toString('utf8') }) installerProcess.on('spawn', () => { - debugLog('msiexec spawned', { + debugLog('installer spawned', { pid: installerProcess.pid, elapsedMs: Date.now() - startedAt }) }) - installerProcess.on('error', async (error) => { + installerProcess.on('error', (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, { @@ -380,24 +129,24 @@ export const launchWindowsInstaller = async ( }) installerProcess.on('exit', async (code, signal) => { - const watchedOutput = await stopProgressWatch() - const output = watchedOutput || processOutput - const finalParse = parseWindowsInstallerProgress(output) + const logOutput = await readInstallerLog(logPath) + const output = [processOutput, logOutput].filter(Boolean).join('\n') - debugLog('msiexec exited', { + debugLog('installer exited', { code, signal, elapsedMs: Date.now() - startedAt, - watchedOutputLength: watchedOutput.length, + logOutputLength: logOutput.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) + const message = + getInstallErrorMessage(null, output) || + `Update installer failed with exit code ${code ?? 'unknown'}.` sendProgress(webContents, { phase: 'error', percent: null, @@ -407,34 +156,10 @@ export const launchWindowsInstaller = async ( return } - 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(webContents, { - phase: 'error', - percent: null, - message - }) - reject(new Error(message)) - return - } - - const { percent, message } = finalParse - sendProgress(webContents, { phase: 'installing', - percent: percent ?? 100, - message: message || 'Installation complete. Restarting Farm Control...' + percent: 100, + message: 'Installation complete. Restarting Farm Control...' }) debugLog('installer completed successfully') diff --git a/src/desktop/appupdate.js b/src/desktop/appupdate.js index bd38dff..edf7025 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: ".msi", + extension: ".exe", osMatchers: ["win32", "win", "windows"], }, }; diff --git a/src/desktop/winappupdate.js b/src/desktop/winappupdate.js index d8b516c..8e8f942 100644 --- a/src/desktop/winappupdate.js +++ b/src/desktop/winappupdate.js @@ -2,143 +2,20 @@ import { spawn } from 'child_process' import { promises as fs } from 'fs' import os from 'os' import path from 'path' -import process from 'process' -const MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) +const MZ_HEADER = Buffer.from([0x4d, 0x5a]) const DEBUG_PREFIX = '[app-update][win-progress]' const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const debugLog = () => {} -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 isValidWindowsExecutable = async (filePath) => { const handle = await fs.open(filePath, 'r') try { - const header = Buffer.alloc(MSI_OLE_HEADER.length) + const header = Buffer.alloc(MZ_HEADER.length) await handle.read(header, 0, header.length, 0) - return header.equals(MSI_OLE_HEADER) + return header.equals(MZ_HEADER) } finally { await handle.close() } @@ -158,7 +35,6 @@ 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) @@ -166,114 +42,20 @@ export const prepareInstallerPath = async (installerPath) => { throw new Error('Update installer file is missing or empty.') } - if (!(await isValidMsiPackage(resolvedPath))) { + if (!(await isValidWindowsExecutable(resolvedPath))) { throw new Error( - 'Downloaded update is not a valid Windows Installer package. The file may be corrupted or incomplete.' + 'Downloaded update is not a valid Windows installer. The file may be corrupted or incomplete.' ) } return resolvedPath } -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 +const readInstallerLog = async (logPath) => { + try { + return await fs.readFile(logPath, 'utf8') + } catch { + return '' } } @@ -292,7 +74,7 @@ export const launchWindowsInstaller = async ( logPath }) - sendProgress( { + sendProgress({ phase: 'installing', percent: 0, message: 'Installing update...' @@ -300,73 +82,45 @@ 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 = [ - '/i', - resolvedPath, - '/qn', - '/norestart', - '/L*v!', - logPath - ] + const installerArgs = ['/S', `/LOG=${logPath}`] - debugLog('spawning msiexec', { + debugLog('spawning NSIS installer', { + installerPath: resolvedPath, args: installerArgs, elapsedMs: Date.now() - startedAt }) - const installerProcess = spawn('msiexec.exe', installerArgs, { + const installerProcess = spawn(resolvedPath, installerArgs, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) installerProcess.stdout?.on('data', (data) => { - const text = data.toString('utf16le') - processOutput += text - debugLog('msiexec stdout chunk', { - length: text.length, - preview: text.slice(0, 200) - }) + processOutput += data.toString('utf8') }) installerProcess.stderr?.on('data', (data) => { - const text = data.toString('utf16le') - processOutput += text - debugLog('msiexec stderr chunk', { - length: text.length, - preview: text.slice(0, 200) - }) + processOutput += data.toString('utf8') }) installerProcess.on('spawn', () => { - debugLog('msiexec spawned', { + debugLog('installer spawned', { pid: installerProcess.pid, elapsedMs: Date.now() - startedAt }) }) - installerProcess.on('error', async (error) => { + installerProcess.on('error', (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 @@ -375,25 +129,25 @@ export const launchWindowsInstaller = async ( }) installerProcess.on('exit', async (code, signal) => { - const watchedOutput = await stopProgressWatch() - const output = watchedOutput || processOutput - const finalParse = parseWindowsInstallerProgress(output) + const logOutput = await readInstallerLog(logPath) + const output = [processOutput, logOutput].filter(Boolean).join('\n') - debugLog('msiexec exited', { + debugLog('installer exited', { code, signal, elapsedMs: Date.now() - startedAt, - watchedOutputLength: watchedOutput.length, + logOutputLength: logOutput.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) - sendProgress( { + const message = + getInstallErrorMessage(null, output) || + `Update installer failed with exit code ${code ?? 'unknown'}.` + sendProgress({ phase: 'error', percent: null, message @@ -402,34 +156,10 @@ export const launchWindowsInstaller = async ( return } - 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( { + sendProgress({ phase: 'installing', - percent: percent ?? 100, - message: message || 'Installation complete. Restarting Farm Control...' + percent: 100, + message: 'Installation complete. Restarting Farm Control...' }) debugLog('installer completed successfully') From 79ee30fd25d990ecc0e73ef0cfb2d0f9745af9d6 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 3 Aug 2026 18:21:52 +0100 Subject: [PATCH 2/4] Refactor window state handling in `window.js` and adjust frame dimensions in `windows-work-area.js` - Removed redundant window state synchronization calls in `applyWindowsStartupWindowState` to streamline the startup process. - Updated the `clampWindowToWorkArea` function to adjust the window frame dimensions, ensuring proper fitting within the work area. --- src/desktop/window.js | 4 ---- src/desktop/windows-work-area.js | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/desktop/window.js b/src/desktop/window.js index bf75a10..57ca70a 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -184,10 +184,6 @@ function applyWindowsStartupWindowState(window) { setTimeout(() => { window.maximize?.() - handleWindowsWindowChange(window) - setTimeout(() => syncWindowsWebviewLayout(window), 0) - setTimeout(() => syncWindowsWebviewLayout(window), 100) - setTimeout(broadcastWindowState, 100) }, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS) } diff --git a/src/desktop/windows-work-area.js b/src/desktop/windows-work-area.js index 7645e81..ce49de7 100644 --- a/src/desktop/windows-work-area.js +++ b/src/desktop/windows-work-area.js @@ -78,6 +78,6 @@ export function clampWindowToWorkArea(window) { return false } - window.setFrame(workArea.x, workArea.y, workArea.width, workArea.height) + window.setFrame(workArea.x - 8, workArea.y, workArea.width + 16, workArea.height + 8) return true } From debf6802cecf441a2981123d48ded368a1338d33 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 3 Aug 2026 22:44:46 +0100 Subject: [PATCH 3/4] 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') From 2383d14a804d5f30080e57a0845af37bb7b9272f Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 3 Aug 2026 22:54:06 +0100 Subject: [PATCH 4/4] Refactor Windows installer shortcut creation process - Removed the `fixShortcutWorkingDir` macro to simplify the shortcut creation logic. - Updated `createDesktopShortcut` and `createStartMenuShortcut` macros to directly set the output path for shortcuts, enhancing clarity and maintainability. --- packaging/windows/installer.nsh | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/packaging/windows/installer.nsh b/packaging/windows/installer.nsh index e3b6fa3..6756d0b 100644 --- a/packaging/windows/installer.nsh +++ b/packaging/windows/installer.nsh @@ -42,40 +42,17 @@ done_uninstall: !macroend -!macro fixShortcutWorkingDir SHORTCUT_PATH WORKING_DIR - Push $0 - Push $1 - Push $2 - StrCpy $1 "${SHORTCUT_PATH}" - StrCpy $2 "${WORKING_DIR}" - InitPluginsDir - FileOpen $0 "$PLUGINSDIR\fix-shortcut.ps1" w - FileWrite $0 '$$s = (New-Object -COM WScript.Shell).CreateShortcut("' - FileWrite $0 $1 - FileWrite $0 '")$\r$\n' - FileWrite $0 '$$s.WorkingDirectory = "' - FileWrite $0 $2 - FileWrite $0 '"$\r$\n' - FileWrite $0 '$$s.Save()$\r$\n' - FileClose $0 - ExecWait '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\fix-shortcut.ps1"' - Delete "$PLUGINSDIR\fix-shortcut.ps1" - Pop $2 - Pop $1 - Pop $0 -!macroend - !macro createDesktopShortcut SetShellVarContext current + SetOutPath "$INSTDIR\bin" CreateShortCut "$DESKTOP\Farm Control.lnk" "$INSTDIR\bin\launcher.exe" "" "$INSTDIR\bin\launcher.exe" 0 SW_SHOWNORMAL "" "Farm Control" - !insertmacro fixShortcutWorkingDir "$DESKTOP\Farm Control.lnk" "$INSTDIR\bin" !macroend !macro createStartMenuShortcut SetShellVarContext current CreateDirectory "$SMPROGRAMS\Farm Control" + SetOutPath "$INSTDIR\bin" CreateShortCut "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$INSTDIR\bin\launcher.exe" "" "$INSTDIR\bin\launcher.exe" 0 SW_SHOWNORMAL "" "Farm Control" - !insertmacro fixShortcutWorkingDir "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$INSTDIR\bin" !macroend !macro removeDesktopShortcut