farmcontrol-ui/src/desktop/appupdate.js
Tom Butcher ee62045416
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Add support for automatic restart after successful installation on Windows
- Implemented a new function to restart Farm Control after a successful installation, triggered by the `/RESTARTFC` option.
- Enhanced the installer script to wait for the application to close before relaunching, improving user experience during updates.
- Updated the app update process to conditionally schedule a restart based on the platform, ensuring compatibility with macOS.
2026-08-04 00:45:52 +01:00

266 lines
7.3 KiB
JavaScript

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