All checks were successful
farmcontrol/farmcontrol-server/pipeline/head This commit looks good
- Adjusted the DMG window dimensions to enhance the visual presentation during installation, changing the height and width values for better content accommodation.
603 lines
15 KiB
JavaScript
603 lines
15 KiB
JavaScript
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
getReleaseArch,
|
|
getReleaseArtifactName,
|
|
getReleaseVersion,
|
|
} from "./release-artifact-utils.mjs";
|
|
import {
|
|
cleanExpandedWindowsApp,
|
|
expandWindowsAppFromArchive,
|
|
} from "./expand-windows-installer.mjs";
|
|
import { codesignMacAppBundle } from "./codesign-macos-app.mjs";
|
|
import { prepareWindowsAppBinaries } from "./prepare-windows-app-binaries.mjs";
|
|
|
|
const rootDir = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"..",
|
|
);
|
|
const packageJson = JSON.parse(
|
|
readFileSync(path.join(rootDir, "package.json"), "utf8"),
|
|
);
|
|
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "stable";
|
|
const targetOs =
|
|
process.env.ELECTROBUN_OS ||
|
|
(process.platform === "darwin"
|
|
? "macos"
|
|
: process.platform === "win32"
|
|
? "win"
|
|
: process.platform === "linux"
|
|
? "linux"
|
|
: null);
|
|
const buildArch = getReleaseArch(process.env.ELECTROBUN_ARCH || process.arch);
|
|
const version =
|
|
process.env.ELECTROBUN_APP_VERSION || getReleaseVersion(packageJson);
|
|
const artifactDir =
|
|
process.env.ELECTROBUN_ARTIFACT_DIR || path.join(rootDir, "app_dist");
|
|
const identifier =
|
|
process.env.ELECTROBUN_APP_IDENTIFIER || "com.tombutcher.farmcontrolserver";
|
|
const artifactPrefix = `farmcontrol-server-${version}-`;
|
|
|
|
function getBuildRoot() {
|
|
const electrobunBuildDir = process.env.ELECTROBUN_BUILD_DIR;
|
|
if (!electrobunBuildDir) {
|
|
return path.join(rootDir, "build");
|
|
}
|
|
|
|
const baseName = path.basename(electrobunBuildDir);
|
|
if (baseName.startsWith("stable-")) {
|
|
return path.dirname(electrobunBuildDir);
|
|
}
|
|
|
|
return electrobunBuildDir;
|
|
}
|
|
|
|
function walkFiles(dir) {
|
|
const files = [];
|
|
if (!existsSync(dir)) {
|
|
return files;
|
|
}
|
|
|
|
for (const entry of readdirSync(dir)) {
|
|
const fullPath = path.join(dir, entry);
|
|
const stats = statSync(fullPath);
|
|
if (stats.isDirectory()) {
|
|
files.push(...walkFiles(fullPath));
|
|
} else {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function findByExtension(root, extension) {
|
|
return walkFiles(root).find((filePath) =>
|
|
filePath.toLowerCase().endsWith(extension.toLowerCase()),
|
|
);
|
|
}
|
|
|
|
function findMacAppBundle(arch) {
|
|
const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`);
|
|
if (!existsSync(platformDir)) {
|
|
return null;
|
|
}
|
|
|
|
for (const child of readdirSync(platformDir)) {
|
|
if (child.endsWith(".app")) {
|
|
return path.join(platformDir, child);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findMacDmgSource(arch) {
|
|
const prefixedArtifact = walkFiles(artifactDir).find(
|
|
(filePath) =>
|
|
filePath.includes(`stable-macos-${arch}`) &&
|
|
filePath.toLowerCase().endsWith(".dmg"),
|
|
);
|
|
if (prefixedArtifact) {
|
|
return prefixedArtifact;
|
|
}
|
|
|
|
const buildDmg = findByExtension(
|
|
path.join(getBuildRoot(), `stable-macos-${arch}`),
|
|
".dmg",
|
|
);
|
|
if (buildDmg) {
|
|
return buildDmg;
|
|
}
|
|
|
|
return findByExtension(artifactDir, ".dmg");
|
|
}
|
|
|
|
function findWindowsInstallerFiles() {
|
|
const buildRoot = getBuildRoot();
|
|
if (!existsSync(buildRoot)) {
|
|
return null;
|
|
}
|
|
|
|
for (const entry of readdirSync(buildRoot)) {
|
|
if (!entry.startsWith("stable-win-")) {
|
|
continue;
|
|
}
|
|
|
|
const platformDir = path.join(buildRoot, entry);
|
|
const files = walkFiles(platformDir);
|
|
const setupExe = files.find((filePath) => /-setup\.exe$/i.test(filePath));
|
|
const setupArchive = files.find((filePath) =>
|
|
/-setup\.tar\.zst$/i.test(filePath),
|
|
);
|
|
const setupMetadata = files.find((filePath) =>
|
|
/-setup\.metadata\.json$/i.test(filePath),
|
|
);
|
|
const setupZip = files.find((filePath) => /-setup\.zip$/i.test(filePath));
|
|
|
|
if (setupExe && setupArchive && setupMetadata) {
|
|
return { setupExe, setupArchive, setupMetadata, setupZip };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function publishArtifact(sourcePath, arch, ext) {
|
|
if (!sourcePath || !existsSync(sourcePath)) {
|
|
throw new Error(
|
|
`Missing source artifact for ${arch}.${ext}: ${sourcePath ?? "not found"}`,
|
|
);
|
|
}
|
|
|
|
mkdirSync(artifactDir, { recursive: true });
|
|
const destination = path.join(
|
|
artifactDir,
|
|
getReleaseArtifactName(version, arch, ext),
|
|
);
|
|
cpSync(sourcePath, destination);
|
|
console.log(`Published ${destination}`);
|
|
return destination;
|
|
}
|
|
|
|
function cleanStagingArtifacts(keepNames) {
|
|
if (!existsSync(artifactDir)) {
|
|
return;
|
|
}
|
|
|
|
for (const entry of readdirSync(artifactDir)) {
|
|
if (keepNames.includes(entry) || entry.startsWith(artifactPrefix)) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
entry.includes("stable-macos-") ||
|
|
entry.includes("stable-win-") ||
|
|
entry.endsWith("-update.json") ||
|
|
entry.endsWith(".tar.gz")
|
|
) {
|
|
rmSync(path.join(artifactDir, entry), { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
const MAC_DMG_ASSETS_DIR = path.join(rootDir, "packaging/macos/dmg");
|
|
const MAC_DMG_BACKGROUND_PATH = path.join(rootDir, "assets/dmg/background.png");
|
|
const MAC_DMG_BACKGROUND_RETINA_PATH = path.join(
|
|
rootDir,
|
|
"assets/dmg/background@2x.png",
|
|
);
|
|
const MAC_DMG_APP_NAME = "Farm Control Server.app";
|
|
const MAC_DMG_WINDOW_WIDTH = 540;
|
|
const MAC_DMG_WINDOW_HEIGHT = 410;
|
|
|
|
function ensureMacDmgAssets() {
|
|
mkdirSync(MAC_DMG_ASSETS_DIR, { recursive: true });
|
|
|
|
const voliconPath = path.join(MAC_DMG_ASSETS_DIR, "volicon.icns");
|
|
const iconsetPath = path.join(rootDir, "assets/icon.iconset");
|
|
|
|
if (!existsSync(voliconPath) && existsSync(iconsetPath)) {
|
|
const iconutil = spawnSync(
|
|
"iconutil",
|
|
["-c", "icns", "-o", voliconPath, iconsetPath],
|
|
{ stdio: "inherit" },
|
|
);
|
|
|
|
if (iconutil.status !== 0) {
|
|
throw new Error(
|
|
`iconutil failed to create DMG volicon with exit code ${iconutil.status ?? 1}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!existsSync(MAC_DMG_BACKGROUND_PATH)) {
|
|
throw new Error(`DMG background not found at ${MAC_DMG_BACKGROUND_PATH}`);
|
|
}
|
|
|
|
if (!existsSync(MAC_DMG_BACKGROUND_RETINA_PATH)) {
|
|
console.warn(
|
|
`finalize-desktop-artifacts: retina DMG background not found at ${MAC_DMG_BACKGROUND_RETINA_PATH}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
volicon: existsSync(voliconPath) ? voliconPath : null,
|
|
background: MAC_DMG_BACKGROUND_PATH,
|
|
};
|
|
}
|
|
|
|
function findCreateDmgCommand() {
|
|
const which = spawnSync("which", ["create-dmg"], { encoding: "utf8" });
|
|
if (which.status === 0 && which.stdout.trim()) {
|
|
return which.stdout.trim();
|
|
}
|
|
|
|
for (const candidate of [
|
|
"/opt/homebrew/bin/create-dmg",
|
|
"/usr/local/bin/create-dmg",
|
|
]) {
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildMacDmgWithCreateDmg(
|
|
createDmg,
|
|
dmgPath,
|
|
sourceFolder,
|
|
appBundleName,
|
|
dmgAssets,
|
|
) {
|
|
rmSync(dmgPath, { force: true });
|
|
|
|
const args = [
|
|
"--volname",
|
|
"Farm Control Server",
|
|
"--window-pos",
|
|
"200",
|
|
"120",
|
|
"--window-size",
|
|
String(MAC_DMG_WINDOW_WIDTH),
|
|
String(MAC_DMG_WINDOW_HEIGHT),
|
|
"--icon-size",
|
|
"120",
|
|
"--icon",
|
|
appBundleName,
|
|
"108",
|
|
"210",
|
|
"--hide-extension",
|
|
appBundleName,
|
|
"--app-drop-link",
|
|
"302",
|
|
"210",
|
|
"--format",
|
|
"UDZO",
|
|
];
|
|
|
|
if (dmgAssets.volicon) {
|
|
args.push("--volicon", dmgAssets.volicon);
|
|
}
|
|
|
|
if (dmgAssets.background) {
|
|
args.push("--background", dmgAssets.background);
|
|
}
|
|
|
|
args.push(dmgPath, sourceFolder);
|
|
|
|
const result = spawnSync(createDmg, args, { stdio: "inherit" });
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`create-dmg failed with exit code ${result.status ?? 1}`);
|
|
}
|
|
|
|
if (!existsSync(dmgPath)) {
|
|
throw new Error(`create-dmg did not produce ${dmgPath}`);
|
|
}
|
|
|
|
return dmgPath;
|
|
}
|
|
|
|
function copyMacAppBundle(sourcePath, destinationPath) {
|
|
rmSync(destinationPath, { recursive: true, force: true });
|
|
mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
|
|
const result = spawnSync("ditto", [sourcePath, destinationPath], {
|
|
stdio: "inherit",
|
|
});
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`ditto failed with exit code ${result.status ?? 1}`);
|
|
}
|
|
}
|
|
|
|
function canUseDirectDmgSourceFolder(appBundlePath) {
|
|
const platformDir = path.dirname(appBundlePath);
|
|
const appName = path.basename(appBundlePath);
|
|
const entries = readdirSync(platformDir).filter(
|
|
(entry) => !entry.startsWith(".") && entry !== ".finalize-dmg-staging",
|
|
);
|
|
|
|
return entries.length === 1 && entries[0] === appName;
|
|
}
|
|
|
|
async function buildMacDmg(appBundlePath, arch) {
|
|
const createDmg = findCreateDmgCommand();
|
|
if (!createDmg) {
|
|
throw new Error(
|
|
"create-dmg not found. Install with: brew install create-dmg",
|
|
);
|
|
}
|
|
|
|
const appBundleName = path.basename(appBundlePath);
|
|
if (appBundleName !== MAC_DMG_APP_NAME) {
|
|
console.warn(
|
|
`finalize-desktop-artifacts: expected ${MAC_DMG_APP_NAME}, found ${appBundleName}`,
|
|
);
|
|
}
|
|
|
|
const dmgPath = path.join(
|
|
artifactDir,
|
|
getReleaseArtifactName(version, arch, "dmg"),
|
|
);
|
|
|
|
mkdirSync(artifactDir, { recursive: true });
|
|
|
|
const stagingDir = path.join(
|
|
path.dirname(appBundlePath),
|
|
".finalize-dmg-staging",
|
|
);
|
|
const useDirectSource = canUseDirectDmgSourceFolder(appBundlePath);
|
|
const sourceFolder = useDirectSource
|
|
? path.dirname(appBundlePath)
|
|
: stagingDir;
|
|
|
|
if (!useDirectSource) {
|
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
mkdirSync(stagingDir, { recursive: true });
|
|
const stagedAppPath = path.join(stagingDir, path.basename(appBundlePath));
|
|
copyMacAppBundle(appBundlePath, stagedAppPath);
|
|
codesignMacAppBundle(stagedAppPath);
|
|
}
|
|
|
|
let builtDmgPath;
|
|
const dmgAssets = ensureMacDmgAssets();
|
|
|
|
try {
|
|
builtDmgPath = buildMacDmgWithCreateDmg(
|
|
createDmg,
|
|
dmgPath,
|
|
sourceFolder,
|
|
appBundleName,
|
|
dmgAssets,
|
|
);
|
|
} finally {
|
|
if (!useDirectSource) {
|
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
console.log(`Published ${builtDmgPath}`);
|
|
return builtDmgPath;
|
|
}
|
|
|
|
function cleanMacBuildDir(arch) {
|
|
const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`);
|
|
if (existsSync(platformDir)) {
|
|
rmSync(platformDir, { recursive: true, force: true });
|
|
console.log(`Removed build output ${platformDir}`);
|
|
}
|
|
}
|
|
|
|
function buildMacPkg(appBundlePath, arch) {
|
|
const pkgPath = path.join(
|
|
artifactDir,
|
|
getReleaseArtifactName(version, arch, "pkg"),
|
|
);
|
|
|
|
const result = spawnSync(
|
|
"pkgbuild",
|
|
[
|
|
"--component",
|
|
appBundlePath,
|
|
"--install-location",
|
|
"/Applications",
|
|
"--identifier",
|
|
identifier,
|
|
"--version",
|
|
version,
|
|
pkgPath,
|
|
],
|
|
{ stdio: "inherit" },
|
|
);
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`pkgbuild failed with exit code ${result.status ?? 1}`);
|
|
}
|
|
|
|
console.log(`Published ${pkgPath}`);
|
|
return pkgPath;
|
|
}
|
|
|
|
function cleanWinBuildDir(arch) {
|
|
const platformDir = path.join(getBuildRoot(), `stable-win-${arch}`);
|
|
if (existsSync(platformDir)) {
|
|
rmSync(platformDir, { recursive: true, force: true });
|
|
console.log(`Removed build output ${platformDir}`);
|
|
}
|
|
}
|
|
|
|
function buildWindowsNsis(appDir, arch) {
|
|
const scriptPath = path.join(rootDir, "scripts/build-windows-nsis.ps1");
|
|
const exePath = path.join(
|
|
artifactDir,
|
|
getReleaseArtifactName(version, arch, "exe"),
|
|
);
|
|
const powershell = process.env.SystemRoot
|
|
? path.join(
|
|
process.env.SystemRoot,
|
|
"System32",
|
|
"WindowsPowerShell",
|
|
"v1.0",
|
|
"powershell.exe",
|
|
)
|
|
: "powershell.exe";
|
|
|
|
const result = spawnSync(
|
|
powershell,
|
|
[
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-File",
|
|
scriptPath,
|
|
"-AppDir",
|
|
appDir,
|
|
"-OutputExe",
|
|
exePath,
|
|
"-Version",
|
|
version,
|
|
],
|
|
{ stdio: "inherit" },
|
|
);
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(
|
|
`build-windows-nsis.ps1 failed with exit code ${result.status ?? 1}`,
|
|
);
|
|
}
|
|
|
|
console.log(`Published ${exePath}`);
|
|
return exePath;
|
|
}
|
|
|
|
function buildWindowsMsi(setupExePath, arch) {
|
|
const scriptPath = path.join(rootDir, "scripts/build-windows-msi.ps1");
|
|
const msiPath = path.join(
|
|
artifactDir,
|
|
getReleaseArtifactName(version, arch, "msi"),
|
|
);
|
|
const powershell = process.env.SystemRoot
|
|
? path.join(
|
|
process.env.SystemRoot,
|
|
"System32",
|
|
"WindowsPowerShell",
|
|
"v1.0",
|
|
"powershell.exe",
|
|
)
|
|
: "powershell.exe";
|
|
|
|
const result = spawnSync(
|
|
powershell,
|
|
[
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-File",
|
|
scriptPath,
|
|
"-SetupExe",
|
|
setupExePath,
|
|
"-OutputMsi",
|
|
msiPath,
|
|
"-Version",
|
|
version,
|
|
],
|
|
{ stdio: "inherit" },
|
|
);
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(
|
|
`build-windows-msi.ps1 failed with exit code ${result.status ?? 1}`,
|
|
);
|
|
}
|
|
|
|
console.log(`Published ${msiPath}`);
|
|
return msiPath;
|
|
}
|
|
|
|
if (buildEnv === "dev") {
|
|
console.log("finalize-desktop-artifacts: skipping dev build");
|
|
process.exit(0);
|
|
}
|
|
|
|
async function main() {
|
|
if (targetOs === "macos") {
|
|
const appBundle = findMacAppBundle(buildArch);
|
|
|
|
if (!appBundle) {
|
|
console.log(
|
|
`finalize-desktop-artifacts: no macOS ${buildArch} app bundle found, skipping`,
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
codesignMacAppBundle(appBundle);
|
|
|
|
const existingDmg = findMacDmgSource(buildArch);
|
|
const dmgPath = existingDmg
|
|
? publishArtifact(existingDmg, buildArch, "dmg")
|
|
: await buildMacDmg(appBundle, buildArch);
|
|
|
|
const published = [dmgPath, buildMacPkg(appBundle, buildArch)];
|
|
|
|
cleanStagingArtifacts(published.map((filePath) => path.basename(filePath)));
|
|
cleanMacBuildDir(buildArch);
|
|
return;
|
|
}
|
|
|
|
if (targetOs === "win") {
|
|
const arch = getReleaseArch(process.env.ELECTROBUN_ARCH || "x64");
|
|
const installerFiles = findWindowsInstallerFiles();
|
|
|
|
if (!installerFiles?.setupArchive) {
|
|
throw new Error(
|
|
"Could not find the Windows setup archive (.tar.zst) to expand",
|
|
);
|
|
}
|
|
|
|
const platformDir = path.dirname(installerFiles.setupArchive);
|
|
const appDir = expandWindowsAppFromArchive(
|
|
installerFiles.setupArchive,
|
|
platformDir,
|
|
);
|
|
await prepareWindowsAppBinaries(appDir);
|
|
|
|
let published;
|
|
try {
|
|
const setupExe = buildWindowsNsis(appDir, arch);
|
|
published = [setupExe, buildWindowsMsi(setupExe, arch)];
|
|
|
|
if (installerFiles.setupZip) {
|
|
published.push(publishArtifact(installerFiles.setupZip, arch, "zip"));
|
|
}
|
|
} finally {
|
|
cleanExpandedWindowsApp(platformDir);
|
|
}
|
|
|
|
cleanStagingArtifacts(published.map((filePath) => path.basename(filePath)));
|
|
cleanWinBuildDir(arch);
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
"finalize-desktop-artifacts: no desktop packaging configured for",
|
|
targetOs ?? "unknown target",
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
await main();
|