Enhance build configuration and scripts for multi-platform support
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit

- Updated electrobun.config.ts to include environment-based settings for macOS builds.
- Added new build scripts for Windows and macOS, including support for architecture-specific builds.
- Introduced scripts for cleaning build artifacts, managing dependencies, and ensuring core binaries are present.
- Enhanced Jenkinsfile to integrate new build processes and improve artifact management.
- Added new assets for application icons and installer configurations.
- Implemented NSIS and MSI packaging scripts for Windows installer creation.
- Updated package.json with new scripts for development and build processes, including post-installation tasks.
This commit is contained in:
Tom Butcher 2026-08-02 00:28:33 +01:00
parent f1826548ea
commit 888c0fe38a
39 changed files with 3013 additions and 176 deletions

154
Jenkinsfile vendored
View File

@ -2,6 +2,56 @@ properties([
buildDiscarder(logRotator(numToKeepStr: '10'))
])
def bunBinDir() {
if (isUnix()) {
return "${env.HOME}/.bun/bin"
}
return "${env.USERPROFILE}/.bun/bin"
}
def withBun(Closure body) {
def sep = isUnix() ? ':' : ';'
withEnv(["PATH=${bunBinDir()}${sep}${env.PATH}"]) {
body()
}
}
def checkBun() {
if (isUnix()) {
sh 'bun -v'
} else {
bat 'bun -v'
}
}
def writeBuildMetadata() {
if (isUnix()) {
sh '''
bun --eval "await Bun.write('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n')"
'''
} else {
bat '''
bun --eval "await Bun.write('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n')"
'''
}
}
def runBuild(buildCommand) {
def releaseBaseUrl =
"https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
withEnv([
"VITE_BUILD_NUMBER=${env.BUILD_NUMBER ?: 'dev'}",
'NODE_ENV=production',
"ELECTROBUN_RELEASE_BASE_URL=${releaseBaseUrl}",
]) {
if (isUnix()) {
sh buildCommand
} else {
bat buildCommand
}
}
}
def deploy() {
node('ubuntu') {
try {
@ -30,7 +80,6 @@ def deploy() {
stage('Deploy (Ubuntu)') {
nodejs(nodeJSInstallationName: 'Node23') {
// Deploy to Cloudflare Pages using wrangler
withCredentials([string(credentialsId: 'cloudflare-api-token', variable: 'CLOUDFLARE_API_TOKEN')]) {
sh 'pnpm wrangler pages deploy build --branch main'
}
@ -42,66 +91,84 @@ def deploy() {
}
}
def prepareMacBuildWorkspace() {
if (isUnix()) {
sh '''
df -h .
for vol in /Volumes/Farm\\ Control*; do
if [ -d "$vol" ]; then
hdiutil detach "$vol" -force || true
fi
done
rm -rf build/stable-macos-*/.dmg-staging build/stable-macos-*/.finalize-dmg-staging 2>/dev/null || true
'''
}
}
def buildOnLabel(label, buildCommand) {
return {
node(label) {
stage("Checkout (${label})") {
checkout scm
}
try {
stage("Checkout (${label})") {
checkout scm
}
stage("Setup Node.js (${label})") {
nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh 'node -v'
sh 'pnpm -v'
} else {
bat 'node -v'
bat 'pnpm -v'
withBun {
stage("Check Bun (${label})") {
checkBun()
}
if (label.startsWith('macos')) {
stage("Prepare macOS workspace (${label})") {
prepareMacBuildWorkspace()
}
}
stage("Install Dependencies (${label})") {
if (isUnix()) {
sh 'bun install --frozen-lockfile'
} else {
bat 'bun install --frozen-lockfile'
}
}
stage("Write Build Metadata (${label})") {
writeBuildMetadata()
}
stage("Build (${label})") {
runBuild(buildCommand)
}
}
}
stage("Install Dependencies (${label})") {
nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh 'pnpm install --frozen-lockfile --production=false'
} else {
bat 'pnpm install --frozen-lockfile --production=false'
}
stage("Archive Artifacts (${label})") {
archiveArtifacts artifacts: 'app_dist/farmcontrol-*', fingerprint: true
}
}
stage("Build (${label})") {
nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production ${buildCommand}"
} else {
bat "set VITE_BUILD_NUMBER=${env.BUILD_NUMBER} && set NODE_ENV=production && ${buildCommand}"
}
}
}
stage("Archive Artifacts (${label})") {
archiveArtifacts artifacts: 'app_dist/**/farmcontrol-*.dmg, app_dist/**/farmcontrol-*.exe, app_dist/**/farmcontrol-*.pkg, app_dist/**/farmcontrol-*.msi', fingerprint: true
} finally {
cleanWs()
}
}
}
}
def buildMacOnLabel(label, targetArch) {
return buildOnLabel(label, "ELECTROBUN_TARGET_ARCH=${targetArch} bun run build:app:mac")
}
def setBuildNameFromPackageVersion() {
node('ubuntu') {
stage('Set Build Name') {
checkout scm
def version
nodejs(nodeJSInstallationName: 'Node23') {
version = sh(
script: "node -p \"require('./package.json').version\"",
withBun {
checkBun()
def version = sh(
script: "bun --eval \"console.log(JSON.parse(await Bun.file('package.json').text()).version)\"",
returnStdout: true
).trim()
def buildName = "v${version}-b${env.BUILD_NUMBER}"
currentBuild.displayName = buildName
echo "Build name set to: ${buildName}"
}
def buildName = "v${version}-b${env.BUILD_NUMBER}"
currentBuild.displayName = buildName
echo "Build name set to: ${buildName}"
}
}
}
@ -110,8 +177,9 @@ try {
setBuildNameFromPackageVersion()
parallel(
'Windows Build': buildOnLabel('windows', 'pnpm build:electron'),
'MacOS Build': buildOnLabel('macos', 'pnpm build:electron'),
'Windows Build': buildOnLabel('windows', 'bun run build:app'),
'MacOS x64 Build': buildMacOnLabel('macos', 'x64'),
'MacOS arm64 Build': buildMacOnLabel('macos', 'arm64'),
'Ubuntu Deploy': { deploy() }
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

709
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,16 @@ import type { ElectrobunConfig } from "electrobun";
import { readFileSync } from "node:fs";
const packageJson = JSON.parse(readFileSync("./package.json", "utf8"));
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
const isStable = buildEnv === "stable";
const canCodesign = Boolean(process.env.ELECTROBUN_DEVELOPER_ID);
const targetOs =
process.env.ELECTROBUN_OS ||
(process.platform === "darwin"
? "macos"
: process.platform === "win32"
? "win"
: "linux");
export default {
app: {
@ -17,29 +27,43 @@ export default {
build: {
buildFolder: "build",
artifactFolder: "app_dist",
// macOS WebKit loads views:// assets reliably from a flat app folder;
// ASAR-packed views can fail to load module scripts on first launch.
useAsar: isStable && targetOs !== "macos",
asarUnpack: [
"*.node",
"*.dll",
"*.dylib",
"*.so",
"views/**",
],
bun: {
entrypoint: "src/bun/index.js",
},
copy: {
"dist/mainview": "views/mainview",
},
watch: ["scripts"],
watch: ["scripts", "src"],
watchIgnore: ["dist/**", "build/**", "app_dist/**"],
mac: {
bundleCEF: false,
icon: "assets/logos/farmcontrolicon.png",
icons: "assets/icon.iconset",
createDmg: false,
codesign: isStable && canCodesign,
notarize: isStable && canCodesign,
},
linux: {
bundleCEF: false,
icon: "assets/logos/farmcontrolicon.png",
icon: "assets/icon.png",
},
win: {
bundleCEF: false,
icon: "assets/logos/farmcontrolicon.png",
icon: "assets/icon.iconset/icon_256x256.png",
},
},
scripts: {
preBuild: "scripts/pre-build.mjs",
postWrap: "scripts/expand-macos-bundle.mjs",
postPackage: "scripts/finalize-desktop-artifacts.mjs",
},
release: {

View File

@ -83,13 +83,19 @@
"description": "3D Printer ERP and Control Software.",
"scripts": {
"dev": "cross-env NODE_ENV=development vite",
"dev:app": "concurrently \"cross-env NODE_ENV=development bun run dev:renderer\" \"cross-env NODE_ENV=development electrobun dev\"",
"dev:renderer": "vite --port 5780 --no-open",
"electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .",
"start": "serve -s build",
"build": "vite build",
"build:app": "electrobun build --env=stable",
"build:app:mac": "bun scripts/build-macos.mjs",
"dev:electron": "concurrently \"cross-env NODE_ENV=development vite --port 5780 --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 NODE_ENV=development electron public/electron.js\"",
"build:electron": "vite build && electron-builder",
"build:cloudflare": "cross-env VITE_DEPLOY_TARGET=cloudflare vite build",
"deploy": "npm run build:cloudflare && wrangler pages deploy --branch main"
"deploy": "npm run build:cloudflare && wrangler pages deploy --branch main",
"postinstall": "bun scripts/patch-electrobun-src.mjs",
"clean": "bun scripts/clean-build.mjs"
},
"eslintConfig": {
"extends": [
@ -113,7 +119,11 @@
"@eslint/js": "^9.39.2",
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1",
"electrobun": "^1.18.1",
"electron": "^38.7.1",
"jimp": "^1.6.1",
"png-to-ico": "^2.1.8",
"rcedit": "^4.0.1",
"electron-builder": "^26.0.12",
"electron-packager": "^17.1.2",
"eslint": "^9.34.0",

View File

@ -0,0 +1,94 @@
!include "MUI2.nsh"
!include "LogicLib.nsh"
!include "installer.nsh"
!ifndef OUTFILE
!define OUTFILE "farmcontrol-installer.exe"
!endif
!ifndef VERSION
!define VERSION "0.1.0"
!endif
!ifndef APP_SOURCE_DIR
!define APP_SOURCE_DIR "app"
!endif
Name "Farm Control"
OutFile "${OUTFILE}"
InstallDir "$PROGRAMFILES64\Farm Control"
InstallDirRegKey HKLM "Software\Tom Butcher\Farm Control" "InstallDir"
RequestExecutionLevel admin
!define MUI_ABORTWARNING
!ifndef INSTALLER_ICON
!define INSTALLER_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico"
!endif
!define MUI_ICON "${INSTALLER_ICON}"
!define MUI_UNICON "${INSTALLER_ICON}"
Icon "${INSTALLER_ICON}"
UninstallIcon "${INSTALLER_ICON}"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_COMPONENTS
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
Function .onInit
${If} ${Silent}
SetAutoClose true
${EndIf}
FunctionEnd
Section "Farm Control" SecMain
SectionIn RO
SetOutPath $INSTDIR
File /r "${APP_SOURCE_DIR}\*.*"
!insertmacro customInstall
WriteRegStr HKLM "Software\Tom Butcher\Farm Control" "InstallDir" $INSTDIR
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"DisplayName" "Farm Control"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"DisplayVersion" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"Publisher" "Tom Butcher"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"UninstallString" "$INSTDIR\Uninstall.exe"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"NoModify" 1
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"NoRepair" 1
WriteUninstaller "$INSTDIR\Uninstall.exe"
SectionEnd
Section "Desktop shortcut" SecDesktop
!insertmacro createDesktopShortcut
SectionEnd
Section "Start Menu shortcut" SecStartMenu
!insertmacro createStartMenuShortcut
SectionEnd
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
!insertmacro MUI_DESCRIPTION_TEXT ${SecMain} "Install Farm Control."
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} "Create a shortcut on the Desktop."
!insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} "Create a shortcut in the Start Menu."
!insertmacro MUI_FUNCTION_DESCRIPTION_END
Section "Uninstall"
!insertmacro customUnInstall
Delete "$INSTDIR\Uninstall.exe"
RMDir /r "$INSTDIR"
DeleteRegKey HKLM "Software\Tom Butcher\Farm Control"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control"
SectionEnd

View File

@ -0,0 +1,34 @@
!macro createDesktopShortcut
CreateShortCut "$DESKTOP\Farm Control.lnk" "$INSTDIR\bin\launcher.exe"
!macroend
!macro createStartMenuShortcut
CreateDirectory "$SMPROGRAMS\Farm Control"
CreateShortCut "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$INSTDIR\bin\launcher.exe"
!macroend
!macro removeDesktopShortcut
Delete "$DESKTOP\Farm Control.lnk"
!macroend
!macro removeStartMenuShortcut
Delete "$SMPROGRAMS\Farm Control\Farm Control.lnk"
RMDir "$SMPROGRAMS\Farm Control"
!macroend
!macro customInstall
DetailPrint "Register farmcontrol URI Handler"
DeleteRegKey HKCR "farmcontrol"
WriteRegStr HKCR "farmcontrol" "" "URL:farmcontrol"
WriteRegStr HKCR "farmcontrol" "URL Protocol" ""
WriteRegStr HKCR "farmcontrol\DefaultIcon" "" "$INSTDIR\bin\launcher.exe"
WriteRegStr HKCR "farmcontrol\shell" "" ""
WriteRegStr HKCR "farmcontrol\shell\Open" "" ""
WriteRegStr HKCR "farmcontrol\shell\Open\command" "" '"$INSTDIR\bin\launcher.exe" "%1"'
!macroend
!macro customUnInstall
!insertmacro removeDesktopShortcut
!insertmacro removeStartMenuShortcut
DeleteRegKey HKCR "farmcontrol"
!macroend

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product
Id="__PRODUCT_ID__"
Name="Farm Control"
Language="1033"
Version="__VERSION__"
Manufacturer="Tom Butcher"
UpgradeCode="__UPGRADE_CODE__">
<Package
InstallerVersion="500"
Compressed="yes"
InstallScope="perMachine"
Platform="x64" />
<Condition Message="Windows 7 and above is required"><![CDATA[Installed OR VersionNT >= 601]]></Condition>
<MajorUpgrade
AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of [ProductName] is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Property Id="DISABLEADVTSHORTCUTS" Value="1" />
<Property Id="MSIINSTALLPERUSER" Value="1" />
<Binary Id="WrappedExe" SourceFile="__SETUP_EXE__" />
<CustomAction
Id="RunInstaller"
BinaryKey="WrappedExe"
ExeCommand="/S"
Execute="deferred"
Impersonate="no"
Return="check" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="TempFolder" Name="Temp">
<Component Id="EmptyComponent" Guid="8A3F2E1D-9C4B-4A7E-B6D5-1F0E3C2B4A59">
<CreateFolder />
</Component>
</Directory>
</Directory>
<Feature Id="EmptyFeature" Level="0">
<ComponentRef Id="EmptyComponent" />
</Feature>
<InstallExecuteSequence>
<Custom Action="RunInstaller" After="ProcessComponents">NOT Installed</Custom>
</InstallExecuteSequence>
</Product>
</Wix>

View File

@ -1,9 +1,21 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { getReleaseArch } from "./release-artifact-utils.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const targetArchs = ["arm64", "x64"];
const hostArch = getReleaseArch(process.arch);
const targetArchs = process.env.ELECTROBUN_TARGET_ARCH
? [getReleaseArch(process.env.ELECTROBUN_TARGET_ARCH)]
: ["arm64", "x64"];
function canRunUnderArch(archFlag) {
const result = spawnSync("arch", [archFlag, "true"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return result.status === 0;
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
@ -18,15 +30,80 @@ function run(command, args, options = {}) {
}
}
run("bun", ["run", "build"]);
for (const targetArch of targetArchs) {
console.log(`\n=== Building macOS ${targetArch} ===\n`);
run("bun", ["electrobun", "build", "--env=stable"], {
env: {
...process.env,
ELECTROBUN_ARCH: targetArch,
},
});
function buildElectrobunEnv(targetArch) {
return {
...process.env,
ELECTROBUN_BUILD_ENV: "stable",
ELECTROBUN_OS: "macos",
ELECTROBUN_ARCH: targetArch,
ELECTROBUN_FORCE_ARCH: targetArch,
ELECTROBUN_TARGET_ARCH: targetArch,
NODE_ENV: "production",
};
}
function getInvocation(targetArch) {
const env = buildElectrobunEnv(targetArch);
const runScript = path.join(rootDir, "scripts/run-electrobun-build.mjs");
if (targetArch === hostArch) {
return { command: "bun", args: [runScript], env };
}
if (targetArch === "x64" && hostArch === "arm64" && canRunUnderArch("-x86_64")) {
return {
command: "arch",
args: ["-x86_64", "bun", runScript],
env,
};
}
if (targetArch === "arm64" && hostArch === "x64") {
return { command: "bun", args: [runScript], env };
}
return null;
}
const orderedTargets = [...targetArchs].sort((a, b) => {
if (a === hostArch) return -1;
if (b === hostArch) return 1;
return 0;
});
console.log(`Host architecture: ${hostArch}`);
console.log(`Build order: ${orderedTargets.join(", ")}`);
let builtCount = 0;
for (const targetArch of orderedTargets) {
const invocation = getInvocation(targetArch);
if (!invocation) {
console.error(
`Cannot build macOS ${targetArch} from host ${hostArch}.`,
);
continue;
}
console.log(`\n=== Building macOS ${targetArch} (host ${hostArch}) ===`);
console.log(
`ELECTROBUN_BUILD_ENV=stable ELECTROBUN_OS=macos ELECTROBUN_ARCH=${targetArch}\n`,
);
run(invocation.command, invocation.args, { env: invocation.env });
builtCount += 1;
}
if (builtCount === 0) {
console.error("No macOS architectures were built.");
process.exit(1);
}
if (builtCount < targetArchs.length) {
console.error(
`Built ${builtCount}/${targetArchs.length} macOS architectures.`,
);
process.exit(1);
}
console.log(`\nBuilt macOS architectures: ${orderedTargets.join(", ")}`);

View File

@ -0,0 +1,36 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const result = spawnSync("bun", ["x", "vite", "build"], {
cwd: rootDir,
stdio: "inherit",
env: {
...process.env,
NODE_ENV: process.env.NODE_ENV || "production",
},
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
const syncResult = spawnSync(
"bun",
[path.join(rootDir, "scripts/sync-electrobun-views.mjs")],
{
cwd: rootDir,
stdio: "inherit",
env: process.env,
},
);
if (syncResult.status !== 0) {
process.exit(syncResult.status ?? 1);
}
console.log(
`build-renderer: output at ${path.join(rootDir, "dist/mainview")}`,
);

View File

@ -0,0 +1,124 @@
param(
[Parameter(Mandatory = $true)]
[string]$SetupExe,
[Parameter(Mandatory = $true)]
[string]$OutputMsi,
[Parameter(Mandatory = $true)]
[string]$Version,
[string]$UpgradeCode = "735812DB-E33B-57A0-8FBC-5FC3155925AA",
[string]$ProductId = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
)
$ErrorActionPreference = "Stop"
function Get-MsiVersion {
param([string]$InputVersion)
$parts = $InputVersion.Split(".")
while ($parts.Count -lt 4) {
$parts += "0"
}
return ($parts[0..3] -join ".")
}
function Find-WixToolset {
$searchRoots = @(
$env:WIX,
$env:WIX_TOOLSET_PATH,
"${env:ProgramFiles(x86)}\WiX Toolset v3.14\bin",
"${env:ProgramFiles}\WiX Toolset v3.14\bin",
"${env:ProgramFiles(x86)}\WiX Toolset v3.11\bin",
"${env:ProgramFiles}\WiX Toolset v3.11\bin"
)
foreach ($root in $searchRoots) {
if (-not $root) {
continue
}
$candle = Join-Path $root "candle.exe"
$light = Join-Path $root "light.exe"
if ((Test-Path $candle) -and (Test-Path $light)) {
return @{
Candle = $candle
Light = $light
}
}
}
$candleCommand = Get-Command candle.exe -ErrorAction SilentlyContinue
$lightCommand = Get-Command light.exe -ErrorAction SilentlyContinue
if ($candleCommand -and $lightCommand) {
return @{
Candle = $candleCommand.Source
Light = $lightCommand.Source
}
}
throw @"
WiX Toolset not found. Install WiX Toolset 3.11+ on the Windows build agent and ensure candle.exe and light.exe are on PATH.
Example: choco install wixtoolset --version=3.14.0.4118
"@
}
function Escape-WixSourcePath {
param([string]$Path)
return $Path.Replace("\", "\\")
}
$rootDir = Split-Path -Parent $PSScriptRoot
$templatePath = Join-Path $rootDir "packaging/windows/msi-wrapped.wxs"
$workDir = Join-Path $env:TEMP "farmcontrol-wix"
$wix = Find-WixToolset
if (Test-Path $workDir) {
Remove-Item $workDir -Recurse -Force
}
New-Item -ItemType Directory -Path $workDir | Out-Null
$setupExePath = (Resolve-Path -LiteralPath $SetupExe).Path
$outputMsiPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputMsi)
$outputDir = Split-Path $outputMsiPath -Parent
if ($outputDir -and -not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
$msiVersion = Get-MsiVersion $Version
$wxsContent = Get-Content -LiteralPath $templatePath -Raw
$wxsContent = $wxsContent.Replace("__PRODUCT_ID__", $ProductId)
$wxsContent = $wxsContent.Replace("__UPGRADE_CODE__", $UpgradeCode)
$wxsContent = $wxsContent.Replace("__VERSION__", $msiVersion)
$wxsContent = $wxsContent.Replace("__SETUP_EXE__", (Escape-WixSourcePath $setupExePath))
$projectWxs = Join-Path $workDir "project.wxs"
Set-Content -LiteralPath $projectWxs -Value $wxsContent -Encoding UTF8
$wixObj = Join-Path $workDir "project.wixobj"
Push-Location $workDir
try {
& $wix.Candle "-arch" "x64" $projectWxs
if ($LASTEXITCODE -ne 0) {
throw "candle.exe failed with exit code $LASTEXITCODE"
}
& $wix.Light "-out" $outputMsiPath "-spdb" "-sw1076" $wixObj
if ($LASTEXITCODE -ne 0) {
throw "light.exe failed with exit code $LASTEXITCODE"
}
} finally {
Pop-Location
Remove-Item $workDir -Recurse -Force
}
if (-not (Test-Path $outputMsiPath)) {
throw "MSI installer was not created at $outputMsiPath"
}
Write-Host "Created MSI installer at $outputMsiPath"

View File

@ -0,0 +1,129 @@
param(
[Parameter(Mandatory = $true)]
[string]$AppDir,
[Parameter(Mandatory = $true)]
[string]$OutputExe,
[Parameter(Mandatory = $true)]
[string]$Version,
[long]$MinInstallerBytes = 10485760
)
$ErrorActionPreference = "Stop"
function Find-Makensis() {
$command = Get-Command makensis -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
$searchRoots = @(
"${env:ProgramFiles(x86)}\NSIS\makensis.exe",
"${env:ProgramFiles}\NSIS\makensis.exe"
)
foreach ($candidate in $searchRoots) {
if (Test-Path $candidate) {
return $candidate
}
}
throw "Could not find makensis. Install NSIS 3.x on the Windows build agent."
}
function Get-DirectoryBytes {
param([string]$Path)
$total = 0
foreach ($item in Get-ChildItem -LiteralPath $Path -Recurse -File -Force) {
$total += $item.Length
}
return $total
}
$rootDir = Split-Path -Parent $PSScriptRoot
$nsiPath = Join-Path $rootDir "packaging/windows/farmcontrol.nsi"
$installerInclude = Join-Path $rootDir "packaging/windows/installer.nsh"
$workDir = Join-Path $env:TEMP "farmcontrol-nsis"
$localInstallerName = "farmcontrol-installer.exe"
if (Test-Path $workDir) {
Remove-Item $workDir -Recurse -Force
}
New-Item -ItemType Directory -Path $workDir | Out-Null
$appDirPath = (Resolve-Path -LiteralPath $AppDir).Path
$stagingAppDir = Join-Path $workDir "app"
Write-Host "Staging application files for NSIS from $appDirPath"
Copy-Item -LiteralPath $appDirPath -Destination $stagingAppDir -Recurse -Force
$stagedAppBytes = Get-DirectoryBytes $stagingAppDir
Write-Host "Staged application size: $([math]::Round($stagedAppBytes / 1MB, 2)) MB"
if ($stagedAppBytes -lt 5242880) {
throw "Staged application is only $([math]::Round($stagedAppBytes / 1KB, 1)) KiB. Expected a full Electrobun app bundle before building the installer."
}
$requiredExe = Join-Path $stagingAppDir "bin\launcher.exe"
if (-not (Test-Path $requiredExe)) {
throw "Staged application is missing bin\launcher.exe"
}
Copy-Item -LiteralPath $nsiPath -Destination (Join-Path $workDir "farmcontrol.nsi")
Copy-Item -LiteralPath $installerInclude -Destination (Join-Path $workDir "installer.nsh")
$iconPath = Join-Path $rootDir "assets\icon.ico"
if (Test-Path $iconPath) {
Copy-Item -LiteralPath $iconPath -Destination (Join-Path $workDir "icon.ico") -Force
Write-Host "Using custom installer icon from $iconPath"
}
$makensis = Find-Makensis
$outputExePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputExe)
$outputDir = Split-Path $outputExePath -Parent
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
$makensisArgs = @(
"/V3"
"/NOCD"
"/DOUTFILE=$localInstallerName"
"/DVERSION=$Version"
"/DAPP_SOURCE_DIR=app"
)
if (Test-Path (Join-Path $workDir "icon.ico")) {
$makensisArgs += "/DINSTALLER_ICON=icon.ico"
}
$makensisArgs += (Join-Path $workDir "farmcontrol.nsi")
Push-Location $workDir
try {
& $makensis @makensisArgs
if ($LASTEXITCODE -ne 0) {
throw "makensis failed with exit code $LASTEXITCODE"
}
} finally {
Pop-Location
}
$localInstallerPath = Join-Path $workDir $localInstallerName
if (-not (Test-Path $localInstallerPath)) {
throw "NSIS installer was not created at $localInstallerPath"
}
$installerBytes = (Get-Item -LiteralPath $localInstallerPath).Length
Write-Host "NSIS installer size: $([math]::Round($installerBytes / 1MB, 2)) MB"
if ($installerBytes -lt $MinInstallerBytes) {
throw "NSIS installer is only $([math]::Round($installerBytes / 1KB, 1)) KiB at $localInstallerPath. The application files were not packaged. Check NSIS logs above."
}
Move-Item -LiteralPath $localInstallerPath -Destination $outputExePath -Force
Write-Host "Created NSIS installer at $outputExePath"

11
scripts/clean-build.mjs Normal file
View File

@ -0,0 +1,11 @@
import { rmSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
for (const dir of ["build", "dist", "app_dist", "artifacts"]) {
rmSync(path.join(rootDir, dir), { recursive: true, force: true });
}
console.log("clean: removed build, dist, app_dist, and artifacts");

View File

@ -0,0 +1,89 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
function getCodesignArgs() {
const identity = process.env.ELECTROBUN_DEVELOPER_ID || "-";
const args = ["--force", "--deep"];
if (identity !== "-") {
args.push("--options", "runtime", "--timestamp");
}
args.push("--sign", identity);
return { identity, args };
}
export function codesignMacAppBundle(appBundlePath) {
if (process.platform !== "darwin") {
console.log("codesign-macos-app: skipping (not macOS)");
return;
}
if (!appBundlePath || !existsSync(appBundlePath)) {
throw new Error(`codesign-macos-app: app bundle not found: ${appBundlePath}`);
}
const contentsPath = path.join(appBundlePath, "Contents");
if (!existsSync(contentsPath)) {
throw new Error(`codesign-macos-app: invalid app bundle: ${appBundlePath}`);
}
const { identity, args } = getCodesignArgs();
const result = spawnSync("codesign", [...args, appBundlePath], {
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(
`codesign failed with exit code ${result.status ?? 1}`,
);
}
const verify = spawnSync(
"codesign",
["--verify", "--deep", "--strict", "--verbose=2", appBundlePath],
{ encoding: "utf8" },
);
if (verify.status !== 0) {
throw new Error(
`codesign verify failed: ${verify.stderr || verify.stdout || "unknown error"}`,
);
}
const label = identity === "-" ? "ad-hoc" : identity;
console.log(`codesign-macos-app: signed ${appBundlePath} (${label})`);
}
function resolveAppBundlePath() {
const fromEnv = process.env.ELECTROBUN_WRAPPER_BUNDLE_PATH;
if (fromEnv && existsSync(fromEnv)) {
return path.resolve(fromEnv);
}
const fromArgv = process.argv[2];
if (fromArgv && existsSync(fromArgv)) {
return path.resolve(fromArgv);
}
return null;
}
const invokedPath = process.argv[1]
? path.resolve(process.argv[1])
: null;
const modulePath = fileURLToPath(import.meta.url);
if (invokedPath === modulePath) {
const appBundlePath = resolveAppBundlePath();
if (!appBundlePath) {
console.error(
"codesign-macos-app: set ELECTROBUN_WRAPPER_BUNDLE_PATH or pass app bundle path",
);
process.exit(1);
}
codesignMacAppBundle(appBundlePath);
}

View File

@ -0,0 +1,127 @@
import {
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
unlinkSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getReleaseArch } from "./release-artifact-utils.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const electrobunDir = path.join(rootDir, "node_modules/electrobun");
const electrobunVersion = JSON.parse(
await Bun.file(path.join(electrobunDir, "package.json")).text(),
).version;
function getPlatformPaths(targetOS, targetArch) {
const binExt = targetOS === "win" ? ".exe" : "";
const platformDistDir = path.join(
electrobunDir,
`dist-${targetOS}-${targetArch}`,
);
return {
platformDistDir,
bunBinary: path.join(platformDistDir, "bun") + binExt,
bsdiff: path.join(platformDistDir, "bsdiff") + binExt,
bspatch: path.join(platformDistDir, "bspatch") + binExt,
launcher: path.join(platformDistDir, "launcher") + binExt,
nativeWrapperMacos: path.join(platformDistDir, "libNativeWrapper.dylib"),
};
}
async function downloadFile(url, destination) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
}
mkdirSync(path.dirname(destination), { recursive: true });
const fileStream = createWriteStream(destination);
const reader = response.body?.getReader();
if (!reader) {
throw new Error(`No response body for ${url}`);
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
fileStream.write(Buffer.from(value));
}
await new Promise((resolve, reject) => {
fileStream.end((error) => (error ? reject(error) : resolve()));
});
}
function extractTarGz(tarPath, destination) {
mkdirSync(destination, { recursive: true });
const result = spawnSync("tar", ["-xzf", tarPath, "-C", destination], {
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`tar extraction failed for ${tarPath}`);
}
}
async function ensureCoreDependencies(targetOS, targetArch) {
const paths = getPlatformPaths(targetOS, targetArch);
const required = [
paths.bunBinary,
paths.bsdiff,
paths.bspatch,
paths.launcher,
paths.nativeWrapperMacos,
];
if (required.every((filePath) => existsSync(filePath))) {
console.log(`ensure-electrobun-core: ${targetOS}-${targetArch} already present`);
return;
}
const platformName =
targetOS === "macos" ? "darwin" : targetOS === "win" ? "win" : "linux";
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${electrobunVersion}/electrobun-core-${platformName}-${targetArch}.tar.gz`;
const tempFile = path.join(
electrobunDir,
`core-${targetOS}-${targetArch}-temp.tar.gz`,
);
console.log(`ensure-electrobun-core: downloading ${targetOS}-${targetArch}`);
console.log(url);
await downloadFile(url, tempFile);
extractTarGz(tempFile, paths.platformDistDir);
if (existsSync(tempFile)) {
unlinkSync(tempFile);
}
const missing = required.filter((filePath) => !existsSync(filePath));
if (missing.length > 0) {
const extracted = existsSync(paths.platformDistDir)
? readdirSync(paths.platformDistDir)
: [];
throw new Error(
`Missing ${targetOS}-${targetArch} binaries after extract: ${missing.join(", ")}; extracted=${extracted.join(", ")}`,
);
}
console.log(`ensure-electrobun-core: ${targetOS}-${targetArch} ready`);
}
const targetOS = "macos";
const hostArch = getReleaseArch(process.arch);
const targetArch = getReleaseArch(
process.env.ELECTROBUN_TARGET_ARCH ||
process.env.ELECTROBUN_FORCE_ARCH ||
process.arch,
);
const archesToEnsure = new Set([hostArch, targetArch]);
for (const arch of archesToEnsure) {
await ensureCoreDependencies(targetOS, arch);
}

View File

@ -0,0 +1,152 @@
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
} from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
const targetOs = process.env.ELECTROBUN_OS || "macos";
const wrapperPath = process.env.ELECTROBUN_WRAPPER_BUNDLE_PATH;
if (buildEnv === "dev" || targetOs !== "macos") {
console.log("expand-macos-bundle: skipping (dev or non-macOS build)");
process.exit(0);
}
if (!wrapperPath || !existsSync(wrapperPath)) {
console.error("expand-macos-bundle: ELECTROBUN_WRAPPER_BUNDLE_PATH not set or missing");
process.exit(1);
}
const resolvedWrapperPath = path.resolve(wrapperPath);
const contentsPath = path.join(resolvedWrapperPath, "Contents");
const resourcesPath = path.join(contentsPath, "Resources");
const metadataPath = path.join(resourcesPath, "metadata.json");
if (!existsSync(metadataPath)) {
console.log("expand-macos-bundle: no extractor metadata, assuming already expanded");
process.exit(0);
}
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
const hash = metadata.hash;
const appName = metadata.name || "Farm Control";
const tarZstPath = path.resolve(resourcesPath, `${hash}.tar.zst`);
if (!existsSync(tarZstPath)) {
console.log(`expand-macos-bundle: ${hash}.tar.zst not found, skipping`);
process.exit(0);
}
function decompressTarZst(inputPath, outputPath) {
const systemZstd = spawnSync("zstd", ["--version"], { encoding: "utf8" });
if (systemZstd.status === 0) {
return spawnSync("zstd", ["-d", "-f", "-o", outputPath, inputPath], {
stdio: "inherit",
});
}
const hostArch = process.arch === "arm64" ? "arm64" : "x64";
const zigZstd = path.join(
rootDir,
"node_modules/electrobun/dist-macos-" + hostArch,
"zig-zstd",
);
if (existsSync(zigZstd)) {
return spawnSync(
zigZstd,
["decompress", "-i", inputPath, "-o", outputPath, "--no-timing"],
{ stdio: "inherit" },
);
}
console.error(
"expand-macos-bundle: zstd not found (install zstd or use electrobun dist binaries)",
);
process.exit(1);
}
const workDir = path.join(tmpdir(), `farmcontrol-expand-${hash}`);
const tarPath = path.join(workDir, `${hash}.tar`);
rmSync(workDir, { recursive: true, force: true });
mkdirSync(workDir, { recursive: true });
const decompress = decompressTarZst(tarZstPath, tarPath);
if (decompress.status !== 0) {
console.error("expand-macos-bundle: zstd decompression failed");
process.exit(decompress.status ?? 1);
}
const extractTar = spawnSync("tar", ["-xf", tarPath, "-C", workDir], {
stdio: "inherit",
});
if (extractTar.status !== 0) {
console.error("expand-macos-bundle: tar extraction failed");
process.exit(extractTar.status ?? 1);
}
const innerAppPath = path.join(workDir, `${appName}.app`);
if (!existsSync(innerAppPath)) {
const appBundle = readdirSync(workDir).find((entry) => entry.endsWith(".app"));
if (!appBundle) {
console.error("expand-macos-bundle: extracted app bundle not found");
process.exit(1);
}
}
const resolvedInnerApp = existsSync(innerAppPath)
? innerAppPath
: path.join(workDir, readdirSync(workDir).find((e) => e.endsWith(".app")));
const innerContentsPath = path.join(resolvedInnerApp, "Contents");
function replaceDirectory(sourceDir, destinationDir) {
rmSync(destinationDir, { recursive: true, force: true });
const result = spawnSync("ditto", [sourceDir, destinationDir], {
stdio: "inherit",
});
if (result.status !== 0) {
console.error("expand-macos-bundle: ditto copy failed");
process.exit(result.status ?? 1);
}
}
replaceDirectory(
path.join(innerContentsPath, "MacOS"),
path.join(contentsPath, "MacOS"),
);
rmSync(resourcesPath, { recursive: true, force: true });
replaceDirectory(path.join(innerContentsPath, "Resources"), resourcesPath);
const innerFrameworks = path.join(innerContentsPath, "Frameworks");
const wrapperFrameworks = path.join(contentsPath, "Frameworks");
if (existsSync(innerFrameworks)) {
replaceDirectory(innerFrameworks, wrapperFrameworks);
}
const infoPlistResult = spawnSync(
"ditto",
[path.join(innerContentsPath, "Info.plist"), path.join(contentsPath, "Info.plist")],
{ stdio: "inherit" },
);
if (infoPlistResult.status !== 0) {
console.error("expand-macos-bundle: Info.plist copy failed");
process.exit(infoPlistResult.status ?? 1);
}
rmSync(workDir, { recursive: true, force: true });
if (process.platform === "darwin") {
spawnSync("xattr", ["-cr", resolvedWrapperPath], { stdio: "inherit" });
}
console.log(`expand-macos-bundle: pre-expanded ${resolvedWrapperPath}`);

View File

@ -0,0 +1,144 @@
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const electrobunDir = path.join(rootDir, "node_modules/electrobun");
function findZstdDecompressCommand() {
const hostArch = process.arch === "arm64" ? "arm64" : "x64";
const zigCandidates = [
path.join(electrobunDir, `dist-win-${hostArch}`, "zig-zstd.exe"),
path.join(electrobunDir, "dist-win-x64", "zig-zstd.exe"),
path.join(electrobunDir, `dist-macos-${hostArch}`, "zig-zstd"),
path.join(electrobunDir, "dist-macos-x64", "zig-zstd"),
path.join(electrobunDir, "dist-win-x64", "zig-zstd", "x64", "zig-zstd.exe"),
path.join(electrobunDir, "dist-win-x64", "zig-zstd", "arm64", "zig-zstd.exe"),
path.join(electrobunDir, "vendors", "zig-zstd", "x64", "zig-zstd.exe"),
path.join(electrobunDir, "vendors", "zig-zstd", "arm64", "zig-zstd.exe"),
];
for (const candidate of zigCandidates) {
if (!existsSync(candidate)) {
continue;
}
return {
kind: "zig-zstd",
command: candidate,
args: (inputPath, outputPath) => [
"decompress",
"-i",
inputPath,
"-o",
outputPath,
"--no-timing",
],
};
}
const systemZstd = spawnSync("zstd", ["--version"], {
encoding: "utf8",
shell: process.platform === "win32",
});
if (systemZstd.status === 0) {
return {
kind: "system-zstd",
command: "zstd",
args: (inputPath, outputPath) => [
"-d",
"-f",
"-o",
outputPath,
inputPath,
],
};
}
return null;
}
function decompressTarZst(inputPath, outputPath) {
const zstd = findZstdDecompressCommand();
if (!zstd) {
throw new Error(
"expand-windows-installer: zstd not found (install zstd or use electrobun dist binaries)",
);
}
const resolvedInput = path.resolve(inputPath);
const resolvedOutput = path.resolve(outputPath);
const args = zstd.args(resolvedInput, resolvedOutput);
console.log(
`expand-windows-installer: decompressing with ${zstd.command} (${zstd.kind})`,
);
const result = spawnSync(zstd.command, args, {
stdio: "inherit",
shell: false,
windowsHide: true,
});
if (result.status !== 0) {
throw new Error(
`expand-windows-installer: decompression failed (exit ${result.status ?? 1}) using ${zstd.command} ${args.join(" ")}`,
);
}
}
export function expandWindowsAppFromArchive(setupArchivePath, parentDir) {
const resolvedArchive = path.resolve(setupArchivePath);
if (!existsSync(resolvedArchive)) {
throw new Error(`expand-windows-installer: archive not found: ${resolvedArchive}`);
}
const workDir = path.join(path.resolve(parentDir), ".expanded-app");
const tarPath = path.join(workDir, "app.tar");
rmSync(workDir, { recursive: true, force: true });
mkdirSync(workDir, { recursive: true });
const archiveForDecompress = path.join(workDir, "setup.tar.zst");
cpSync(resolvedArchive, archiveForDecompress);
decompressTarZst(archiveForDecompress, tarPath);
const extractTar = spawnSync("tar", ["-xf", tarPath, "-C", workDir], {
stdio: "inherit",
shell: false,
windowsHide: true,
});
if (extractTar.status !== 0) {
rmSync(workDir, { recursive: true, force: true });
throw new Error(
`expand-windows-installer: tar extraction failed (exit ${extractTar.status ?? 1})`,
);
}
const appDir = readdirSync(workDir)
.filter((entry) => entry !== "app.tar" && entry !== "__MACOSX")
.map((entry) => path.join(workDir, entry))
.find((entryPath) => statSync(entryPath).isDirectory());
if (!appDir) {
rmSync(workDir, { recursive: true, force: true });
throw new Error("expand-windows-installer: app folder not found in archive");
}
console.log(`expand-windows-installer: expanded ${appDir}`);
return appDir;
}
export function cleanExpandedWindowsApp(parentDir) {
const workDir = path.join(path.resolve(parentDir), ".expanded-app");
rmSync(workDir, { recursive: true, force: true });
}

View File

@ -15,8 +15,16 @@ import {
getReleaseArtifactName,
getReleaseVersion,
} from "./release-artifact-utils.mjs";
import {
cleanExpandedWindowsApp,
expandWindowsAppFromArchive,
} from "./expand-windows-installer.mjs";
import { codesignMacAppBundle } from "./codesign-macos-app.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const packageJson = JSON.parse(
readFileSync(path.join(rootDir, "package.json"), "utf8"),
);
@ -30,9 +38,7 @@ const targetOs =
: process.platform === "linux"
? "linux"
: null);
const buildArch = getReleaseArch(
process.env.ELECTROBUN_ARCH || process.arch,
);
const buildArch = getReleaseArch(process.env.ELECTROBUN_ARCH || process.arch);
const version =
process.env.ELECTROBUN_APP_VERSION || getReleaseVersion(packageJson);
const artifactDir =
@ -116,6 +122,36 @@ function findMacDmgSource(arch) {
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(
@ -133,6 +169,238 @@ function publishArtifact(sourcePath, arch, ext) {
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.app";
const MAC_DMG_WINDOW_WIDTH = 540;
const MAC_DMG_WINDOW_HEIGHT = 380;
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",
"--window-pos",
"200",
"120",
"--window-size",
String(MAC_DMG_WINDOW_WIDTH),
String(MAC_DMG_WINDOW_HEIGHT),
"--icon-size",
"100",
"--icon",
appBundleName,
"130",
"220",
"--hide-extension",
appBundleName,
"--app-drop-link",
"410",
"220",
"--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,
@ -163,12 +431,173 @@ function buildMacPkg(appBundlePath, arch) {
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}`,
);
}
const minInstallerBytes = 10 * 1024 * 1024;
const installerSize = statSync(exePath).size;
if (installerSize < minInstallerBytes) {
throw new Error(
`Windows installer ${path.basename(exePath)} is only ${(installerSize / 1024).toFixed(1)} KiB; expected at least ${minInstallerBytes / (1024 * 1024)} MiB`,
);
}
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);
}
if (targetOs !== "macos") {
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,
);
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",
@ -176,34 +605,4 @@ if (targetOs !== "macos") {
process.exit(0);
}
const dmgSource = findMacDmgSource(buildArch);
const appBundle = findMacAppBundle(buildArch);
if (!dmgSource || !appBundle) {
console.log(
`finalize-desktop-artifacts: no macOS ${buildArch} release artifacts found, skipping`,
);
process.exit(0);
}
const published = [
publishArtifact(dmgSource, buildArch, "dmg"),
buildMacPkg(appBundle, buildArch),
];
for (const filePath of published) {
const basename = path.basename(filePath);
for (const entry of readdirSync(artifactDir)) {
if (entry === basename || entry.startsWith(artifactPrefix)) {
continue;
}
if (
entry.includes("stable-macos-") ||
entry.endsWith("-update.json") ||
entry.endsWith(".tar.gz")
) {
rmSync(path.join(artifactDir, entry), { recursive: true, force: true });
}
}
}
await main();

View File

@ -0,0 +1,277 @@
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const electrobunDir = path.join(rootDir, "node_modules/electrobun");
const sharedSrcDir = path.join(electrobunDir, "src/shared");
const sharedDistDir = path.join(electrobunDir, "dist/api/shared");
if (!existsSync(sharedDistDir)) {
console.error("patch-electrobun-src: electrobun dist/api/shared not found");
process.exit(1);
}
mkdirSync(sharedSrcDir, { recursive: true });
for (const fileName of [
"cef-version.ts",
"bun-version.ts",
"electrobun-version.ts",
"naming.ts",
"rpc.ts",
]) {
const source = path.join(sharedDistDir, fileName);
const destination = path.join(sharedSrcDir, fileName);
if (existsSync(source)) {
cpSync(source, destination);
}
}
const platformPatch = `import { platform, arch } from "os";
export type SupportedOS = "macos" | "win" | "linux";
export type SupportedArch = "arm64" | "x64";
const platformName = platform();
const archName = arch();
export const OS: SupportedOS = (() => {
switch (platformName) {
case "win32":
return "win";
case "darwin":
return "macos";
case "linux":
return "linux";
default:
throw new Error(\`Unsupported platform: \${platformName}\`);
}
})();
function normalizeForcedArch(value) {
if (value === "arm64" || value === "aarch64") {
return "arm64";
}
if (value === "x64" || value === "amd64") {
return "x64";
}
return null;
}
export const ARCH: SupportedArch = (() => {
const forcedArch = normalizeForcedArch(
process.env.ELECTROBUN_FORCE_ARCH || process.env.ELECTROBUN_TARGET_ARCH,
);
if (forcedArch) {
return forcedArch;
}
if (OS === "win") {
return "x64";
}
switch (archName) {
case "arm64":
return "arm64";
case "x64":
return "x64";
default:
throw new Error(\`Unsupported architecture: \${archName}\`);
}
})();
export const HOST_ARCH: SupportedArch = (() => {
if (OS === "win") {
return "x64";
}
switch (archName) {
case "arm64":
return "arm64";
case "x64":
return "x64";
default:
throw new Error(\`Unsupported architecture: \${archName}\`);
}
})();
export function getPlatformOS(): SupportedOS {
return OS;
}
export function getPlatformArch(): SupportedArch {
return ARCH;
}
`;
writeFileSync(path.join(sharedSrcDir, "platform.ts"), platformPatch);
const templatesDir = path.join(electrobunDir, "src/cli/templates");
mkdirSync(templatesDir, { recursive: true });
writeFileSync(
path.join(templatesDir, "embedded.ts"),
`export function getTemplateNames() {
return [];
}
export function getTemplate(name: string) {
throw new Error(\`Template not available in patched electrobun CLI: \${name}\`);
}
`,
);
const RCEDIT_RESOLVER_FN = `function resolveProjectRceditPkgPath(projectRoot) {
const candidates = [
join(projectRoot, "node_modules/rcedit/package.json"),
join(projectRoot, "node_modules/electrobun/node_modules/rcedit/package.json"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
throw new Error(
"rcedit not found under " + projectRoot + ". Install rcedit in the project (bun add -d rcedit).",
);
}`;
const cliPath = path.join(electrobunDir, "src/cli/index.ts");
let cliSource = readFileSync(cliPath, "utf8");
if (!cliSource.includes("HOST_ARCH")) {
cliSource = cliSource.replace(
'import { OS, ARCH } from "../shared/platform";',
'import { OS, ARCH, HOST_ARCH } from "../shared/platform";',
);
cliSource = cliSource.replace(
"const hostPaths = getPlatformPaths(OS, ARCH);",
"const hostPaths = getPlatformPaths(OS, HOST_ARCH);",
);
}
if (!cliSource.includes("toolPaths")) {
cliSource = cliSource.replace(
"const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);",
"const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);\n\t\tconst toolPaths = getPlatformPaths(currentTarget.os, ARCH !== HOST_ARCH ? HOST_ARCH : ARCH);",
);
cliSource = cliSource.replaceAll("const zstdPath = targetPaths.ZSTD;", "const zstdPath = toolPaths.ZSTD;");
cliSource = cliSource.replaceAll("const bsdiffpath = targetPaths.BSDIFF;", "const bsdiffpath = toolPaths.BSDIFF;");
cliSource = cliSource.replace(
"zigAsarCli = join(targetPaths.BSPATCH).replace(\"bspatch\", \"zig-asar\");",
"zigAsarCli = join(toolPaths.BSPATCH).replace(\"bspatch\", \"zig-asar\");",
);
writeFileSync(cliPath, cliSource);
cliSource = readFileSync(cliPath, "utf8");
}
if (!cliSource.includes("hookBunBinary")) {
cliSource = cliSource.replace(
`const hostPaths = getPlatformPaths(OS, HOST_ARCH);
const result = Bun.spawnSync([hostPaths.BUN_BINARY, hookScript],`,
`const hostPaths = getPlatformPaths(OS, HOST_ARCH);
const hookBunBinary = existsSync(hostPaths.BUN_BINARY)
? hostPaths.BUN_BINARY
: process.execPath;
const result = Bun.spawnSync([hookBunBinary, hookScript],`,
);
cliSource = cliSource.replace(
"console.error(\"Tried to run with bun at:\", hostPaths.BUN_BINARY);",
"console.error(\"Tried to run with bun at:\", hookBunBinary);",
);
writeFileSync(cliPath, cliSource);
cliSource = readFileSync(cliPath, "utf8");
}
if (!cliSource.includes("resolveProjectRceditPkgPath")) {
cliSource = cliSource.replaceAll(
"const rceditPkgPath = require.resolve(\"rcedit/package.json\");",
"const rceditPkgPath = resolveProjectRceditPkgPath(projectRoot);",
);
cliSource = cliSource.replace(
"function getPlatformPaths(",
`${RCEDIT_RESOLVER_FN}
function getPlatformPaths(`,
);
writeFileSync(cliPath, cliSource);
cliSource = readFileSync(cliPath, "utf8");
} else {
cliSource = cliSource.replace(
/function resolveProjectRceditPkgPath\(projectRoot\) \{[\s\S]*?\n\}/m,
RCEDIT_RESOLVER_FN,
);
writeFileSync(cliPath, cliSource);
}
const electrobunCjsPath = path.join(electrobunDir, "bin/electrobun.cjs");
let electrobunCjs = readFileSync(electrobunCjsPath, "utf8");
if (!electrobunCjs.includes("farmcontrol-use-patched-cli")) {
electrobunCjs = electrobunCjs.replace(
`async function main() {
try {
const args = process.argv.slice(2);
const cliPath = await ensureCliBinary();
// Replace this process with the actual CLI
const child = spawn(cliPath, args, {
stdio: 'inherit',
cwd: process.cwd()
});`,
`async function main() {
try {
const args = process.argv.slice(2);
const patchedCliPath = join(electrobunDir, 'src', 'cli', 'index.ts');
if (existsSync(patchedCliPath)) {
// farmcontrol-use-patched-cli: bundled electrobun.exe cannot resolve project rcedit
const bunBinary = process.env.BUN_INSTALL
? join(process.env.BUN_INSTALL, 'bin', 'bun' + binExt)
: 'bun';
const child = spawn(bunBinary, [patchedCliPath, ...args], {
stdio: 'inherit',
cwd: process.cwd(),
env: process.env,
shell: platform === 'win',
});
child.on('exit', (code) => {
process.exit(code || 0);
});
child.on('error', (error) => {
console.error('Failed to start electrobun patched CLI:', error.message);
process.exit(1);
});
return;
}
const cliPath = await ensureCliBinary();
// Replace this process with the actual CLI
const child = spawn(cliPath, args, {
stdio: 'inherit',
cwd: process.cwd()
});`,
);
writeFileSync(electrobunCjsPath, electrobunCjs);
}
const rceditSrc = path.join(rootDir, "node_modules/rcedit");
const rceditDest = path.join(electrobunDir, "node_modules/rcedit");
if (existsSync(rceditSrc)) {
mkdirSync(path.join(electrobunDir, "node_modules"), { recursive: true });
cpSync(rceditSrc, rceditDest, { recursive: true, force: true });
}
const markerPath = path.join(electrobunDir, ".farmcontrol-electrobun-patched");
writeFileSync(markerPath, `patched-at=${new Date().toISOString()}\n`);
console.log("patch-electrobun-src: electrobun src/shared ready for cross-arch builds");

View File

@ -1,5 +1,6 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@ -35,4 +36,66 @@ if (buildEnv === "stable" && process.env.ELECTROBUN_OS === "macos") {
}
}
console.log(`pre-build: validation passed (${buildEnv})`);
const prepareIcons = spawnSync(
"bun",
[
path.join(rootDir, "scripts/prepare-app-icons.mjs"),
"assets/logos/farmcontrolicon.png",
],
{ cwd: rootDir, stdio: "inherit", env: process.env },
);
if (prepareIcons.status !== 0) {
process.exit(prepareIcons.status ?? 1);
}
const windowsIconPath = path.join(rootDir, "assets/icon.ico");
if (!existsSync(windowsIconPath)) {
console.error(
"pre-build: assets/icon.ico not found after prepare-app-icons (required for build.win.icon)",
);
process.exit(1);
}
const writeBuildInfo = spawnSync(
"bun",
[path.join(rootDir, "scripts/write-build-info.mjs")],
{ cwd: rootDir, stdio: "inherit", env: process.env },
);
if (writeBuildInfo.status !== 0) {
process.exit(writeBuildInfo.status ?? 1);
}
if (buildEnv === "dev") {
console.log(
"pre-build: dev environment — skipping production renderer build (use dev:renderer for Vite)",
);
console.log(`pre-build: validation passed (${buildEnv})`);
process.exit(0);
}
const buildRenderer = spawnSync(
"bun",
[path.join(rootDir, "scripts/build-renderer.mjs")],
{
cwd: rootDir,
stdio: "inherit",
env: {
...process.env,
NODE_ENV: "production",
},
},
);
if (buildRenderer.status !== 0) {
process.exit(buildRenderer.status ?? 1);
}
const rendererIndex = path.join(rootDir, "dist/mainview/index.html");
if (!existsSync(rendererIndex)) {
console.error(`pre-build: renderer output not found: dist/mainview/index.html`);
process.exit(1);
}
console.log(`pre-build: validation and renderer build passed (${buildEnv})`);

View File

@ -0,0 +1,104 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Jimp } from "jimp";
import pngToIco from "png-to-ico";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const MAC_ICONSET_ENTRIES = [
{ name: "icon_16x16.png", size: 16 },
{ name: "icon_16x16@2x.png", size: 32 },
{ name: "icon_32x32.png", size: 32 },
{ name: "icon_32x32@2x.png", size: 64 },
{ name: "icon_128x128.png", size: 128 },
{ name: "icon_128x128@2x.png", size: 256 },
{ name: "icon_256x256.png", size: 256 },
{ name: "icon_256x256@2x.png", size: 512 },
{ name: "icon_512x512.png", size: 512 },
{ name: "icon_512x512@2x.png", size: 1024 },
];
const WIN_ICO_SIZES = [16, 32, 48, 256];
const LINUX_ICON_SIZE = 256;
function resolveSourcePath(arg) {
if (!arg) {
return path.join(rootDir, "assets/farmcontrolhosticon.png");
}
const candidate = path.isAbsolute(arg) ? arg : path.resolve(rootDir, arg);
return candidate;
}
async function resizePng(sourceImage, size) {
return sourceImage
.clone()
.resize({ w: size, h: size })
.getBuffer("image/png");
}
async function writePng(filePath, sourceImage, size) {
const png = await resizePng(sourceImage, size);
await Bun.write(filePath, png);
return png;
}
function parseArgs(argv) {
const sourceArg = argv.find((arg) => !arg.startsWith("-"));
const outDirArgIndex = argv.indexOf("--out-dir");
const outDir =
outDirArgIndex >= 0 ? argv[outDirArgIndex + 1] : path.join(rootDir, "assets");
return {
sourcePath: resolveSourcePath(sourceArg),
outDir: path.isAbsolute(outDir) ? outDir : path.resolve(rootDir, outDir),
};
}
async function main() {
const { sourcePath, outDir } = parseArgs(process.argv.slice(2));
if (!existsSync(sourcePath)) {
console.error(`prepare-app-icons: source image not found: ${sourcePath}`);
process.exit(1);
}
const sourceImage = await Jimp.read(sourcePath);
if (sourceImage.width < 256 || sourceImage.height < 256) {
console.warn(
`prepare-app-icons: source image is ${sourceImage.width}x${sourceImage.height}; Electrobun recommends at least 256x256`,
);
}
mkdirSync(outDir, { recursive: true });
const iconsetDir = path.join(outDir, "icon.iconset");
rmSync(iconsetDir, { recursive: true, force: true });
mkdirSync(iconsetDir, { recursive: true });
for (const entry of MAC_ICONSET_ENTRIES) {
const iconPath = path.join(iconsetDir, entry.name);
await writePng(iconPath, sourceImage, entry.size);
}
const icoPngs = [];
for (const size of WIN_ICO_SIZES) {
icoPngs.push(await resizePng(sourceImage, size));
}
const icoPath = path.join(outDir, "icon.ico");
await Bun.write(icoPath, await pngToIco(icoPngs));
const linuxIconPath = path.join(outDir, "icon.png");
await writePng(linuxIconPath, sourceImage, LINUX_ICON_SIZE);
console.log(`prepare-app-icons: wrote ${iconsetDir}`);
console.log(`prepare-app-icons: wrote ${icoPath}`);
console.log(`prepare-app-icons: wrote ${linuxIconPath}`);
}
main().catch((error) => {
console.error(`prepare-app-icons: ${error.message}`);
process.exit(1);
});

View File

@ -0,0 +1,75 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getReleaseArch } from "./release-artifact-utils.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const electrobunCli = path.join(
rootDir,
"node_modules/electrobun/src/cli/index.ts",
);
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "stable";
const targetArch = getReleaseArch(
process.env.ELECTROBUN_TARGET_ARCH ||
process.env.ELECTROBUN_FORCE_ARCH ||
process.arch,
);
const patchResult = spawnSync("bun", [path.join(rootDir, "scripts/patch-electrobun-src.mjs")], {
cwd: rootDir,
stdio: "inherit",
env: process.env,
});
if (patchResult.status !== 0) {
process.exit(patchResult.status ?? 1);
}
const ensureCoreEnv = {
...process.env,
ELECTROBUN_TARGET_ARCH: targetArch,
ELECTROBUN_FORCE_ARCH: targetArch,
};
const ensureCoreResult = spawnSync(
"bun",
[path.join(rootDir, "scripts/ensure-electrobun-core.mjs")],
{
cwd: rootDir,
stdio: "inherit",
env: ensureCoreEnv,
},
);
if (ensureCoreResult.status !== 0) {
process.exit(ensureCoreResult.status ?? 1);
}
const env = {
...process.env,
ELECTROBUN_BUILD_ENV: buildEnv,
ELECTROBUN_OS: "macos",
ELECTROBUN_ARCH: targetArch,
ELECTROBUN_FORCE_ARCH: targetArch,
ELECTROBUN_TARGET_ARCH: targetArch,
NODE_ENV: process.env.NODE_ENV || "production",
};
console.log(
`run-electrobun-build: env=${buildEnv} os=macos arch=${targetArch} (host ${getReleaseArch(process.arch)})`,
);
const result = spawnSync(
"bun",
[electrobunCli, "build", `--env=${buildEnv}`],
{
cwd: rootDir,
stdio: "inherit",
env,
},
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}

View File

@ -0,0 +1,15 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const buildNumber =
process.env.BUILD_NUMBER || process.env.VITE_BUILD_NUMBER || "dev";
const buildInfoPath = path.join(rootDir, "src/buildInfo.json");
await Bun.write(
buildInfoPath,
`${JSON.stringify({ buildNumber }, null, 2)}\n`,
);
console.log(`write-build-info: ${buildInfoPath} (buildNumber=${buildNumber})`);

3
src/buildInfo.json Normal file
View File

@ -0,0 +1,3 @@
{
"buildNumber": "dev"
}

View File

@ -2,14 +2,23 @@ import { createContext, useCallback, useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import { useNavigate } from 'react-router-dom'
// Only available in Electron renderer
const electron = window.require ? window.require('electron') : null
const ipcRenderer = electron ? electron.ipcRenderer : null
const desktopAPI = window.electronAPI
// Utility to check if running in Electron
// eslint-disable-next-line react-refresh/only-export-components
export function isElectron() {
// Renderer process
if (desktopAPI?.isDesktop) {
return true
}
if (
typeof window !== 'undefined' &&
window.__electrobunWebviewId &&
window.__electrobunRpcSocketPort
) {
return true
}
if (
typeof window !== 'undefined' &&
@ -19,7 +28,6 @@ export function isElectron() {
return true
}
// User agent
if (
typeof navigator === 'object' &&
typeof navigator.userAgent === 'string' &&
@ -27,6 +35,7 @@ export function isElectron() {
) {
return true
}
return false
}
@ -52,6 +61,7 @@ const ElectronProvider = ({ children }) => {
const [isMaximized, setIsMaximized] = useState(false)
const [isFullScreen, setIsFullScreen] = useState(false)
const [electronAvailable] = useState(isElectron())
const useElectrobun = Boolean(desktopAPI?.isDesktop)
const navigate = useNavigate()
const lastNavigationAtRef = useRef(0)
@ -70,8 +80,20 @@ const ElectronProvider = ({ children }) => {
[navigate]
)
// Function to open external URL via Electron
const applyWindowState = useCallback((state) => {
if (state && typeof state.isMaximized === 'boolean') {
setIsMaximized(state.isMaximized)
}
if (state && typeof state.isFullScreen === 'boolean') {
setIsFullScreen(state.isFullScreen)
}
}, [])
const openExternalUrl = (url) => {
if (useElectrobun) {
desktopAPI.openExternalUrl(url)
return true
}
if (electronAvailable && ipcRenderer) {
ipcRenderer.invoke('open-external-url', url)
return true
@ -79,8 +101,11 @@ const ElectronProvider = ({ children }) => {
return false
}
// Function to open internal URL via Electron
const openInternalUrl = (url) => {
if (useElectrobun) {
desktopAPI.openInternalUrl(url)
return true
}
if (electronAvailable && ipcRenderer) {
ipcRenderer.invoke('open-internal-url', url)
return true
@ -89,36 +114,45 @@ const ElectronProvider = ({ children }) => {
}
useEffect(() => {
if (!electronAvailable) return
if (useElectrobun) {
desktopAPI.getOsInfo().then((info) => {
if (info?.platform) setPlatform(info.platform)
})
desktopAPI.getWindowState().then(applyWindowState)
const unsubWindowState = desktopAPI.onMessage('windowState', applyWindowState)
const unsubNavigate = desktopAPI.onMessage('navigate', (url) => {
navigate(url)
})
const unsubNavigationGesture = desktopAPI.onMessage(
'navigationGesture',
navigateHistory
)
return () => {
unsubWindowState()
unsubNavigate()
unsubNavigationGesture()
}
}
if (!ipcRenderer) return
// Get initial platform
ipcRenderer.invoke('os-info').then((info) => {
if (info && info.platform) setPlatform(info.platform)
if (info?.platform) setPlatform(info.platform)
})
// Get initial window state
ipcRenderer.invoke('window-state').then((state) => {
if (state && typeof state.isMaximized === 'boolean') {
setIsMaximized(state.isMaximized)
}
if (state && typeof state.isFullScreen === 'boolean') {
setIsFullScreen(state.isFullScreen)
}
})
ipcRenderer.invoke('window-state').then(applyWindowState)
// Listen for window state changes
const windowStateHandler = (event, state) => {
if (state && typeof state.isMaximized === 'boolean') {
setIsMaximized(state.isMaximized)
}
if (state && typeof state.isFullScreen === 'boolean') {
setIsFullScreen(state.isFullScreen)
}
const windowStateHandler = (_event, state) => {
applyWindowState(state)
}
ipcRenderer.on('window-state', windowStateHandler)
// Listen for navigate
const navigateHandler = (event, url) => {
const navigateHandler = (_event, url) => {
navigate(url)
}
ipcRenderer.on('navigate', navigateHandler)
@ -133,7 +167,13 @@ const ElectronProvider = ({ children }) => {
ipcRenderer.removeListener('navigation-gesture', navigationGestureHandler)
ipcRenderer.removeListener('window-state', windowStateHandler)
}
}, [navigate, navigateHistory])
}, [
applyWindowState,
electronAvailable,
navigate,
navigateHistory,
useElectrobun
])
useEffect(() => {
if (!electronAvailable || platform !== 'darwin') return
@ -165,56 +205,89 @@ const ElectronProvider = ({ children }) => {
}
}, [electronAvailable, navigateHistory, platform])
// Window control handler
const handleWindowControl = (action) => {
if (useElectrobun) {
desktopAPI.windowControl(action)
return
}
if (electronAvailable && ipcRenderer) {
ipcRenderer.send('window-control', action)
}
}
const getAuthSession = async () => {
if (!electronAvailable || !ipcRenderer) return null
if (!electronAvailable) return null
if (useElectrobun) return await desktopAPI.getAuthSession()
if (!ipcRenderer) return null
return await ipcRenderer.invoke('auth-session-get')
}
const setAuthSession = async (session) => {
if (!electronAvailable || !ipcRenderer) return false
if (!electronAvailable) return false
if (useElectrobun) {
const result = await desktopAPI.setAuthSession(session)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('auth-session-set', session)
}
const clearAuthSession = async () => {
if (!electronAvailable || !ipcRenderer) return false
if (!electronAvailable) return false
if (useElectrobun) {
const result = await desktopAPI.clearAuthSession()
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('auth-session-clear')
}
const getAppSettings = useCallback(async () => {
if (!electronAvailable || !ipcRenderer) return {}
if (!electronAvailable) return {}
if (useElectrobun) return await desktopAPI.getAppSettings()
if (!ipcRenderer) return {}
return await ipcRenderer.invoke('app-settings-get')
}, [electronAvailable])
}, [electronAvailable, useElectrobun])
const setAppSettings = useCallback(
async (settings) => {
if (!electronAvailable || !ipcRenderer) return false
if (!electronAvailable) return false
if (useElectrobun) {
const result = await desktopAPI.setAppSettings(settings)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('app-settings-set', settings)
},
[electronAvailable]
[electronAvailable, useElectrobun]
)
const startAppUpdate = useCallback(
async (update) => {
if (!electronAvailable || !ipcRenderer) return false
if (!electronAvailable) return false
if (useElectrobun) {
const result = await desktopAPI.startAppUpdate(update)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('app-update-start', update)
},
[electronAvailable]
[electronAvailable, useElectrobun]
)
const onAppUpdateProgress = useCallback(
(handler) => {
if (!electronAvailable || !ipcRenderer || typeof handler !== 'function') {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
const progressHandler = (event, progress) => {
if (useElectrobun) {
return desktopAPI.onMessage('appUpdateProgress', handler)
}
if (!ipcRenderer) return () => {}
const progressHandler = (_event, progress) => {
handler(progress)
}
@ -224,10 +297,9 @@ const ElectronProvider = ({ children }) => {
ipcRenderer.removeListener('app-update-progress', progressHandler)
}
},
[electronAvailable]
[electronAvailable, useElectrobun]
)
// Backwards-compatible helpers
const getToken = async () => {
const session = await getAuthSession()
return session?.token || null
@ -239,8 +311,13 @@ const ElectronProvider = ({ children }) => {
}
const resizeSpotlightWindow = async (height) => {
if (!electronAvailable || !ipcRenderer) return false
if (!electronAvailable) return false
try {
if (useElectrobun) {
const result = await desktopAPI.resizeSpotlightWindow(height)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('spotlight-window-resize', height)
} catch (error) {
console.warn(
@ -253,16 +330,23 @@ const ElectronProvider = ({ children }) => {
const setSidebarViewMenu = useCallback(
async (sections) => {
if (!electronAvailable || !ipcRenderer) return false
if (!electronAvailable) return false
if (useElectrobun) {
const result = await desktopAPI.setSidebarViewMenu(sections)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('set-sidebar-view-menu', sections)
},
[electronAvailable]
[electronAvailable, useElectrobun]
)
const getElectronVersion = useCallback(async () => {
if (!electronAvailable || !ipcRenderer) return null
if (!electronAvailable) return null
if (useElectrobun) return await desktopAPI.getAppVersion()
if (!ipcRenderer) return null
return await ipcRenderer.invoke('electron-version')
}, [electronAvailable])
}, [electronAvailable, useElectrobun])
return (
<ElectronContext.Provider

View File

@ -1,3 +1,4 @@
import './electrobun-bridge.js'
import reportWebVitals from './reportWebVitals'
import ReactDOM from 'react-dom/client'
import FarmControlApp from './App'