Compare commits

...

3 Commits

Author SHA1 Message Date
d5b9714630 Merge branch 'electrobun' of https://git.tombutcher.work/tom/farmcontrol-ui into electrobun
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-08-03 22:58:46 +01:00
2a72697129 Refactor window management for macOS and Windows
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated `createWindow` and `createMainWindow` functions to conditionally set title bar styles and traffic light positions based on the operating system.
- Enhanced window layout synchronization by introducing `syncMaximizedWindowFrame` and improving the logic in `isWindowWorkAreaMaximized` to account for maximized window frames.
- Added functions to calculate DWM border padding and adjusted frame handling to ensure proper window sizing and positioning across different platforms.
2026-08-03 13:53:40 +01:00
cfb8c6ede1 Update Windows installer to use .exe extension and refactor validation logic
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed the installer file extension from `.msi` to `.exe` in both `appupdate.js` and `winappupdate.js` to align with the new installation format.
- Refactored the validation function to check for Windows executables instead of MSI packages, enhancing compatibility with the updated installer format.
2026-08-03 12:22:21 +01:00
10 changed files with 345 additions and 685 deletions

View File

@ -42,17 +42,40 @@
done_uninstall: done_uninstall:
!macroend !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 !macro createDesktopShortcut
SetShellVarContext current SetShellVarContext current
SetOutPath "$INSTDIR\bin"
CreateShortCut "$DESKTOP\Farm Control.lnk" "$INSTDIR\bin\launcher.exe" "" "$INSTDIR\bin\launcher.exe" 0 SW_SHOWNORMAL "" "Farm Control" 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 !macroend
!macro createStartMenuShortcut !macro createStartMenuShortcut
SetShellVarContext current SetShellVarContext current
CreateDirectory "$SMPROGRAMS\Farm Control" 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" 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 !macroend
!macro removeDesktopShortcut !macro removeDesktopShortcut

View File

