Compare commits
3 Commits
2383d14a80
...
d5b9714630
| Author | SHA1 | Date | |
|---|---|---|---|
| d5b9714630 | |||
| 2a72697129 | |||
| cfb8c6ede1 |
@ -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
|
||||||
|
|||||||
@ -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=""[PREVIOUS_UNINSTALL_CMD]" /S"
|
Value=""[PREVIOUS_UNINSTALL_CMD]" /S"
|
||||||
Execute="immediate" />
|
Execute="immediate" />
|
||||||
|
|
||||||
<CustomAction
|
|
||||||
Id="SetUninstallPreviousFromRegistryLegacy"
|
|
||||||
Property="QtExecCmdLine"
|
|
||||||
Value=""[PREVIOUS_UNINSTALL_CMD_LEGACY]" /S"
|
|
||||||
Execute="immediate" />
|
|
||||||
|
|
||||||
<CustomAction
|
<CustomAction
|
||||||
Id="SetUninstallPreviousFromInstallDir"
|
Id="SetUninstallPreviousFromInstallDir"
|
||||||
Property="QtExecCmdLine"
|
Property="QtExecCmdLine"
|
||||||
Value=""[PREVIOUS_INSTALL_DIR]Uninstall.exe" /S"
|
Value=""[PREVIOUS_INSTALL_DIR]Uninstall.exe" /S"
|
||||||
Execute="immediate" />
|
Execute="immediate" />
|
||||||
|
|
||||||
<CustomAction
|
|
||||||
Id="SetUninstallPreviousFromInstallDirLegacy"
|
|
||||||
Property="QtExecCmdLine"
|
|
||||||
Value=""[PREVIOUS_INSTALL_DIR_LEGACY]Uninstall.exe" /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>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user