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.
122
Jenkinsfile
vendored
@ -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,76 +91,95 @@ 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) {
|
||||
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})") {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
if (isUnix()) {
|
||||
sh 'pnpm install --frozen-lockfile --production=false'
|
||||
sh 'bun install --frozen-lockfile'
|
||||
} else {
|
||||
bat 'pnpm install --frozen-lockfile --production=false'
|
||||
}
|
||||
bat 'bun install --frozen-lockfile'
|
||||
}
|
||||
}
|
||||
|
||||
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("Write Build Metadata (${label})") {
|
||||
writeBuildMetadata()
|
||||
}
|
||||
|
||||
stage("Build (${label})") {
|
||||
runBuild(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
|
||||
archiveArtifacts artifacts: 'app_dist/farmcontrol-*', 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 26 KiB |
BIN
assets/icon.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
assets/icon.iconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
assets/icon.iconset/icon_128x128@2x.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/icon.iconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 613 B |
BIN
assets/icon.iconset/icon_16x16@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icon.iconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/icon.iconset/icon_256x256@2x.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
assets/icon.iconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icon.iconset/icon_32x32@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/icon.iconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
assets/icon.iconset/icon_512x512@2x.png
Normal file
|
After Width: | Height: | Size: 567 KiB |
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
@ -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: {
|
||||
|
||||
12
package.json
@ -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",
|
||||
|
||||
94
packaging/windows/farmcontrol.nsi
Normal 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
|
||||
34
packaging/windows/installer.nsh
Normal 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
|
||||
52
packaging/windows/msi-wrapped.wxs
Normal 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>
|
||||
@ -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: {
|
||||
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(", ")}`);
|
||||
|
||||
36
scripts/build-renderer.mjs
Normal 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")}`,
|
||||
);
|
||||
124
scripts/build-windows-msi.ps1
Normal 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"
|
||||
129
scripts/build-windows-nsis.ps1
Normal 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
@ -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");
|
||||
89
scripts/codesign-macos-app.mjs
Normal 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);
|
||||
}
|
||||
127
scripts/ensure-electrobun-core.mjs
Normal 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);
|
||||
}
|
||||
152
scripts/expand-macos-bundle.mjs
Normal 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}`);
|
||||
144
scripts/expand-windows-installer.mjs
Normal 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 });
|
||||
}
|
||||
@ -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();
|
||||
|
||||
277
scripts/patch-electrobun-src.mjs
Normal 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");
|
||||
@ -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") {
|
||||
}
|
||||
}
|
||||
|
||||
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})`);
|
||||
|
||||
104
scripts/prepare-app-icons.mjs
Normal 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);
|
||||
});
|
||||
75
scripts/run-electrobun-build.mjs
Normal 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);
|
||||
}
|
||||
15
scripts/write-build-info.mjs
Normal 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
@ -0,0 +1,3 @@
|
||||
{
|
||||
"buildNumber": "dev"
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import './electrobun-bridge.js'
|
||||
import reportWebVitals from './reportWebVitals'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import FarmControlApp from './App'
|
||||
|
||||