@ -1,13 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<!--
Dual-purpose wrapper around the NSIS setup.exe.
ALLUSERS=2 + MSIINSTALLPERUSER=1 defaults to per-user (matching NSIS
RequestExecutionLevel user / $LOCALAPPDATA install) and is not blocked by
DisableMsi=1 / error 1625 the way a pure InstallScope=perUser package is.
-->
<Product <Product
Id="*" Id="__PRODUCT_ID__"
Name="Farm Control" Name="Farm Control"
Language="1033" Language="1033"
Version="__VERSION__" Version="__VERSION__"
@ -16,7 +10,7 @@
<Package <Package
InstallerVersion="500" InstallerVersion="500"
Compressed="yes" Compressed="yes"
InstallPrivileges="limited" InstallScope="perUser"
Platform="x64" /> Platform="x64" />
<Condition Message="Windows 7 and above is required"><![CDATA[Installed OR VersionNT >= 601]]></Condition> <Condition Message="Windows 7 and above is required"><![CDATA[Installed OR VersionNT >= 601]]></Condition>
@ -30,46 +24,24 @@
<Property Id="ARPPRODUCTICON" Value="InstallerIcon" /> <Property Id="ARPPRODUCTICON" Value="InstallerIcon" />
<Property Id="DISABLEADVTSHORTCUTS" Value="1" /> <Property Id="DISABLEADVTSHORTCUTS" Value="1" />
<Property Id="ALLUSERS" Secure="yes" Value="2" />
<Property Id="MSIINSTALLPERUSER" Secure="yes" Value="1" />
<Property Id="REBOOT" Value="ReallySuppress" />
<!-- Prefer current per-user NSIS install; fall back to legacy per-machine. --> <Property Id="PREVIOUS_UNINSTALL_CMD">
<Property Id="PREVIOUS_UNINSTALL_CMD"> <RegistrySearch
<RegistrySearch Id="PreviousUninstallCmdSearch"
Id="PreviousUninstallCmdHkcuSearch" Root="HKCU"
Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control"
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" Name="UninstallString"
Name="UninstallString" Type="raw" />
Type="raw" /> </Property>
</Property>
<Property Id="PREVIOUS_UNINSTALL_CMD_LEGACY">
<RegistrySearch
Id="PreviousUninstallCmdHklmSearch"
Root="HKLM"
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control"
Name="UninstallString"
Type="raw"
Win64="yes" />
</Property>
<Property Id="PREVIOUS_INSTALL_DIR"> <Property Id="PREVIOUS_INSTALL_DIR">
<RegistrySearch <RegistrySearch
Id="PreviousInstallDirHkcuSearch" Id="PreviousInstallDirSearch"
Root="HKCU" Root="HKCU"
Key="Software\Tom Butcher\Farm Control" Key="Software\Tom Butcher\Farm Control"
Name="InstallDir" Name="InstallDir"
Type="directory" /> Type="directory" />
</Property> </Property>
<Property Id="PREVIOUS_INSTALL_DIR_LEGACY">
<RegistrySearch
Id="PreviousInstallDirHklmSearch"
Root="HKLM"
Key="Software\Tom Butcher\Farm Control"
Name="InstallDir"
Type="directory"
Win64="yes" />
</Property>
<Binary Id="WrappedExe" SourceFile="__SETUP_EXE__" /> <Binary Id="WrappedExe" SourceFile="__SETUP_EXE__" />
@ -98,19 +70,15 @@
Return="check" /> Return="check" />
<Directory Id="TARGETDIR" Name="SourceDir"> <Directory Id="TARGETDIR" Name="SourceDir">
<Component Id="PerUserMarker" Guid="8A3F2E1D-9C4B-4A7E-B6D5-1F0E3C2B4A59"> <Directory Id="TempFolder" Name="Temp">
<RegistryValue <Component Id="EmptyComponent" Guid="8A3F2E1D-9C4B-4A7E-B6D5-1F0E3C2B4A59">
Root="HKCU" <CreateFolder />
Key="Software\Tom Butcher\Farm Control" </Component>
Name="MsiWrapper" </Directory>
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</Directory> </Directory>
<Feature Id="MainFeature" Title="Farm Control" Level="1"> <Feature Id="EmptyFeature" Level="0">
<ComponentRef Id="PerUserMarker" /> <ComponentRef Id="EmptyComponent" />
</Feature> </Feature>
<CustomAction <CustomAction
@ -125,34 +93,18 @@
Value="&quot;[PREVIOUS_UNINSTALL_CMD]&quot; /S" Value="&quot;[PREVIOUS_UNINSTALL_CMD]&quot; /S"
Execute="immediate" /> Execute="immediate" />
<CustomAction
Id="SetUninstallPreviousFromRegistryLegacy"
Property="QtExecCmdLine"
Value="&quot;[PREVIOUS_UNINSTALL_CMD_LEGACY]&quot; /S"
Execute="immediate" />
<CustomAction <CustomAction
Id="SetUninstallPreviousFromInstallDir" Id="SetUninstallPreviousFromInstallDir"
Property="QtExecCmdLine" Property="QtExecCmdLine"
Value="&quot;[PREVIOUS_INSTALL_DIR]Uninstall.exe&quot; /S" Value="&quot;[PREVIOUS_INSTALL_DIR]Uninstall.exe&quot; /S"
Execute="immediate" /> Execute="immediate" />
<CustomAction
Id="SetUninstallPreviousFromInstallDirLegacy"
Property="QtExecCmdLine"
Value="&quot;[PREVIOUS_INSTALL_DIR_LEGACY]Uninstall.exe&quot; /S"
Execute="immediate" />
<InstallExecuteSequence> <InstallExecuteSequence>
<Custom Action="SetKillFarmControl" Before="KillFarmControl">NOT Installed</Custom> <Custom Action="SetKillFarmControl" Before="KillFarmControl">NOT Installed</Custom>
<Custom Action="KillFarmControl" After="InstallInitialize">NOT Installed</Custom> <Custom Action="KillFarmControl" After="InstallInitialize">NOT Installed</Custom>
<Custom Action="SetUninstallPreviousFromRegistry" Before="UninstallPrevious">NOT Installed AND PREVIOUS_UNINSTALL_CMD</Custom> <Custom Action="SetUninstallPreviousFromRegistry" Before="UninstallPrevious">NOT Installed AND PREVIOUS_UNINSTALL_CMD</Custom>
<Custom Action="SetUninstallPreviousFromRegistryLegacy" Before="UninstallPrevious">NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND PREVIOUS_UNINSTALL_CMD_LEGACY</Custom> <Custom Action="SetUninstallPreviousFromInstallDir" Before="UninstallPrevious">NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND PREVIOUS_INSTALL_DIR</Custom>
<Custom Action="SetUninstallPreviousFromInstallDir" Before="UninstallPrevious">NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND NOT PREVIOUS_UNINSTALL_CMD_LEGACY AND PREVIOUS_INSTALL_DIR</Custom> <Custom Action="UninstallPrevious" Before="RunInstaller">NOT Installed AND (PREVIOUS_UNINSTALL_CMD OR PREVIOUS_INSTALL_DIR)</Custom>
<Custom Action="SetUninstallPreviousFromInstallDirLegacy" Before="UninstallPrevious">NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND NOT PREVIOUS_UNINSTALL_CMD_LEGACY AND NOT PREVIOUS_INSTALL_DIR AND PREVIOUS_INSTALL_DIR_LEGACY</Custom>
<Custom Action="UninstallPrevious" Before="RunInstaller">NOT Installed AND (PREVIOUS_UNINSTALL_CMD OR PREVIOUS_UNINSTALL_CMD_LEGACY OR PREVIOUS_INSTALL_DIR OR PREVIOUS_INSTALL_DIR_LEGACY)</Custom>
<Custom Action="RunInstaller" After="KillFarmControl">NOT Installed</Custom> <Custom Action="RunInstaller" After="KillFarmControl">NOT Installed</Custom>
</InstallExecuteSequence> </InstallExecuteSequence>
</Product> </Product>

View File

