diff --git a/public/appupdate.js b/public/appupdate.js index 57ed27c..7b1a457 100644 --- a/public/appupdate.js +++ b/public/appupdate.js @@ -1,21 +1,21 @@ -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 { 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'] }, win32: { - extension: '.exe', + extension: '.msi', osMatchers: ['win32', 'win', 'windows'] } } @@ -87,14 +87,6 @@ const selectUpdateArtifact = ( 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() @@ -110,11 +102,17 @@ 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.' } -const installerHelpers = { sendProgress, getInstallErrorMessage } - const getDownloadUrl = (url, redirectCount = 0) => new Promise((resolve, reject) => { if (redirectCount > 5) { @@ -144,7 +142,7 @@ const getDownloadUrl = (url, redirectCount = 0) => request.on('error', reject) }) -const downloadArtifact = async (artifact, destinationPath, webContents) => { +const downloadArtifact = async (artifact, destinationPath, sendProgress) => { const { response } = await getDownloadUrl(artifact.url) if (response.statusCode < 200 || response.statusCode >= 300) { @@ -165,7 +163,7 @@ const downloadArtifact = async (artifact, destinationPath, webContents) => { ? Math.round((downloadedBytes / totalBytes) * 100) : null - sendProgress(webContents, { + sendProgress({ phase: 'downloading', percent, downloadedBytes, @@ -183,33 +181,36 @@ const downloadArtifact = async (artifact, destinationPath, webContents) => { }) } -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, + await launchMacInstaller( + mainWindow, installerPath, - webContents, + sendProgress, installerHelpers ) - restartApp(app) - return + } 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 runAppUpdate = async (mainWindow, update, sendProgress) => { const artifact = selectUpdateArtifact(update) const tempDirectory = await fs.mkdtemp( path.join(os.tmpdir(), 'farmcontrol-update-') @@ -217,16 +218,16 @@ const runAppUpdate = async (app, update, webContents) => { const artifactName = path.basename(getArtifactName(artifact)) const installerPath = path.join(tempDirectory, artifactName) - sendProgress(webContents, { + sendProgress({ phase: 'preparing', percent: 0, artifact, message: 'Preparing update download' }) - await downloadArtifact(artifact, installerPath, webContents) + await downloadArtifact(artifact, installerPath, sendProgress) - sendProgress(webContents, { + sendProgress({ phase: 'downloaded', percent: 100, downloadedBytes: null, @@ -235,28 +236,25 @@ const runAppUpdate = async (app, update, webContents) => { message: 'Update downloaded' }) - await launchInstallerAndQuit(app, installerPath, webContents) + await launchInstallerAndRestart(mainWindow, installerPath, sendProgress) } -export function setupAppUpdateIPC(app) { - ipcMain.handle('app-update-start', async (event, update) => { - if (runningUpdate) return runningUpdate +export function startAppUpdate(mainWindow, update, sendProgress) { + 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 + 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 - }) + return runningUpdate } diff --git a/public/winappupdate.js b/public/winappupdate.js index 8a64fc1..25a3424 100644 --- a/public/winappupdate.js +++ b/public/winappupdate.js @@ -21,7 +21,7 @@ const isValidWindowsExecutable = async (filePath) => { } } -const prepareInstallerPath = async (installerPath) => { +export const prepareInstallerPath = async (installerPath) => { const fileName = path.basename(installerPath) const updateDir = path.join( os.homedir(), @@ -51,16 +51,107 @@ const prepareInstallerPath = async (installerPath) => { 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 +165,7 @@ export const launchWindowsInstaller = async ( logPath }) - sendProgress(webContents, { + sendProgress({ phase: 'installing', percent: 0, message: 'Installing update...' @@ -84,11 +175,26 @@ export const launchWindowsInstaller = async ( 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, @@ -120,7 +226,7 @@ export const launchWindowsInstaller = async ( console.error(`${DEBUG_PREFIX} installer spawn error:`, error) const message = error?.message || 'Failed to start update installer.' - sendProgress(webContents, { + sendProgress({ phase: 'error', percent: null, message @@ -144,10 +250,8 @@ export const launchWindowsInstaller = async ( 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,7 +260,31 @@ 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...' diff --git a/src/desktop/winappupdate.js b/src/desktop/winappupdate.js index 8e8f942..c568974 100644 --- a/src/desktop/winappupdate.js +++ b/src/desktop/winappupdate.js @@ -88,7 +88,17 @@ export const launchWindowsInstaller = async ( 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, diff --git a/src/desktop/windows-work-area.js b/src/desktop/windows-work-area.js index 1e9fbdb..7df97a1 100644 --- a/src/desktop/windows-work-area.js +++ b/src/desktop/windows-work-area.js @@ -153,10 +153,10 @@ export function syncMaximizedWindowFrame(window) { } window.setFrame( - maximizedFrame.x, - maximizedFrame.y, - maximizedFrame.width, - maximizedFrame.height + workArea.x - 8, + workArea.y, + workArea.width + 16, + workArea.height + 8 ) return true }