From 2bbecb0d6add06b13743cba4381546377ea4893b Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 9 Aug 2026 10:32:56 +0100 Subject: [PATCH] Implement file enumeration and progress tracking in Windows installer - Added a new PowerShell script to generate NSIS file lists, enabling the installer to track file copy progress. - Updated the NSIS scripts to include macros for logging total bytes and individual file copy progress during installation. - Enhanced the `winappupdate.js` to parse and display copy progress information, improving user feedback during updates. --- packaging/windows/farmcontrol.nsi | 18 ++++++--- packaging/windows/installer.nsh | 19 +++++++++ scripts/build-windows-nsis.ps1 | 3 ++ scripts/generate-nsis-file-list.ps1 | 62 +++++++++++++++++++++++++++++ src/desktop/winappupdate.js | 44 +++++++++++++++++++- 5 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 scripts/generate-nsis-file-list.ps1 diff --git a/packaging/windows/farmcontrol.nsi b/packaging/windows/farmcontrol.nsi index 642a26a..2bbd8c6 100644 --- a/packaging/windows/farmcontrol.nsi +++ b/packaging/windows/farmcontrol.nsi @@ -18,6 +18,14 @@ !define APP_SOURCE_DIR "app" !endif +!ifndef APP_FILE_LIST_GENERATOR + !define APP_FILE_LIST_GENERATOR "..\..\scripts\generate-nsis-file-list.ps1" +!endif + +; Enumerate APP_SOURCE_DIR now, while compiling, so the generated installer +; knows the exact unpacked byte total and can log each extracted file. +!insertmacro compileProgressFileCopy "${APP_SOURCE_DIR}" "${APP_FILE_LIST_GENERATOR}" + Name "Farm Control" OutFile "${OUTFILE}" InstallDir "$LOCALAPPDATA\Programs\Farm Control" @@ -58,18 +66,17 @@ Function .onInit !insertmacro progressPhase "Starting installation" !insertmacro progressStatus "Starting Farm Control installation..." - !insertmacro progressPercent 5 + !insertmacro progressCopyTotal "${APP_COPY_TOTAL_BYTES}" + !insertmacro progressPercent 0 ; In-app updates install into Farm Control.new while the running process ; keeps using Farm Control; the folders are swapped after exit (/RESTARTFC). ; Interactive / silent fresh installs still remove the previous version first. ${If} $IsInAppUpdate == "1" !insertmacro prepareSideBySideUpdate - !insertmacro progressPercent 15 ${Else} StrCpy $FinalInstDir $INSTDIR !insertmacro uninstallPreviousFarmControl - !insertmacro progressPercent 20 ${EndIf} FunctionEnd @@ -86,11 +93,10 @@ Section "Farm Control" SecMain !insertmacro progressPhase "Copying application files" !insertmacro progressStatus "Copying application files..." - !insertmacro progressPercent 35 + !insertmacro progressPercent 0 - SetOutPath $INSTDIR SetOverwrite try - File /r "${APP_SOURCE_DIR}\*.*" + !insertmacro copyApplicationFilesWithProgress !insertmacro progressPercent 75 !insertmacro customInstall diff --git a/packaging/windows/installer.nsh b/packaging/windows/installer.nsh index ed4a844..576eb06 100644 --- a/packaging/windows/installer.nsh +++ b/packaging/windows/installer.nsh @@ -7,6 +7,17 @@ Var IsInAppUpdate Var RestartAfterInstall Var FinalInstDir +; Generate an include at compile time that defines APP_COPY_TOTAL_BYTES and +; copyApplicationFilesWithProgress. The generated macro contains one File +; command and one byte-progress log entry for every file in the source tree. +!macro compileProgressFileCopy sourceDir generator + !tempfile GENERATED_FILE_COPY_INCLUDE + !system 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${generator}" -SourceDir "${sourceDir}" -OutputPath "${GENERATED_FILE_COPY_INCLUDE}"' = 0 + !include "${GENERATED_FILE_COPY_INCLUDE}" + !delfile "${GENERATED_FILE_COPY_INCLUDE}" + !undef GENERATED_FILE_COPY_INCLUDE +!macroend + !macro initProgressLog StrCpy $ProgressLogFile "" StrCpy $IsInAppUpdate "0" @@ -87,6 +98,14 @@ Var FinalInstDir !insertmacro progressLog "installer:STATUS:${status}" !macroend +!macro progressCopyTotal bytes + !insertmacro progressLog "installer:COPY_TOTAL:${bytes}" +!macroend + +!macro progressCopyFile bytes relativePath + !insertmacro progressLog "installer:COPY_FILE:${bytes}:${relativePath}" +!macroend + !macro progressSuccess !insertmacro progressPercent 100 !insertmacro progressStatus "Installation complete. Restarting Farm Control..." diff --git a/scripts/build-windows-nsis.ps1 b/scripts/build-windows-nsis.ps1 index b60ee9f..05abc1d 100644 --- a/scripts/build-windows-nsis.ps1 +++ b/scripts/build-windows-nsis.ps1 @@ -48,6 +48,7 @@ function Get-DirectoryBytes { $rootDir = Split-Path -Parent $PSScriptRoot $nsiPath = Join-Path $rootDir "packaging/windows/farmcontrol.nsi" $installerInclude = Join-Path $rootDir "packaging/windows/installer.nsh" +$fileListGenerator = Join-Path $rootDir "scripts/generate-nsis-file-list.ps1" $workDir = Join-Path $env:TEMP "farmcontrol-nsis" $localInstallerName = "farmcontrol-installer.exe" @@ -80,6 +81,7 @@ if (-not (Test-Path $requiredDeeplinkScript)) { Copy-Item -LiteralPath $nsiPath -Destination (Join-Path $workDir "farmcontrol.nsi") Copy-Item -LiteralPath $installerInclude -Destination (Join-Path $workDir "installer.nsh") +Copy-Item -LiteralPath $fileListGenerator -Destination (Join-Path $workDir "generate-nsis-file-list.ps1") $iconPath = Join-Path $rootDir "assets\installer.ico" if (Test-Path $iconPath) { @@ -107,6 +109,7 @@ $makensisArgs = @( "/DVERSION=$Version" "/DBUILD_NUMBER=$BuildNumber" "/DAPP_SOURCE_DIR=app" + "/DAPP_FILE_LIST_GENERATOR=generate-nsis-file-list.ps1" ) if (Test-Path (Join-Path $workDir "icon.ico")) { diff --git a/scripts/generate-nsis-file-list.ps1 b/scripts/generate-nsis-file-list.ps1 new file mode 100644 index 0000000..2128cec --- /dev/null +++ b/scripts/generate-nsis-file-list.ps1 @@ -0,0 +1,62 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SourceDir, + + [Parameter(Mandatory = $true)] + [string]$OutputPath +) + +$ErrorActionPreference = "Stop" + +function ConvertTo-NsisString { + param([string]$Value) + + return $Value.Replace('$', '$$').Replace('"', '$\"') +} + +$sourceRoot = (Resolve-Path -LiteralPath $SourceDir).Path.TrimEnd('\') +$sourcePrefix = "$sourceRoot\" +$files = @( + Get-ChildItem -LiteralPath $sourceRoot -Recurse -File -Force | + Sort-Object FullName +) + +if ($files.Count -eq 0) { + throw "No files were found in NSIS application source directory: $sourceRoot" +} + +[long]$totalBytes = ($files | Measure-Object -Property Length -Sum).Sum +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add("!define APP_COPY_TOTAL_BYTES `"$totalBytes`"") +$lines.Add("!macro copyApplicationFilesWithProgress") + +$lastRelativeDirectory = $null +foreach ($file in $files) { + $relativePath = $file.FullName.Substring($sourcePrefix.Length) + $relativeDirectory = Split-Path -Parent $relativePath + $escapedRelativePath = ConvertTo-NsisString $relativePath + $escapedSourcePath = ConvertTo-NsisString $file.FullName + $escapedFileName = ConvertTo-NsisString $file.Name + + if ($relativeDirectory -ne $lastRelativeDirectory) { + if ([string]::IsNullOrEmpty($relativeDirectory)) { + $lines.Add(' SetOutPath "$INSTDIR"') + } else { + $escapedDirectory = ConvertTo-NsisString $relativeDirectory + $lines.Add(" SetOutPath `"`$INSTDIR\$escapedDirectory`"") + } + $lastRelativeDirectory = $relativeDirectory + } + + $lines.Add(" File `"/oname=$escapedFileName`" `"$escapedSourcePath`"") + $lines.Add( + " !insertmacro progressCopyFile `"$($file.Length)`" `"$escapedRelativePath`"" + ) +} + +$lines.Add("!macroend") + +$encoding = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllLines($OutputPath, $lines, $encoding) + +Write-Host "Generated NSIS commands for $($files.Count) files ($totalBytes bytes)." diff --git a/src/desktop/winappupdate.js b/src/desktop/winappupdate.js index b989d4b..6cd1c7d 100644 --- a/src/desktop/winappupdate.js +++ b/src/desktop/winappupdate.js @@ -4,13 +4,22 @@ import os from 'os' import path from 'path' const PE_MZ_HEADER = Buffer.from([0x4d, 0x5a]) // "MZ" +const COPY_PROGRESS_PERCENT = 75 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const formatBytes = (bytes) => { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB` +} + const parseWindowsInstallerProgress = (output) => { const lines = String(output || '').split(/\r?\n/) let percent = null let message = 'Installing update...' + let copyTotalBytes = 0 + let copiedBytes = 0 for (const line of lines) { if (line.startsWith('installer:PHASE:')) { @@ -23,11 +32,41 @@ const parseWindowsInstallerProgress = (output) => { if (Number.isFinite(value)) { percent = Math.min(100, Math.round(value <= 1 ? value * 100 : value)) } + } else if (line.startsWith('installer:COPY_TOTAL:')) { + const value = Number.parseInt( + line.slice('installer:COPY_TOTAL:'.length), + 10 + ) + if (Number.isFinite(value) && value > 0) { + copyTotalBytes = value + copiedBytes = 0 + } + } else if (line.startsWith('installer:COPY_FILE:')) { + const copyFile = line.match(/^installer:COPY_FILE:(\d+):(.*)$/) + if (copyFile) { + const fileBytes = Number.parseInt(copyFile[1], 10) + const relativePath = copyFile[2].trim() + + if (Number.isFinite(fileBytes)) { + copiedBytes += fileBytes + if (copyTotalBytes > 0) { + percent = Math.min( + COPY_PROGRESS_PERCENT, + Math.round((copiedBytes / copyTotalBytes) * COPY_PROGRESS_PERCENT) + ) + } + } + + if (relativePath) { + message = `Copying ${relativePath} (${formatBytes(fileBytes)})` + } + } } else if ( line.startsWith('installer: ') && !line.startsWith('installer:PHASE:') && !line.startsWith('installer:STATUS:') && - !line.startsWith('installer:%') + !line.startsWith('installer:%') && + !line.startsWith('installer:COPY_') ) { const text = line.slice('installer: '.length).trim() if (text) message = text @@ -223,7 +262,8 @@ export const launchWindowsInstaller = async ( // /S = silent (https://nsis.sourceforge.io/Reference/SilentInstall) // /UPDATE = in-app update (stage into Farm Control.new; swap after exit) // /RESTARTFC = installer waits for this process to exit, swaps folders, relaunches - // /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS) + // /LOG= + FARMCONTROL_INSTALL_LOG = progress log + // (installer:% / PHASE / STATUS / COPY_TOTAL / COPY_FILE) const installerArgs = ['/S', '/UPDATE', '/RESTARTFC', `/LOG=${logPath}`] const installerProcess = spawn(resolvedPath, installerArgs, {