@ -1,105 +1,105 @@
import { createWriteStream, promises as fs } from "node:fs"; import { createWriteStream, promises as fs } from 'node:fs'
import http from "node:http"; import http from 'node:http'
import https from "node:https"; import https from 'node:https'
import os from "node:os"; import os from 'node:os'
import path from "node:path"; import path from 'node:path'
import process from "node:process"; import process from 'node:process'
import { Utils } from "electrobun/bun"; import { Utils } from 'electrobun/bun'
import { launchMacInstaller } from "./macappupdate.js"; import { launchMacInstaller } from './macappupdate.js'
import { launchWindowsInstaller } from "./winappupdate.js"; import { launchWindowsInstaller } from './winappupdate.js'
import { scheduleAppRestart } from "./updater-runner.js"; import { scheduleAppRestart } from './updater-runner.js'
const SUPPORTED_TARGETS = { const SUPPORTED_TARGETS = {
darwin: { darwin: {
extension: ".pkg", extension: '.pkg',
osMatchers: ["darwin", "mac", "macos", "osx"], osMatchers: ['darwin', 'mac', 'macos', 'osx']
}, },
win32: { win32: {
extension: ".msi", extension: '.msi',
osMatchers: ["win32", "win", "windows"], osMatchers: ['win32', 'win', 'windows']
}, }
}; }
let runningUpdate = null; let runningUpdate = null
const getArtifactName = (artifact) => const getArtifactName = (artifact) =>
String(artifact?.fileName || artifact?.relativePath || artifact?.url || ""); String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
const normalizeArch = (arch) => { const normalizeArch = (arch) => {
if (arch === "x64" || arch === "amd64") return "x64"; if (arch === 'x64' || arch === 'amd64') return 'x64'
if (arch === "arm64" || arch === "aarch64") return "arm64"; if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
return arch; return arch
}; }
const artifactMatchesPlatform = (artifact, target, platform, arch) => { const artifactMatchesPlatform = (artifact, target, platform, arch) => {
const name = getArtifactName(artifact).toLowerCase(); const name = getArtifactName(artifact).toLowerCase()
const normalizedArch = normalizeArch(arch); const normalizedArch = normalizeArch(arch)
const artifactArch = normalizeArch(String(artifact?.arch || "").toLowerCase()); const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
const artifactPlatform = String( const artifactPlatform = String(
artifact?.platform || artifact?.os || artifact?.target || "", artifact?.platform || artifact?.os || artifact?.target || ''
).toLowerCase(); ).toLowerCase()
if (!name.endsWith(target.extension)) return false; if (!name.endsWith(target.extension)) return false
if (!artifact?.url) return false; if (!artifact?.url) return false
const matchesArch = const matchesArch =
artifactArch === normalizedArch || artifactArch === normalizedArch ||
name.includes(`-${normalizedArch}`) || name.includes(`-${normalizedArch}`) ||
name.includes(`_${normalizedArch}`) || name.includes(`_${normalizedArch}`) ||
name.includes(`.${normalizedArch}.`) || name.includes(`.${normalizedArch}.`) ||
name.includes(normalizedArch); name.includes(normalizedArch)
const matchesOs = const matchesOs =
!artifactPlatform || !artifactPlatform ||
target.osMatchers.includes(artifactPlatform) || target.osMatchers.includes(artifactPlatform) ||
target.osMatchers.some((matcher) => name.includes(matcher)) || target.osMatchers.some((matcher) => name.includes(matcher)) ||
(platform === "darwin" && name.includes("mac")) || (platform === 'darwin' && name.includes('mac')) ||
(platform === "win32" && name.includes("win")); (platform === 'win32' && name.includes('win'))
return matchesArch && matchesOs; return matchesArch && matchesOs
}; }
const selectUpdateArtifact = ( const selectUpdateArtifact = (
update, update,
platform = process.platform, platform = process.platform,
arch = process.arch, arch = process.arch
) => { ) => {
const target = SUPPORTED_TARGETS[platform]; const target = SUPPORTED_TARGETS[platform]
if (!target) { 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) => const matchingArtifact = artifacts.find((artifact) =>
artifactMatchesPlatform(artifact, target, platform, arch), artifactMatchesPlatform(artifact, target, platform, arch)
); )
const fallbackArtifact = artifacts.find((artifact) => { const fallbackArtifact = artifacts.find((artifact) => {
const name = getArtifactName(artifact).toLowerCase(); const name = getArtifactName(artifact).toLowerCase()
return artifact?.url && name.endsWith(target.extension); return artifact?.url && name.endsWith(target.extension)
}); })
if (!matchingArtifact && !fallbackArtifact) { if (!matchingArtifact && !fallbackArtifact) {
throw new Error( 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 getInstallErrorMessage = (error, output = "") => { const getInstallErrorMessage = (error, output = '') => {
const combined = `${output}\n${error?.message || ""}`.trim(); const combined = `${output}\n${error?.message || ''}`.trim()
if ( if (
/cancel/i.test(combined) || /cancel/i.test(combined) ||
/did not grant permission/i.test(combined) || /did not grant permission/i.test(combined) ||
/user canceled/i.test(combined) /user canceled/i.test(combined)
) { ) {
return "Update installation was cancelled."; return 'Update installation was cancelled.'
} }
if (/incorrect/i.test(combined)) { if (/incorrect/i.test(combined)) {
return "The administrator password was incorrect."; return 'The administrator password was incorrect.'
} }
if ( if (
@ -107,154 +107,154 @@ const getInstallErrorMessage = (error, output = "") => {
/forbidden by system policy/i.test(combined) || /forbidden by system policy/i.test(combined) ||
/Non-assigned apps are disabled/i.test(combined) /Non-assigned apps are disabled/i.test(combined)
) { ) {
return "Update installation was blocked by Windows Installer policy."; return 'Update installation was blocked by Windows Installer policy.'
} }
return combined || "Failed to install update."; return combined || 'Failed to install update.'
}; }
const getDownloadUrl = (url, redirectCount = 0) => const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
if (redirectCount > 5) { if (redirectCount > 5) {
reject(new Error("Too many redirects while downloading update.")); reject(new Error('Too many redirects while downloading update.'))
return; return
} }
const parsedUrl = new URL(url); const parsedUrl = new URL(url)
const client = parsedUrl.protocol === "https:" ? https : http; const client = parsedUrl.protocol === 'https:' ? https : http
const request = client.get(parsedUrl, (response) => { const request = client.get(parsedUrl, (response) => {
const location = response.headers.location; const location = response.headers.location
if (response.statusCode >= 300 && response.statusCode < 400 && location) { if (response.statusCode >= 300 && response.statusCode < 400 && location) {
response.resume(); response.resume()
resolve( resolve(
getDownloadUrl( getDownloadUrl(
new URL(location, parsedUrl).toString(), new URL(location, parsedUrl).toString(),
redirectCount + 1, redirectCount + 1
), )
); )
return; return
} }
resolve({ response, url: parsedUrl.toString() }); resolve({ response, url: parsedUrl.toString() })
}); })
request.on("error", reject); request.on('error', reject)
}); })
const downloadArtifact = async (artifact, destinationPath, sendProgress) => { const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
const { response } = await getDownloadUrl(artifact.url); const { response } = await getDownloadUrl(artifact.url)
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
response.resume(); response.resume()
throw new Error(`Update download failed with HTTP ${response.statusCode}.`); throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
} }
const totalBytes = const totalBytes =
Number.parseInt(response.headers["content-length"], 10) || 0; Number.parseInt(response.headers['content-length'], 10) || 0
let downloadedBytes = 0; let downloadedBytes = 0
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const output = createWriteStream(destinationPath); const output = createWriteStream(destinationPath)
response.on("data", (chunk) => { response.on('data', (chunk) => {
downloadedBytes += chunk.length; downloadedBytes += chunk.length
const percent = totalBytes const percent = totalBytes
? Math.round((downloadedBytes / totalBytes) * 100) ? Math.round((downloadedBytes / totalBytes) * 100)
: null; : null
sendProgress({ sendProgress({
phase: "downloading", phase: 'downloading',
percent, percent,
downloadedBytes, downloadedBytes,
totalBytes, totalBytes,
message: totalBytes message: totalBytes
? `Downloading update (${percent}%)` ? `Downloading update (${percent}%)`
: "Downloading update", : 'Downloading update'
}); })
}); })
response.on("error", reject); response.on('error', reject)
output.on("error", reject); output.on('error', reject)
output.on("finish", resolve); output.on('finish', resolve)
response.pipe(output); response.pipe(output)
}); })
}; }
const launchInstallerAndRestart = async ( const launchInstallerAndRestart = async (
mainWindow, mainWindow,
installerPath, installerPath,
sendProgress, sendProgress
) => { ) => {
const installerHelpers = { sendProgress, getInstallErrorMessage }; const installerHelpers = { sendProgress, getInstallErrorMessage }
if (process.platform === "darwin") { if (process.platform === 'darwin') {
await launchMacInstaller( await launchMacInstaller(
mainWindow, mainWindow,
installerPath, installerPath,
sendProgress, sendProgress,
installerHelpers, installerHelpers
); )
} else if (process.platform === "win32") { } else if (process.platform === 'win32') {
await launchWindowsInstaller( await launchWindowsInstaller(
mainWindow, mainWindow,
installerPath, installerPath,
sendProgress, sendProgress,
installerHelpers, installerHelpers
); )
} else { } else {
throw new Error(`App updates are not supported on ${process.platform}.`); throw new Error(`App updates are not supported on ${process.platform}.`)
} }
scheduleAppRestart(); scheduleAppRestart()
Utils.quit(); Utils.quit()
}; }
const runAppUpdate = async (mainWindow, update, sendProgress) => { const runAppUpdate = async (mainWindow, update, sendProgress) => {
const artifact = selectUpdateArtifact(update); const artifact = selectUpdateArtifact(update)
const tempDirectory = await fs.mkdtemp( const tempDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), "farmcontrol-update-"), path.join(os.tmpdir(), 'farmcontrol-update-')
); )
const artifactName = path.basename(getArtifactName(artifact)); const artifactName = path.basename(getArtifactName(artifact))
const installerPath = path.join(tempDirectory, artifactName); const installerPath = path.join(tempDirectory, artifactName)
sendProgress({ sendProgress({
phase: "preparing", phase: 'preparing',
percent: 0, percent: 0,
artifact, artifact,
message: "Preparing update download", message: 'Preparing update download'
}); })
await downloadArtifact(artifact, installerPath, sendProgress); await downloadArtifact(artifact, installerPath, sendProgress)
sendProgress({ sendProgress({
phase: "downloaded", phase: 'downloaded',
percent: 100, percent: 100,
downloadedBytes: null, downloadedBytes: null,
totalBytes: null, totalBytes: null,
artifact, artifact,
message: "Update downloaded", message: 'Update downloaded'
}); })
await launchInstallerAndRestart(mainWindow, installerPath, sendProgress); await launchInstallerAndRestart(mainWindow, installerPath, sendProgress)
}; }
export function startAppUpdate(mainWindow, update, sendProgress) { export function startAppUpdate(mainWindow, update, sendProgress) {
if (runningUpdate) return runningUpdate; if (runningUpdate) return runningUpdate
runningUpdate = runAppUpdate(mainWindow, update, sendProgress) runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
.then(() => ({ ok: true })) .then(() => ({ ok: true }))
.catch((error) => { .catch((error) => {
sendProgress({ sendProgress({
phase: "error", phase: 'error',
percent: null, percent: null,
message: error?.message || "Failed to update app.", message: error?.message || 'Failed to update app.'
}); })
throw error; throw error
}) })
.finally(() => { .finally(() => {
runningUpdate = null; runningUpdate = null
}); })
return runningUpdate; return runningUpdate
} }

