diff --git a/packaging/windows/farmcontrol.nsi b/packaging/windows/farmcontrol.nsi index 49884d5..c2174ff 100644 --- a/packaging/windows/farmcontrol.nsi +++ b/packaging/windows/farmcontrol.nsi @@ -71,6 +71,10 @@ Function .onInstFailed !insertmacro progressFailure "Installation failed." FunctionEnd +Function .onInstSuccess + Call restartFarmControlAfterUpdate +FunctionEnd + Section "Farm Control" SecMain SectionIn RO diff --git a/packaging/windows/installer.nsh b/packaging/windows/installer.nsh index 3701bc9..2af167b 100644 --- a/packaging/windows/installer.nsh +++ b/packaging/windows/installer.nsh @@ -4,10 +4,12 @@ Var ProgressLogFile Var IsInAppUpdate +Var RestartAfterInstall !macro initProgressLog StrCpy $ProgressLogFile "" StrCpy $IsInAppUpdate "0" + StrCpy $RestartAfterInstall "0" ${GetParameters} $R9 @@ -17,6 +19,12 @@ Var IsInAppUpdate StrCpy $IsInAppUpdate "1" ${EndIf} + ClearErrors + ${GetOptions} $R9 "/RESTARTFC" $R8 + ${IfNot} ${Errors} + StrCpy $RestartAfterInstall "1" + ${EndIf} + ; Prefer env var so paths with spaces are reliable; /LOG= remains supported. ReadEnvStr $ProgressLogFile "FARMCONTROL_INSTALL_LOG" ${If} $ProgressLogFile == "" @@ -229,3 +237,24 @@ Var IsInAppUpdate !insertmacro removeStartMenuShortcut DeleteRegKey HKCU "Software\Classes\farmcontrol" !macroend + +; Wait for the running app to exit, then relaunch. Only used when /RESTARTFC is passed. +Function restartFarmControlAfterUpdate + ${If} $RestartAfterInstall != "1" + Return + ${EndIf} + + !insertmacro progressStatus "Waiting for Farm Control to close..." + Sleep 1000 + + restart_wait_loop: + ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq launcher.exe" 2>nul | find /I "launcher.exe"' $R0 + ${If} $R0 == 0 + Sleep 1000 + Goto restart_wait_loop + ${EndIf} + + !insertmacro progressStatus "Starting Farm Control..." + SetOutPath "$INSTDIR\bin" + Exec "$INSTDIR\bin\launcher.exe" +FunctionEnd diff --git a/src/desktop/appupdate.js b/src/desktop/appupdate.js index c36518b..c06691a 100644 --- a/src/desktop/appupdate.js +++ b/src/desktop/appupdate.js @@ -206,8 +206,11 @@ const launchInstallerAndRestart = async ( throw new Error(`App updates are not supported on ${process.platform}.`); } - scheduleAppRestart(); - // Give the detached restart watcher a moment to start, and let the UI show 100%. + if (process.platform === "darwin") { + scheduleAppRestart(); + } + + // Give the UI a moment to show completion before the app exits. await new Promise((resolve) => setTimeout(resolve, 1000)); Utils.quit(); }; diff --git a/src/desktop/updater-runner.js b/src/desktop/updater-runner.js index e9dbd4a..3c3de5f 100644 --- a/src/desktop/updater-runner.js +++ b/src/desktop/updater-runner.js @@ -1,5 +1,5 @@ import { execSync, spawn } from 'node:child_process' -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import process from 'node:process' @@ -131,57 +131,6 @@ const spawnDetachedMacRestart = ({ appLaunchPath, parentPid }) => { child.unref() } -const spawnDetachedWindowsRestart = ({ appLaunchPath, parentPid }) => { - const updateDir = path.join( - os.homedir(), - 'AppData', - 'Local', - 'FarmControl', - 'Updates' - ) - const scriptPath = path.join(updateDir, `restart-${parentPid}.cmd`) - const appLaunchWin = appLaunchPath.replaceAll('/', '\\') - const appDirWin = path.dirname(appLaunchWin) - - // Separate process: wait until Farm Control's PID exits, then relaunch launcher.exe. - const script = `@echo off -setlocal EnableExtensions - -set "PARENT_PID=${parentPid}" -set "APP_LAUNCHER=${appLaunchWin}" -set "APP_DIR=${appDirWin}" - -:waitparent -tasklist /FI "PID eq %PARENT_PID%" 2>NUL | findstr /I /C:"%PARENT_PID%" >NUL -if not errorlevel 1 ( - timeout /t 1 /nobreak >NUL - goto waitparent -) - -timeout /t 1 /nobreak >NUL -cd /d "%APP_DIR%" -start "" "%APP_LAUNCHER%" - -timeout /t 1 /nobreak >NUL -del "%~f0" >NUL 2>&1 -` - - mkdirSync(updateDir, { recursive: true }) - writeFileSync(scriptPath, script, 'utf8') - - const child = spawn( - process.env.ComSpec || 'cmd.exe', - ['/d', '/c', scriptPath], - { - detached: true, - stdio: 'ignore', - windowsHide: true, - cwd: updateDir - } - ) - child.unref() -} - export const scheduleAppRestart = ({ parentPid = process.pid } = {}) => { const appLaunchPath = resolveAppLaunchPath() @@ -190,10 +139,5 @@ export const scheduleAppRestart = ({ parentPid = process.pid } = {}) => { return { appLaunchPath } } - if (process.platform === 'win32') { - spawnDetachedWindowsRestart({ appLaunchPath, parentPid }) - return { appLaunchPath } - } - - throw new Error(`App updates are not supported on ${process.platform}.`) + throw new Error(`App restart scheduling is not supported on ${process.platform}.`) } diff --git a/src/desktop/winappupdate.js b/src/desktop/winappupdate.js index c6bebac..2fc0687 100644 --- a/src/desktop/winappupdate.js +++ b/src/desktop/winappupdate.js @@ -84,11 +84,16 @@ export const prepareInstallerPath = async (installerPath) => { return resolvedPath } -const startWindowsInstallerProgressWatch = (logPath, sendProgress) => { +const startWindowsInstallerProgressWatch = ( + logPath, + sendProgress, + onInstallSuccessful +) => { let installerOutput = '' let offset = 0 let lastPercent = null let lastMessage = null + let installSuccessNotified = false const poll = async () => { try { @@ -114,6 +119,15 @@ const startWindowsInstallerProgressWatch = (logPath, sendProgress) => { message: resolvedMessage }) } + + if ( + !installSuccessNotified && + isWindowsInstallSuccessful(installerOutput) && + !isWindowsInstallFailed(installerOutput) + ) { + installSuccessNotified = true + onInstallSuccessful?.(installerOutput) + } } finally { await handle.close() } @@ -162,81 +176,12 @@ export const launchWindowsInstaller = async ( mainWindow.show?.() } - const stopProgressWatch = startWindowsInstallerProgressWatch( - logPath, - sendProgress - ) - return new Promise((resolve, reject) => { - // Silent NSIS install in a child process (not a batch file). - // /S = silent (https://nsis.sourceforge.io/Reference/SilentInstall) - // /UPDATE = in-app update (skip killing this process; overwrite in place) - // /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS) - const installerArgs = ['/S', '/UPDATE', `/LOG=${logPath}`] + let settled = false - const installerProcess = spawn(resolvedPath, installerArgs, { - detached: false, - env: { - ...process.env, - FARMCONTROL_INSTALL_LOG: logPath - }, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true - }) - - let processOutput = '' - - installerProcess.stdout?.on('data', (data) => { - processOutput += data.toString('utf8') - }) - - installerProcess.stderr?.on('data', (data) => { - processOutput += data.toString('utf8') - }) - - installerProcess.on('error', async (error) => { - const watchedOutput = await stopProgressWatch() - const output = watchedOutput || processOutput - console.error('[app-update] installer error:', error) - const message = getInstallErrorMessage(error, output) - sendProgress({ - phase: 'error', - percent: null, - message - }) - reject(new Error(message)) - }) - - installerProcess.on('exit', async (code) => { - const watchedOutput = await stopProgressWatch() - const output = watchedOutput || processOutput - - await fs.unlink(logPath).catch(() => {}) - - if (code !== 0) { - const message = getInstallErrorMessage( - new Error(`Installer exited with code ${code}.`), - output - ) - sendProgress({ - phase: 'error', - percent: null, - message - }) - reject(new Error(message)) - return - } - - if (isWindowsInstallFailed(output) || !isWindowsInstallSuccessful(output)) { - const message = getInstallErrorMessage(null, output) - sendProgress({ - phase: 'error', - percent: null, - message - }) - reject(new Error(message)) - return - } + const settleSuccess = (output) => { + if (settled) return + settled = true const { percent, message } = parseWindowsInstallerProgress(output) @@ -247,6 +192,85 @@ export const launchWindowsInstaller = async ( }) resolve() + } + + const settleFailure = (message) => { + if (settled) return + settled = true + + sendProgress({ + phase: 'error', + percent: null, + message + }) + reject(new Error(message)) + } + + const stopProgressWatch = startWindowsInstallerProgressWatch( + logPath, + sendProgress, + (output) => { + settleSuccess(output) + } + ) + + const refreshInstallerOutput = async () => { + return await stopProgressWatch() + } + + // Silent NSIS install in a detached child process (not a batch file). + // /S = silent (https://nsis.sourceforge.io/Reference/SilentInstall) + // /UPDATE = in-app update (skip killing this process; overwrite in place) + // /RESTARTFC = installer waits for this process to exit and relaunches the app + // /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS) + const installerArgs = [ + '/S', + '/UPDATE', + '/RESTARTFC', + `/LOG=${logPath}` + ] + + const installerProcess = spawn(resolvedPath, installerArgs, { + detached: true, + env: { + ...process.env, + FARMCONTROL_INSTALL_LOG: logPath + }, + stdio: 'ignore', + windowsHide: true + }) + + installerProcess.unref() + + installerProcess.on('error', async (error) => { + const output = await refreshInstallerOutput() + console.error('[app-update] installer error:', error) + settleFailure(getInstallErrorMessage(error, output)) + }) + + installerProcess.on('exit', async (code) => { + const output = await refreshInstallerOutput() + + await fs.unlink(logPath).catch(() => {}) + + if (settled) return + + if (code !== 0) { + settleFailure( + getInstallErrorMessage( + new Error(`Installer exited with code ${code}.`), + output + ) + ) + return + } + + if (isWindowsInstallFailed(output) || !isWindowsInstallSuccessful(output)) { + settleFailure(getInstallErrorMessage(null, output)) + return + } + + settleSuccess(output) }) }) }