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: ".msi", 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."; } 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}.`); } scheduleAppRestart(); 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; }