View File

@ -205,8 +205,12 @@ export function createWindow() {
width: 1200, width: 1200,
height: 800, height: 800,
frame: false, frame: false,
titleBarStyle: 'hiddenInset', ...(process.platform === 'darwin'
trafficLightPosition: { x: 14, y: 12 }, ? {
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 14, y: 12 }
}
: {}),
backgroundColor: '#141414', backgroundColor: '#141414',
icon: path.join(__dirname, './logo512.png'), icon: path.join(__dirname, './logo512.png'),
webPreferences: { webPreferences: {

View File

@ -2,143 +2,20 @@ import { spawn } from 'child_process'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import os from 'os' import os from 'os'
import path from 'path' 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 DEBUG_PREFIX = '[app-update][win-progress]'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const debugLog = () => {} const debugLog = () => {}
const decodeMsiLogBuffer = (buffer) => { const isValidWindowsExecutable = async (filePath) => {
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') const handle = await fs.open(filePath, 'r')
try { try {
const header = Buffer.alloc(MSI_OLE_HEADER.length) const header = Buffer.alloc(MZ_HEADER.length)
await handle.read(header, 0, header.length, 0) await handle.read(header, 0, header.length, 0)
return header.equals(MSI_OLE_HEADER) return header.equals(MZ_HEADER)
} finally { } finally {
await handle.close() await handle.close()
} }
@ -158,7 +35,6 @@ export const prepareInstallerPath = async (installerPath) => {
const stablePath = path.join(updateDir, fileName) const stablePath = path.join(updateDir, fileName)
await fs.copyFile(installerPath, stablePath) 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 resolvedPath = await fs.realpath(stablePath)
const stats = await fs.stat(resolvedPath) const stats = await fs.stat(resolvedPath)
@ -166,9 +42,9 @@ export const prepareInstallerPath = async (installerPath) => {
throw new Error('Update installer file is missing or empty.') throw new Error('Update installer file is missing or empty.')
} }
if (!(await isValidMsiPackage(resolvedPath))) { if (!(await isValidWindowsExecutable(resolvedPath))) {
throw new Error( 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.'
) )
} }
@ -226,10 +102,7 @@ const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
resolvedMessage resolvedMessage
}) })
if ( if (resolvedPercent !== lastPercent || resolvedMessage !== lastMessage) {
resolvedPercent !== lastPercent ||
resolvedMessage !== lastMessage
) {
debugLog(`poll #${pollCount}: sending progress update`, { debugLog(`poll #${pollCount}: sending progress update`, {
percent: resolvedPercent, percent: resolvedPercent,
message: resolvedMessage message: resolvedMessage
@ -237,7 +110,7 @@ const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
lastPercent = resolvedPercent lastPercent = resolvedPercent
lastMessage = resolvedMessage lastMessage = resolvedMessage
sendProgress( { sendProgress({
phase: 'installing', phase: 'installing',
percent: resolvedPercent, percent: resolvedPercent,
message: resolvedMessage message: resolvedMessage
@ -292,7 +165,7 @@ export const launchWindowsInstaller = async (
logPath logPath
}) })
sendProgress( { sendProgress({
phase: 'installing', phase: 'installing',
percent: 0, percent: 0,
message: 'Installing update...' message: 'Installing update...'
@ -300,7 +173,6 @@ export const launchWindowsInstaller = async (
await fs.unlink(logPath).catch(() => {}) await fs.unlink(logPath).catch(() => {})
// Allow file handles from the download/copy to settle before msiexec opens the MSI.
await sleep(2000) await sleep(2000)
const stopProgressWatch = startWindowsInstallerProgressWatch( const stopProgressWatch = startWindowsInstallerProgressWatch(
@ -324,52 +196,37 @@ export const launchWindowsInstaller = async (
logPath logPath
] ]
debugLog('spawning msiexec', { debugLog('spawning NSIS installer', {
installerPath: resolvedPath,
args: installerArgs, args: installerArgs,
elapsedMs: Date.now() - startedAt elapsedMs: Date.now() - startedAt
}) })
const installerProcess = spawn('msiexec.exe', installerArgs, { const installerProcess = spawn(resolvedPath, installerArgs, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true windowsHide: true
}) })
installerProcess.stdout?.on('data', (data) => { installerProcess.stdout?.on('data', (data) => {
const text = data.toString('utf16le') processOutput += data.toString('utf8')
processOutput += text
debugLog('msiexec stdout chunk', {
length: text.length,
preview: text.slice(0, 200)
})
}) })
installerProcess.stderr?.on('data', (data) => { installerProcess.stderr?.on('data', (data) => {
const text = data.toString('utf16le') processOutput += data.toString('utf8')
processOutput += text
debugLog('msiexec stderr chunk', {
length: text.length,
preview: text.slice(0, 200)
})
}) })
installerProcess.on('spawn', () => { installerProcess.on('spawn', () => {
debugLog('msiexec spawned', { debugLog('installer spawned', {
pid: installerProcess.pid, pid: installerProcess.pid,
elapsedMs: Date.now() - startedAt elapsedMs: Date.now() - startedAt
}) })
}) })
installerProcess.on('error', async (error) => { installerProcess.on('error', (error) => {
console.error(`${DEBUG_PREFIX} installer spawn 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.' const message = error?.message || 'Failed to start update installer.'
sendProgress( { sendProgress({
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -378,17 +235,15 @@ export const launchWindowsInstaller = async (
}) })
installerProcess.on('exit', async (code, signal) => { installerProcess.on('exit', async (code, signal) => {
const watchedOutput = await stopProgressWatch() const logOutput = await readInstallerLog(logPath)
const output = watchedOutput || processOutput const output = [processOutput, logOutput].filter(Boolean).join('\n')
const finalParse = parseWindowsInstallerProgress(output)
debugLog('msiexec exited', { debugLog('installer exited', {
code, code,
signal, signal,
elapsedMs: Date.now() - startedAt, elapsedMs: Date.now() - startedAt,
watchedOutputLength: watchedOutput.length, logOutputLength: logOutput.length,
processOutputLength: processOutput.length, processOutputLength: processOutput.length,
parsed: finalParse.stats,
outputPreview: output.slice(0, 500).replace(/\s+/g, ' ') outputPreview: output.slice(0, 500).replace(/\s+/g, ' ')
}) })
@ -396,7 +251,7 @@ export const launchWindowsInstaller = async (
if (code !== 0) { if (code !== 0) {
const message = getInstallErrorMessage(null, output) const message = getInstallErrorMessage(null, output)
sendProgress( { sendProgress({
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -418,7 +273,7 @@ export const launchWindowsInstaller = async (
if (!succeeded) { if (!succeeded) {
const message = getInstallErrorMessage(null, output) const message = getInstallErrorMessage(null, output)
sendProgress( { sendProgress({
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -429,10 +284,10 @@ export const launchWindowsInstaller = async (
const { percent, message } = finalParse const { percent, message } = finalParse
sendProgress( { sendProgress({
phase: 'installing', phase: 'installing',
percent: percent ?? 100, percent: 100,
message: message || 'Installation complete. Restarting Farm Control...' message: 'Installation complete. Restarting Farm Control...'
}) })
debugLog('installer completed successfully') debugLog('installer completed successfully')

View File

@ -8,7 +8,9 @@ param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Version, [string]$Version,
[string]$UpgradeCode = "735812DB-E33B-57A0-8FBC-5FC3155925AA" [string]$UpgradeCode = "735812DB-E33B-57A0-8FBC-5FC3155925AA",
[string]$ProductId = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -97,6 +99,7 @@ Copy-Item -LiteralPath $installerIconPath -Destination $installerIconWorkPath -F
$msiVersion = Get-MsiVersion $Version $msiVersion = Get-MsiVersion $Version
$wxsContent = Get-Content -LiteralPath $templatePath -Raw $wxsContent = Get-Content -LiteralPath $templatePath -Raw
$wxsContent = $wxsContent.Replace("__PRODUCT_ID__", $ProductId)
$wxsContent = $wxsContent.Replace("__UPGRADE_CODE__", $UpgradeCode) $wxsContent = $wxsContent.Replace("__UPGRADE_CODE__", $UpgradeCode)
$wxsContent = $wxsContent.Replace("__VERSION__", $msiVersion) $wxsContent = $wxsContent.Replace("__VERSION__", $msiVersion)
$wxsContent = $wxsContent.Replace("__SETUP_EXE__", (Escape-WixSourcePath $setupExePath)) $wxsContent = $wxsContent.Replace("__SETUP_EXE__", (Escape-WixSourcePath $setupExePath))

View File

@ -15,7 +15,7 @@ const SUPPORTED_TARGETS = {
osMatchers: ["darwin", "mac", "macos", "osx"], osMatchers: ["darwin", "mac", "macos", "osx"],
}, },
win32: { win32: {
extension: ".msi", extension: ".exe",
osMatchers: ["win32", "win", "windows"], osMatchers: ["win32", "win", "windows"],
}, },
}; };
@ -102,14 +102,6 @@ const getInstallErrorMessage = (error, output = "") => {
return "The administrator password was incorrect."; 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."; return combined || "Failed to install update.";
}; };

View File

@ -2,143 +2,20 @@ import { spawn } from 'child_process'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import os from 'os' import os from 'os'
import path from 'path' 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 DEBUG_PREFIX = '[app-update][win-progress]'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const debugLog = () => {} const debugLog = () => {}
const decodeMsiLogBuffer = (buffer) => { const isValidWindowsExecutable = async (filePath) => {
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') const handle = await fs.open(filePath, 'r')
try { try {
const header = Buffer.alloc(MSI_OLE_HEADER.length) const header = Buffer.alloc(MZ_HEADER.length)
await handle.read(header, 0, header.length, 0) await handle.read(header, 0, header.length, 0)
return header.equals(MSI_OLE_HEADER) return header.equals(MZ_HEADER)
} finally { } finally {
await handle.close() await handle.close()
} }
@ -158,7 +35,6 @@ export const prepareInstallerPath = async (installerPath) => {
const stablePath = path.join(updateDir, fileName) const stablePath = path.join(updateDir, fileName)
await fs.copyFile(installerPath, stablePath) 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 resolvedPath = await fs.realpath(stablePath)
const stats = await fs.stat(resolvedPath) 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.') throw new Error('Update installer file is missing or empty.')
} }
if (!(await isValidMsiPackage(resolvedPath))) { if (!(await isValidWindowsExecutable(resolvedPath))) {
throw new Error( 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 return resolvedPath
} }
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => { const readInstallerLog = async (logPath) => {
let installerOutput = '' try {
let lastLogSize = 0 return await fs.readFile(logPath, 'utf8')
let lastPercent = null } catch {
let lastMessage = null return ''
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
} }
} }
@ -292,7 +74,7 @@ export const launchWindowsInstaller = async (
logPath logPath
}) })
sendProgress( { sendProgress({
phase: 'installing', phase: 'installing',
percent: 0, percent: 0,
message: 'Installing update...' message: 'Installing update...'
@ -300,14 +82,8 @@ export const launchWindowsInstaller = async (
await fs.unlink(logPath).catch(() => {}) await fs.unlink(logPath).catch(() => {})
// Allow file handles from the download/copy to settle before msiexec opens the MSI.
await sleep(2000) await sleep(2000)
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress
)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let processOutput = '' let processOutput = ''
const startedAt = Date.now() const startedAt = Date.now()
@ -324,52 +100,37 @@ export const launchWindowsInstaller = async (
logPath logPath
] ]
debugLog('spawning msiexec', { debugLog('spawning NSIS installer', {
installerPath: resolvedPath,
args: installerArgs, args: installerArgs,
elapsedMs: Date.now() - startedAt elapsedMs: Date.now() - startedAt
}) })
const installerProcess = spawn('msiexec.exe', installerArgs, { const installerProcess = spawn(resolvedPath, installerArgs, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true windowsHide: true
}) })
installerProcess.stdout?.on('data', (data) => { installerProcess.stdout?.on('data', (data) => {
const text = data.toString('utf16le') processOutput += data.toString('utf8')
processOutput += text
debugLog('msiexec stdout chunk', {
length: text.length,
preview: text.slice(0, 200)
})
}) })
installerProcess.stderr?.on('data', (data) => { installerProcess.stderr?.on('data', (data) => {
const text = data.toString('utf16le') processOutput += data.toString('utf8')
processOutput += text
debugLog('msiexec stderr chunk', {
length: text.length,
preview: text.slice(0, 200)
})
}) })
installerProcess.on('spawn', () => { installerProcess.on('spawn', () => {
debugLog('msiexec spawned', { debugLog('installer spawned', {
pid: installerProcess.pid, pid: installerProcess.pid,
elapsedMs: Date.now() - startedAt elapsedMs: Date.now() - startedAt
}) })
}) })
installerProcess.on('error', async (error) => { installerProcess.on('error', (error) => {
console.error(`${DEBUG_PREFIX} installer spawn 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.' const message = error?.message || 'Failed to start update installer.'
sendProgress( { sendProgress({
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -378,25 +139,25 @@ export const launchWindowsInstaller = async (
}) })
installerProcess.on('exit', async (code, signal) => { installerProcess.on('exit', async (code, signal) => {
const watchedOutput = await stopProgressWatch() const logOutput = await readInstallerLog(logPath)
const output = watchedOutput || processOutput const output = [processOutput, logOutput].filter(Boolean).join('\n')
const finalParse = parseWindowsInstallerProgress(output)
debugLog('msiexec exited', { debugLog('installer exited', {
code, code,
signal, signal,
elapsedMs: Date.now() - startedAt, elapsedMs: Date.now() - startedAt,
watchedOutputLength: watchedOutput.length, logOutputLength: logOutput.length,
processOutputLength: processOutput.length, processOutputLength: processOutput.length,
parsed: finalParse.stats,
outputPreview: output.slice(0, 500).replace(/\s+/g, ' ') outputPreview: output.slice(0, 500).replace(/\s+/g, ' ')
}) })
debugLog('keeping install log', { logPath }) debugLog('keeping install log', { logPath })
if (code !== 0) { if (code !== 0) {
const message = getInstallErrorMessage(null, output) const message =
sendProgress( { getInstallErrorMessage(null, output) ||
`Update installer failed with exit code ${code ?? 'unknown'}.`
sendProgress({
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -405,34 +166,10 @@ export const launchWindowsInstaller = async (
return return
} }
const succeeded = sendProgress({
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', phase: 'installing',
percent: percent ?? 100, percent: 100,
message: message || 'Installation complete. Restarting Farm Control...' message: 'Installation complete. Restarting Farm Control...'
}) })
debugLog('installer completed successfully') debugLog('installer completed successfully')

View File

@ -7,7 +7,8 @@ import {
import { sendToRenderer, setMessageSender } from './notify.js' import { sendToRenderer, setMessageSender } from './notify.js'
import { import {
clampWindowToWorkArea, clampWindowToWorkArea,
isWindowWorkAreaMaximized isWindowWorkAreaMaximized,
syncMaximizedWindowFrame
} from './windows-work-area.js' } from './windows-work-area.js'
const isMacOS = process.platform === 'darwin' const isMacOS = process.platform === 'darwin'
@ -158,6 +159,11 @@ function applyStartupWindowState(window) {
} }
function syncWindowsWebviewLayout(window) { function syncWindowsWebviewLayout(window) {
if (isWindowWorkAreaMaximized(window)) {
syncMaximizedWindowFrame(window)
return
}
if (!window?.getSize || !window?.setSize) { if (!window?.getSize || !window?.setSize) {
return return
} }
@ -172,7 +178,7 @@ function syncWindowsWebviewLayout(window) {
} }
function handleWindowsWindowChange(window) { function handleWindowsWindowChange(window) {
if (clampWindowToWorkArea(window)) { if (clampWindowToWorkArea(window) || isWindowWorkAreaMaximized(window)) {
syncWindowsWebviewLayout(window) syncWindowsWebviewLayout(window)
} }
@ -184,6 +190,10 @@ function applyWindowsStartupWindowState(window) {
setTimeout(() => { setTimeout(() => {
window.maximize?.() window.maximize?.()
handleWindowsWindowChange(window)
setTimeout(() => syncWindowsWebviewLayout(window), 0)
setTimeout(() => syncWindowsWebviewLayout(window), 100)
setTimeout(broadcastWindowState, 100)
}, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS) }, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS)
} }
@ -242,10 +252,15 @@ export async function createMainWindow(rpc) {
title: 'Farm Control', title: 'Farm Control',
url, url,
rpc, rpc,
titleBarStyle: 'hiddenInset',
...(isMacOS ...(isMacOS
? { transparent: true, trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET } ? {
: {}), titleBarStyle: 'hiddenInset',
transparent: true,
trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET
}
: {
titleBarStyle: 'hidden'
}),
frame: { frame: {
width: 1200, width: 1200,
height: 800, height: 800,

View File

@ -1,6 +1,7 @@
import { Screen } from 'electrobun/bun' import { Screen } from 'electrobun/bun'
const FRAME_TOLERANCE_PX = 4 const FRAME_TOLERANCE_PX = 4
const BASE_DWM_BORDER_PX = 7
function framesMatch(a, b, tolerance = FRAME_TOLERANCE_PX) { function framesMatch(a, b, tolerance = FRAME_TOLERANCE_PX) {
return ( return (
@ -34,6 +35,33 @@ function getDisplayForFrame(frame) {
return match ?? Screen.getPrimaryDisplay() return match ?? Screen.getPrimaryDisplay()
} }
function getDwmBorderPadding(display) {
const scaleFactor = display?.scaleFactor ?? 1
return Math.round(BASE_DWM_BORDER_PX * scaleFactor)
}
function getMaximizedFrame(workArea, display) {
const borderPx = getDwmBorderPadding(display)
// Windows keeps invisible resize borders on the left, right, and bottom when
// maximized. Extend the outer frame so the webview fills the visible area.
return {
x: workArea.x - borderPx,
y: workArea.y,
width: workArea.width + borderPx * 2,
height: workArea.height + borderPx
}
}
function frameCoversWorkArea(frame, workArea) {
return (
frame.x <= workArea.x + FRAME_TOLERANCE_PX &&
frame.y <= workArea.y + FRAME_TOLERANCE_PX &&
frame.x + frame.width >= workArea.x + workArea.width - FRAME_TOLERANCE_PX &&
frame.y + frame.height >= workArea.y + workArea.height - FRAME_TOLERANCE_PX
)
}
function frameCoversMonitor(frame, bounds) { function frameCoversMonitor(frame, bounds) {
return ( return (
frame.x <= bounds.x + FRAME_TOLERANCE_PX && frame.x <= bounds.x + FRAME_TOLERANCE_PX &&
@ -49,8 +77,14 @@ export function isWindowWorkAreaMaximized(window) {
} }
const frame = window.getFrame() const frame = window.getFrame()
const { workArea } = getDisplayForFrame(frame) const display = getDisplayForFrame(frame)
return framesMatch(frame, workArea) const maximizedFrame = getMaximizedFrame(display.workArea, display)
return (
framesMatch(frame, display.workArea) ||
framesMatch(frame, maximizedFrame) ||
frameCoversWorkArea(frame, display.workArea)
)
} }
export function clampWindowToWorkArea(window) { export function clampWindowToWorkArea(window) {
@ -69,15 +103,60 @@ export function clampWindowToWorkArea(window) {
const display = getDisplayForFrame(frame) const display = getDisplayForFrame(frame)
const { bounds, workArea } = display const { bounds, workArea } = display
const maximizedFrame = getMaximizedFrame(workArea, display)
if (framesMatch(frame, workArea)) { if (framesMatch(frame, maximizedFrame)) {
return false return false
} }
if (!frameCoversMonitor(frame, bounds)) { if (
!frameCoversWorkArea(frame, workArea) &&
!frameCoversMonitor(frame, bounds)
) {
return false return false
} }
window.setFrame(workArea.x - 8, workArea.y, workArea.width + 16, workArea.height + 8) window.setFrame(
maximizedFrame.x,
maximizedFrame.y,
maximizedFrame.width,
maximizedFrame.height
)
return true
}
export function syncMaximizedWindowFrame(window) {
if (!window?.getFrame || !window?.setFrame) {
return false
}
if (window.isFullScreen?.()) {
return false
}
const frame = window.getFrame()
if (!frame.width || !frame.height) {
return false
}
const display = getDisplayForFrame(frame)
if (
!frameCoversWorkArea(frame, display.workArea) &&
!frameCoversMonitor(frame, display.bounds)
) {
return false
}
const maximizedFrame = getMaximizedFrame(display.workArea, display)
if (framesMatch(frame, maximizedFrame)) {
return false
}
window.setFrame(
workArea.x - 8,
workArea.y,
workArea.width + 16,
workArea.height + 8
)
return true return true
} }