Compare commits

..

2 Commits

Author SHA1 Message Date
e9f4c1f3d3 Refine CEF installer artifact detection logic in app update process
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated isCefInstallerArtifact function to use a regex pattern for identifying CEF installer artifacts, improving accuracy in artifact name matching.
- Enhanced comments for clarity on CEF build naming conventions, aiding future maintenance and understanding of the code.
2026-08-08 21:56:32 +01:00
1d741d8932 Add artifact filtering for CEF installer in app update process
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced isCefInstallerArtifact function to identify CEF installer artifacts based on their file names.
- Updated artifact selection logic to filter out CEF installer artifacts, ensuring only relevant updates are processed.
- Enhanced overall update handling by improving artifact management and reducing unnecessary processing.
2026-08-08 21:10:12 +01:00
121 changed files with 654 additions and 14515 deletions

5
.gitignore vendored
View File

@ -30,8 +30,3 @@ yarn-error.log*
stats.html stats.html
test-results.xml test-results.xml
dist/*
# Native macOS vibrancy (built by scripts/build-macos-effects.mjs)
src/bun/libMacWindowEffects.dylib

164
Jenkinsfile vendored
View File

@ -1,57 +1,7 @@
properties([ properties([
buildDiscarder(logRotator(numToKeepStr: '10')) buildDiscarder(logRotator(numToKeepStr: '20'))
]) ])
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() { def deploy() {
node('ubuntu') { node('ubuntu') {
try { try {
@ -80,6 +30,7 @@ def deploy() {
stage('Deploy (Ubuntu)') { stage('Deploy (Ubuntu)') {
nodejs(nodeJSInstallationName: 'Node23') { nodejs(nodeJSInstallationName: 'Node23') {
// Deploy to Cloudflare Pages using wrangler
withCredentials([string(credentialsId: 'cloudflare-api-token', variable: 'CLOUDFLARE_API_TOKEN')]) { withCredentials([string(credentialsId: 'cloudflare-api-token', variable: 'CLOUDFLARE_API_TOKEN')]) {
sh 'pnpm wrangler pages deploy build --branch main' sh 'pnpm wrangler pages deploy build --branch main'
} }
@ -91,87 +42,66 @@ def deploy() {
} }
} }
def prepareMacBuildWorkspace() { def buildOnLabel(label, buildCommand) {
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, bundleCef = false) {
def cefLabel = bundleCef ? 'cef' : 'native'
return { return {
node(label) { node(label) {
try { stage("Checkout (${label})") {
stage("Checkout (${label}/${cefLabel})") { checkout scm
checkout scm }
}
withBun { stage("Setup Node.js (${label})") {
stage("Check Bun (${label}/${cefLabel})") { nodejs(nodeJSInstallationName: 'Node23') {
checkBun() if (isUnix()) {
} sh 'node -v'
sh 'pnpm -v'
if (label.startsWith('macos')) { } else {
stage("Prepare macOS workspace (${label}/${cefLabel})") { bat 'node -v'
prepareMacBuildWorkspace() bat 'pnpm -v'
}
}
stage("Install Dependencies (${label}/${cefLabel})") {
if (isUnix()) {
sh 'bun install --frozen-lockfile'
} else {
bat 'bun install --frozen-lockfile'
}
}
stage("Write Build Metadata (${label}/${cefLabel})") {
writeBuildMetadata()
}
stage("Build (${label}/${cefLabel})") {
withEnv(["ELECTROBUN_BUNDLE_CEF=${bundleCef ? 'true' : 'false'}"]) {
runBuild(buildCommand)
}
} }
} }
}
stage("Archive Artifacts (${label}/${cefLabel})") { stage("Install Dependencies (${label})") {
archiveArtifacts artifacts: 'app_dist/farmcontrol-*', fingerprint: true nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh 'pnpm install --frozen-lockfile --production=false'
} else {
bat 'pnpm install --frozen-lockfile --production=false'
}
} }
} finally { }
cleanWs()
stage("Build (${label})") {
nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production ${buildCommand}"
} else {
bat "set VITE_BUILD_NUMBER=${env.BUILD_NUMBER} && set NODE_ENV=production && ${buildCommand}"
}
}
}
stage("Archive Artifacts (${label})") {
archiveArtifacts artifacts: 'app_dist/**/farmcontrol-*.dmg, app_dist/**/farmcontrol-*.exe, app_dist/**/farmcontrol-*.pkg, app_dist/**/farmcontrol-*.msi', fingerprint: true
} }
} }
} }
} }
def buildMacOnLabel(label, targetArch, bundleCef = false) {
return buildOnLabel(label, "ELECTROBUN_TARGET_ARCH=${targetArch} bun run build:app:mac", bundleCef)
}
def setBuildNameFromPackageVersion() { def setBuildNameFromPackageVersion() {
node('ubuntu') { node('ubuntu') {
stage('Set Build Name') { stage('Set Build Name') {
checkout scm checkout scm
withBun { def version
checkBun() nodejs(nodeJSInstallationName: 'Node23') {
def version = sh( version = sh(
script: "bun --eval \"console.log(JSON.parse(await Bun.file('package.json').text()).version)\"", script: "node -p \"require('./package.json').version\"",
returnStdout: true returnStdout: true
).trim() ).trim()
def buildName = "v${version}-b${env.BUILD_NUMBER}"
currentBuild.displayName = buildName
echo "Build name set to: ${buildName}"
} }
def buildName = "v${version}-b${env.BUILD_NUMBER}"
currentBuild.displayName = buildName
echo "Build name set to: ${buildName}"
} }
} }
} }
@ -180,12 +110,8 @@ try {
setBuildNameFromPackageVersion() setBuildNameFromPackageVersion()
parallel( parallel(
'Windows Build': buildOnLabel('windows', 'bun run build:app', false), 'Windows Build': buildOnLabel('windows', 'pnpm build:electron'),
'Windows CEF Build': buildOnLabel('windows', 'bun run build:app', true), 'MacOS Build': buildOnLabel('macos', 'pnpm build:electron'),
'MacOS x64 Build': buildMacOnLabel('macos', 'x64', false),
'MacOS x64 CEF Build': buildMacOnLabel('macos', 'x64', true),
'MacOS arm64 Build': buildMacOnLabel('macos', 'arm64', false),
'MacOS arm64 CEF Build': buildMacOnLabel('macos', 'arm64', true),
'Ubuntu Deploy': { deploy() } 'Ubuntu Deploy': { deploy() }
) )

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

View File

@ -105,146 +105,23 @@
} }
.electron-navigation-wrapper { .electron-navigation-wrapper {
user-select: none; -webkit-app-region: drag;
--webkit-user-select: none;
} }
.electron-body * { .electron-navigation-wrapper li,
-webkit-user-select: none; .electron-navigation-wrapper button,
-khtml-user-select: none; .electron-navigation-wrapper .ant-tag {
-moz-user-select: none; -webkit-app-region: no-drag;
-o-user-select: none;
user-select: none;
}
.electron-body .ant-descriptions-item-content *,
.electron-body .ant-table-cell * {
-webkit-user-select: text;
-khtml-user-select: text;
-moz-user-select: text;
-o-user-select: text;
user-select: text;
}
/* Native macOS vibrancy — transparent web content over NSVisualEffectView */
html.macos-vibrancy {
--macos-window-corner-radius: 15px; /* keep in sync with MAC_WINDOW_CORNER_RADIUS */
}
html.macos-vibrancy,
html.macos-vibrancy body,
html.macos-vibrancy #root {
background: transparent !important;
}
html.macos-vibrancy .ant-layout.ant-layout-has-sider,
html.macos-vibrancy .main-layout {
background: transparent !important;
}
html.macos-vibrancy .light-mode .ant-layout.main-content-layout {
background: rgba(255, 255, 255, 0.88) !important;
}
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout {
background: rgba(0, 0, 0, 0.88) !important;
}
html.macos-vibrancy .light-mode .ant-layout-sider {
background: rgba(244, 244, 244, 0.82) !important;
}
html.macos-vibrancy .dark-mode .ant-layout-sider {
background: rgba(10, 10, 10, 0.82) !important;
}
html.macos-vibrancy .light-mode .ant-layout-content {
background: transparent !important;
}
html.macos-vibrancy .dark-mode .ant-layout-content {
background: transparent !important;
}
html.macos-vibrancy .ant-layout-sider-light,
html.macos-vibrancy .electron-sider .ant-menu-light {
background: transparent !important;
}
html.macos-vibrancy
.dark-mode
.electron-sider
.ant-menu-light
.ant-menu-item-selected {
background-color: color-mix(in srgb, var(--color-primary) 30%, #00000010);
color: color-mix(in srgb, var(--color-primary) 75%, #ffffff);
}
html.macos-vibrancy
.light-mode
.electron-sider
.ant-menu-light
.ant-menu-item-selected {
background-color: color-mix(in srgb, var(--color-primary) 80%, #ffffff3d);
color: #ffffff;
}
html.macos-vibrancy .dark-mode .electron-navigation-wrapper {
background: rgba(10, 10, 10, 0.9) !important;
}
html.macos-vibrancy .light-mode .electron-navigation-wrapper {
background: rgba(255, 255, 255, 0.9) !important;
}
html.macos-vibrancy .electron-navigation {
background: rgba(0, 0, 0, 0) !important;
}
html.macos-vibrancy .light-mode .ant-layout.main-content-layout .ant-card {
background: rgba(255, 255, 255, 0.5) !important;
}
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout .ant-card {
background: rgba(31, 31, 31, 0.5) !important;
}
.redTrafficLight {
width: 12px;
height: 12px;
background-color: #fe5f57;
border-radius: 50%;
}
.yellowTrafficLight {
width: 12px;
height: 12px;
background-color: #febc2e;
border-radius: 50%;
}
.greenTrafficLight {
width: 12px;
height: 12px;
background-color: #28c840;
border-radius: 50%;
} }
.electron-navigation { .electron-navigation {
line-height: 40px; line-height: 40px;
} }
.electron-sidebar.ant-menu-inline .ant-menu-item {
padding-left: 20px !important;
}
.electron-navigation .ant-menu-overflow-item-rest { .electron-navigation .ant-menu-overflow-item-rest {
padding-inline: 10px; padding-inline: 10px;
} }
.electron-navigation.ant-menu-horizontal .ant-menu-item {
padding-inline: 12px;
}
.electron-sidebar .ant-menu-item, .electron-sidebar .ant-menu-item,
.electron-sidebar .ant-menu-submenu-title { .electron-sidebar .ant-menu-submenu-title {
height: 32.5px !important; height: 32.5px !important;
@ -965,124 +842,3 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.electron-body .ant-modal-wrap,
.electron-body .ant-modal-mask {
top: 41px;
}
@keyframes h-progress-macos-wave {
0% {
transform: translateX(0);
}
100% {
/* One gradient period (40cqw of a 200cqw-wide element) for a seamless loop */
transform: translateX(20%);
}
}
.h-progress {
display: inline-block;
width: 100%;
font-size: 14px;
line-height: 1;
}
.h-progress-outer {
display: inline-flex;
align-items: center;
width: 100%;
}
.h-progress-inner {
position: relative;
display: inline-block;
width: 100%;
flex: 1;
overflow: hidden;
vertical-align: middle;
/* Lets the wave size itself against the full track via cqw units */
container-type: inline-size;
}
.h-progress-bg,
.h-progress-success-bg {
position: relative;
transition: all 0.1s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.h-progress-success-bg {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
}
/* Mask that clips the wave to the filled portion of the bar. Its width
tracks the fill, while the wave inside keeps a constant width so Safari
doesn't restart/freeze the animation when progress updates. */
.h-progress-bg-mask {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
overflow: hidden;
pointer-events: none;
transition: width 0.1s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.h-progress-bg-wave {
position: absolute;
inset-block: 0;
/* Constant width relative to the full track (.h-progress-inner),
independent of the mask's changing width. Starts one period early so
the pattern always covers the track while sliding right. */
inset-inline-start: -40cqw;
width: 200cqw;
background: repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.35) 20cqw,
rgba(255, 255, 255, 0) 40cqw
);
animation: h-progress-macos-wave 1s linear infinite;
}
.h-progress-text {
display: inline-block;
margin-inline-start: 8px;
line-height: 1;
width: 2em;
white-space: nowrap;
text-align: start;
vertical-align: middle;
word-break: normal;
}
.h-progress-text-start {
width: max-content;
margin-inline-start: 0;
margin-inline-end: 8px;
}
.h-progress-text-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin-inline-start: 0;
padding: 0 4px;
color: #fff;
}
.h-progress-layout-bottom {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.h-progress-layout-bottom .h-progress-text {
width: max-content;
margin-inline-start: 0;
margin-top: 4px;
}

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(241,0,27);"/>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(255,92,96);"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 804 B

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(202,2,24);"/>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(212,76,80);"/>
<g>
<path d="M22.5,57.8L57.8,22.5C59.2,21.1 61.4,21.1 62.8,22.5L62.9,22.6C64.3,24 64.3,26.2 62.9,27.6L27.6,62.9C26.2,64.3 24,64.3 22.6,62.9L22.5,62.8C21.2,61.4 21.2,59.2 22.5,57.8Z" style="fill:rgb(128,47,49);stroke:rgb(128,47,49);stroke-width:2.5px;"/>
<path d="M27.6,22.5L62.9,57.8C64.3,59.2 64.3,61.4 62.9,62.8L62.8,62.9C61.4,64.3 59.2,64.3 57.8,62.9L22.5,27.6C21.1,26.2 21.1,24 22.5,22.6L22.6,22.5C24,21.2 26.2,21.2 27.6,22.5Z" style="fill:rgb(128,47,49);stroke:rgb(128,47,49);stroke-width:2.5px;"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(241,0,27);"/>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(255,92,96);"/>
<g>
<path d="M22.5,57.8L57.8,22.5C59.2,21.1 61.4,21.1 62.8,22.5L62.9,22.6C64.3,24 64.3,26.2 62.9,27.6L27.6,62.9C26.2,64.3 24,64.3 22.6,62.9L22.5,62.8C21.2,61.4 21.2,59.2 22.5,57.8Z" style="fill:rgb(128,47,49);stroke:rgb(128,47,49);stroke-width:2.5px;"/>
<path d="M27.6,22.5L62.9,57.8C64.3,59.2 64.3,61.4 62.9,62.8L62.8,62.9C61.4,64.3 59.2,64.3 57.8,62.9L22.5,27.6C21.1,26.2 21.1,24 22.5,22.6L22.6,22.5C24,21.2 26.2,21.2 27.6,22.5Z" style="fill:rgb(128,47,49);stroke:rgb(128,47,49);stroke-width:2.5px;"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(66,130,52);"/>
<g>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(46,164,75);"/>
<g transform="matrix(0,-0.842628,0.842628,0,6.811922,78.99493)">
<g transform="matrix(-1,0,-0,-1,106.637317,63.746683)">
<path d="M28.052,20.855L57.9,20.8C61.5,20.8 64.4,23.7 64.4,27.3L64.444,57.088L28.052,20.855Z" style="fill:rgb(17,49,7);"/>
</g>
<g transform="matrix(-1,0,-0,-1,63.293317,107.454683)">
<path d="M56.994,64.5L27.6,64.5C24,64.5 21.1,61.6 21.1,58L21.1,28.154L56.994,64.5Z" style="fill:rgb(17,49,7);"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(0,183,30);"/>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(54,198,89);"/>
<g transform="matrix(0,-0.842628,0.842628,0,6.811922,78.99493)">
<g>
<g transform="matrix(-1,0,0,-1,106.637317,63.746683)">
<path d="M28.052,20.855L57.9,20.8C61.5,20.8 64.4,23.7 64.4,27.3L64.444,57.088L28.052,20.855Z" style="fill:rgb(1,97,0);"/>
</g>
<g transform="matrix(-1,0,0,-1,63.293317,107.454683)">
<path d="M56.994,64.5L27.6,64.5C24,64.5 21.1,61.6 21.1,58L21.1,28.154L56.994,64.5Z" style="fill:rgb(1,97,0);"/>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(0,183,30);"/>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(54,198,89);"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 804 B

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(66,130,52);"/>
<g>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(46,164,75);"/>
<g transform="matrix(0,-0.842628,0.842628,0,6.811922,78.99493)">
<path d="M28.052,20.855L57.9,20.8C61.5,20.8 64.4,23.7 64.4,27.3L64.444,57.088L28.052,20.855ZM56.994,64.5L27.6,64.5C24,64.5 21.1,61.6 21.1,58L21.1,28.154L56.994,64.5Z" style="fill:rgb(17,49,7);"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(0,183,30);"/>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(54,198,89);"/>
<g transform="matrix(0,-0.842628,0.842628,0,6.811922,78.99493)">
<path d="M28.052,20.855L57.9,20.8C61.5,20.8 64.4,23.7 64.4,27.3L64.444,57.088L28.052,20.855ZM56.994,64.5L27.6,64.5C24,64.5 21.1,61.6 21.1,58L21.1,28.154L56.994,64.5Z" style="fill:rgb(17,49,7);"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(239,174,0);"/>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(250,200,0);"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 805 B

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(200,146,0);"/>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(208,165,5);"/>
<path d="M22.154,39.1L63.792,39.1C65.692,39.1 67.292,40.7 67.292,42.6L67.292,42.7C67.292,44.6 65.692,46.2 63.792,46.2L22.154,46.2C20.254,46.2 18.654,44.6 18.654,42.7L18.654,42.6C18.654,40.7 20.154,39.1 22.154,39.1Z" style="fill:rgb(126,100,12);stroke:rgb(126,100,12);stroke-width:3.5px;"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(239,174,0);"/>
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(250,200,0);"/>
<path d="M22.154,39.1L63.792,39.1C65.692,39.1 67.292,40.7 67.292,42.6L67.292,42.7C67.292,44.6 65.692,46.2 63.792,46.2L22.154,46.2C20.254,46.2 18.654,44.6 18.654,42.7L18.654,42.6C18.654,40.7 20.154,39.1 22.154,39.1Z" style="fill:rgb(126,100,12);stroke:rgb(126,100,12);stroke-width:3.5px;"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(209,208,210);fill-opacity:0.3;"/>
<path d="M42.7,81.7C64.3,81.7 81.8,64.2 81.8,42.6C81.8,21 64.3,3.5 42.7,3.5C21.1,3.5 3.6,21 3.6,42.6C3.6,64.2 21.1,81.7 42.7,81.7Z" style="fill:rgb(199,199,199);fill-opacity:0.3;"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

3419
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -1,88 +0,0 @@
import type { ElectrobunConfig } from "electrobun";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(
readFileSync(path.join(rootDir, "package.json"), "utf8"),
);
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
const isStable = buildEnv === "stable";
const canCodesign = Boolean(process.env.ELECTROBUN_DEVELOPER_ID);
const bundleCEF = ["1", "true", "yes"].includes(
String(process.env.ELECTROBUN_BUNDLE_CEF || "").toLowerCase(),
);
const defaultRenderer = bundleCEF ? "cef" : "native";
const targetOs =
process.env.ELECTROBUN_OS ||
(process.platform === "darwin"
? "macos"
: process.platform === "win32"
? "win"
: "linux");
export default {
app: {
name: "Farm Control",
identifier: "com.tombutcher.farmcontrol",
version: packageJson.version,
description: "3D Printer ERP and Control Software.",
urlSchemes: ["farmcontrol"],
},
runtime: {
exitOnLastWindowClosed: true,
},
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",
"src/bun/libMacWindowEffects.dylib": "bun/libMacWindowEffects.dylib",
},
watch: ["scripts", "src"],
watchIgnore: ["dist/**", "build/**", "app_dist/**"],
mac: {
bundleCEF,
defaultRenderer,
icons: "assets/icon.iconset",
createDmg: false,
codesign: isStable && canCodesign,
notarize: isStable && canCodesign,
},
linux: {
bundleCEF: false,
defaultRenderer: "native",
icon: "assets/icon.png",
},
win: {
bundleCEF,
defaultRenderer,
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: {
baseUrl:
process.env.ELECTROBUN_RELEASE_BASE_URL ||
process.env.RELEASE_BASE_URL ||
"",
},
} satisfies ElectrobunConfig;

View File

@ -1,515 +0,0 @@
#import <Cocoa/Cocoa.h>
#import <objc/runtime.h>
static NSString *const kElectrobunVibrancyViewIdentifier =
@"ElectrobunVibrancyView";
static NSString *const kElectrobunNativeDragViewIdentifier =
@"ElectrobunNativeDragView";
static NSString *const kElectrobunWindowBorderViewIdentifier =
@"ElectrobunWindowBorderView";
static CGFloat gWindowCornerRadius = 15.0;
static CGFloat gTrafficLightsX = 16.0;
static CGFloat gTrafficLightsY = 12.0;
static const void *kElectrobunChromeObserverKey = &kElectrobunChromeObserverKey;
@interface ElectrobunWindowBorderView : NSView
@property(nonatomic) CGFloat cornerRadius;
@end
@implementation ElectrobunWindowBorderView
@synthesize cornerRadius = _cornerRadius;
- (instancetype)initWithFrame:(NSRect)frameRect {
self = [super initWithFrame:frameRect];
if (self) {
_cornerRadius = gWindowCornerRadius;
}
return self;
}
- (BOOL)isOpaque {
return NO;
}
- (BOOL)acceptsFirstMouse:(NSEvent *)event {
(void)event;
return NO;
}
- (NSView *)hitTest:(NSPoint)point {
(void)point;
return nil;
}
- (void)setFrame:(NSRect)frame {
[super setFrame:frame];
[self setNeedsDisplay:YES];
}
- (void)drawRect:(NSRect)dirtyRect {
(void)dirtyRect;
NSRect bounds = [self bounds];
CGFloat radius = MAX(0.0, self.cornerRadius);
NSBezierPath *borderPath = [NSBezierPath
bezierPathWithRoundedRect:NSInsetRect(bounds, 0.5, 0.5)
xRadius:radius
yRadius:radius];
if (@available(macOS 10.14, *)) {
NSAppearance *appearance = [self effectiveAppearance];
BOOL isDark =
[[appearance bestMatchFromAppearancesWithNames:@[
NSAppearanceNameAqua, NSAppearanceNameDarkAqua
]] isEqualToString:NSAppearanceNameDarkAqua];
if (isDark) {
[[NSColor colorWithWhite:1.0 alpha:0.18] setStroke];
} else {
[[NSColor colorWithWhite:1.0 alpha:0.72] setStroke];
}
} else {
[[NSColor colorWithWhite:1.0 alpha:0.72] setStroke];
}
borderPath.lineWidth = 1.0;
[borderPath stroke];
}
@end
@interface ElectrobunNativeDragView : NSView
@end
@implementation ElectrobunNativeDragView
- (BOOL)isOpaque {
return NO;
}
- (void)drawRect:(NSRect)dirtyRect {
(void)dirtyRect;
}
- (void)mouseDown:(NSEvent *)event {
NSWindow *window = [self window];
if (window != nil && event != nil) {
[window performWindowDragWithEvent:event];
}
}
@end
@interface ElectrobunWindowChromeObserver : NSObject
@property(nonatomic, weak) NSWindow *window;
@end
static bool applyTrafficLightsPosition(NSWindow *window, CGFloat x,
CGFloat yFromTop);
static void refreshWindowChrome(NSWindow *window);
static BOOL isWindowFullScreen(NSWindow *window);
@implementation ElectrobunWindowChromeObserver
- (instancetype)initWithWindow:(NSWindow *)window {
self = [super init];
if (self) {
_window = window;
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self
selector:@selector(refreshChrome)
name:NSWindowDidResizeNotification
object:window];
[center addObserver:self
selector:@selector(refreshChrome)
name:NSWindowDidEndLiveResizeNotification
object:window];
[center addObserver:self
selector:@selector(refreshChrome)
name:NSWindowDidEnterFullScreenNotification
object:window];
[center addObserver:self
selector:@selector(refreshChrome)
name:NSWindowDidExitFullScreenNotification
object:window];
[center addObserver:self
selector:@selector(refreshChrome)
name:NSWindowDidBecomeKeyNotification
object:window];
[center addObserver:self
selector:@selector(refreshChrome)
name:NSWindowWillEnterFullScreenNotification
object:window];
}
return self;
}
- (void)refreshChrome {
if (self.window != nil) {
refreshWindowChrome(self.window);
applyTrafficLightsPosition(self.window, gTrafficLightsX, gTrafficLightsY);
}
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
@end
static NSVisualEffectView *findVibrancyView(NSView *contentView) {
for (NSView *subview in [contentView subviews]) {
if ([subview isKindOfClass:[NSVisualEffectView class]] &&
[[subview identifier]
isEqualToString:kElectrobunVibrancyViewIdentifier]) {
return (NSVisualEffectView *)subview;
}
}
return nil;
}
static ElectrobunNativeDragView *findNativeDragView(NSView *contentView) {
for (NSView *subview in [contentView subviews]) {
if ([subview isKindOfClass:[ElectrobunNativeDragView class]] &&
[[subview identifier]
isEqualToString:kElectrobunNativeDragViewIdentifier]) {
return (ElectrobunNativeDragView *)subview;
}
}
return nil;
}
static ElectrobunWindowBorderView *findWindowBorderView(NSView *contentView) {
for (NSView *subview in [contentView subviews]) {
if ([subview isKindOfClass:[ElectrobunWindowBorderView class]] &&
[[subview identifier]
isEqualToString:kElectrobunWindowBorderViewIdentifier]) {
return (ElectrobunWindowBorderView *)subview;
}
}
return nil;
}
static BOOL isWindowFullScreen(NSWindow *window) {
return (window.styleMask & NSWindowStyleMaskFullScreen) != 0;
}
static void refreshWindowChrome(NSWindow *window) {
BOOL fullScreen = isWindowFullScreen(window);
CGFloat radius = fullScreen ? 0.0 : gWindowCornerRadius;
NSView *contentView = [window contentView];
if (contentView == nil) {
return;
}
contentView.wantsLayer = YES;
contentView.layer.cornerRadius = radius;
contentView.layer.masksToBounds = YES;
NSVisualEffectView *effectView = findVibrancyView(contentView);
if (effectView != nil) {
effectView.wantsLayer = YES;
effectView.layer.cornerRadius = radius;
effectView.layer.masksToBounds = YES;
}
ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView);
if (borderView != nil) {
borderView.hidden = fullScreen;
if (!fullScreen) {
borderView.cornerRadius = gWindowCornerRadius;
[borderView setNeedsDisplay:YES];
}
}
}
static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) {
gWindowCornerRadius = MAX(0.0, radius);
refreshWindowChrome(window);
}
static void ensureWindowBorder(NSWindow *window) {
NSView *contentView = [window contentView];
if (contentView == nil) {
return;
}
ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView);
if (borderView == nil) {
borderView = [[ElectrobunWindowBorderView alloc]
initWithFrame:[contentView bounds]];
[borderView setIdentifier:kElectrobunWindowBorderViewIdentifier];
[borderView
setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)];
}
[borderView setFrame:[contentView bounds]];
if ([borderView superview] == nil) {
[contentView addSubview:borderView
positioned:NSWindowAbove
relativeTo:nil];
} else {
[borderView removeFromSuperview];
[contentView addSubview:borderView
positioned:NSWindowAbove
relativeTo:nil];
}
refreshWindowChrome(window);
}
static bool applyTrafficLightsPosition(NSWindow *window, CGFloat x,
CGFloat yFromTop) {
NSButton *closeButton = [window standardWindowButton:NSWindowCloseButton];
NSButton *minimizeButton =
[window standardWindowButton:NSWindowMiniaturizeButton];
NSButton *zoomButton = [window standardWindowButton:NSWindowZoomButton];
if (closeButton == nil || minimizeButton == nil || zoomButton == nil) {
return false;
}
NSView *buttonContainer = [closeButton superview];
if (buttonContainer == nil) {
return false;
}
CGFloat spacing = NSMinX(minimizeButton.frame) - NSMinX(closeButton.frame);
if (spacing <= 0) {
spacing = closeButton.frame.size.width + 6.0;
}
BOOL flipped = [buttonContainer isFlipped];
CGFloat targetY = yFromTop;
if (!flipped) {
targetY = buttonContainer.frame.size.height - yFromTop -
closeButton.frame.size.height;
}
targetY = MAX(0.0, targetY);
CGFloat currentX = x;
NSArray *buttons = @[ closeButton, minimizeButton, zoomButton ];
for (NSButton *button in buttons) {
[button setAutoresizingMask:NSViewNotSizable];
[button setFrameOrigin:NSMakePoint(currentX, targetY)];
currentX += spacing;
}
return true;
}
static void ensureTrafficLightsObserver(NSWindow *window) {
ElectrobunWindowChromeObserver *existingObserver =
objc_getAssociatedObject(window, kElectrobunChromeObserverKey);
if (existingObserver != nil) {
return;
}
ElectrobunWindowChromeObserver *observer =
[[ElectrobunWindowChromeObserver alloc] initWithWindow:window];
objc_setAssociatedObject(window, kElectrobunChromeObserverKey, observer,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
static void scheduleTrafficLightsPosition(NSWindow *window, NSInteger attempt) {
if (applyTrafficLightsPosition(window, gTrafficLightsX, gTrafficLightsY)) {
return;
}
if (attempt >= 10) {
return;
}
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(50 * NSEC_PER_MSEC)),
dispatch_get_main_queue(), ^{
scheduleTrafficLightsPosition(window, attempt + 1);
});
}
extern "C" bool enableWindowVibrancy(void *windowPtr, double cornerRadius) {
if (windowPtr == nullptr) {
return false;
}
__block BOOL success = NO;
dispatch_sync(dispatch_get_main_queue(), ^{
NSWindow *window = (__bridge NSWindow *)windowPtr;
if (![window isKindOfClass:[NSWindow class]]) {
return;
}
applyWindowCornerRadius(window, (CGFloat)cornerRadius);
[window setOpaque:NO];
[window setBackgroundColor:[NSColor clearColor]];
[window setTitlebarAppearsTransparent:YES];
[window setHasShadow:YES];
NSView *contentView = [window contentView];
if (contentView == nil) {
return;
}
NSVisualEffectView *effectView = findVibrancyView(contentView);
if (effectView == nil) {
effectView = [[NSVisualEffectView alloc]
initWithFrame:[contentView bounds]];
[effectView setIdentifier:kElectrobunVibrancyViewIdentifier];
[effectView
setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)];
}
if (@available(macOS 10.14, *)) {
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
} else {
[effectView setMaterial:NSVisualEffectMaterialSidebar];
}
[effectView setBlendingMode:NSVisualEffectBlendingModeBehindWindow];
[effectView setState:NSVisualEffectStateActive];
if ([effectView superview] == nil) {
NSView *relativeView = [[contentView subviews] firstObject];
if (relativeView != nil) {
[contentView addSubview:effectView
positioned:NSWindowBelow
relativeTo:relativeView];
} else {
[contentView addSubview:effectView];
}
}
ensureWindowBorder(window);
applyWindowCornerRadius(window, gWindowCornerRadius);
ensureTrafficLightsObserver(window);
scheduleTrafficLightsPosition(window, 0);
[window invalidateShadow];
success = YES;
});
return success;
}
extern "C" bool setWindowCornerRadius(void *windowPtr, double cornerRadius) {
if (windowPtr == nullptr) {
return false;
}
__block BOOL success = NO;
dispatch_sync(dispatch_get_main_queue(), ^{
NSWindow *window = (__bridge NSWindow *)windowPtr;
if (![window isKindOfClass:[NSWindow class]]) {
return;
}
applyWindowCornerRadius(window, (CGFloat)cornerRadius);
ensureWindowBorder(window);
[window invalidateShadow];
success = YES;
});
return success;
}
extern "C" bool ensureWindowShadow(void *windowPtr) {
if (windowPtr == nullptr) {
return false;
}
__block BOOL success = NO;
dispatch_sync(dispatch_get_main_queue(), ^{
NSWindow *window = (__bridge NSWindow *)windowPtr;
if (![window isKindOfClass:[NSWindow class]]) {
return;
}
[window setHasShadow:YES];
[window invalidateShadow];
ensureWindowBorder(window);
applyWindowCornerRadius(window, gWindowCornerRadius);
success = YES;
});
return success;
}
extern "C" bool setWindowTrafficLightsPosition(void *windowPtr, double x,
double yFromTop) {
if (windowPtr == nullptr) {
return false;
}
__block BOOL success = NO;
dispatch_sync(dispatch_get_main_queue(), ^{
NSWindow *window = (__bridge NSWindow *)windowPtr;
if (![window isKindOfClass:[NSWindow class]]) {
return;
}
gTrafficLightsX = (CGFloat)x;
gTrafficLightsY = (CGFloat)yFromTop;
ensureTrafficLightsObserver(window);
success = applyTrafficLightsPosition(window, gTrafficLightsX, gTrafficLightsY);
if (success) {
[window invalidateShadow];
}
});
return success;
}
extern "C" bool setNativeWindowDragRegion(void *windowPtr, double x,
double height) {
if (windowPtr == nullptr) {
return false;
}
__block BOOL success = NO;
dispatch_sync(dispatch_get_main_queue(), ^{
NSWindow *window = (__bridge NSWindow *)windowPtr;
if (![window isKindOfClass:[NSWindow class]]) {
return;
}
NSView *contentView = [window contentView];
if (contentView == nil) {
return;
}
CGFloat dragX = MAX(0.0, x);
CGFloat dragHeight = MAX(0.0, height);
CGFloat dragWidth = MAX(0.0, contentView.bounds.size.width - dragX);
if (dragHeight <= 0.0 || dragWidth <= 0.0) {
return;
}
BOOL flipped = [contentView isFlipped];
CGFloat dragY = flipped ? 0.0 : contentView.bounds.size.height - dragHeight;
dragY = MAX(0.0, dragY);
ElectrobunNativeDragView *dragView = findNativeDragView(contentView);
if (dragView == nil) {
dragView = [[ElectrobunNativeDragView alloc] initWithFrame:NSZeroRect];
[dragView setIdentifier:kElectrobunNativeDragViewIdentifier];
}
[dragView setFrame:NSMakeRect(dragX, dragY, dragWidth, dragHeight)];
[dragView setAutoresizingMask:NSViewWidthSizable];
if ([dragView superview] == nil) {
[contentView addSubview:dragView
positioned:NSWindowAbove
relativeTo:nil];
}
success = YES;
});
return success;
}

View File

@ -4,7 +4,7 @@
"name": "Tom Butcher", "name": "Tom Butcher",
"email": "tom@tombutcher.work" "email": "tom@tombutcher.work"
}, },
"version": "0.1.1", "version": "0.1.0",
"type": "module", "type": "module",
"private": true, "private": true,
"homepage": "./", "homepage": "./",
@ -71,7 +71,10 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"simplebar-react": "^3.3.2", "simplebar-react": "^3.3.2",
"socket.io-client": "*", "socket.io-client": "*",
"standard": "^17.1.2",
"styled-components": "^6.1.19", "styled-components": "^6.1.19",
"svgo": "^4.0.0",
"svgo-loader": "^4.0.0",
"three": "^0.179.1", "three": "^0.179.1",
"tsparticles": "^3.9.1", "tsparticles": "^3.9.1",
"web-vitals": "^5.1.0" "web-vitals": "^5.1.0"
@ -80,21 +83,13 @@
"description": "3D Printer ERP and Control Software.", "description": "3D Printer ERP and Control Software.",
"scripts": { "scripts": {
"dev": "cross-env NODE_ENV=development vite", "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 .", "electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .",
"start": "serve -s build", "start": "serve -s build",
"build": "vite 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\"", "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:electron": "vite build && electron-builder",
"build:cloudflare": "cross-env VITE_DEPLOY_TARGET=cloudflare vite build", "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 && bun scripts/build-macos-effects.mjs",
"generate-app-icons": "bun scripts/generate-app-icons.mjs",
"build:macos-effects": "bun scripts/build-macos-effects.mjs",
"clean": "bun scripts/clean-build.mjs"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@ -118,10 +113,7 @@
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"electrobun": "1.18.1",
"electron": "^38.7.1", "electron": "^38.7.1",
"jimp": "^1.6.1",
"png-to-ico": "^2.1.8",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-packager": "^17.1.2", "electron-packager": "^17.1.2",
"eslint": "^9.34.0", "eslint": "^9.34.0",
@ -134,7 +126,6 @@
"globals": "^15.12.0", "globals": "^15.12.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-eslint": "^16.4.2", "prettier-eslint": "^16.4.2",
"rcedit": "^4.0.1",
"rollup-plugin-visualizer": "^6.0.5", "rollup-plugin-visualizer": "^6.0.5",
"serve": "^14.2.4", "serve": "^14.2.4",
"standard": "^17.1.2", "standard": "^17.1.2",
@ -246,13 +237,13 @@
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"include": "scripts/installer.nsh", "include": "scripts/installer.nsh",
"perMachine": false "perMachine": true
}, },
"msiWrapped": { "msiWrapped": {
"upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}", "upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}",
"perMachine": false, "perMachine": true,
"impersonate": true, "impersonate": false,
"wrappedInstallerArgs": "/S /UPDATE /RESTARTFC" "wrappedInstallerArgs": "/S"
} }
} }
} }

Binary file not shown.

View File

@ -1,148 +0,0 @@
!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 BUILD_NUMBER
!define BUILD_NUMBER "dev"
!endif
!ifndef APP_SOURCE_DIR
!define APP_SOURCE_DIR "app"
!endif
!ifndef APP_FILE_LIST_GENERATOR
!define APP_FILE_LIST_GENERATOR "..\..\scripts\generate-nsis-file-list.ps1"
!endif
; Enumerate APP_SOURCE_DIR now, while compiling, so the generated installer
; knows the exact unpacked byte total and can log each extracted file.
!insertmacro compileProgressFileCopy "${APP_SOURCE_DIR}" "${APP_FILE_LIST_GENERATOR}"
Name "Farm Control"
OutFile "${OUTFILE}"
InstallDir "$LOCALAPPDATA\Programs\Farm Control"
InstallDirRegKey HKCU "Software\Tom Butcher\Farm Control" "InstallDir"
RequestExecutionLevel user
SilentInstall normal
!define MUI_ABORTWARNING
!ifndef INSTALLER_ICON
!define INSTALLER_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico"
!endif
!ifndef UNINSTALLER_ICON
!define UNINSTALLER_ICON "${INSTALLER_ICON}"
!endif
!define MUI_ICON "${INSTALLER_ICON}"
!define MUI_UNICON "${UNINSTALLER_ICON}"
Icon "${INSTALLER_ICON}"
UninstallIcon "${UNINSTALLER_ICON}"
BrandingText "Farm Control v${VERSION}-b${BUILD_NUMBER} Installer"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
Function .onInit
!insertmacro initProgressLog
${If} ${Silent}
SetAutoClose true
${EndIf}
!insertmacro progressPhase "Starting installation"
!insertmacro progressStatus "Starting Farm Control installation..."
!insertmacro progressCopyTotal "${APP_COPY_TOTAL_BYTES}"
!insertmacro progressStart
; In-app updates install into Farm Control.new while the running process
; keeps using Farm Control; the folders are swapped after exit (/RESTARTFC).
; Interactive / silent fresh installs still remove the previous version first.
${If} $IsInAppUpdate == "1"
!insertmacro prepareSideBySideUpdate
${Else}
StrCpy $FinalInstDir $INSTDIR
!insertmacro uninstallPreviousFarmControl
${EndIf}
!insertmacro progressPrepare
FunctionEnd
Function .onInstFailed
!insertmacro progressFailure "Installation failed."
FunctionEnd
Function .onInstSuccess
Call restartFarmControlAfterUpdate
FunctionEnd
Section "Farm Control" SecMain
SectionIn RO
!insertmacro progressPhase "Copying application files"
!insertmacro progressStatus "Copying application files..."
!insertmacro progressCopyStart
SetOverwrite try
!insertmacro copyApplicationFilesWithProgress
!insertmacro progressCopyComplete
!insertmacro customInstall
!insertmacro progressFinalizeInstall
!insertmacro progressPhase "Finalizing installation"
!insertmacro progressStatus "Writing installation registry entries..."
WriteRegStr HKCU "Software\Tom Butcher\Farm Control" "InstallDir" $FinalInstDir
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"DisplayName" "Farm Control"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"DisplayVersion" "${VERSION}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"Publisher" "Tom Butcher"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"UninstallString" "$FinalInstDir\Uninstall.exe"
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"NoRepair" 1
!insertmacro progressWriteUninstaller
!insertmacro progressStatus "Writing uninstall information..."
WriteUninstaller "$INSTDIR\Uninstall.exe"
!insertmacro progressFinalize
; For in-app updates (/RESTARTFC) the success signal is emitted from
; restartFarmControlAfterUpdate, right before the install directory swap,
; so the running app quits while this installer stays alive.
${If} $RestartAfterInstall != "1"
!insertmacro progressSuccess
${EndIf}
SectionEnd
Section "Uninstall"
!insertmacro customUnInstall
Delete "$INSTDIR\Uninstall.exe"
RMDir /r "$INSTDIR"
DeleteRegKey HKCU "Software\Tom Butcher\Farm Control"
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control"
SectionEnd

View File

@ -1,460 +0,0 @@
!include "FileFunc.nsh"
!insertmacro GetParameters
!insertmacro GetOptions
Var ProgressLogFile
Var IsInAppUpdate
Var RestartAfterInstall
Var FinalInstDir
Var UpdateParentPid
; Generate an include at compile time that defines APP_COPY_TOTAL_BYTES and
; copyApplicationFilesWithProgress. The generated macro contains one File
; command and one byte-progress log entry for every file in the source tree.
!macro compileProgressFileCopy sourceDir generator
!tempfile GENERATED_FILE_COPY_INCLUDE
!system 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${generator}" -SourceDir "${sourceDir}" -OutputPath "${GENERATED_FILE_COPY_INCLUDE}"' = 0
!include "${GENERATED_FILE_COPY_INCLUDE}"
!delfile "${GENERATED_FILE_COPY_INCLUDE}"
!undef GENERATED_FILE_COPY_INCLUDE
!macroend
!macro initProgressLog
StrCpy $ProgressLogFile ""
StrCpy $IsInAppUpdate "0"
StrCpy $RestartAfterInstall "0"
StrCpy $FinalInstDir ""
StrCpy $UpdateParentPid "0"
${GetParameters} $R9
ClearErrors
${GetOptions} $R9 "/UPDATE" $R8
${IfNot} ${Errors}
StrCpy $IsInAppUpdate "1"
${EndIf}
ClearErrors
${GetOptions} $R9 "/RESTARTFC" $R8
${IfNot} ${Errors}
StrCpy $RestartAfterInstall "1"
${EndIf}
ClearErrors
${GetOptions} $R9 "/PARENTPID=" $R8
${IfNot} ${Errors}
; Convert to an integer before it is used in Win32 calls or taskkill.
IntOp $UpdateParentPid $R8 + 0
${EndIf}
; Prefer env var so paths with spaces are reliable; /LOG= remains supported.
ReadEnvStr $ProgressLogFile "FARMCONTROL_INSTALL_LOG"
${If} $ProgressLogFile == ""
ClearErrors
${GetOptions} $R9 "/LOG=" $ProgressLogFile
${If} ${Errors}
StrCpy $ProgressLogFile ""
${EndIf}
${EndIf}
${If} $ProgressLogFile != ""
; Strip surrounding quotes from /LOG="C:\path with spaces\log.log"
StrCpy $R8 $ProgressLogFile 1
${If} $R8 == '"'
StrCpy $ProgressLogFile $ProgressLogFile "" 1
StrLen $R8 $ProgressLogFile
IntOp $R8 $R8 - 1
${If} $R8 > 0
StrCpy $ProgressLogFile $ProgressLogFile $R8
${EndIf}
${EndIf}
; Truncate any previous log so progress starts clean.
Push $0
FileOpen $0 "$ProgressLogFile" w
${If} $0 != ""
FileClose $0
${EndIf}
Pop $0
${EndIf}
!macroend
; Open/append/close each line so the updater can read the log concurrently
; (avoids an exclusive lock for the whole install; LogEx SHARE_READ is an alternative).
!macro progressLog line
DetailPrint "${line}"
${If} $ProgressLogFile != ""
Push $0
FileOpen $0 "$ProgressLogFile" a
${If} $0 != ""
FileSeek $0 0 END
FileWrite $0 "${line}$\r$\n"
FileClose $0
${EndIf}
Pop $0
${EndIf}
!macroend
!define PROGRESS_START 1
!define PREPARE_PROGRESS 3
!define COPY_PROGRESS_START 5
!define COPY_PROGRESS_END 90
!define CONFIGURE_PROGRESS 91
!define SHORTCUTS_PROGRESS 93
!define FINALIZE_PROGRESS 95
!define UNINSTALLER_PROGRESS 97
!define COMPLETE_PROGRESS 99
!macro progressPercent percent
!insertmacro progressLog "installer:%${percent}"
!macroend
!macro progressStart
!insertmacro progressPercent "${PROGRESS_START}"
!macroend
!macro progressPrepare
!insertmacro progressPercent "${PREPARE_PROGRESS}"
!macroend
!macro progressCopyStart
!insertmacro progressPercent "${COPY_PROGRESS_START}"
!macroend
!macro progressCopyComplete
!insertmacro progressPercent "${COPY_PROGRESS_END}"
!macroend
!macro progressConfigure
!insertmacro progressPercent "${CONFIGURE_PROGRESS}"
!macroend
!macro progressShortcuts
!insertmacro progressPercent "${SHORTCUTS_PROGRESS}"
!macroend
!macro progressFinalizeInstall
!insertmacro progressPercent "${FINALIZE_PROGRESS}"
!macroend
!macro progressWriteUninstaller
!insertmacro progressPercent "${UNINSTALLER_PROGRESS}"
!macroend
!macro progressFinalize
!insertmacro progressPercent "${COMPLETE_PROGRESS}"
!macroend
!macro progressPhase phase
!insertmacro progressLog "installer:PHASE:${phase}"
!macroend
!macro progressStatus status
!insertmacro progressLog "installer:STATUS:${status}"
!macroend
!macro progressCopyTotal bytes
!insertmacro progressLog "installer:COPY_TOTAL:${bytes}"
!macroend
!macro progressCopyFile bytes relativePath
!insertmacro progressLog "installer:COPY_FILE:${bytes}:${relativePath}"
!macroend
!macro progressSuccess
!insertmacro progressPercent 100
!insertmacro progressStatus "Installation complete. Restarting Farm Control..."
!insertmacro progressLog "installer: The install was successful."
!macroend
!macro progressFailure message
!insertmacro progressLog "installer:STATUS:${message}"
!insertmacro progressLog "installer: The install failed."
!macroend
!macro quitFarmControl
!insertmacro progressPhase "Stopping Farm Control"
!insertmacro progressStatus "Stopping running Farm Control processes..."
DetailPrint "Stopping running Farm Control processes..."
ExecWait 'taskkill /F /IM FarmControl.exe /T' $R0
ExecWait 'taskkill /F /IM launcher.exe /T' $R0
!macroend
; Side-by-side update: install into Farm Control.new while the running app
; keeps using Farm Control, then swap folders after the process exits.
!macro prepareSideBySideUpdate
!insertmacro progressPhase "Preparing update"
!insertmacro progressStatus "Preparing staging folder for update..."
StrCpy $FinalInstDir $INSTDIR
StrCpy $INSTDIR "$FinalInstDir.new"
${If} ${FileExists} "$INSTDIR"
DetailPrint "Removing leftover staging folder..."
RMDir /r "$INSTDIR"
${EndIf}
!macroend
; Swap order: Farm Control -> .old, Farm Control.new -> Farm Control, delete .old.
!macro swapUpdateStaging
!insertmacro progressPhase "Applying update"
!insertmacro progressStatus "Replacing Farm Control with the new version..."
DetailPrint "Replacing Farm Control with staged update..."
; The install section ran SetOutPath into the install tree; a directory that
; is any process's working directory cannot be renamed, so move out first.
SetOutPath "$TEMP"
Sleep 3000
; Clear a leftover .old from a previous interrupted update.
${If} ${FileExists} "$FinalInstDir.old"
DetailPrint "Removing leftover Farm Control.old..."
RMDir /r "$FinalInstDir.old"
${EndIf}
${If} ${FileExists} "$FinalInstDir"
!insertmacro progressStatus "Waiting for Farm Control to release install files..."
DetailPrint "Waiting to rename install directory to Farm Control.old..."
; Retry every 500ms; success is verified on disk rather than via the
; error flag, which Rename does not set reliably.
StrCpy $R7 0
swap_rename_old_retry:
Rename "$FinalInstDir" "$FinalInstDir.old"
${IfNot} ${FileExists} "$FinalInstDir"
Goto swap_rename_old_done
${EndIf}
IntOp $R7 $R7 + 1
${If} $R7 < 120
Sleep 500
Goto swap_rename_old_retry
${EndIf}
!insertmacro progressFailure "Could not move the previous Farm Control installation aside."
Abort
swap_rename_old_done:
DetailPrint "Install directory renamed to Farm Control.old"
${EndIf}
!insertmacro progressStatus "Activating new Farm Control installation..."
DetailPrint "Renaming Farm Control.new to Farm Control..."
StrCpy $R7 0
swap_rename_new_retry:
Rename "$INSTDIR" "$FinalInstDir"
${If} ${FileExists} "$FinalInstDir"
Goto swap_rename_new_done
${EndIf}
IntOp $R7 $R7 + 1
${If} $R7 < 20
Sleep 500
Goto swap_rename_new_retry
${EndIf}
; Best-effort rollback so the previous install is usable again.
${If} ${FileExists} "$FinalInstDir.old"
Rename "$FinalInstDir.old" "$FinalInstDir"
${EndIf}
!insertmacro progressFailure "Could not activate the new Farm Control installation."
Abort
swap_rename_new_done:
StrCpy $INSTDIR $FinalInstDir
!insertmacro progressLog "installer:STATUS:Update files activated"
${If} ${FileExists} "$FinalInstDir.old"
!insertmacro progressStatus "Removing previous Farm Control installation..."
DetailPrint "Removing Farm Control.old..."
StrCpy $R7 0
swap_delete_old_retry:
RMDir /r "$FinalInstDir.old"
${If} ${FileExists} "$FinalInstDir.old"
IntOp $R7 $R7 + 1
${If} $R7 < 20
Sleep 500
Goto swap_delete_old_retry
${EndIf}
; New version is already active; leftover .old is non-fatal.
DetailPrint "Warning: could not fully remove Farm Control.old"
!insertmacro progressLog "installer:STATUS:Warning: could not fully remove previous installation"
${EndIf}
${EndIf}
!macroend
!macro uninstallPreviousFarmControl
ClearErrors
ReadRegStr $R0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString"
StrCmp $R0 "" check_install_dir run_uninstall
check_install_dir:
ReadRegStr $R0 HKCU "Software\Tom Butcher\Farm Control" "InstallDir"
StrCmp $R0 "" check_legacy_uninstall
StrCpy $R0 "$R0\Uninstall.exe"
Goto run_uninstall
check_legacy_uninstall:
ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString"
StrCmp $R0 "" check_legacy_install_dir run_uninstall
check_legacy_install_dir:
ReadRegStr $R0 HKLM "Software\Tom Butcher\Farm Control" "InstallDir"
StrCmp $R0 "" check_default_dir
StrCpy $R0 "$R0\Uninstall.exe"
Goto run_uninstall
check_default_dir:
StrCpy $R0 "$LOCALAPPDATA\Programs\Farm Control\Uninstall.exe"
IfFileExists $R0 run_uninstall check_legacy_default_dir
check_legacy_default_dir:
StrCpy $R0 "$PROGRAMFILES64\Farm Control\Uninstall.exe"
IfFileExists $R0 run_uninstall done_uninstall
run_uninstall:
IfFileExists $R0 0 done_uninstall
!insertmacro progressPhase "Removing previous version"
!insertmacro progressStatus "Removing previous Farm Control installation..."
DetailPrint "Removing previous Farm Control installation..."
!insertmacro quitFarmControl
ExecWait '"$R0" /S' $R1
DetailPrint "Previous installation removed (exit code: $R1)"
!insertmacro progressLog "installer:STATUS:Previous installation removed"
done_uninstall:
!macroend
!macro createDesktopShortcut
SetShellVarContext current
SetOutPath "$FinalInstDir\bin"
CreateShortCut "$DESKTOP\Farm Control.lnk" "$FinalInstDir\bin\FarmControl.exe" "" "$FinalInstDir\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control"
!macroend
!macro createStartMenuShortcut
SetShellVarContext current
CreateDirectory "$SMPROGRAMS\Farm Control"
SetOutPath "$FinalInstDir\bin"
CreateShortCut "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$FinalInstDir\bin\FarmControl.exe" "" "$FinalInstDir\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control"
!macroend
!macro removeDesktopShortcut
SetShellVarContext current
Delete "$DESKTOP\Farm Control.lnk"
!macroend
!macro removeStartMenuShortcut
SetShellVarContext current
Delete "$SMPROGRAMS\Farm Control\Farm Control.lnk"
RMDir "$SMPROGRAMS\Farm Control"
!macroend
!macro customInstall
!insertmacro progressConfigure
!insertmacro progressPhase "Configuring Farm Control"
!insertmacro progressStatus "Registering farmcontrol URI handler..."
DetailPrint "Register farmcontrol URI Handler"
DeleteRegKey HKCU "Software\Classes\farmcontrol"
WriteRegStr HKCU "Software\Classes\farmcontrol" "" "URL:farmcontrol"
WriteRegStr HKCU "Software\Classes\farmcontrol" "URL Protocol" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\DefaultIcon" "" "$FinalInstDir\bin\FarmControl.exe"
WriteRegStr HKCU "Software\Classes\farmcontrol\shell" "" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" '"$FinalInstDir\bin\bun.exe" "$FinalInstDir\bin\deeplink.js" "%1"'
!insertmacro progressShortcuts
!insertmacro progressStatus "Creating shortcuts..."
DetailPrint "Creating shortcuts"
!insertmacro createDesktopShortcut
!insertmacro createStartMenuShortcut
!macroend
!macro customUnInstall
!insertmacro removeDesktopShortcut
!insertmacro removeStartMenuShortcut
DeleteRegKey HKCU "Software\Classes\farmcontrol"
!macroend
; Wait for the running app to exit, swap staged update into place, then relaunch.
; Only used when /RESTARTFC is passed.
Function restartFarmControlAfterUpdate
${If} $RestartAfterInstall != "1"
Return
${EndIf}
; All files are copied and we are about to rename the install directory.
; Emit the success line (matched by isWindowsInstallSuccessful in
; winappupdate.js) so the app knows to quit and restart; this installer
; stays alive to swap the folders and relaunch Farm Control.
!insertmacro progressSuccess
!insertmacro progressStatus "Waiting for Farm Control to close..."
${If} $UpdateParentPid > 0
; Wait for the exact updater process rather than an executable name. CEF
; builds can keep bun/renderer processes alive after the launcher exits.
System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $UpdateParentPid) p .r0'
${If} $0 != 0
; Allow two seconds for a graceful exit, then terminate the process.
System::Call 'kernel32::WaitForSingleObject(p r0, i 2000) i .r1'
${If} $1 == 258
!insertmacro progressStatus "Stopping Farm Control..."
DetailPrint "Farm Control process $UpdateParentPid did not exit; terminating it..."
; Never use taskkill /T here: this installer is a direct child of the
; updater process, so a tree kill would terminate the installer itself
; before the folder swap (CEF builds hung at "Stopping Farm Control...").
ExecWait 'taskkill /F /PID $UpdateParentPid' $R0
; Verify that taskkill actually terminated the process before swapping.
System::Call 'kernel32::WaitForSingleObject(p r0, i 10000) i .r1'
${EndIf}
System::Call 'kernel32::CloseHandle(p r0)'
${If} $1 != 0
!insertmacro progressFailure "Could not stop the running Farm Control process."
Abort
${EndIf}
${EndIf}
${Else}
; Compatibility fallback for manually launched older installers.
Sleep 2000
!insertmacro quitFarmControl
; Confirm both known launcher names have stopped before continuing.
StrCpy $R7 0
restart_verify_loop:
ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq FarmControl.exe" 2>nul | find /I "FarmControl.exe"' $R0
${If} $R0 != 0
ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq launcher.exe" 2>nul | find /I "launcher.exe"' $R0
${EndIf}
${If} $R0 != 0
Goto restart_wait_done
${EndIf}
IntOp $R7 $R7 + 1
${If} $R7 < 20
Sleep 500
Goto restart_verify_loop
${EndIf}
!insertmacro progressFailure "Could not stop the running Farm Control process."
Abort
${EndIf}
restart_wait_done:
; CEF helper processes normally exit on their own once the main process is
; gone, but sweep up any stragglers still running from the install
; directory so the rename cannot be blocked. Matching on executable path
; (not a tree kill) guarantees this installer, which runs from the Updates
; folder, is never terminated.
ExecWait `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -like '$FinalInstDir\*' } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"` $R0
; Give CEF descendants a moment to release DLLs before renaming the tree.
Sleep 3000
${If} $IsInAppUpdate == "1"
!insertmacro swapUpdateStaging
${EndIf}
!insertmacro progressStatus "Starting Farm Control..."
SetOutPath "$FinalInstDir\bin"
Exec "$FinalInstDir\bin\FarmControl.exe"
FunctionEnd

View File

@ -1,94 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<!--
MSI wrapper around the compiled NSIS setup.exe.
Runs the bundled installer with the same silent in-app update flags used by
src/desktop/winappupdate.js: /S /UPDATE /RESTARTFC /LOG=...
ALLUSERS=2 + MSIINSTALLPERUSER=1 defaults to per-user (matching NSIS) and is
not blocked by DisableMsi=1 / error 1625 the way a pure InstallScope=perUser
package is.
-->
<Product
Id="*"
Name="Farm Control"
Language="1033"
Version="__VERSION__"
Manufacturer="Tom Butcher"
UpgradeCode="__UPGRADE_CODE__">
<Package
InstallerVersion="500"
Compressed="yes"
InstallPrivileges="limited"
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" />
<Icon Id="InstallerIcon" SourceFile="__INSTALLER_ICON__" />
<Property Id="ARPPRODUCTICON" Value="InstallerIcon" />
<Property Id="DISABLEADVTSHORTCUTS" Value="1" />
<Property Id="ALLUSERS" Secure="yes" Value="2" />
<Property Id="MSIINSTALLPERUSER" Secure="yes" Value="1" />
<Property Id="REBOOT" Value="ReallySuppress" />
<Binary Id="WrappedExe" SourceFile="__SETUP_EXE__" />
<!-- ExeCommand is a Formatted field, so [FarmControlUpdatesDir] resolves to the
real per-user path here. An intermediate Property would not resolve it
(property values are not recursively formatted). -->
<CustomAction
Id="RunInstaller"
BinaryKey="WrappedExe"
ExeCommand="/S /UPDATE /RESTARTFC /LOG=&quot;[FarmControlUpdatesDir]install.log&quot;"
Execute="immediate"
Impersonate="yes"
Return="check" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="LocalAppDataFolder">
<Directory Id="FarmControlAppDataDir" Name="FarmControl">
<Directory Id="FarmControlUpdatesDir" Name="Updates">
<Component Id="UpdatesFolderComponent" Guid="A1B2C3D4-E5F6-7890-ABCD-EF1234567890">
<CreateFolder />
<!-- ICE64: per-user directories must be scheduled for removal on uninstall. -->
<RemoveFile Id="RemoveUpdatesFiles" Directory="FarmControlUpdatesDir" Name="*" On="uninstall" />
<RemoveFolder Id="RemoveFarmControlUpdatesDir" Directory="FarmControlUpdatesDir" On="uninstall" />
<RemoveFolder Id="RemoveFarmControlAppDataDir" Directory="FarmControlAppDataDir" On="uninstall" />
<RegistryValue
Root="HKCU"
Key="Software\Tom Butcher\Farm Control"
Name="UpdatesDir"
Type="string"
Value="[FarmControlUpdatesDir]"
KeyPath="yes" />
</Component>
</Directory>
</Directory>
</Directory>
<Component Id="PerUserMarker" Guid="8A3F2E1D-9C4B-4A7E-B6D5-1F0E3C2B4A59">
<RegistryValue
Root="HKCU"
Key="Software\Tom Butcher\Farm Control"
Name="MsiWrapper"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</Directory>
<Feature Id="MainFeature" Title="Farm Control" Level="1">
<ComponentRef Id="UpdatesFolderComponent" />
<ComponentRef Id="PerUserMarker" />
</Feature>
<InstallExecuteSequence>
<Custom Action="RunInstaller" After="InstallFiles">NOT Installed</Custom>
</InstallExecuteSequence>
</Product>
</Wix>

903
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,260 +1,270 @@
import { createWriteStream, promises as fs } from "node:fs"; import { ipcMain } from 'electron'
import http from "node:http"; import { createWriteStream, promises as fs } from 'fs'
import https from "node:https"; import http from 'http'
import os from "node:os"; import https from 'https'
import path from "node:path"; import os from 'os'
import process from "node:process"; import path from 'path'
import { Utils } from "electrobun/bun"; import process from 'process'
import { launchMacInstaller } from "./macappupdate.js"; import { launchMacInstaller } from './macappupdate.js'
import { launchWindowsInstaller } from "./winappupdate.js"; import { launchWindowsInstaller } from './winappupdate.js'
import { scheduleAppRestart } from "./updater-runner.js";
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
const SUPPORTED_TARGETS = { const SUPPORTED_TARGETS = {
darwin: { darwin: {
extension: ".pkg", extension: '.pkg',
osMatchers: ["darwin", "mac", "macos", "osx"], osMatchers: ['darwin', 'mac', 'macos', 'osx']
}, },
win32: { win32: {
extension: ".msi", extension: '.msi',
osMatchers: ["win32", "win", "windows"], osMatchers: ['win32', 'win', 'windows']
}, }
}; }
let runningUpdate = null; let runningUpdate = null
const getArtifactName = (artifact) => const getArtifactName = (artifact) =>
String(artifact?.fileName || artifact?.relativePath || artifact?.url || ""); String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
const isCefInstallerArtifact = (artifact) => {
const name = getArtifactName(artifact).toLowerCase()
// CEF builds are named like farmcontrol-0.1.1-x64-cef.msi / -cef.pkg / -cef.exe
return /-cef\.[a-z0-9]+$/.test(name)
}
const normalizeArch = (arch) => { const normalizeArch = (arch) => {
if (arch === "x64" || arch === "amd64") return "x64"; if (arch === 'x64' || arch === 'amd64') return 'x64'
if (arch === "arm64" || arch === "aarch64") return "arm64"; if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
return arch; return arch
}; }
const artifactMatchesPlatform = (artifact, target, platform, arch) => { const artifactMatchesPlatform = (artifact, target, platform, arch) => {
const name = getArtifactName(artifact).toLowerCase(); const name = getArtifactName(artifact).toLowerCase()
const normalizedArch = normalizeArch(arch); const normalizedArch = normalizeArch(arch)
const artifactArch = normalizeArch(String(artifact?.arch || "").toLowerCase()); const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
const artifactPlatform = String( const artifactPlatform = String(
artifact?.platform || artifact?.os || artifact?.target || "", artifact?.platform || artifact?.os || artifact?.target || ''
).toLowerCase(); ).toLowerCase()
if (!name.endsWith(target.extension)) return false; if (!name.endsWith(target.extension)) return false
if (!artifact?.url) return false; if (!artifact?.url) return false
const matchesArch = const matchesArch =
artifactArch === normalizedArch || artifactArch === normalizedArch ||
name.includes(`-${normalizedArch}`) || name.includes(`-${normalizedArch}`) ||
name.includes(`_${normalizedArch}`) || name.includes(`_${normalizedArch}`) ||
name.includes(`.${normalizedArch}.`) || name.includes(`.${normalizedArch}.`) ||
name.includes(normalizedArch); name.includes(normalizedArch)
const matchesOs = const matchesOs =
!artifactPlatform || !artifactPlatform ||
target.osMatchers.includes(artifactPlatform) || target.osMatchers.includes(artifactPlatform) ||
target.osMatchers.some((matcher) => name.includes(matcher)) || target.osMatchers.some((matcher) => name.includes(matcher)) ||
(platform === "darwin" && name.includes("mac")) || (platform === 'darwin' && name.includes('mac')) ||
(platform === "win32" && name.includes("win")); (platform === 'win32' && name.includes('win'))
return matchesArch && matchesOs; return matchesArch && matchesOs
}; }
const selectUpdateArtifact = ( const selectUpdateArtifact = (
update, update,
platform = process.platform, platform = process.platform,
arch = process.arch, arch = process.arch
) => { ) => {
const target = SUPPORTED_TARGETS[platform]; const target = SUPPORTED_TARGETS[platform]
if (!target) { if (!target) {
throw new Error(`App updates are not supported on ${platform}.`); throw new Error(`App updates are not supported on ${platform}.`)
} }
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []; const artifacts = (Array.isArray(update?.artifacts) ? update.artifacts : []).filter(
(artifact) => !isCefInstallerArtifact(artifact)
)
const matchingArtifact = artifacts.find((artifact) => const matchingArtifact = artifacts.find((artifact) =>
artifactMatchesPlatform(artifact, target, platform, arch), artifactMatchesPlatform(artifact, target, platform, arch)
); )
const fallbackArtifact = artifacts.find((artifact) => { const fallbackArtifact = artifacts.find((artifact) => {
const name = getArtifactName(artifact).toLowerCase(); const name = getArtifactName(artifact).toLowerCase()
return artifact?.url && name.endsWith(target.extension); return artifact?.url && name.endsWith(target.extension)
}); })
if (!matchingArtifact && !fallbackArtifact) { if (!matchingArtifact && !fallbackArtifact) {
throw new Error( throw new Error(
`No ${target.extension} update artifact found for ${platform}/${arch}.`, `No ${target.extension} update artifact found for ${platform}/${arch}.`
); )
} }
return matchingArtifact || fallbackArtifact; return matchingArtifact || fallbackArtifact
}; }
const getInstallErrorMessage = (error, output = "") => { const sendProgress = (webContents, payload) => {
const combined = `${output}\n${error?.message || ""}`.trim(); if (!webContents || webContents.isDestroyed()) return
webContents.send(UPDATE_PROGRESS_CHANNEL, {
timestamp: new Date().toISOString(),
...payload
})
}
const getInstallErrorMessage = (error, output = '') => {
const combined = `${output}\n${error?.message || ''}`.trim()
if ( if (
/cancel/i.test(combined) || /cancel/i.test(combined) ||
/did not grant permission/i.test(combined) || /did not grant permission/i.test(combined) ||
/user canceled/i.test(combined) /user canceled/i.test(combined)
) { ) {
return "Update installation was cancelled."; return 'Update installation was cancelled.'
} }
if (/incorrect/i.test(combined)) { if (/incorrect/i.test(combined)) {
return "The administrator password was incorrect."; return 'The administrator password was incorrect.'
} }
if ( return combined || 'Failed to install update.'
/1625/.test(combined) || }
/forbidden by system policy/i.test(combined) ||
/Non-assigned apps are disabled/i.test(combined)
) {
return "Update installation was blocked by Windows Installer policy.";
}
return combined || "Failed to install update."; const installerHelpers = { sendProgress, getInstallErrorMessage }
};
const getDownloadUrl = (url, redirectCount = 0) => const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
if (redirectCount > 5) { if (redirectCount > 5) {
reject(new Error("Too many redirects while downloading update.")); reject(new Error('Too many redirects while downloading update.'))
return; return
} }
const parsedUrl = new URL(url); const parsedUrl = new URL(url)
const client = parsedUrl.protocol === "https:" ? https : http; const client = parsedUrl.protocol === 'https:' ? https : http
const request = client.get(parsedUrl, (response) => { const request = client.get(parsedUrl, (response) => {
const location = response.headers.location; const location = response.headers.location
if (response.statusCode >= 300 && response.statusCode < 400 && location) { if (response.statusCode >= 300 && response.statusCode < 400 && location) {
response.resume(); response.resume()
resolve( resolve(
getDownloadUrl( getDownloadUrl(
new URL(location, parsedUrl).toString(), new URL(location, parsedUrl).toString(),
redirectCount + 1, redirectCount + 1
), )
); )
return; return
} }
resolve({ response, url: parsedUrl.toString() }); resolve({ response, url: parsedUrl.toString() })
}); })
request.on("error", reject); request.on('error', reject)
}); })
const downloadArtifact = async (artifact, destinationPath, sendProgress) => { const downloadArtifact = async (artifact, destinationPath, webContents) => {
const { response } = await getDownloadUrl(artifact.url); const { response } = await getDownloadUrl(artifact.url)
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
response.resume(); response.resume()
throw new Error(`Update download failed with HTTP ${response.statusCode}.`); throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
} }
const totalBytes = const totalBytes =
Number.parseInt(response.headers["content-length"], 10) || 0; Number.parseInt(response.headers['content-length'], 10) || 0
let downloadedBytes = 0; let downloadedBytes = 0
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const output = createWriteStream(destinationPath); const output = createWriteStream(destinationPath)
response.on("data", (chunk) => { response.on('data', (chunk) => {
downloadedBytes += chunk.length; downloadedBytes += chunk.length
const percent = totalBytes const percent = totalBytes
? Math.round((downloadedBytes / totalBytes) * 100) ? Math.round((downloadedBytes / totalBytes) * 100)
: null; : null
sendProgress({ sendProgress(webContents, {
phase: "downloading", phase: 'downloading',
percent, percent,
downloadedBytes, downloadedBytes,
totalBytes, totalBytes,
message: totalBytes message: totalBytes
? `Downloading update (${percent}%)` ? `Downloading update (${percent}%)`
: "Downloading update", : 'Downloading update'
}); })
}); })
response.on("error", reject); response.on('error', reject)
output.on("error", reject); output.on('error', reject)
output.on("finish", resolve); output.on('finish', resolve)
response.pipe(output); response.pipe(output)
}); })
}; }
const launchInstallerAndRestart = async ( const restartApp = (app) => {
mainWindow, app.relaunch()
installerPath, app.exit(0)
sendProgress, }
) => {
const installerHelpers = { sendProgress, getInstallErrorMessage };
if (process.platform === "darwin") { const launchInstallerAndQuit = async (app, installerPath, webContents) => {
await launchMacInstaller( if (process.platform === 'darwin') {
mainWindow, await launchMacInstaller(app, installerPath, webContents, installerHelpers)
installerPath, restartApp(app)
sendProgress, return
installerHelpers,
);
} else if (process.platform === "win32") {
await launchWindowsInstaller(
mainWindow,
installerPath,
sendProgress,
installerHelpers,
);
} else {
throw new Error(`App updates are not supported on ${process.platform}.`);
} }
scheduleAppRestart(); if (process.platform === 'win32') {
Utils.quit(); await launchWindowsInstaller(
}; app,
installerPath,
webContents,
installerHelpers
)
restartApp(app)
return
}
const runAppUpdate = async (mainWindow, update, sendProgress) => { throw new Error(`App updates are not supported on ${process.platform}.`)
const artifact = selectUpdateArtifact(update); }
const runAppUpdate = async (app, update, webContents) => {
const artifact = selectUpdateArtifact(update)
const tempDirectory = await fs.mkdtemp( const tempDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), "farmcontrol-update-"), path.join(os.tmpdir(), 'farmcontrol-update-')
); )
const artifactName = path.basename(getArtifactName(artifact)); const artifactName = path.basename(getArtifactName(artifact))
const installerPath = path.join(tempDirectory, artifactName); const installerPath = path.join(tempDirectory, artifactName)
sendProgress({ sendProgress(webContents, {
phase: "preparing", phase: 'preparing',
percent: 0, percent: 0,
artifact, artifact,
message: "Preparing update download", message: 'Preparing update download'
}); })
await downloadArtifact(artifact, installerPath, sendProgress); await downloadArtifact(artifact, installerPath, webContents)
sendProgress({ sendProgress(webContents, {
phase: "downloaded", phase: 'downloaded',
percent: 100, percent: 100,
downloadedBytes: null, downloadedBytes: null,
totalBytes: null, totalBytes: null,
artifact, artifact,
message: "Update downloaded", message: 'Update downloaded'
}); })
await launchInstallerAndRestart(mainWindow, installerPath, sendProgress); await launchInstallerAndQuit(app, installerPath, webContents)
}; }
export function startAppUpdate(mainWindow, update, sendProgress) { export function setupAppUpdateIPC(app) {
if (runningUpdate) return runningUpdate; ipcMain.handle('app-update-start', async (event, update) => {
if (runningUpdate) return runningUpdate
runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
.then(() => ({ ok: true })) const webContents = event.sender
.catch((error) => { runningUpdate = runAppUpdate(app, update, webContents)
sendProgress({ .then(() => ({ ok: true }))
phase: "error", .catch((error) => {
percent: null, sendProgress(webContents, {
message: error?.message || "Failed to update app.", phase: 'error',
}); percent: null,
throw error; message: error?.message || 'Failed to update app.'
}) })
.finally(() => { throw error
runningUpdate = null; })
}); .finally(() => {
runningUpdate = null
return runningUpdate; })
return runningUpdate
})
} }

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, Menu } from 'electron' import { BrowserWindow, ipcMain, Menu } from 'electron'
import path, { dirname } from 'path' import path, { dirname } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@ -227,12 +227,6 @@ export function createWindow() {
setupWindowEvents() setupWindowEvents()
attachKeyboardShortcuts(win) attachKeyboardShortcuts(win)
setupNavigationGestures(win) setupNavigationGestures(win)
if (process.platform === 'darwin') {
win.maximize()
} else if (process.platform === 'win32') {
win.maximize()
}
} }
export function getWindow() { export function getWindow() {
@ -256,7 +250,7 @@ export function setupMainWindowIPC() {
// IPC handlers for window controls // IPC handlers for window controls
ipcMain.on('window-control', (event, action) => { ipcMain.on('window-control', (event, action) => {
if (!win && action !== 'quit') return if (!win) return
switch (action) { switch (action) {
case 'minimize': case 'minimize':
win.minimize() win.minimize()
@ -271,17 +265,6 @@ export function setupMainWindowIPC() {
case 'close': case 'close':
win.close() win.close()
break break
case 'fullscreen':
win.setFullScreen(!win.isFullScreen())
break
case 'quit':
app.quit()
break
case 'toggle-devtools':
if (win && !win.isDestroyed()) {
win.webContents.toggleDevTools()
}
break
default: default:
break break
} }

View File

@ -6,6 +6,18 @@
align-items: center; align-items: center;
} }
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
@media (prefers-color-scheme: light) {
body {
background-color: white;
}
}
/* HTML: <div class="loader"></div> */ /* HTML: <div class="loader"></div> */
.fc-loader { .fc-loader {
width: 35px; width: 35px;

View File

@ -144,12 +144,10 @@ const isValidMsiPackage = async (filePath) => {
} }
} }
export const prepareInstallerPath = async (installerPath) => { const prepareInstallerPath = async (installerPath) => {
const fileName = path.basename(installerPath) const fileName = path.basename(installerPath)
const updateDir = path.join( const updateDir = path.join(
os.homedir(), process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'AppData',
'Local',
'FarmControl', 'FarmControl',
'Updates' 'Updates'
) )
@ -175,7 +173,11 @@ export const prepareInstallerPath = async (installerPath) => {
return resolvedPath return resolvedPath
} }
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => { const startWindowsInstallerProgressWatch = (
logPath,
webContents,
sendProgress
) => {
let installerOutput = '' let installerOutput = ''
let lastLogSize = 0 let lastLogSize = 0
let lastPercent = null let lastPercent = null
@ -237,7 +239,7 @@ const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
lastPercent = resolvedPercent lastPercent = resolvedPercent
lastMessage = resolvedMessage lastMessage = resolvedMessage
sendProgress( { sendProgress(webContents, {
phase: 'installing', phase: 'installing',
percent: resolvedPercent, percent: resolvedPercent,
message: resolvedMessage message: resolvedMessage
@ -278,7 +280,7 @@ const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
} }
export const launchWindowsInstaller = async ( export const launchWindowsInstaller = async (
mainWindow, app,
installerPath, installerPath,
webContents, webContents,
{ sendProgress, getInstallErrorMessage } { sendProgress, getInstallErrorMessage }
@ -292,7 +294,7 @@ export const launchWindowsInstaller = async (
logPath logPath
}) })
sendProgress( { sendProgress(webContents, {
phase: 'installing', phase: 'installing',
percent: 0, percent: 0,
message: 'Installing update...' message: 'Installing update...'
@ -305,6 +307,7 @@ export const launchWindowsInstaller = async (
const stopProgressWatch = startWindowsInstallerProgressWatch( const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath, logPath,
webContents,
sendProgress sendProgress
) )
@ -317,9 +320,6 @@ export const launchWindowsInstaller = async (
resolvedPath, resolvedPath,
'/qn', '/qn',
'/norestart', '/norestart',
'ALLUSERS=2',
'MSIINSTALLPERUSER=1',
'REBOOT=ReallySuppress',
'/L*v!', '/L*v!',
logPath logPath
] ]
@ -369,7 +369,7 @@ export const launchWindowsInstaller = async (
}) })
const message = error?.message || 'Failed to start update installer.' const message = error?.message || 'Failed to start update installer.'
sendProgress( { sendProgress(webContents, {
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -396,7 +396,7 @@ export const launchWindowsInstaller = async (
if (code !== 0) { if (code !== 0) {
const message = getInstallErrorMessage(null, output) const message = getInstallErrorMessage(null, output)
sendProgress( { sendProgress(webContents, {
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -418,7 +418,7 @@ export const launchWindowsInstaller = async (
if (!succeeded) { if (!succeeded) {
const message = getInstallErrorMessage(null, output) const message = getInstallErrorMessage(null, output)
sendProgress( { sendProgress(webContents, {
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -429,7 +429,7 @@ export const launchWindowsInstaller = async (
const { percent, message } = finalParse const { percent, message } = finalParse
sendProgress( { sendProgress(webContents, {
phase: 'installing', phase: 'installing',
percent: percent ?? 100, percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...' message: message || 'Installation complete. Restarting Farm Control...'

View File

@ -1,121 +0,0 @@
import { existsSync, mkdirSync, statSync, writeFileSync } 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 srcFile = path.join(rootDir, "native/macos/window-effects.mm");
export const outFile = path.join(rootDir, "src/bun/libMacWindowEffects.dylib");
const minDylibBytes = 10_000;
function normalizeArch(value) {
if (value === "x64" || value === "amd64" || value === "x86_64") {
return "x86_64";
}
if (value === "arm64" || value === "aarch64") {
return "arm64";
}
return null;
}
export function resolveTargetArch() {
return (
normalizeArch(process.env.ELECTROBUN_TARGET_ARCH) ||
normalizeArch(process.env.ELECTROBUN_ARCH) ||
normalizeArch(process.arch) ||
"arm64"
);
}
function createPlaceholder() {
mkdirSync(path.dirname(outFile), { recursive: true });
writeFileSync(outFile, "");
console.log(`build-macos-effects: created placeholder dylib at ${outFile}`);
}
function readDylibArch(dylibPath) {
const lipo = spawnSync("lipo", ["-info", dylibPath], { encoding: "utf8" });
if (lipo.status === 0) {
return lipo.stdout;
}
const file = spawnSync("file", ["-b", dylibPath], { encoding: "utf8" });
return file.stdout || "";
}
export function validateMacosEffectsDylib({
dylibPath = outFile,
expectedArch = resolveTargetArch(),
required = process.platform === "darwin",
} = {}) {
if (!required) {
return true;
}
if (!existsSync(dylibPath)) {
console.error(`build-macos-effects: missing dylib at ${dylibPath}`);
return false;
}
const { size } = statSync(dylibPath);
if (size < minDylibBytes) {
console.error(
`build-macos-effects: dylib at ${dylibPath} is too small (${size} bytes)`,
);
return false;
}
const archInfo = readDylibArch(dylibPath);
if (!archInfo.includes(expectedArch)) {
console.error(
`build-macos-effects: dylib architecture mismatch (expected ${expectedArch}): ${archInfo.trim()}`,
);
return false;
}
return true;
}
if (process.platform !== "darwin") {
createPlaceholder();
process.exit(0);
}
if (!existsSync(srcFile)) {
console.error(`build-macos-effects: missing source file ${srcFile}`);
process.exit(1);
}
mkdirSync(path.dirname(outFile), { recursive: true });
const targetArch = resolveTargetArch();
const result = spawnSync(
"xcrun",
[
"clang++",
"-dynamiclib",
"-fobjc-arc",
"-arch",
targetArch,
"-mmacosx-version-min=11.0",
"-framework",
"Cocoa",
srcFile,
"-o",
outFile,
],
{ cwd: rootDir, stdio: "inherit" },
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
if (!validateMacosEffectsDylib({ dylibPath: outFile, expectedArch: targetArch })) {
process.exit(1);
}
const { size } = statSync(outFile);
console.log(
`build-macos-effects: built ${outFile} (${size} bytes, arch=${targetArch})`,
);

View File

@ -1,111 +0,0 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { getReleaseArch, isBundleCefEnabled } from "./release-artifact-utils.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const hostArch = getReleaseArch(process.arch);
const targetArchs = process.env.ELECTROBUN_TARGET_ARCH
? [getReleaseArch(process.env.ELECTROBUN_TARGET_ARCH)]
: ["arm64", "x64"];
const bundleCef = isBundleCefEnabled();
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, {
cwd: rootDir,
stdio: "inherit",
env: process.env,
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
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(`CEF bundling: ${bundleCef ? "enabled" : "disabled"}`);
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}, cef=${bundleCef}) ===`);
console.log(
`ELECTROBUN_BUILD_ENV=stable ELECTROBUN_OS=macos ELECTROBUN_ARCH=${targetArch} ELECTROBUN_BUNDLE_CEF=${bundleCef}\n`,
);
run(invocation.command, invocation.args, { env: invocation.env });
builtCount += 1;
}
if (builtCount === 0) {
console.error("No macOS architectures were built.");
process.exit(1);
}
if (builtCount < targetArchs.length) {
console.error(
`Built ${builtCount}/${targetArchs.length} macOS architectures.`,
);
process.exit(1);
}
console.log(`\nBuilt macOS architectures: ${orderedTargets.join(", ")}`);

View File

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

View File

@ -1,56 +0,0 @@
import { existsSync, mkdirSync } 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 entrypoint = path.join(rootDir, 'src/bun/deeplink.js')
export function stageWindowsDeeplink(appDir) {
if (!existsSync(entrypoint)) {
throw new Error(`stage-windows-deeplink: entrypoint not found: ${entrypoint}`)
}
const outputPath = path.join(appDir, 'bin', 'deeplink.js')
mkdirSync(path.dirname(outputPath), { recursive: true })
const result = spawnSync(
'bun',
[
'build',
'--minify',
'--target=bun',
entrypoint,
'--outfile',
outputPath
],
{
cwd: rootDir,
stdio: 'inherit',
env: process.env
}
)
if (result.status !== 0) {
throw new Error(
`stage-windows-deeplink: bun build failed with exit code ${result.status ?? 1}`
)
}
if (!existsSync(outputPath)) {
throw new Error(`stage-windows-deeplink: output not created: ${outputPath}`)
}
console.log(`stage-windows-deeplink: created ${outputPath}`)
return outputPath
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const appDirArg = process.argv[2]
if (!appDirArg) {
console.error('Usage: bun scripts/build-windows-deeplink.mjs <app-dir>')
process.exit(1)
}
stageWindowsDeeplink(path.resolve(appDirArg))
}

View File

@ -1,130 +0,0 @@
param(
[Parameter(Mandatory = $true)]
[string]$SetupExe,
[Parameter(Mandatory = $true)]
[string]$OutputMsi,
[Parameter(Mandatory = $true)]
[string]$Version,
[string]$UpgradeCode = "735812DB-E33B-57A0-8FBC-5FC3155925AA"
)
$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
}
$installerIconPath = Join-Path $rootDir "assets\installer.ico"
if (-not (Test-Path $installerIconPath)) {
throw "Installer icon not found at $installerIconPath. Run 'bun run generate-app-icons' first."
}
$installerIconWorkPath = Join-Path $workDir "installer.ico"
Copy-Item -LiteralPath $installerIconPath -Destination $installerIconWorkPath -Force
$msiVersion = Get-MsiVersion $Version
$wxsContent = Get-Content -LiteralPath $templatePath -Raw
$wxsContent = $wxsContent.Replace("__UPGRADE_CODE__", $UpgradeCode)
$wxsContent = $wxsContent.Replace("__VERSION__", $msiVersion)
$wxsContent = $wxsContent.Replace("__SETUP_EXE__", (Escape-WixSourcePath $setupExePath))
$wxsContent = $wxsContent.Replace("__INSTALLER_ICON__", (Escape-WixSourcePath $installerIconWorkPath))
$projectWxs = Join-Path $workDir "project.wxs"
Set-Content -LiteralPath $projectWxs -Value $wxsContent -Encoding UTF8
$wixObj = Join-Path $workDir "project.wixobj"
Push-Location $workDir
try {
& $wix.Candle "-arch" "x64" $projectWxs
if ($LASTEXITCODE -ne 0) {
throw "candle.exe failed with exit code $LASTEXITCODE"
}
& $wix.Light "-out" $outputMsiPath "-spdb" "-sw1076" $wixObj
if ($LASTEXITCODE -ne 0) {
throw "light.exe failed with exit code $LASTEXITCODE"
}
} finally {
Pop-Location
Remove-Item $workDir -Recurse -Force
}
if (-not (Test-Path $outputMsiPath)) {
throw "MSI installer was not created at $outputMsiPath"
}
Write-Host "Created MSI installer at $outputMsiPath"

View File

@ -1,149 +0,0 @@
param(
[Parameter(Mandatory = $true)]
[string]$AppDir,
[Parameter(Mandatory = $true)]
[string]$OutputExe,
[Parameter(Mandatory = $true)]
[string]$Version,
[string]$BuildNumber = "dev",
[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"
$fileListGenerator = Join-Path $rootDir "scripts/generate-nsis-file-list.ps1"
$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\FarmControl.exe"
$requiredDeeplinkScript = Join-Path $stagingAppDir "bin\deeplink.js"
if (-not (Test-Path $requiredExe)) {
throw "Staged application is missing bin\FarmControl.exe"
}
if (-not (Test-Path $requiredDeeplinkScript)) {
throw "Staged application is missing bin\deeplink.js"
}
Copy-Item -LiteralPath $nsiPath -Destination (Join-Path $workDir "farmcontrol.nsi")
Copy-Item -LiteralPath $installerInclude -Destination (Join-Path $workDir "installer.nsh")
Copy-Item -LiteralPath $fileListGenerator -Destination (Join-Path $workDir "generate-nsis-file-list.ps1")
$iconPath = Join-Path $rootDir "assets\installer.ico"
if (Test-Path $iconPath) {
Copy-Item -LiteralPath $iconPath -Destination (Join-Path $workDir "icon.ico") -Force
Write-Host "Using installer icon from $iconPath"
}
$uninstallerIconPath = Join-Path $rootDir "assets\uninstaller.ico"
if (Test-Path $uninstallerIconPath) {
Copy-Item -LiteralPath $uninstallerIconPath -Destination (Join-Path $workDir "uninstaller.ico") -Force
Write-Host "Using uninstaller icon from $uninstallerIconPath"
}
$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"
"/DBUILD_NUMBER=$BuildNumber"
"/DAPP_SOURCE_DIR=app"
"/DAPP_FILE_LIST_GENERATOR=generate-nsis-file-list.ps1"
)
if (Test-Path (Join-Path $workDir "icon.ico")) {
$makensisArgs += "/DINSTALLER_ICON=icon.ico"
}
if (Test-Path (Join-Path $workDir "uninstaller.ico")) {
$makensisArgs += "/DUNINSTALLER_ICON=uninstaller.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"

View File

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

View File

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

View File

@ -1,131 +0,0 @@
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";
import { fixMacosHeaderpad } from "./fix-macos-headerpad.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);
// electrobun#485: x64 core binaries lack Mach-O headerpad and get corrupted
// by codesign; free a load-command slot before anything copies or signs them.
fixMacosHeaderpad(getPlatformPaths(targetOS, arch).platformDistDir);
}

View File

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

View File

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

View File

@ -1,681 +0,0 @@
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync
} from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import {
getReleaseArch,
getReleaseArtifactName,
getReleaseVersion,
isBundleCefEnabled
} from './release-artifact-utils.mjs'
import {
cleanExpandedWindowsApp,
expandWindowsAppFromArchive
} from './expand-windows-installer.mjs'
import { stageWindowsDeeplink } from './build-windows-deeplink.mjs'
import { patchWindowsBinaries } from './patch-windows-binaries.mjs'
import { codesignMacAppBundle } from './codesign-macos-app.mjs'
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const packageJson = JSON.parse(
readFileSync(path.join(rootDir, 'package.json'), 'utf8')
)
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || 'stable'
const targetOs =
process.env.ELECTROBUN_OS ||
(process.platform === 'darwin'
? 'macos'
: process.platform === 'win32'
? 'win'
: process.platform === 'linux'
? 'linux'
: null)
const buildArch = getReleaseArch(process.env.ELECTROBUN_ARCH || process.arch)
const version =
process.env.ELECTROBUN_APP_VERSION || getReleaseVersion(packageJson)
const bundleCef = isBundleCefEnabled()
const artifactDir =
process.env.ELECTROBUN_ARTIFACT_DIR || path.join(rootDir, 'app_dist')
const identifier =
process.env.ELECTROBUN_APP_IDENTIFIER || 'com.tombutcher.farmcontrol'
const artifactPrefix = `farmcontrol-${version}-`
function artifactName(arch, ext) {
return getReleaseArtifactName(version, arch, ext, { cef: bundleCef })
}
function getBuildRoot() {
const electrobunBuildDir = process.env.ELECTROBUN_BUILD_DIR
if (!electrobunBuildDir) {
return path.join(rootDir, 'build')
}
const baseName = path.basename(electrobunBuildDir)
if (baseName.startsWith('stable-')) {
return path.dirname(electrobunBuildDir)
}
return electrobunBuildDir
}
function walkFiles(dir) {
const files = []
if (!existsSync(dir)) {
return files
}
for (const entry of readdirSync(dir)) {
const fullPath = path.join(dir, entry)
const stats = statSync(fullPath)
if (stats.isDirectory()) {
files.push(...walkFiles(fullPath))
} else {
files.push(fullPath)
}
}
return files
}
function findByExtension(root, extension) {
return walkFiles(root).find((filePath) =>
filePath.toLowerCase().endsWith(extension.toLowerCase())
)
}
function findMacAppBundle(arch) {
const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`)
if (!existsSync(platformDir)) {
return null
}
for (const child of readdirSync(platformDir)) {
if (child.endsWith('.app')) {
return path.join(platformDir, child)
}
}
return null
}
function findMacDmgSource(arch) {
const prefixedArtifact = walkFiles(artifactDir).find(
(filePath) =>
filePath.includes(`stable-macos-${arch}`) &&
filePath.toLowerCase().endsWith('.dmg')
)
if (prefixedArtifact) {
return prefixedArtifact
}
const buildDmg = findByExtension(
path.join(getBuildRoot(), `stable-macos-${arch}`),
'.dmg'
)
if (buildDmg) {
return buildDmg
}
return findByExtension(artifactDir, '.dmg')
}
function findWindowsInstallerFiles() {
const buildRoot = getBuildRoot()
if (!existsSync(buildRoot)) {
return null
}
for (const entry of readdirSync(buildRoot)) {
if (!entry.startsWith('stable-win-')) {
continue
}
const platformDir = path.join(buildRoot, entry)
const files = walkFiles(platformDir)
const setupExe = files.find((filePath) => /-setup\.exe$/i.test(filePath))
const setupArchive = files.find((filePath) =>
/-setup\.tar\.zst$/i.test(filePath)
)
const setupMetadata = files.find((filePath) =>
/-setup\.metadata\.json$/i.test(filePath)
)
const setupZip = files.find((filePath) => /-setup\.zip$/i.test(filePath))
if (setupExe && setupArchive && setupMetadata) {
return { setupExe, setupArchive, setupMetadata, setupZip }
}
}
return null
}
function publishArtifact(sourcePath, arch, ext) {
if (!sourcePath || !existsSync(sourcePath)) {
throw new Error(
`Missing source artifact for ${arch}.${ext}: ${sourcePath ?? 'not found'}`
)
}
mkdirSync(artifactDir, { recursive: true })
const destination = path.join(artifactDir, artifactName(arch, ext))
cpSync(sourcePath, destination)
console.log(`Published ${destination}`)
return destination
}
function cleanStagingArtifacts(keepNames) {
if (!existsSync(artifactDir)) {
return
}
for (const entry of readdirSync(artifactDir)) {
if (keepNames.includes(entry) || entry.startsWith(artifactPrefix)) {
continue
}
if (
entry.includes('stable-macos-') ||
entry.includes('stable-win-') ||
entry.endsWith('-update.json') ||
entry.endsWith('.tar.gz')
) {
rmSync(path.join(artifactDir, entry), { recursive: true, force: true })
}
}
}
const MAC_DMG_ASSETS_DIR = path.join(rootDir, 'packaging/macos/dmg')
const MAC_INSTALLER_ICONSET_PATH = path.join(rootDir, 'assets/installer.iconset')
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 findCommand(command) {
const which = spawnSync('which', [command], { encoding: 'utf8' })
if (which.status === 0 && which.stdout.trim()) {
return which.stdout.trim()
}
return null
}
function buildInstallerIcns(destinationPath) {
if (!existsSync(MAC_INSTALLER_ICONSET_PATH)) {
throw new Error(
`Installer iconset not found at ${MAC_INSTALLER_ICONSET_PATH}. Run \`bun run generate-app-icons\` first.`
)
}
mkdirSync(path.dirname(destinationPath), { recursive: true })
rmSync(destinationPath, { force: true })
const iconutil = spawnSync(
'iconutil',
['-c', 'icns', '-o', destinationPath, MAC_INSTALLER_ICONSET_PATH],
{ stdio: 'inherit' }
)
if (iconutil.status !== 0) {
throw new Error(
`iconutil failed to create installer icns with exit code ${iconutil.status ?? 1}`
)
}
return destinationPath
}
function applyMacInstallerFileIcon(targetPath, icnsPath) {
if (!existsSync(icnsPath)) {
console.warn(
`finalize-desktop-artifacts: installer icns not found at ${icnsPath}, skipping custom icon`
)
return false
}
const fileicon = findCommand('fileicon')
if (fileicon) {
const result = spawnSync(fileicon, ['set', targetPath, icnsPath], {
stdio: 'inherit'
})
if (result.status === 0) {
return true
}
}
const escapedTarget = targetPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
const escapedIcon = icnsPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
const result = spawnSync(
'osascript',
[
'-e',
`tell application "Finder" to set icon of (POSIX file "${escapedTarget}") to (read (POSIX file "${escapedIcon}") as picture)`
],
{ stdio: 'inherit' }
)
if (result.status === 0) {
return true
}
console.warn(
'finalize-desktop-artifacts: could not apply installer icon; install fileicon or run on macOS with Finder access'
)
return false
}
function ensureMacDmgAssets() {
mkdirSync(MAC_DMG_ASSETS_DIR, { recursive: true })
const voliconPath = path.join(MAC_DMG_ASSETS_DIR, 'volicon.icns')
buildInstallerIcns(voliconPath)
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: voliconPath,
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',
'212',
'--hide-extension',
appBundleName,
'--app-drop-link',
'410',
'212',
'--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, artifactName(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
)
applyMacInstallerFileIcon(builtDmgPath, dmgAssets.volicon)
} 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, artifactName(arch, 'pkg'))
const result = spawnSync(
'pkgbuild',
[
'--component',
appBundlePath,
'--install-location',
'/Applications',
'--identifier',
identifier,
'--version',
version,
pkgPath
],
{ stdio: 'inherit' }
)
if (result.status !== 0) {
throw new Error(`pkgbuild failed with exit code ${result.status ?? 1}`)
}
console.log(`Published ${pkgPath}`)
return pkgPath
}
function stageWindowsDeeplinkScript(appDir) {
return stageWindowsDeeplink(appDir)
}
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, artifactName(arch, 'exe'))
const buildInfoPath = path.join(rootDir, 'src/buildInfo.json')
const buildInfo = existsSync(buildInfoPath)
? JSON.parse(readFileSync(buildInfoPath, 'utf8'))
: {}
const buildNumber =
process.env.BUILD_NUMBER ||
process.env.VITE_BUILD_NUMBER ||
buildInfo.buildNumber ||
'dev'
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,
'-BuildNumber',
buildNumber
],
{ 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, artifactName(arch, 'msi'))
const powershell = process.env.SystemRoot
? path.join(
process.env.SystemRoot,
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe'
)
: 'powershell.exe'
const result = spawnSync(
powershell,
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
scriptPath,
'-SetupExe',
setupExePath,
'-OutputMsi',
msiPath,
'-Version',
version
],
{ stdio: 'inherit' }
)
if (result.status !== 0) {
throw new Error(
`build-windows-msi.ps1 failed with exit code ${result.status ?? 1}`
)
}
console.log(`Published ${msiPath}`)
return msiPath
}
if (buildEnv === 'dev') {
console.log('finalize-desktop-artifacts: skipping dev build')
process.exit(0)
}
async function main() {
console.log(
`finalize-desktop-artifacts: os=${targetOs} arch=${buildArch} cef=${bundleCef} version=${version}`
)
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
)
stageWindowsDeeplinkScript(appDir)
patchWindowsBinaries(appDir)
let published
try {
const setupExe = buildWindowsNsis(appDir, arch)
published = [setupExe, buildWindowsMsi(setupExe, arch)]
if (installerFiles.setupZip) {
published.push(publishArtifact(installerFiles.setupZip, arch, 'zip'))
}
} finally {
cleanExpandedWindowsApp(platformDir)
}
cleanStagingArtifacts(published.map((filePath) => path.basename(filePath)))
cleanWinBuildDir(arch)
return
}
console.log(
'finalize-desktop-artifacts: no desktop packaging configured for',
targetOs ?? 'unknown target'
)
process.exit(0)
}
await main()

View File

@ -1,152 +0,0 @@
// Workaround for https://github.com/blackboardsh/electrobun/issues/485
//
// Electrobun's darwin-x64 core binaries (launcher, extractor, libasar.dylib,
// bsdiff, zig-asar in v1.18.1) are Zig-built with zero/near-zero Mach-O
// headerpad and no code signature. When codesign adds the 16-byte
// LC_CODE_SIGNATURE load command it silently overwrites the first bytes of
// __text, and the signed binary segfaults on launch (Intel Macs only;
// arm64 binaries always ship with a signature slot that is re-signed in
// place).
//
// This script frees room in the load-command area by dropping the 16-byte
// LC_SOURCE_VERSION command (LC_UUID as a fallback) from any thin x86_64
// Mach-O that has less than 16 bytes of headerpad and no existing code
// signature. It is idempotent and a no-op once upstream ships rebuilt
// binaries with headerpad_size set.
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const MH_MAGIC_64 = 0xfeedfacf;
const CPU_TYPE_X86_64 = 0x01000007;
const LC_SEGMENT_64 = 0x19;
const LC_UUID = 0x1b;
const LC_CODE_SIGNATURE = 0x1d;
const LC_SOURCE_VERSION = 0x2a;
const HEADER_SIZE = 32;
const CODE_SIGNATURE_CMD_SIZE = 16;
function analyzeMachO(buffer) {
if (buffer.length < HEADER_SIZE || buffer.readUInt32LE(0) !== MH_MAGIC_64) {
return null;
}
const cputype = buffer.readInt32LE(4);
const ncmds = buffer.readUInt32LE(16);
const sizeofcmds = buffer.readUInt32LE(20);
let offset = HEADER_SIZE;
let firstSectionOffset = null;
let hasCodeSignature = false;
let sourceVersionCmd = null;
let uuidCmd = null;
for (let i = 0; i < ncmds; i += 1) {
const cmd = buffer.readUInt32LE(offset);
const cmdsize = buffer.readUInt32LE(offset + 4);
if (cmd === LC_CODE_SIGNATURE) {
hasCodeSignature = true;
} else if (cmd === LC_SOURCE_VERSION) {
sourceVersionCmd = { offset, cmdsize };
} else if (cmd === LC_UUID) {
uuidCmd = { offset, cmdsize };
} else if (cmd === LC_SEGMENT_64) {
const nsects = buffer.readUInt32LE(offset + 64);
let sectionOffset = offset + 72;
for (let s = 0; s < nsects; s += 1) {
const size = Number(buffer.readBigUInt64LE(sectionOffset + 40));
const fileOffset = buffer.readUInt32LE(sectionOffset + 48);
if (fileOffset > 0 && size > 0) {
firstSectionOffset =
firstSectionOffset === null
? fileOffset
: Math.min(firstSectionOffset, fileOffset);
}
sectionOffset += 80;
}
}
offset += cmdsize;
}
return {
cputype,
ncmds,
sizeofcmds,
headerpad:
firstSectionOffset === null
? Infinity
: firstSectionOffset - (HEADER_SIZE + sizeofcmds),
hasCodeSignature,
removableCmd: sourceVersionCmd ?? uuidCmd,
};
}
function fixFile(filePath) {
const buffer = readFileSync(filePath);
const info = analyzeMachO(buffer);
const name = path.basename(filePath);
if (!info || info.cputype !== CPU_TYPE_X86_64) {
return;
}
if (info.hasCodeSignature || info.headerpad >= CODE_SIGNATURE_CMD_SIZE) {
return;
}
if (!info.removableCmd) {
console.warn(
`fix-macos-headerpad: ${name} has headerpad ${info.headerpad} but no removable load command; signing may corrupt it`,
);
return;
}
const { offset, cmdsize } = info.removableCmd;
const loadCommandsEnd = HEADER_SIZE + info.sizeofcmds;
// Shift the remaining load commands over the removed one, then zero the
// freed tail so codesign finds clean padding.
buffer.copyWithin(offset, offset + cmdsize, loadCommandsEnd);
buffer.fill(0, loadCommandsEnd - cmdsize, loadCommandsEnd);
buffer.writeUInt32LE(info.ncmds - 1, 16);
buffer.writeUInt32LE(info.sizeofcmds - cmdsize, 20);
writeFileSync(filePath, buffer);
console.log(
`fix-macos-headerpad: ${name} freed ${cmdsize} bytes for LC_CODE_SIGNATURE (headerpad was ${info.headerpad})`,
);
}
export function fixMacosHeaderpad(directory) {
if (process.platform !== "darwin") {
return;
}
let entries;
try {
entries = readdirSync(directory);
} catch {
return;
}
for (const entry of entries) {
const filePath = path.join(directory, entry);
if (statSync(filePath).isFile()) {
fixFile(filePath);
}
}
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
const modulePath = fileURLToPath(import.meta.url);
if (invokedPath === modulePath) {
const rootDir = path.resolve(path.dirname(modulePath), "..");
const target =
process.argv[2] ||
path.join(rootDir, "node_modules/electrobun/dist-macos-x64");
fixMacosHeaderpad(path.resolve(target));
}

View File

@ -1,27 +0,0 @@
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 steps = [
[
path.join(rootDir, 'scripts/prepare-app-icons.mjs'),
'assets/logos/farmcontrolicon.png'
],
[path.join(rootDir, 'scripts/prepare-installer-icons.mjs')]
]
for (const args of steps) {
const result = spawnSync('bun', args, {
cwd: rootDir,
stdio: 'inherit',
env: process.env
})
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
console.log('generate-app-icons: done')

View File

@ -1,62 +0,0 @@
param(
[Parameter(Mandatory = $true)]
[string]$SourceDir,
[Parameter(Mandatory = $true)]
[string]$OutputPath
)
$ErrorActionPreference = "Stop"
function ConvertTo-NsisString {
param([string]$Value)
return $Value.Replace('$', '$$').Replace('"', '$\"')
}
$sourceRoot = (Resolve-Path -LiteralPath $SourceDir).Path.TrimEnd('\')
$sourcePrefix = "$sourceRoot\"
$files = @(
Get-ChildItem -LiteralPath $sourceRoot -Recurse -File -Force |
Sort-Object FullName
)
if ($files.Count -eq 0) {
throw "No files were found in NSIS application source directory: $sourceRoot"
}
[long]$totalBytes = ($files | Measure-Object -Property Length -Sum).Sum
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add("!define APP_COPY_TOTAL_BYTES `"$totalBytes`"")
$lines.Add("!macro copyApplicationFilesWithProgress")
$lastRelativeDirectory = $null
foreach ($file in $files) {
$relativePath = $file.FullName.Substring($sourcePrefix.Length)
$relativeDirectory = Split-Path -Parent $relativePath
$escapedRelativePath = ConvertTo-NsisString $relativePath
$escapedSourcePath = ConvertTo-NsisString $file.FullName
$escapedFileName = ConvertTo-NsisString $file.Name
if ($relativeDirectory -ne $lastRelativeDirectory) {
if ([string]::IsNullOrEmpty($relativeDirectory)) {
$lines.Add(' SetOutPath "$INSTDIR"')
} else {
$escapedDirectory = ConvertTo-NsisString $relativeDirectory
$lines.Add(" SetOutPath `"`$INSTDIR\$escapedDirectory`"")
}
$lastRelativeDirectory = $relativeDirectory
}
$lines.Add(" File `"/oname=$escapedFileName`" `"$escapedSourcePath`"")
$lines.Add(
" !insertmacro progressCopyFile `"$($file.Length)`" `"$escapedRelativePath`""
)
}
$lines.Add("!macroend")
$encoding = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllLines($OutputPath, $lines, $encoding)
Write-Host "Generated NSIS commands for $($files.Count) files ($totalBytes bytes)."

View File

@ -1,10 +1,10 @@
!macro customInstall !macro customInstall
DetailPrint "Register farmcontrol URI Handler" DetailPrint "Register farmcontrol URI Handler"
DeleteRegKey HKCU "Software\Classes\farmcontrol" DeleteRegKey HKCR "farmcontrol"
WriteRegStr HKCU "Software\Classes\farmcontrol" "" "URL:farmcontrol" WriteRegStr HKCR "farmcontrol" "" "URL:farmcontrol"
WriteRegStr HKCU "Software\Classes\farmcontrol" "URL Protocol" "" WriteRegStr HKCR "farmcontrol" "URL Protocol" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}" WriteRegStr HKCR "farmcontrol\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
WriteRegStr HKCU "Software\Classes\farmcontrol\shell" "" "" WriteRegStr HKCR "farmcontrol\shell" "" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" "" WriteRegStr HKCR "farmcontrol\shell\Open" "" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1" WriteRegStr HKCR "farmcontrol\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1"
!macroend !macroend

View File

@ -1,350 +0,0 @@
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 })
}
function patchNativeWrapperPath(nativeTsPath) {
if (!existsSync(nativeTsPath)) {
return
}
let source = readFileSync(nativeTsPath, 'utf8')
if (source.includes('function resolveNativeWrapperPath()')) {
return
}
if (!source.includes('existsSync')) {
if (source.includes('import { dirname, join } from "path";')) {
source = source.replace(
'import { dirname, join } from "path";',
'import { existsSync } from "fs";\nimport { dirname, join } from "path";'
)
} else {
source = source.replace(
'import { join } from "path";',
'import { existsSync } from "fs";\nimport { dirname, join } from "path";'
)
}
} else if (!source.includes('dirname')) {
source = source.replace(
'import { join } from "path";',
'import { dirname, join } from "path";'
)
}
const helper = `
function resolveNativeWrapperPath() {
\tconst fileName = \`libNativeWrapper.\${suffix}\`;
\tconst candidates = [
\t\tjoin(dirname(process.execPath), fileName),
\t\tjoin(process.cwd(), fileName),
\t];
\tfor (const candidate of candidates) {
\t\tif (existsSync(candidate)) {
\t\t\treturn candidate;
\t\t}
\t}
\treturn candidates[0]!;
}
`
source = source.replace(
'export const native = (() => {',
`${helper}\nexport const native = (() => {`
)
source = source.replace(
/const nativeWrapperPath = join\((?:dirname\(process\.execPath\)|process\.cwd\(\)), `libNativeWrapper\.\$\{suffix\}`\);/,
'const nativeWrapperPath = resolveNativeWrapperPath();'
)
writeFileSync(nativeTsPath, source)
}
for (const distDir of ['dist', 'dist-macos-arm64', 'dist-win-x64']) {
patchNativeWrapperPath(
path.join(electrobunDir, distDir, 'api/bun/proc/native.ts')
)
}
const markerPath = path.join(electrobunDir, '.farmcontrol-electrobun-patched')
writeFileSync(markerPath, `patched-at=${new Date().toISOString()}\n`)
console.log(
'patch-electrobun-src: electrobun src/shared ready for cross-arch builds'
)

View File

@ -1,45 +0,0 @@
import { existsSync, renameSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export const WINDOWS_LAUNCHER_EXE = 'FarmControl.exe'
const SOURCE_LAUNCHER_EXE = 'launcher.exe'
export function patchWindowsBinaries(appDir) {
const binDir = path.join(appDir, 'bin')
if (!existsSync(binDir)) {
throw new Error(`patch-windows-binaries: bin directory not found at ${binDir}`)
}
const sourcePath = path.join(binDir, SOURCE_LAUNCHER_EXE)
const destPath = path.join(binDir, WINDOWS_LAUNCHER_EXE)
if (existsSync(destPath) && !existsSync(sourcePath)) {
console.log(`patch-windows-binaries: ${WINDOWS_LAUNCHER_EXE} already present`)
return
}
if (!existsSync(sourcePath)) {
console.warn(`patch-windows-binaries: skipping missing ${sourcePath}`)
return
}
if (existsSync(destPath)) {
renameSync(destPath, `${destPath}.old`)
}
renameSync(sourcePath, destPath)
console.log(
`patch-windows-binaries: renamed ${SOURCE_LAUNCHER_EXE} -> ${WINDOWS_LAUNCHER_EXE}`
)
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const appDirArg = process.argv[2]
if (!appDirArg) {
console.error('Usage: bun scripts/patch-windows-binaries.mjs <app-dir>')
process.exit(1)
}
patchWindowsBinaries(path.resolve(appDirArg))
}

View File

@ -1,145 +0,0 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
const requiredFiles = [
"src/bun/index.js",
"electrobun.config.ts",
"package.json",
];
for (const relativePath of requiredFiles) {
const filePath = path.join(rootDir, relativePath);
if (!existsSync(filePath)) {
console.error(`pre-build: required file not found: ${relativePath}`);
process.exit(1);
}
}
if (buildEnv === "stable" && process.env.ELECTROBUN_OS === "macos") {
const codesignVars = [
"ELECTROBUN_DEVELOPER_ID",
"ELECTROBUN_APPLEID",
"ELECTROBUN_APPLEIDPASS",
"ELECTROBUN_TEAMID",
];
const missing = codesignVars.filter((name) => !process.env[name]);
if (missing.length > 0) {
console.warn(
`pre-build: stable macOS build without code signing credentials (${missing.join(", ")}); Electrobun will skip signing.`,
);
}
}
const buildMacosEffects = spawnSync(
"bun",
[path.join(rootDir, "scripts/build-macos-effects.mjs")],
{ cwd: rootDir, stdio: "inherit", env: process.env },
);
if (buildMacosEffects.status !== 0) {
process.exit(buildMacosEffects.status ?? 1);
}
if (process.platform === "darwin") {
const { validateMacosEffectsDylib, outFile } = await import(
pathToFileURL(
path.join(rootDir, "scripts/build-macos-effects.mjs"),
).href
);
if (!validateMacosEffectsDylib({ dylibPath: outFile })) {
console.error(
"pre-build: macOS window effects dylib is missing or invalid; blur and traffic lights will not work in packaged builds.",
);
process.exit(1);
}
}
const requiredIconPaths = [
path.join(rootDir, "assets/icon.ico"),
path.join(rootDir, "assets/icon.png"),
path.join(rootDir, "assets/icon.iconset/icon_256x256.png"),
path.join(rootDir, "assets/installer.ico"),
path.join(rootDir, "assets/uninstaller.ico"),
];
if (buildEnv === "stable") {
const prepareIcons = spawnSync(
"bun",
[path.join(rootDir, "scripts/generate-app-icons.mjs")],
{ cwd: rootDir, stdio: "inherit", env: process.env },
);
if (prepareIcons.status !== 0) {
process.exit(prepareIcons.status ?? 1);
}
}
const missingIcons = requiredIconPaths.filter((filePath) => !existsSync(filePath));
if (missingIcons.length > 0) {
console.error(
`pre-build: missing generated icons:\n${missingIcons
.map((filePath) => ` - ${path.relative(rootDir, filePath)}`)
.join("\n")}\nRun \`bun run generate-app-icons\` to create them.`,
);
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)",
);
// Electrobun still copies dist/mainview into the app bundle; provide a stub so
// dev builds don't warn when Vite serves the renderer instead.
const stubDir = path.join(rootDir, "dist/mainview");
mkdirSync(stubDir, { recursive: true });
writeFileSync(
path.join(stubDir, "index.html"),
"<!doctype html><html><body>Dev mode — start Vite with <code>bun run dev:renderer</code>.</body></html>",
);
console.log(`pre-build: validation passed (${buildEnv})`);
process.exit(0);
}
const buildRenderer = spawnSync(
"bun",
[path.join(rootDir, "scripts/build-renderer.mjs")],
{
cwd: rootDir,
stdio: "inherit",
env: {
...process.env,
NODE_ENV: "production",
},
},
);
if (buildRenderer.status !== 0) {
process.exit(buildRenderer.status ?? 1);
}
const rendererIndex = path.join(rootDir, "dist/mainview/index.html");
if (!existsSync(rendererIndex)) {
console.error(`pre-build: renderer output not found: dist/mainview/index.html`);
process.exit(1);
}
console.log(`pre-build: validation and renderer build passed (${buildEnv})`);

View File

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

View File

@ -1,110 +0,0 @@
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 INSTALLER_SOURCE = path.join(
rootDir,
'assets/logos/farmcontrolinstaller.png'
)
const UNINSTALLER_SOURCE = path.join(
rootDir,
'assets/logos/farmcontroluninstaller.png'
)
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]
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
}
async function writeWindowsIco(sourceImage, icoPath) {
const icoPngs = []
for (const size of WIN_ICO_SIZES) {
icoPngs.push(await resizePng(sourceImage, size))
}
await Bun.write(icoPath, await pngToIco(icoPngs))
console.log(`prepare-installer-icons: wrote ${icoPath}`)
}
async function main() {
if (!existsSync(INSTALLER_SOURCE)) {
console.error(
`prepare-installer-icons: source image not found: ${INSTALLER_SOURCE}`
)
process.exit(1)
}
if (!existsSync(UNINSTALLER_SOURCE)) {
console.error(
`prepare-installer-icons: source image not found: ${UNINSTALLER_SOURCE}`
)
process.exit(1)
}
const installerImage = await Jimp.read(INSTALLER_SOURCE)
if (installerImage.width < 256 || installerImage.height < 256) {
console.warn(
`prepare-installer-icons: installer source is ${installerImage.width}x${installerImage.height}; recommend at least 256x256`
)
}
const uninstallerImage = await Jimp.read(UNINSTALLER_SOURCE)
if (uninstallerImage.width < 256 || uninstallerImage.height < 256) {
console.warn(
`prepare-installer-icons: uninstaller source is ${uninstallerImage.width}x${uninstallerImage.height}; recommend at least 256x256`
)
}
const assetsDir = path.join(rootDir, 'assets')
mkdirSync(assetsDir, { recursive: true })
const iconsetDir = path.join(assetsDir, 'installer.iconset')
rmSync(iconsetDir, { recursive: true, force: true })
mkdirSync(iconsetDir, { recursive: true })
for (const entry of MAC_ICONSET_ENTRIES) {
await writePng(
path.join(iconsetDir, entry.name),
installerImage,
entry.size
)
}
console.log(`prepare-installer-icons: wrote ${iconsetDir}`)
await writeWindowsIco(installerImage, path.join(assetsDir, 'installer.ico'))
await writeWindowsIco(
uninstallerImage,
path.join(assetsDir, 'uninstaller.ico')
)
}
main().catch((error) => {
console.error(`prepare-installer-icons: ${error.message}`)
process.exit(1)
})

View File

@ -1,28 +0,0 @@
export function getReleaseVersion(packageJson) {
return packageJson.version;
}
export function getReleaseArch(platformArch = process.arch) {
if (platformArch === "arm64" || platformArch === "aarch64") {
return "arm64";
}
if (platformArch === "x64" || platformArch === "amd64") {
return "x64";
}
return platformArch;
}
export function isBundleCefEnabled(
value = process.env.ELECTROBUN_BUNDLE_CEF,
) {
return ["1", "true", "yes"].includes(String(value || "").toLowerCase());
}
export function getReleaseArtifactName(version, arch, ext, options = {}) {
const cef =
typeof options === "boolean"
? options
: Boolean(options.cef ?? isBundleCefEnabled());
const cefSuffix = cef ? "-cef" : "";
return `farmcontrol-${version}-${arch}${cefSuffix}.${ext}`;
}

View File

@ -1,101 +0,0 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { getReleaseArch, isBundleCefEnabled } 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 bundleCef = isBundleCefEnabled();
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 macosEffectsResult = spawnSync(
"bun",
[path.join(rootDir, "scripts/build-macos-effects.mjs")],
{ cwd: rootDir, stdio: "inherit", env: process.env },
);
if (macosEffectsResult.status !== 0) {
process.exit(macosEffectsResult.status ?? 1);
}
if (process.platform === "darwin") {
const { validateMacosEffectsDylib, outFile } = await import(
pathToFileURL(
path.join(rootDir, "scripts/build-macos-effects.mjs"),
).href
);
if (!validateMacosEffectsDylib({ dylibPath: outFile })) {
console.error(
"run-electrobun-build: macOS window effects dylib is missing or invalid.",
);
process.exit(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} cef=${bundleCef} (host ${getReleaseArch(process.arch)})`,
);
const result = spawnSync(
"bun",
[electrobunCli, "build", `--env=${buildEnv}`],
{
cwd: rootDir,
stdio: "inherit",
env,
},
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}

View File

@ -1,13 +0,0 @@
import { cpSync, mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const buildDir = path.join(rootDir, "build");
const viewDir = path.join(rootDir, "dist", "mainview");
rmSync(viewDir, { recursive: true, force: true });
mkdirSync(viewDir, { recursive: true });
cpSync(buildDir, viewDir, { recursive: true });
console.log(`Synced renderer build to ${viewDir}`);

View File

@ -1,15 +0,0 @@
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})`);

View File

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

View File

@ -1,50 +0,0 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import {
buildDeeplinkPayload,
findProtocolUrl,
forwardDeeplinkToRunningInstance,
writeDeeplinkSignal
} from '../desktop/deeplink-ipc.js'
const url = findProtocolUrl(process.argv)
console.log('[deeplink] argv:', process.argv)
console.log('[deeplink] url:', url ?? null)
if (!url) {
process.exit(1)
}
const payload = buildDeeplinkPayload(url, process.argv)
const forwarded = await forwardDeeplinkToRunningInstance(payload)
if (forwarded) {
console.log('[deeplink] forwarded to running Farm Control instance')
process.exit(0)
}
console.log('[deeplink] no running instance found, launching Farm Control')
writeDeeplinkSignal(payload)
const binDir = dirname(process.execPath)
const launcherPath = ['FarmControl.exe', 'launcher.exe']
.map((name) => join(binDir, name))
.find((candidate) => existsSync(candidate))
if (!launcherPath) {
console.error('[deeplink] FarmControl.exe not found in', binDir)
process.exit(1)
}
const child = spawn(launcherPath, [], {
cwd: dirname(launcherPath),
detached: true,
stdio: 'ignore',
windowsHide: true
})
child.unref()
process.exit(0)

View File

@ -1,43 +0,0 @@
import { ensureWindowsWorkingDirectory } from '../desktop/windows-app-paths.js'
import {
captureLaunchUrl,
closeSingleInstanceServer,
ensureSingleInstanceLock
} from '../desktop/single-instance.js'
ensureWindowsWorkingDirectory()
const launchUrl = captureLaunchUrl()
const gotSingleInstanceLock = await ensureSingleInstanceLock({ launchUrl })
if (!gotSingleInstanceLock) {
process.exit(0)
}
const { createAppRpc } = await import('../desktop/rpc.js')
const {
registerGlobalShortcuts,
unregisterGlobalShortcuts
} = await import('../desktop/spotlight.js')
const {
createMainWindow,
handleDeepLinkFromArgv,
setupDevAuthServer,
setupNavigationGestures,
setupWindowsDeepLinkHandling
} = await import('../desktop/window.js')
setupWindowsDeepLinkHandling()
const rpc = createAppRpc()
const mainWindow = await createMainWindow(rpc)
setupNavigationGestures(mainWindow)
registerGlobalShortcuts(rpc)
setupDevAuthServer()
handleDeepLinkFromArgv(launchUrl)
process.on('exit', () => {
unregisterGlobalShortcuts()
closeSingleInstanceServer()
})

View File

@ -30,7 +30,7 @@ const DashboardLayout = ({ children }) => {
<MessageProvider> <MessageProvider>
<Layout <Layout
style={{ height: 'var(--unit-100vh)' }} style={{ height: 'var(--unit-100vh)' }}
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout`} className={isDarkMode ? 'dark-mode' : 'light-mode'}
> >
<DashboardNavigation /> <DashboardNavigation />
<Layout> <Layout>
@ -49,7 +49,7 @@ const DashboardLayout = ({ children }) => {
) : ( ) : (
<ProductionSidebar /> // Default to production sidebar <ProductionSidebar /> // Default to production sidebar
)} )}
<Layout style={{ padding: '24px' }} className='main-content-layout'> <Layout style={{ padding: '24px' }}>
<Content> <Content>
<Flex vertical style={{ height: '100%' }} gap='20px'> <Flex vertical style={{ height: '100%' }} gap='20px'>
<Flex justify='space-between'> <Flex justify='space-between'>

View File

@ -30,7 +30,7 @@ const About = () => {
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
const { fetchApiServerVersion, fetchWsServerVersion } = const { fetchApiServerVersion, fetchWsServerVersion } =
useContext(ApiServerContext) useContext(ApiServerContext)
const { isElectron, getAppEngine } = useContext(ElectronContext) const { isElectron, getElectronVersion } = useContext(ElectronContext)
const { checkForUpdates } = useContext(AppUpdateContext) const { checkForUpdates } = useContext(AppUpdateContext)
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
@ -74,18 +74,18 @@ const About = () => {
useEffect(() => { useEffect(() => {
if (!isElectron) return if (!isElectron) return
getAppEngine() getElectronVersion()
.then((engine) => { .then((version) => {
setAppEngine(engine === 'chromium' ? 'Chromium' : 'Native') setElectronVersion(version || 'unknown')
}) })
.catch(() => { .catch(() => {
setAppEngine('Unknown') setElectronVersion('unknown')
}) })
}, [getAppEngine, isElectron]) }, [getElectronVersion, isElectron])
const [apiServerVersion, setApiServerVersion] = useState(null) const [apiServerVersion, setApiServerVersion] = useState(null)
const [wsServerVersion, setWsServerVersion] = useState(null) const [wsServerVersion, setWsServerVersion] = useState(null)
const [appEngine, setAppEngine] = useState(null) const [electronVersion, setElectronVersion] = useState(null)
const apiServerVersionText = apiServerVersion ? ( const apiServerVersionText = apiServerVersion ? (
<Text> <Text>
@ -99,8 +99,8 @@ const About = () => {
) : ( ) : (
<Skeleton.Input active size='small' className='text-skeleton' /> <Skeleton.Input active size='small' className='text-skeleton' />
) )
const appEngineText = appEngine ? ( const electronVersionText = electronVersion ? (
<Text>{appEngine}</Text> <Text>{`v${electronVersion}`}</Text>
) : ( ) : (
<Skeleton.Input active size='small' className='text-skeleton' /> <Skeleton.Input active size='small' className='text-skeleton' />
) )
@ -153,7 +153,9 @@ const About = () => {
</Text> </Text>
</Text> </Text>
{isElectron && ( {isElectron && (
<Text type='secondary'>Engine: {appEngineText}</Text> <Text type='secondary'>
Electron: {electronVersionText}
</Text>
)} )}
<Text type='secondary'>REST API: {apiServerVersionText}</Text> <Text type='secondary'>REST API: {apiServerVersionText}</Text>

View File

@ -1,11 +1,10 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { Button, Flex, Modal, Typography, theme, Divider } from 'antd' import { Button, Flex, Modal, Progress, Typography, theme, Divider } from 'antd'
import CloudIcon from '../../../Icons/CloudIcon' import CloudIcon from '../../../Icons/CloudIcon'
import HostIcon from '../../../Icons/HostIcon' import HostIcon from '../../../Icons/HostIcon'
import ReloadIcon from '../../../Icons/ReloadIcon' import ReloadIcon from '../../../Icons/ReloadIcon'
import HProgress from '../../common/HProgress'
import CheckCircleIcon from '../../../Icons/CheckCircleIcon' import CheckCircleIcon from '../../../Icons/CheckCircleIcon'
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon' import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
@ -66,9 +65,16 @@ const getStageColor = (status, token) => {
return token.colorTextQuaternary return token.colorTextQuaternary
} }
const STAGE_INDEX = { download: 0, install: 1, restart: 2 } const getDownloadStageStatus = (phase, isError) => {
if (isError && ['preparing', 'downloading'].includes(phase)) return 'error'
if (['downloaded', 'installing'].includes(phase)) return 'complete'
if (['preparing', 'downloading'].includes(phase)) return 'active'
return 'pending'
}
const isInstallComplete = (phase, message) => {
if (phase !== 'installing') return false
const isCompletionMessage = (message) => {
const normalized = String(message || '').toLowerCase() const normalized = String(message || '').toLowerCase()
return ( return (
@ -78,33 +84,17 @@ const isCompletionMessage = (message) => {
) )
} }
// Maps a progress event to the stage that should currently be active. const getInstallStageStatus = (phase, isError, message) => {
// Returns null when the event carries no stage information (e.g. errors), if (isError && ['downloaded', 'installing'].includes(phase)) return 'error'
// so the previously reached stage is kept. if (isInstallComplete(phase, message)) return 'complete'
const getStageIndexFromProgress = (phase, percent, message) => { if (phase === 'installing') return 'active'
if (phase === 'restarting') return STAGE_INDEX.restart return 'pending'
if (phase === 'installing') {
return percent >= 100 || isCompletionMessage(message)
? STAGE_INDEX.restart
: STAGE_INDEX.install
}
if (phase === 'downloaded') return STAGE_INDEX.install
if (phase === 'preparing' || phase === 'downloading') {
return phase === 'downloading' && percent >= 100
? STAGE_INDEX.install
: STAGE_INDEX.download
}
return null
} }
const getStageStatus = (stageIndex, activeIndex, isError) => { const getRestartStageStatus = (phase, isError, message) => {
if (stageIndex < activeIndex) return 'complete' if (isError && isInstallComplete(phase, message)) return 'error'
if (stageIndex > activeIndex) return 'pending' if (isInstallComplete(phase, message)) return 'active'
return isError ? 'error' : 'active' return 'pending'
} }
const getProgressStatus = (stageStatus) => { const getProgressStatus = (stageStatus) => {
@ -119,13 +109,15 @@ const UpdateStage = ({ stage, status, percent, detail }) => {
const StageIcon = config.icon const StageIcon = config.icon
const resolvedPercent = const resolvedPercent =
typeof percent === 'number' ? Math.min(percent, 100) : undefined typeof percent === 'number' ? Math.min(percent, 100) : undefined
const color = getStageColor(status, token) const resolvedStatus =
const showProgress = status === 'active' && stage !== 'restart' status !== 'error' && resolvedPercent === 100 ? 'complete' : status
const color = getStageColor(resolvedStatus, token)
const showProgress = resolvedStatus === 'active' && stage !== 'restart'
const StatusIcon = const StatusIcon =
status === 'complete' resolvedStatus === 'complete'
? CheckCircleIcon ? CheckCircleIcon
: status === 'error' : resolvedStatus === 'error'
? XMarkCircleIcon ? XMarkCircleIcon
: StageIcon : StageIcon
@ -133,20 +125,16 @@ const UpdateStage = ({ stage, status, percent, detail }) => {
<Flex align='start' gap='middle' style={{ width: '100%' }}> <Flex align='start' gap='middle' style={{ width: '100%' }}>
<StatusIcon style={{ fontSize: 22, color, flexShrink: 0 }} /> <StatusIcon style={{ fontSize: 22, color, flexShrink: 0 }} />
<Flex align='start' gap='24px' style={{ flex: 1, minWidth: 0 }}> <Flex align='start' gap='24px' style={{ flex: 1, minWidth: 0 }}>
<Text style={{ flexShrink: 0 }}>{config.labels[status]}</Text> <Text style={{ flexShrink: 0 }}>{config.labels[resolvedStatus]}</Text>
{showProgress && ( {showProgress && (
<Flex vertical gap={2} style={{ flex: 1, minWidth: 0 }}> <Flex vertical gap={2} style={{ flex: 1 }}>
<HProgress <Progress
percent={resolvedPercent} percent={resolvedPercent}
status={getProgressStatus(status)} status={getProgressStatus(resolvedStatus)}
showInfo={typeof resolvedPercent === 'number'} showInfo={typeof resolvedPercent === 'number'}
style={{ flex: 1, margin: 0 }} style={{ flex: 1, margin: 0 }}
/> />
{detail && ( {detail && <Text type='secondary'>{detail}</Text>}
<Text type='secondary' ellipsis style={{ minWidth: 0 }}>
{detail}
</Text>
)}
</Flex> </Flex>
)} )}
</Flex> </Flex>
@ -175,46 +163,21 @@ const AppUpdateProgress = ({ progress, update, onClose }) => {
const [errorModalOpen, setErrorModalOpen] = useState(true) const [errorModalOpen, setErrorModalOpen] = useState(true)
// Track the furthest stage reached so out-of-order or skipped progress const downloadStatus = getDownloadStageStatus(phase, isError)
// events can never move the steps backwards. const installStatus = getInstallStageStatus(phase, isError, message)
const [activeStageIndex, setActiveStageIndex] = useState(STAGE_INDEX.download) const restartStatus = getRestartStageStatus(phase, isError, message)
useEffect(() => {
const stageIndex = getStageIndexFromProgress(phase, percent, message)
if (stageIndex !== null) {
setActiveStageIndex((previous) => Math.max(previous, stageIndex))
}
}, [phase, percent, message])
const downloadStatus = getStageStatus(
STAGE_INDEX.download,
activeStageIndex,
isError
)
const installStatus = getStageStatus(
STAGE_INDEX.install,
activeStageIndex,
isError
)
const restartStatus = getStageStatus(
STAGE_INDEX.restart,
activeStageIndex,
isError
)
const downloadPercent = const downloadPercent =
downloadStatus === 'active' && phase === 'downloading' ? percent : 0 downloadStatus === 'active' ? (phase === 'preparing' ? 0 : percent) : null
const installPercent = const installPercent = installStatus === 'active' ? percent : null
installStatus === 'active' && phase === 'installing' ? percent : null
const downloadDetail = const downloadDetail =
downloadStatus === 'active' && downloaded && total downloadStatus === 'active' && downloaded && total
? `${downloaded} of ${total}` ? `${downloaded} of ${total}`
: null : null
const installDetail = const installDetail = installStatus === 'active' ? message : null
installStatus === 'active' && phase === 'installing' ? message : null
return ( return (
<Flex vertical gap='middle'> <Flex vertical gap='middle'>

View File

@ -88,16 +88,6 @@ const NewAppUpdate = ({ update, onCancel, onUpdate }) => {
<Text style={{ margin: 0 }} type='secondary'> <Text style={{ margin: 0 }} type='secondary'>
Branch: <Text>{update?.branch || 'Unknown'}</Text> Branch: <Text>{update?.branch || 'Unknown'}</Text>
</Text> </Text>
<Text style={{ margin: 0 }} type='secondary'>
Engine:{' '}
<Text>
{update?.engine === 'chromium'
? 'Chromium'
: update?.engine === 'native'
? 'Native'
: 'Unknown'}
</Text>
</Text>
</Flex> </Flex>
<Dropdown menu={actionsMenu}> <Dropdown menu={actionsMenu}>
<Button size='small' type='text'> <Button size='small' type='text'>

View File

@ -5,10 +5,6 @@ import { useThemeContext } from '../context/ThemeContext'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import {
normalizeAppUpdateEngine,
useAppUpdateContext
} from '../context/AppUpdateContext'
import { useMessageContext } from '../context/MessageContext' import { useMessageContext } from '../context/MessageContext'
import useCollapseState from '../hooks/useCollapseState' import useCollapseState from '../hooks/useCollapseState'
import InfoCollapse from '../common/InfoCollapse' import InfoCollapse from '../common/InfoCollapse'
@ -18,22 +14,13 @@ import EditButtons from '../common/EditButtons'
const { Text } = Typography const { Text } = Typography
const { Option } = Select const { Option } = Select
const DEFAULT_UPDATE_BRANCH = 'main' const DEFAULT_UPDATE_BRANCH = 'main'
const DEFAULT_UPDATE_ENGINE = 'native'
const engineLabel = (engine) => {
const normalized = normalizeAppUpdateEngine(engine)
if (normalized === 'chromium') return 'Chromium'
if (normalized === 'native') return 'Native'
return 'Not configured'
}
const Settings = () => { const Settings = () => {
const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } = const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
useThemeContext() useThemeContext()
const { fetchAppUpdateBranches } = useContext(ApiServerContext) const { fetchAppUpdateBranches } = useContext(ApiServerContext)
const { isElectron, getAppSettings, setAppSettings, getAppEngine } = const { isElectron, getAppSettings, setAppSettings } =
useContext(ElectronContext) useContext(ElectronContext)
const { recheckForUpdates } = useAppUpdateContext()
const { userProfile, setUserProfile } = useContext(AuthContext) const { userProfile, setUserProfile } = useContext(AuthContext)
const { showSuccess, showError } = useMessageContext() const { showSuccess, showError } = useMessageContext()
const [collapseState, updateCollapseState] = useCollapseState('Settings', { const [collapseState, updateCollapseState] = useCollapseState('Settings', {
@ -47,31 +34,20 @@ const Settings = () => {
const [draftSettings, setDraftSettings] = useState({}) const [draftSettings, setDraftSettings] = useState({})
const [branches, setBranches] = useState([]) const [branches, setBranches] = useState([])
const [branchLoading, setBranchLoading] = useState(false) const [branchLoading, setBranchLoading] = useState(false)
const [runningEngine, setRunningEngine] = useState(DEFAULT_UPDATE_ENGINE)
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
setSettingsLoading(true) setSettingsLoading(true)
const [storedSettings, detectedEngine] = await Promise.all([ const storedSettings = isElectron
isElectron ? getAppSettings() : Promise.resolve(userProfile?.settings || {}), ? await getAppSettings()
isElectron ? getAppEngine() : Promise.resolve(DEFAULT_UPDATE_ENGINE) : userProfile?.settings || {}
]) setAppSettingsState(storedSettings || {})
const nextEngine = setDraftSettings(storedSettings || {})
normalizeAppUpdateEngine(detectedEngine) || DEFAULT_UPDATE_ENGINE
setRunningEngine(nextEngine)
const nextSettings = { ...(storedSettings || {}) }
if (isElectron && !normalizeAppUpdateEngine(nextSettings.appUpdateEngine)) {
nextSettings.appUpdateEngine = nextEngine
}
setAppSettingsState(nextSettings)
setDraftSettings(nextSettings)
setSettingsLoading(false) setSettingsLoading(false)
} }
loadSettings() loadSettings()
}, [getAppEngine, getAppSettings, isElectron, userProfile?.settings]) }, [getAppSettings, isElectron, userProfile?.settings])
useEffect(() => { useEffect(() => {
if (settingsLoading || isEditing) return if (settingsLoading || isEditing) return
@ -142,17 +118,12 @@ const Settings = () => {
(branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) || (branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
branches[0] || branches[0] ||
'Not configured' 'Not configured'
const currentEngine =
normalizeAppUpdateEngine(appSettings.appUpdateEngine) ||
normalizeAppUpdateEngine(runningEngine) ||
DEFAULT_UPDATE_ENGINE
const startEditing = () => { const startEditing = () => {
setDraftSettings({ setDraftSettings({
...appSettings, ...appSettings,
appUpdateBranch: appUpdateBranch:
currentBranch === 'Not configured' ? undefined : currentBranch, currentBranch === 'Not configured' ? undefined : currentBranch,
appUpdateEngine: currentEngine,
theme: currentThemeValue, theme: currentThemeValue,
density: currentDensityValue density: currentDensityValue
}) })
@ -168,18 +139,12 @@ const Settings = () => {
setSaving(true) setSaving(true)
try { try {
const nextEngine =
normalizeAppUpdateEngine(draftSettings.appUpdateEngine) ||
currentEngine
const nextSettings = { const nextSettings = {
...appSettings, ...appSettings,
theme: draftSettings.theme, theme: draftSettings.theme,
density: draftSettings.density, density: draftSettings.density,
...(isElectron ...(isElectron
? { ? { appUpdateBranch: draftSettings.appUpdateBranch }
appUpdateBranch: draftSettings.appUpdateBranch,
appUpdateEngine: nextEngine
}
: {}) : {})
} }
const saved = isElectron const saved = isElectron
@ -208,16 +173,6 @@ const Settings = () => {
setDraftSettings(nextSettings) setDraftSettings(nextSettings)
setIsEditing(false) setIsEditing(false)
showSuccess('Settings saved.') showSuccess('Settings saved.')
const appUpdateSettingsChanged =
isElectron &&
(nextSettings.appUpdateBranch !== appSettings.appUpdateBranch ||
nextSettings.appUpdateEngine !==
normalizeAppUpdateEngine(appSettings.appUpdateEngine))
if (appUpdateSettingsChanged) {
void recheckForUpdates()
}
} finally { } finally {
setSaving(false) setSaving(false)
} }
@ -241,9 +196,7 @@ const Settings = () => {
startEditing={startEditing} startEditing={startEditing}
formValid={ formValid={
Boolean(draftSettings.theme && draftSettings.density) && Boolean(draftSettings.theme && draftSettings.density) &&
(!isElectron || (!isElectron || Boolean(draftSettings.appUpdateBranch))
(Boolean(draftSettings.appUpdateBranch) &&
Boolean(normalizeAppUpdateEngine(draftSettings.appUpdateEngine))))
} }
disabled={settingsLoading || (!isElectron && !userProfile)} disabled={settingsLoading || (!isElectron && !userProfile)}
loading={saving} loading={saving}
@ -344,29 +297,6 @@ const Settings = () => {
<Text>{currentBranch}</Text> <Text>{currentBranch}</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Engine'>
{isEditing ? (
<Select
value={
normalizeAppUpdateEngine(draftSettings.appUpdateEngine) ||
currentEngine
}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
appUpdateEngine: value
}))
}
style={{ width: '100%' }}
placeholder='Select an engine'
>
<Option value='native'>Native</Option>
<Option value='chromium'>Chromium</Option>
</Select>
) : (
<Text>{engineLabel(currentEngine)}</Text>
)}
</Descriptions.Item>
</Descriptions> </Descriptions>
</InfoCollapse> </InfoCollapse>
)} )}

View File

@ -38,7 +38,6 @@ import SettingsIcon from '../../Icons/SettingsIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon' import DeveloperIcon from '../../Icons/DeveloperIcon'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import DashboardWindowButtons from './DashboardWindowButtons' import DashboardWindowButtons from './DashboardWindowButtons'
import WindowAppMenu from './WindowAppMenu'
import WebAppSwitcher from './WebAppSwitcher' import WebAppSwitcher from './WebAppSwitcher'
import { import {
getSidebarDefaultPath, getSidebarDefaultPath,
@ -53,8 +52,7 @@ const DashboardNavigation = () => {
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const { showSpotlight } = useContext(SpotlightContext) const { showSpotlight } = useContext(SpotlightContext)
const { connecting, connected } = useContext(ApiServerContext) const { connecting, connected } = useContext(ApiServerContext)
const { authenticated } = useContext(AuthContext) const { toggleNotificationCenter, unreadCount, notificationCenterVisible } =
const { toggleNotificationCenter, unreadCount } =
useContext(NotificationContext) useContext(NotificationContext)
const [apiServerState, setApiServerState] = useState('disconnected') const [apiServerState, setApiServerState] = useState('disconnected')
const navigate = useNavigate() const navigate = useNavigate()
@ -66,44 +64,34 @@ const DashboardNavigation = () => {
icon: <ProductionIcon /> icon: <ProductionIcon />
}) })
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const { const { platform, isElectron, isFullScreen, setSidebarViewMenu } =
platform, useContext(ElectronContext)
isElectron,
setSidebarViewMenu,
isFullScreen,
isMaximized
} = useContext(ElectronContext)
const { availableUpdate, checkForUpdates } = useAppUpdateContext() const { availableUpdate, checkForUpdates } = useAppUpdateContext()
const mainMenuItems = useMemo( const mainMenuItems = useMemo(
() => [ () => [
{ {
key: 'production', key: 'production',
label: 'Production', label: 'Production',
className: 'electrobun-webkit-app-region-no-drag',
icon: <ProductionIcon /> icon: <ProductionIcon />
}, },
{ {
key: 'inventory', key: 'inventory',
label: 'Inventory', label: 'Inventory',
className: 'electrobun-webkit-app-region-no-drag',
icon: <InventoryIcon /> icon: <InventoryIcon />
}, },
{ {
key: 'sales', key: 'sales',
label: 'Sales', label: 'Sales',
className: 'electrobun-webkit-app-region-no-drag',
icon: <SalesIcon /> icon: <SalesIcon />
}, },
{ {
key: 'finance', key: 'finance',
label: 'Finance', label: 'Finance',
className: 'electrobun-webkit-app-region-no-drag',
icon: <FinanceIcon /> icon: <FinanceIcon />
}, },
{ {
key: 'management', key: 'management',
label: 'Management', label: 'Management',
className: 'electrobun-webkit-app-region-no-drag',
icon: <SettingsIcon /> icon: <SettingsIcon />
} }
], ],
@ -147,26 +135,28 @@ const DashboardNavigation = () => {
setSidebarViewMenu(sections) setSidebarViewMenu(sections)
}, [isElectron, setSidebarViewMenu]) }, [isElectron, setSidebarViewMenu])
const showAppLogo =
(isElectron && platform == 'darwin' && isFullScreen == true) ||
(isElectron && platform != 'darwin')
const isMacOSApp = isElectron && platform == 'darwin' const isMacOSApp = isElectron && platform == 'darwin'
const isOtherApp = isElectron && platform != 'darwin' const isOtherApp = isElectron && platform != 'darwin'
const showDesktopLogo = !isElectron && !isMobile const showDesktopLogo = !isElectron && !isMobile
const showMobileLogo = !isElectron && isMobile const showMobileLogo = !isElectron && isMobile
const showControls = !isElectron || authenticated
const navigationContents = ( const navigationContents = (
<Flex style={{ width: '100%' }} align='center'> <Flex style={{ width: '100%' }} align='center'>
{isMacOSApp ? <DashboardWindowButtons /> : null} {isMacOSApp ? <DashboardWindowButtons /> : null}
{isOtherApp ? ( {showAppLogo == true && (
<> <FarmControlLogoSmall
<WindowAppMenu />{' '} style={{
<Divider fontSize: '46px',
type='vertical' height: '16px',
style={{ height: '14px', margin: '3px 3px 0 1.5px' }} marginLeft: '13px',
/> marginRight: '0px'
</> }}
) : null} />
)}
{showDesktopLogo == true ? ( {showDesktopLogo == true ? (
<FarmControlLogo <FarmControlLogo
style={{ style={{
@ -209,156 +199,134 @@ const DashboardNavigation = () => {
</Text> </Text>
</Flex> </Flex>
)} )}
<div style={{ flexGrow: 1 }}> <Menu
{showControls && ( mode='horizontal'
<Menu className={isElectron ? 'electron-navigation' : null}
mode='horizontal' items={mainMenuItems}
className={isElectron ? 'electron-navigation' : null} style={{
items={mainMenuItems} flexWrap: 'wrap',
style={{ flexGrow: isMobile ? 0 : 1,
flexWrap: 'wrap', border: 0,
flexGrow: isMobile ? 0 : 1, width: isMobile ? '64px' : 'unset'
border: 0, }}
width: isMobile ? '64px' : 'unset' onClick={handleMainMenuClick}
}} selectedKeys={[selectedKey]}
onClick={handleMainMenuClick} overflowedIndicator={
selectedKeys={[selectedKey]} <Button
overflowedIndicator={ type='text'
<Button icon={<MenuIcon />}
type='text' style={{ marginBottom: '4px' }}
icon={<MenuIcon />}
style={{ marginBottom: '4px' }}
/>
}
/> />
)} }
{!showControls && ( />
<Text
type='secondary'
style={{
fontSize: '14px',
marginLeft: '8px',
userSelect: 'none',
'--webkit-user-select': 'none'
}}
>
Farm Control
</Text>
)}
</div>
{isMobile && <div style={{ flexGrow: 1 }} />} {isMobile && <div style={{ flexGrow: 1 }} />}
<Flex
<div className='electrobun-webkit-app-region-no-drag'> gap={'small'}
<Flex align='center'
gap={'small'} style={{ marginTop: '-2px', marginRight: '6px' }}
align='center' >
style={{ marginTop: '-2px', marginRight: '6px' }} <Space style={{ paddingTop: '2px', marginRight: '8px' }}>
> <WebAppSwitcher />
{showControls && ( <KeyboardShortcut
<Space style={{ paddingTop: '2px', marginRight: '8px' }}> shortcut='alt+q'
<WebAppSwitcher /> hint='ALT Q'
<KeyboardShortcut onTrigger={() => showSpotlight()}
shortcut='alt+q' >
hint='ALT Q' <Button
onTrigger={() => showSpotlight()} icon={<SearchIcon />}
> type='text'
<Button style={{ marginTop: '4px' }}
icon={<SearchIcon />} onClick={() => showSpotlight()}
type='text' />
style={{ marginTop: '4px' }} </KeyboardShortcut>
onClick={() => showSpotlight()} <Badge
/> count={unreadCount}
</KeyboardShortcut> size='small'
<Badge offset={[-5, 8]}
count={unreadCount} style={{ padding: 0, fontWeight: 600 }}
size='small' >
offset={[-5, 8]} <KeyboardShortcut
style={{ padding: 0, fontWeight: 600 }} shortcut='alt+n'
> hint='ALT N'
<KeyboardShortcut onTrigger={() => toggleNotificationCenter()}
shortcut='alt+n' >
hint='ALT N' <Button
onTrigger={() => toggleNotificationCenter()} icon={<BellIcon />}
> type='text'
<Button style={{ marginTop: '2px' }}
icon={<BellIcon />} onClick={() => toggleNotificationCenter()}
type='text' />
style={{ marginTop: '2px' }} </KeyboardShortcut>
onClick={() => toggleNotificationCenter()} </Badge>
/> </Space>
</KeyboardShortcut> {import.meta.env.MODE === 'development' && (
</Badge> <Space>
</Space> {apiServerState === 'connected' ? (
)} <Tooltip title='Connected to api server' arrow={false}>
{import.meta.env.MODE === 'development' && (
<Space>
{apiServerState === 'connected' ? (
<Tooltip title='Connected to api server' arrow={false}>
<Tag
color='success'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
) : null}
{apiServerState === 'connecting' ? (
<Tooltip title='Connecting to api erver...' arrow={false}>
<Tag
color='warning'
style={{ marginRight: 0 }}
icon={<LoadingOutlined />}
/>
</Tooltip>
) : null}
{apiServerState === 'disconnected' ? (
<Tooltip title='Disconnected from api server' arrow={false}>
<Tag
color='error'
style={{ marginRight: 0 }}
icon={<CloudIcon />}
/>
</Tooltip>
) : null}
<Tooltip title='Developer' arrow={false}>
<Tag <Tag
color='yellow' color='success'
style={{ marginRight: 0 }} style={{ marginRight: 0 }}
icon={<DeveloperIcon />} icon={<CloudIcon />}
onClick={() => {
navigate('/dashboard/developer/sessionstorage')
}}
/> />
</Tooltip> </Tooltip>
</Space> ) : null}
)} {apiServerState === 'connecting' ? (
{showControls && userProfile ? ( <Tooltip title='Connecting to api erver...' arrow={false}>
<Space> <Tag
<Popover color='warning'
content={userPopoverContent} style={{ marginRight: 0 }}
placement='bottomRight' icon={<LoadingOutlined />}
trigger='hover' />
open={userPopoverOpen} </Tooltip>
onOpenChange={setUserPopoverOpen} ) : null}
arrow={false} {apiServerState === 'disconnected' ? (
> <Tooltip title='Disconnected from api server' arrow={false}>
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}> <Tag
{!isMobile && (userProfile?.name || userProfile.username)} color='error'
</Tag> style={{ marginRight: 0 }}
</Popover> icon={<CloudIcon />}
</Space> />
) : null} </Tooltip>
{showControls && isElectron && availableUpdate ? ( ) : null}
<Tag <Tooltip title='Developer' arrow={false}>
icon={<CloudIcon />} <Tag
style={{ cursor: 'pointer', margin: '2px 0 0 0' }} color='yellow'
color='cyan' style={{ marginRight: 0 }}
onClick={() => checkForUpdates()} icon={<DeveloperIcon />}
onClick={() => {
navigate('/dashboard/developer/sessionstorage')
}}
/>
</Tooltip>
</Space>
)}
{userProfile ? (
<Space>
<Popover
content={userPopoverContent}
placement='bottomRight'
trigger='hover'
open={userPopoverOpen}
onOpenChange={setUserPopoverOpen}
arrow={false}
> >
Update Available <Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
</Tag> {!isMobile && (userProfile?.name || userProfile.username)}
) : null} </Tag>
</Flex> </Popover>
</div> </Space>
) : null}
{isElectron && availableUpdate ? (
<Tag
icon={<CloudIcon />}
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
color='cyan'
onClick={() => checkForUpdates()}
>
Update Available
</Tag>
) : null}
</Flex>
{isOtherApp ? <DashboardWindowButtons /> : null} {isOtherApp ? <DashboardWindowButtons /> : null}
</Flex> </Flex>
) )
@ -367,7 +335,7 @@ const DashboardNavigation = () => {
<> <>
{isElectron ? ( {isElectron ? (
<Flex <Flex
className={`ant-menu-horizontal electron-navigation-wrapper ant-menu-light ${isFullScreen || (isMaximized && isOtherApp) ? 'electrobun-webkit-app-region-no-drag' : 'electrobun-webkit-app-region-drag'}`} className={`ant-menu-horizontal ant-menu-light${!notificationCenterVisible ? ' electron-navigation-wrapper' : ''}`}
style={{ lineHeight: '40px', padding: '0 2px 0 2px' }} style={{ lineHeight: '40px', padding: '0 2px 0 2px' }}
> >
{navigationContents} {navigationContents}

View File

@ -1,11 +1,10 @@
import { useContext } from 'react' import { useContext } from 'react'
import { Flex, Button, Divider } from 'antd' import { Flex, Button } from 'antd'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import MinusIcon from '../../Icons/MinusIcon' import MinusIcon from '../../Icons/MinusIcon'
import ContractIcon from '../../Icons/ContractIcon' import ContractIcon from '../../Icons/ContractIcon'
import ExpandIcon from '../../Icons/ExpandIcon' import ExpandIcon from '../../Icons/ExpandIcon'
import MacOSTrafficLights from './MacOSTrafficLights'
const DashboardWindowButtons = () => { const DashboardWindowButtons = () => {
const { isMaximized, handleWindowControl, platform, isFullScreen } = const { isMaximized, handleWindowControl, platform, isFullScreen } =
@ -18,21 +17,14 @@ const DashboardWindowButtons = () => {
onClick={() => handleWindowControl('close')} onClick={() => handleWindowControl('close')}
/> />
) )
const minimizeButton = ( const maximizeButton = (
<Button <Button
icon={<MinusIcon />} icon={<MinusIcon />}
type={'text'} type={'text'}
onClick={() => handleWindowControl('minimize')} onClick={() => handleWindowControl('minimize')}
/> />
) )
const fullscreenButton = ( const minimizeButton = (
<Button
icon={isFullScreen ? <ContractIcon /> : <ExpandIcon />}
type={'text'}
onClick={() => handleWindowControl('fullscreen')}
/>
)
const maximizeButton = (
<Button <Button
icon={isMaximized ? <ContractIcon /> : <ExpandIcon />} icon={isMaximized ? <ContractIcon /> : <ExpandIcon />}
type={'text'} type={'text'}
@ -40,41 +32,20 @@ const DashboardWindowButtons = () => {
/> />
) )
const customWindowControls = (
<div
className='electrobun-webkit-app-region-no-drag'
style={{ width: '95px', marginRight: '2.5px' }}
>
<Flex style={{ position: 'relative', zIndex: 9999 }}>
{platform == 'darwin' ? (
<>
{closeButton}
{minimizeButton}
{fullscreenButton}
</>
) : (
<>
{minimizeButton}
{maximizeButton}
{closeButton}
</>
)}
</Flex>
</div>
)
return ( return (
<Flex align='center'> <Flex align='center'>
{platform == 'darwin' ? ( {platform == 'darwin' ? (
<> isFullScreen == false ? (
<MacOSTrafficLights /> <div style={{ width: '80px' }} />
<Divider ) : null
type='vertical'
style={{ height: '14px', margin: '3px 6px 0 0' }}
/>
</>
) : ( ) : (
customWindowControls <div style={{ width: '95px', marginRight: '2.5px' }}>
<Flex style={{ position: 'relative', zIndex: 9999 }}>
{maximizeButton}
{minimizeButton}
{closeButton}
</Flex>
</div>
)} )}
</Flex> </Flex>
) )

View File

@ -1,287 +0,0 @@
import { forwardRef, useMemo } from 'react'
import PropTypes from 'prop-types'
import { theme } from 'antd'
import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled'
import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'
const validProgress = (progress) => {
if (!progress || progress < 0) return 0
if (progress > 100) return 100
return progress
}
const getSuccessPercent = ({ success, successPercent }) => {
if (success && 'progress' in success) return success.progress
if (success && 'percent' in success) return success.percent
return successPercent
}
const sortGradient = (gradients) => {
const entries = Object.keys(gradients)
.map((key) => ({
key: Number.parseFloat(key.replace(/%/g, '')),
value: gradients[key]
}))
.filter(({ key }) => !Number.isNaN(key))
.sort((a, b) => a.key - b.key)
return entries.map(({ key, value }) => `${value} ${key}%`).join(', ')
}
const resolveStrokeBackground = (strokeColor, direction = 'to right') => {
if (!strokeColor) return undefined
if (typeof strokeColor === 'string') {
return strokeColor
}
if (Array.isArray(strokeColor)) {
return strokeColor[0]
}
const { from, to, direction: gradientDirection, ...rest } = strokeColor
if (Object.keys(rest).length > 0) {
return `linear-gradient(${gradientDirection || direction}, ${sortGradient(rest)})`
}
return `linear-gradient(${gradientDirection || direction}, ${from}, ${to})`
}
const getBarHeight = ({ size, strokeWidth }) => {
if (typeof strokeWidth === 'number') return strokeWidth
if (size === 'small') return 6
if (typeof size === 'number') return size
if (Array.isArray(size)) return size[1] ?? 8
if (typeof size === 'object' && size?.height != null) return size.height
return 8
}
const getOuterWidth = ({ size, strokeWidth }) => {
if (typeof size === 'number') return size
if (Array.isArray(size)) return size[0] ?? -1
if (typeof size === 'object' && size?.width != null) return size.width
if (strokeWidth) return -1
return -1
}
const HProgress = forwardRef(function HProgress(
{
className,
rootClassName,
style,
percent = 0,
success,
successPercent,
status,
showInfo = true,
strokeColor,
trailColor,
strokeLinecap = 'round',
strokeWidth,
size = 'default',
format,
percentPosition = {}
},
ref
) {
const { token } = theme.useToken()
const { align: infoAlign = 'end', type: infoPosition = 'outer' } =
percentPosition
const resolvedPercent = validProgress(percent)
const resolvedSuccessPercent = getSuccessPercent({ success, successPercent })
const successValue =
resolvedSuccessPercent == null
? undefined
: validProgress(resolvedSuccessPercent)
const percentNumber = Number.parseInt(
String(successValue ?? resolvedPercent),
10
)
const progressStatus = useMemo(() => {
if (status === 'exception' || status === 'success' || status === 'active') {
return status
}
if (percentNumber >= 100) return 'success'
return status || 'normal'
}, [status, percentNumber])
const barHeight = getBarHeight({ size, strokeWidth })
const outerWidth = getOuterWidth({ size, strokeWidth })
const borderRadius =
strokeLinecap === 'square' || strokeLinecap === 'butt' ? 0 : barHeight
const trailBackground = trailColor || token.colorFillSecondary
const defaultFillColor =
progressStatus === 'exception'
? token.colorError
: progressStatus === 'success'
? token.colorSuccess
: token.colorPrimary
const fillBackground =
resolveStrokeBackground(strokeColor) || defaultFillColor
const successBackground =
success?.strokeColor || token.colorSuccess
const textFormatter = format || ((value) => `${value}%`)
const progressTextValue = textFormatter(
resolvedPercent,
successValue ?? undefined
)
const progressInfo = !showInfo ? null : (
<span
className={[
'h-progress-text',
infoPosition === 'outer' && infoAlign === 'start'
? 'h-progress-text-start'
: null
]
.filter(Boolean)
.join(' ')}
title={typeof progressTextValue === 'string' ? progressTextValue : undefined}
style={{ color: token.colorText }}
>
{progressStatus === 'exception' ? (
<CloseCircleFilled style={{ color: token.colorError }} />
) : progressStatus === 'success' ? (
<CheckCircleFilled style={{ color: token.colorSuccess }} />
) : (
progressTextValue
)}
</span>
)
const innerInfo =
infoPosition === 'inner' && showInfo ? (
<span className='h-progress-text-inner'>{progressTextValue}</span>
) : null
const lineInner = (
<div
className='h-progress-inner'
style={{
height: barHeight,
backgroundColor: trailBackground,
borderRadius
}}
>
<div
className='h-progress-bg'
style={{
width: `${resolvedPercent}%`,
height: barHeight,
borderRadius,
background: fillBackground,
overflow: 'hidden'
}}
>
{innerInfo}
</div>
{progressStatus === 'active' && (
<div
className='h-progress-bg-mask'
style={{ width: `${resolvedPercent}%`, borderRadius }}
>
<div className='h-progress-bg-wave' />
</div>
)}
{successValue != null && (
<div
className='h-progress-success-bg'
style={{
width: `${successValue}%`,
height: barHeight,
borderRadius,
background: successBackground
}}
/>
)}
</div>
)
const isOuterStart = infoPosition === 'outer' && infoAlign === 'start'
const isOuterEnd = infoPosition === 'outer' && infoAlign === 'end'
const isLayoutBottom =
infoPosition === 'outer' && infoAlign === 'center'
const content = isLayoutBottom ? (
<div className='h-progress-layout-bottom'>
{lineInner}
{progressInfo}
</div>
) : (
<div
className='h-progress-outer'
style={{ width: outerWidth < 0 ? '100%' : outerWidth }}
>
{isOuterStart && progressInfo}
{lineInner}
{isOuterEnd && progressInfo}
</div>
)
return (
<div
ref={ref}
className={['h-progress', className, rootClassName]
.filter(Boolean)
.join(' ')}
style={style}
role='progressbar'
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={resolvedPercent}
>
{content}
</div>
)
})
HProgress.propTypes = {
className: PropTypes.string,
rootClassName: PropTypes.string,
style: PropTypes.object,
percent: PropTypes.number,
success: PropTypes.shape({
percent: PropTypes.number,
progress: PropTypes.number,
strokeColor: PropTypes.string
}),
successPercent: PropTypes.number,
status: PropTypes.oneOf(['normal', 'exception', 'active', 'success']),
showInfo: PropTypes.bool,
strokeColor: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
PropTypes.object
]),
trailColor: PropTypes.string,
strokeLinecap: PropTypes.oneOf(['butt', 'square', 'round']),
strokeWidth: PropTypes.number,
size: PropTypes.oneOfType([
PropTypes.oneOf(['default', 'small']),
PropTypes.number,
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.shape({
width: PropTypes.number,
height: PropTypes.number
})
]),
format: PropTypes.func,
percentPosition: PropTypes.shape({
align: PropTypes.oneOf(['start', 'center', 'end']),
type: PropTypes.oneOf(['inner', 'outer'])
})
}
export default HProgress

View File

@ -1,212 +0,0 @@
import { useContext, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { Flex } from 'antd'
import { ElectronContext } from '../context/ElectronContext'
import CloseColored from '../../../../assets/trafficlights/closecolored.svg?react'
import CloseHoverColored from '../../../../assets/trafficlights/closehovercolored.svg?react'
import CloseDownColored from '../../../../assets/trafficlights/closedowncolored.svg?react'
import MinimizeColored from '../../../../assets/trafficlights/minimizecolored.svg?react'
import MinimizeHoverColored from '../../../../assets/trafficlights/minimizehovercolored.svg?react'
import MinimizeDownColored from '../../../../assets/trafficlights/minimizedowncolored.svg?react'
import FullscreenColored from '../../../../assets/trafficlights/fullscreencolored.svg?react'
import FullscreenHoverColored from '../../../../assets/trafficlights/fullscreenhovercolored.svg?react'
import FullscreenDownColored from '../../../../assets/trafficlights/fullscreendowncolored.svg?react'
import ExitFullscreenHoverColored from '../../../../assets/trafficlights/exitfullscreenhovercolored.svg?react'
import ExitFullscreenDownColored from '../../../../assets/trafficlights/exitfullscreendowncolored.svg?react'
import NoFocus from '../../../../assets/trafficlights/nofocus.svg?react'
const TRAFFIC_LIGHT_SIZE = 12
const TRAFFIC_LIGHT_BUTTON_STYLE = {
width: TRAFFIC_LIGHT_SIZE,
height: TRAFFIC_LIGHT_SIZE,
padding: 0,
border: 'none',
background: 'transparent',
cursor: 'default',
lineHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
const TRAFFIC_LIGHT_ICON_STYLE = {
width: TRAFFIC_LIGHT_SIZE,
height: TRAFFIC_LIGHT_SIZE,
display: 'block'
}
function getTrafficLightIcon({ isEnabled, isFocused, isActive, isHovered, icons }) {
if (!isEnabled || !isFocused) {
return icons.noFocus
}
if (isActive) {
return icons.down
}
if (isHovered) {
return icons.hover
}
return icons.colored
}
const TrafficLightButton = ({
action,
icons,
isGroupHovered,
activeAction,
isEnabled,
isWindowFocused,
onActive,
onAction
}) => {
const Icon = getTrafficLightIcon({
isEnabled,
isFocused: isWindowFocused,
isActive: activeAction === action,
isHovered: isGroupHovered,
icons
})
const handleMouseDown = (event) => {
if (!isEnabled) return
event.preventDefault()
event.stopPropagation()
onActive(action)
onAction(action)
}
return (
<button
type='button'
aria-label={action}
aria-disabled={!isEnabled}
disabled={!isEnabled}
className='electrobun-webkit-app-region-no-drag'
style={TRAFFIC_LIGHT_BUTTON_STYLE}
onMouseDown={handleMouseDown}
onMouseUp={isEnabled ? () => onActive(null) : undefined}
>
<Icon style={TRAFFIC_LIGHT_ICON_STYLE} />
</button>
)
}
TrafficLightButton.propTypes = {
action: PropTypes.string.isRequired,
icons: PropTypes.shape({
colored: PropTypes.elementType.isRequired,
hover: PropTypes.elementType.isRequired,
down: PropTypes.elementType.isRequired,
noFocus: PropTypes.elementType.isRequired
}).isRequired,
isGroupHovered: PropTypes.bool.isRequired,
activeAction: PropTypes.string,
isEnabled: PropTypes.bool.isRequired,
isWindowFocused: PropTypes.bool.isRequired,
onActive: PropTypes.func.isRequired,
onAction: PropTypes.func.isRequired
}
const MacOSTrafficLights = () => {
const { handleWindowControl, isFullScreen } = useContext(ElectronContext)
const [isGroupHovered, setIsGroupHovered] = useState(false)
const [activeAction, setActiveAction] = useState(null)
const [isWindowFocused, setIsWindowFocused] = useState(
() => document.hasFocus?.() ?? true
)
useEffect(() => {
const handleFocus = () => setIsWindowFocused(true)
const handleBlur = () => setIsWindowFocused(false)
window.addEventListener('focus', handleFocus)
window.addEventListener('blur', handleBlur)
return () => {
window.removeEventListener('focus', handleFocus)
window.removeEventListener('blur', handleBlur)
}
}, [])
const fullscreenIcons = isFullScreen
? {
colored: FullscreenColored,
hover: ExitFullscreenHoverColored,
down: ExitFullscreenDownColored,
noFocus: NoFocus
}
: {
colored: FullscreenColored,
hover: FullscreenHoverColored,
down: FullscreenDownColored,
noFocus: NoFocus
}
const handleAction = (action) => {
handleWindowControl(action)
}
return (
<Flex
className='electrobun-webkit-app-region-no-drag'
style={{
width: '56.6px',
marginLeft: '12.5px',
marginRight: '12.5px',
position: 'relative',
zIndex: 9999
}}
gap={8}
onMouseEnter={() => setIsGroupHovered(true)}
onMouseLeave={() => {
setIsGroupHovered(false)
setActiveAction(null)
}}
>
<TrafficLightButton
action='close'
icons={{
colored: CloseColored,
hover: CloseHoverColored,
down: CloseDownColored,
noFocus: NoFocus
}}
isGroupHovered={isGroupHovered}
activeAction={activeAction}
isEnabled
isWindowFocused={isWindowFocused}
onActive={setActiveAction}
onAction={handleAction}
/>
<TrafficLightButton
action='minimize'
icons={{
colored: MinimizeColored,
hover: MinimizeHoverColored,
down: MinimizeDownColored,
noFocus: NoFocus
}}
isGroupHovered={isGroupHovered}
activeAction={activeAction}
isEnabled={!isFullScreen}
isWindowFocused={isWindowFocused}
onActive={setActiveAction}
onAction={handleAction}
/>
<TrafficLightButton
action='fullscreen'
icons={fullscreenIcons}
isGroupHovered={isGroupHovered}
activeAction={activeAction}
isEnabled
isWindowFocused={isWindowFocused}
onActive={setActiveAction}
onAction={handleAction}
/>
</Flex>
)
}
export default MacOSTrafficLights

View File

@ -47,10 +47,7 @@ import { useActions } from '../context/ActionsContext'
import ActionsIcon from '../../Icons/ActionsIcon' import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon' import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox' import ScrollBox from './ScrollBox'
import { import { useTableStatePersistence } from '../context/TableStateContext'
getActiveFilterValues,
useTableStatePersistence
} from '../context/TableStateContext'
const logger = loglevel.getLogger('DasboardTable') const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
@ -157,7 +154,8 @@ const ObjectTable = forwardRef(
getPersistedSorter, getPersistedSorter,
persistFilter, persistFilter,
persistTableState, persistTableState,
registerPageFilter registerPageFilter,
getActiveFilterValues
} = useTableStatePersistence({ } = useTableStatePersistence({
scope: type, scope: type,
pagePath: location.pathname, pagePath: location.pathname,

View File

@ -6,10 +6,7 @@ import ArrowRightIcon from '../../Icons/ArrowRightIcon.jsx'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { import { useTableStatePersistence } from '../context/TableStateContext'
getActiveFilterValues,
useTableStatePersistence
} from '../context/TableStateContext'
import KeyboardShortcut from './KeyboardShortcut.jsx' import KeyboardShortcut from './KeyboardShortcut.jsx'
const ObjectTableNavigationButtons = ({ const ObjectTableNavigationButtons = ({
@ -24,7 +21,8 @@ const ObjectTableNavigationButtons = ({
const [neighbors, setNeighbors] = useState({ next: null, previous: null }) const [neighbors, setNeighbors] = useState({ next: null, previous: null })
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { getPersistedFilter, getPersistedSorter } = useTableStatePersistence({ const { getPersistedFilter, getPersistedSorter, getActiveFilterValues } =
useTableStatePersistence({
scope: objectType, scope: objectType,
useFilterInSession: true, useFilterInSession: true,
useSortInSession: true useSortInSession: true
@ -89,25 +87,8 @@ const ObjectTableNavigationButtons = ({
[searchParams, setSearchParams, objectType] [searchParams, setSearchParams, objectType]
) )
const handlePrevious = useCallback(() => { const handlePrevious = () => navigateToNeighbor(neighbors.previous)
navigateToNeighbor(neighbors.previous) const handleNext = () => navigateToNeighbor(neighbors.next)
}, [navigateToNeighbor, neighbors.previous])
const handleNext = useCallback(() => {
navigateToNeighbor(neighbors.next)
}, [navigateToNeighbor, neighbors.next])
const handlePreviousShortcut = useCallback(() => {
if (!disabled && !loading && neighbors.previous?._id) {
handlePrevious()
}
}, [disabled, loading, neighbors.previous?._id, handlePrevious])
const handleNextShortcut = useCallback(() => {
if (!disabled && !loading && neighbors.next?._id) {
handleNext()
}
}, [disabled, loading, neighbors.next?._id, handleNext])
return ( return (
<Flex gap='small' align='center'> <Flex gap='small' align='center'>
@ -118,7 +99,11 @@ const ObjectTableNavigationButtons = ({
<KeyboardShortcut <KeyboardShortcut
shortcut='alt+arrowleft' shortcut='alt+arrowleft'
hint='ALT ←' hint='ALT ←'
onTrigger={handlePreviousShortcut} onTrigger={useCallback(() => {
if (!disabled && !loading && neighbors.previous?._id) {
handlePrevious()
}
}, [disabled, loading, neighbors.previous?._id])}
> >
<Button <Button
icon={<ArrowLeftIcon />} icon={<ArrowLeftIcon />}
@ -129,7 +114,11 @@ const ObjectTableNavigationButtons = ({
<KeyboardShortcut <KeyboardShortcut
shortcut='alt+arrowright' shortcut='alt+arrowright'
hint='ALT →' hint='ALT →'
onTrigger={handleNextShortcut} onTrigger={useCallback(() => {
if (!disabled && !loading && neighbors.next?._id) {
handleNext()
}
}, [disabled, loading, neighbors.next?._id])}
> >
<Button <Button
icon={<ArrowRightIcon />} icon={<ArrowRightIcon />}

View File

@ -1,8 +1,7 @@
// PrinterSelect.js // PrinterSelect.js
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Flex, Space, Modal, Button, Typography } from 'antd' import { Progress, Flex, Space, Modal, Button, Typography } from 'antd'
import StateTag from './StateTag' import StateTag from './StateTag'
import HProgress from './HProgress'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import { useState } from 'react' import { useState } from 'react'
@ -44,7 +43,7 @@ const StateDisplay = ({
currentState?.progress && currentState?.progress &&
progressValue !== 100 && progressValue !== 100 &&
currentState?.progress > 0 ? ( currentState?.progress > 0 ? (
<HProgress <Progress
percent={progressValue} percent={progressValue}
status={ status={
activeProgressTypes.includes(currentState.type) ? 'active' : '' activeProgressTypes.includes(currentState.type) ? 'active' : ''

View File

@ -1,259 +0,0 @@
import { useContext, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Button, Dropdown, Flex } from 'antd'
import { useNavigate } from 'react-router-dom'
import { ElectronContext } from '../context/ElectronContext'
import { useAppUpdateContext } from '../context/AppUpdateContext'
import { getSidebarMenuSections } from '../../../database/Sidebars'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
const runEditCommand = (command) => {
try {
document.execCommand(command)
} catch (error) {
console.warn(
`[WindowAppMenu] Failed to run edit command: ${command}`,
error
)
}
}
const mapSidebarItemsToMenuItems = (items = [], navigate) =>
items
.map((item, index) => {
if (item?.type === 'divider') {
return { type: 'divider', key: `divider-${index}` }
}
if (item?.children?.length) {
return {
key: item.key || `group-${item.label}-${index}`,
label: item.label,
children: mapSidebarItemsToMenuItems(item.children, navigate)
}
}
if (item?.path) {
return {
key: item.path,
label: item.label,
onClick: () => navigate(item.path)
}
}
return {
key: item.key || `disabled-${item.label}-${index}`,
label: item.label,
disabled: true
}
})
.filter(Boolean)
const MenuButton = ({ label, items, logo }) => (
<Dropdown menu={{ items }} trigger={['click']} placement='bottomLeft'>
<Button
type='text'
size='small'
className='electrobun-webkit-app-region-no-drag'
style={{
height: '28px',
paddingInline: logo ? '6px' : '8px',
marginRight: logo ? '1px' : '0',
fontWeight: 500
}}
>
{label}
</Button>
</Dropdown>
)
MenuButton.propTypes = {
label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
items: PropTypes.array.isRequired,
logo: PropTypes.bool
}
const WindowAppMenu = () => {
const navigate = useNavigate()
const { handleWindowControl } = useContext(ElectronContext)
const { checkForUpdates } = useAppUpdateContext()
const includeDev = import.meta.env.DEV
const viewSections = useMemo(
() => getSidebarMenuSections({ includeDev }),
[includeDev]
)
const appMenuItems = useMemo(
() => [
{
key: 'about',
label: 'About Farm Control',
onClick: () => navigate('/dashboard/management/about')
},
{ type: 'divider' },
{
key: 'check-for-updates',
label: 'Check For Updates...',
onClick: () => checkForUpdates()
},
{ type: 'divider' },
{
key: 'quit',
label: 'Quit Farm Control',
onClick: () => handleWindowControl('quit')
}
],
[checkForUpdates, handleWindowControl, navigate]
)
const fileMenuItems = useMemo(
() => [
{
key: 'close',
label: 'Close Window',
onClick: () => handleWindowControl('close')
}
],
[handleWindowControl]
)
const editMenuItems = useMemo(
() => [
{
key: 'undo',
label: 'Undo',
onClick: () => runEditCommand('undo')
},
{
key: 'redo',
label: 'Redo',
onClick: () => runEditCommand('redo')
},
{ type: 'divider' },
{
key: 'cut',
label: 'Cut',
onClick: () => runEditCommand('cut')
},
{
key: 'copy',
label: 'Copy',
onClick: () => runEditCommand('copy')
},
{
key: 'paste',
label: 'Paste',
onClick: () => runEditCommand('paste')
},
{
key: 'pasteAndMatchStyle',
label: 'Paste and Match Style',
onClick: () => runEditCommand('paste')
},
{
key: 'delete',
label: 'Delete',
onClick: () => runEditCommand('delete')
},
{ type: 'divider' },
{
key: 'selectAll',
label: 'Select All',
onClick: () => runEditCommand('selectAll')
}
],
[]
)
const viewMenuItems = useMemo(() => {
const sectionItems =
viewSections.length > 0
? viewSections.map((section) => ({
key: `view-section-${section.key}`,
label: section.label,
children: mapSidebarItemsToMenuItems(section.items || [], navigate)
}))
: [
{
key: 'no-sidebar-items',
label: 'No sidebar items available',
disabled: true
}
]
if (!includeDev) {
return sectionItems
}
return [
...sectionItems,
{ type: 'divider' },
{
key: 'toggle-devtools',
label: 'Toggle Developer Tools',
onClick: () => handleWindowControl('toggle-devtools')
}
]
}, [handleWindowControl, includeDev, navigate, viewSections])
const windowMenuItems = useMemo(
() => [
{
key: 'minimize',
label: 'Minimize',
onClick: () => handleWindowControl('minimize')
},
{
key: 'zoom',
label: 'Zoom',
onClick: () => handleWindowControl('maximize')
}
],
[handleWindowControl]
)
const menus = useMemo(
() => [
{ key: 'app', label: 'Farm Control', items: appMenuItems },
{ key: 'file', label: 'File', items: fileMenuItems },
{ key: 'edit', label: 'Edit', items: editMenuItems },
{ key: 'view', label: 'View', items: viewMenuItems },
{ key: 'window', label: 'Window', items: windowMenuItems }
],
[appMenuItems, editMenuItems, fileMenuItems, viewMenuItems, windowMenuItems]
)
return (
<Flex
align='center'
className='electrobun-webkit-app-region-no-drag'
style={{ marginRight: '8px', flexShrink: 0, marginLeft: '4px' }}
>
{menus.map((menu) => {
if (menu.key === 'app') {
return (
<MenuButton
key={menu.key}
logo={true}
label={
<FarmControlLogoSmall
style={{
fontSize: '46px',
height: '16px'
}}
/>
}
items={menu.items}
/>
)
}
return (
<MenuButton key={menu.key} label={menu.label} items={menu.items} />
)
})}
</Flex>
)
}
export default WindowAppMenu

View File

@ -16,28 +16,14 @@ import { ElectronContext } from './ElectronContext'
import NewAppUpdate from '../Management/AppUpdates/NewAppUpdate' import NewAppUpdate from '../Management/AppUpdates/NewAppUpdate'
import AppUpdateProgress from '../Management/AppUpdates/AppUpdateProgress' import AppUpdateProgress from '../Management/AppUpdates/AppUpdateProgress'
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon' import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
import ExclamationOctogonIcon from '../../Icons/ExclamationOctagonIcon'
const { Text } = Typography const { Text } = Typography
const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000 const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
const DEFAULT_MODEL_WIDTH = 710
const DEFAULT_UPDATE_BRANCH = 'main' const DEFAULT_UPDATE_BRANCH = 'main'
const DEFAULT_UPDATE_ENGINE = 'native'
const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER
const APP_UPDATE_DISMISSED_KEY = 'appUpdateDismissed' const APP_UPDATE_DISMISSED_KEY = 'appUpdateDismissed'
// eslint-disable-next-line react-refresh/only-export-components
export const normalizeAppUpdateEngine = (engine) => {
const value = String(engine || '')
.trim()
.toLowerCase()
if (value === 'chromium' || value === 'cef') return 'chromium'
if (value === 'native') return 'native'
return null
}
const getDismissedUpdate = () => { const getDismissedUpdate = () => {
try { try {
const stored = sessionStorage.getItem(APP_UPDATE_DISMISSED_KEY) const stored = sessionStorage.getItem(APP_UPDATE_DISMISSED_KEY)
@ -56,30 +42,10 @@ const isUpdateDismissed = (update) => {
return ( return (
dismissed.version === update.version && dismissed.version === update.version &&
dismissed.buildNumber === update.buildNumber && dismissed.buildNumber === update.buildNumber &&
dismissed.branch === update.branch && dismissed.branch === update.branch
normalizeAppUpdateEngine(dismissed.engine) ===
normalizeAppUpdateEngine(update.engine)
) )
} }
const formatFullAppVersion = (version, buildNumber) => {
const normalizedVersion = String(version || '')
.trim()
.replace(/^v/i, '')
if (!normalizedVersion) return null
const build = String(buildNumber || '').trim()
const buildSuffix =
!build || build === 'dev'
? 'dev'
: build.startsWith('b')
? build
: `b${build}`
return `v${normalizedVersion}-${buildSuffix}`
}
const saveDismissedUpdate = (update) => { const saveDismissedUpdate = (update) => {
if (!update) return if (!update) return
@ -88,8 +54,7 @@ const saveDismissedUpdate = (update) => {
JSON.stringify({ JSON.stringify({
version: update.version, version: update.version,
buildNumber: update.buildNumber, buildNumber: update.buildNumber,
branch: update.branch, branch: update.branch
engine: normalizeAppUpdateEngine(update.engine) || DEFAULT_UPDATE_ENGINE
}) })
) )
} }
@ -144,41 +109,23 @@ export const AppUpdateProvider = ({ children }) => {
const { fetchAppUpdateBranches, fetchAppUpdateCurrent } = const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
useContext(ApiServerContext) useContext(ApiServerContext)
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
const { const { isElectron, getAppSettings, startAppUpdate, onAppUpdateProgress } =
isElectron, useContext(ElectronContext)
getAppSettings,
setAppSettings,
getAppEngine,
startAppUpdate,
checkAppUpdateResult,
checkDuplicateInstallations,
removeDuplicateInstallations,
onDuplicateInstallationsRemoved,
onAppUpdateProgress,
onCheckForUpdatesRequest
} = useContext(ElectronContext)
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const [noUpdateOpen, setNoUpdateOpen] = useState(false) const [noUpdateOpen, setNoUpdateOpen] = useState(false)
const [availableUpdate, setAvailableUpdate] = useState(null) const [availableUpdate, setAvailableUpdate] = useState(null)
const [updatePromptOpen, setUpdatePromptOpen] = useState(false) const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
const [installingUpdate, setInstallingUpdate] = useState(null) const [installingUpdate, setInstallingUpdate] = useState(null)
const [updateProgress, setUpdateProgress] = useState(null) const [updateProgress, setUpdateProgress] = useState(null)
const [completedUpdate, setCompletedUpdate] = useState(null)
const [duplicateInstall, setDuplicateInstall] = useState(null)
const [duplicatePromptOpen, setDuplicatePromptOpen] = useState(false)
const [removingDuplicate, setRemovingDuplicate] = useState(false)
const [duplicateRemovalResult, setDuplicateRemovalResult] = useState(null)
const runningCheckRef = useRef(null) const runningCheckRef = useRef(null)
const updateCheckDependenciesRef = useRef({}) const updateCheckDependenciesRef = useRef({})
const [modelWidth, setModelWidth] = useState(DEFAULT_MODEL_WIDTH) const [modelWidth, setModelWidth] = useState(650)
updateCheckDependenciesRef.current = { updateCheckDependenciesRef.current = {
fetchAppUpdateBranches, fetchAppUpdateBranches,
fetchAppUpdateCurrent, fetchAppUpdateCurrent,
getAppSettings, getAppSettings,
setAppSettings,
getAppEngine,
isElectron, isElectron,
token token
} }
@ -188,8 +135,6 @@ export const AppUpdateProvider = ({ children }) => {
fetchAppUpdateBranches, fetchAppUpdateBranches,
fetchAppUpdateCurrent, fetchAppUpdateCurrent,
getAppSettings, getAppSettings,
setAppSettings,
getAppEngine,
isElectron, isElectron,
token token
} = updateCheckDependenciesRef.current } = updateCheckDependenciesRef.current
@ -198,10 +143,9 @@ export const AppUpdateProvider = ({ children }) => {
if (runningCheckRef.current) return runningCheckRef.current if (runningCheckRef.current) return runningCheckRef.current
const checkPromise = (async () => { const checkPromise = (async () => {
const [branches, appSettings, runningEngine] = await Promise.all([ const [branches, appSettings] = await Promise.all([
fetchAppUpdateBranches(), fetchAppUpdateBranches(),
getAppSettings(), getAppSettings()
getAppEngine()
]) ])
const configuredBranch = appSettings?.appUpdateBranch const configuredBranch = appSettings?.appUpdateBranch
const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH) const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH)
@ -213,52 +157,11 @@ export const AppUpdateProvider = ({ children }) => {
if (!selectedBranch) return null if (!selectedBranch) return null
const selectedEngine =
normalizeAppUpdateEngine(appSettings?.appUpdateEngine) ||
normalizeAppUpdateEngine(runningEngine) ||
DEFAULT_UPDATE_ENGINE
const currentRunningEngine =
normalizeAppUpdateEngine(runningEngine) || DEFAULT_UPDATE_ENGINE
const settingsUpdates = {}
if (!appSettings?.appUpdateRunningBranch) {
settingsUpdates.appUpdateRunningBranch = selectedBranch
}
if (!normalizeAppUpdateEngine(appSettings?.appUpdateEngine)) {
settingsUpdates.appUpdateEngine = selectedEngine
}
if (Object.keys(settingsUpdates).length > 0) {
await setAppSettings({
...appSettings,
...settingsUpdates
})
}
const runningBranch =
appSettings?.appUpdateRunningBranch ||
settingsUpdates.appUpdateRunningBranch ||
selectedBranch
const update = await fetchAppUpdateCurrent(selectedBranch) const update = await fetchAppUpdateCurrent(selectedBranch)
if (!update) return null
const newerVersionAvailable = isAppUpdateAvailable( return isAppUpdateAvailable(update, appVersion, CURRENT_BUILD_NUMBER)
update, ? update
appVersion, : null
CURRENT_BUILD_NUMBER
)
const engineMismatch = selectedEngine !== currentRunningEngine
const branchMismatch = selectedBranch !== runningBranch
if (!newerVersionAvailable && !engineMismatch && !branchMismatch) {
return null
}
return {
...update,
branch: update.branch || selectedBranch,
engine: selectedEngine
}
})() })()
runningCheckRef.current = checkPromise runningCheckRef.current = checkPromise
@ -278,7 +181,6 @@ export const AppUpdateProvider = ({ children }) => {
setNoUpdateOpen(false) setNoUpdateOpen(false)
setAvailableUpdate(update) setAvailableUpdate(update)
if (forcePrompt || !isUpdateDismissed(update)) { if (forcePrompt || !isUpdateDismissed(update)) {
setModelWidth(DEFAULT_MODEL_WIDTH)
setUpdatePromptOpen(true) setUpdatePromptOpen(true)
} }
} }
@ -303,11 +205,6 @@ export const AppUpdateProvider = ({ children }) => {
} }
}, [isElectron, showUpdateIfAvailable]) }, [isElectron, showUpdateIfAvailable])
const recheckForUpdates = useCallback(async () => {
if (!isElectron) return null
return showUpdateIfAvailable({ forcePrompt: true })
}, [isElectron, showUpdateIfAvailable])
useEffect(() => { useEffect(() => {
if (!isElectron) return undefined if (!isElectron) return undefined
@ -322,54 +219,6 @@ export const AppUpdateProvider = ({ children }) => {
} }
}, [isElectron, showUpdateIfAvailable]) }, [isElectron, showUpdateIfAvailable])
useEffect(() => {
if (!isElectron) return undefined
let cancelled = false
const runStartupChecks = async () => {
let result = null
try {
result = await checkAppUpdateResult?.()
if (!cancelled && result?.updated) {
setCompletedUpdate(result)
}
} catch (error) {
console.warn('[AppUpdateContext] Startup update check failed:', error)
}
try {
const installations =
result?.duplicates || (await checkDuplicateInstallations?.())
if (!cancelled && installations?.duplicatePath) {
setDuplicateInstall(installations)
setDuplicatePromptOpen(true)
}
} catch (error) {
console.warn(
'[AppUpdateContext] Duplicate installation check failed:',
error
)
}
}
void runStartupChecks()
return () => {
cancelled = true
}
}, [isElectron, checkAppUpdateResult, checkDuplicateInstallations])
useEffect(() => {
if (!isElectron || !onDuplicateInstallationsRemoved) return undefined
return onDuplicateInstallationsRemoved((result) => {
setRemovingDuplicate(false)
setDuplicateRemovalResult(result || { ok: false })
})
}, [isElectron, onDuplicateInstallationsRemoved])
useEffect(() => { useEffect(() => {
if (!isElectron || !onAppUpdateProgress) return undefined if (!isElectron || !onAppUpdateProgress) return undefined
@ -378,14 +227,6 @@ export const AppUpdateProvider = ({ children }) => {
}) })
}, [isElectron, onAppUpdateProgress]) }, [isElectron, onAppUpdateProgress])
useEffect(() => {
if (!isElectron || !onCheckForUpdatesRequest) return undefined
return onCheckForUpdatesRequest(() => {
void checkForUpdates()
})
}, [isElectron, onCheckForUpdatesRequest, checkForUpdates])
const dismissUpdatePrompt = () => { const dismissUpdatePrompt = () => {
if (availableUpdate) { if (availableUpdate) {
saveDismissedUpdate(availableUpdate) saveDismissedUpdate(availableUpdate)
@ -403,7 +244,7 @@ export const AppUpdateProvider = ({ children }) => {
setNoUpdateOpen(false) setNoUpdateOpen(false)
setUpdatePromptOpen(false) setUpdatePromptOpen(false)
setInstallingUpdate(update) setInstallingUpdate(update)
setModelWidth(580) setModelWidth(550)
setUpdateProgress({ setUpdateProgress({
phase: 'preparing', phase: 'preparing',
percent: 0, percent: 0,
@ -411,20 +252,10 @@ export const AppUpdateProvider = ({ children }) => {
}) })
try { try {
const appSettings = await getAppSettings() const result = await startAppUpdate(update)
const engine =
normalizeAppUpdateEngine(update?.engine) ||
normalizeAppUpdateEngine(appSettings?.appUpdateEngine) ||
DEFAULT_UPDATE_ENGINE
const result = await startAppUpdate({
...update,
engine
})
if (!result) { if (!result) {
throw new Error( throw new Error('App updates are only available in the desktop app.')
'Failed to start the app update. Please restart the app and try again.'
)
} }
} catch (error) { } catch (error) {
setUpdateProgress({ setUpdateProgress({
@ -435,33 +266,12 @@ export const AppUpdateProvider = ({ children }) => {
} }
} }
const handleRemoveDuplicate = async () => {
setRemovingDuplicate(true)
setDuplicateRemovalResult(null)
const started = await removeDuplicateInstallations?.()
if (!started) {
setRemovingDuplicate(false)
setDuplicateRemovalResult({
ok: false,
error: 'Failed to start removing the duplicate installation.'
})
}
}
const closeDuplicatePrompt = () => {
setDuplicatePromptOpen(false)
setDuplicateRemovalResult(null)
}
const updateModalOpen = Boolean(updatePromptOpen || installingUpdate) const updateModalOpen = Boolean(updatePromptOpen || installingUpdate)
const updateModalBusy = const updateModalBusy =
Boolean(installingUpdate) && updateProgress?.phase !== 'error' Boolean(installingUpdate) && updateProgress?.phase !== 'error'
return ( return (
<AppUpdateContext.Provider <AppUpdateContext.Provider value={{ availableUpdate, checkForUpdates }}>
value={{ availableUpdate, checkForUpdates, recheckForUpdates }}
>
{children} {children}
<Modal <Modal
open={checking} open={checking}
@ -479,18 +289,12 @@ export const AppUpdateProvider = ({ children }) => {
</Space> </Space>
</Modal> </Modal>
<Modal <Modal
title={ title='Software Update'
<Flex align='center' gap='middle'>
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
Software Update
</Flex>
}
open={noUpdateOpen} open={noUpdateOpen}
okText='OK' okText='OK'
style={{ maxWidth: 430 }} style={{ maxWidth: 430 }}
centered centered
maskClosable maskClosable
closable={false}
onOk={() => setNoUpdateOpen(false)} onOk={() => setNoUpdateOpen(false)}
onCancel={() => setNoUpdateOpen(false)} onCancel={() => setNoUpdateOpen(false)}
footer={[ footer={[
@ -540,103 +344,6 @@ export const AppUpdateProvider = ({ children }) => {
/> />
)} )}
</Modal> </Modal>
<Modal
title={
<Flex align='center' gap='middle'>
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
Update Installed
</Flex>
}
open={Boolean(completedUpdate)}
style={{ maxWidth: 430 }}
centered
closable={false}
onCancel={() => setCompletedUpdate(null)}
footer={[
<Button
key='ok'
type='primary'
onClick={() => setCompletedUpdate(null)}
>
OK
</Button>
]}
>
<Text>
{completedUpdate?.message ||
`Farm Control was successfully updated to version ${
completedUpdate?.current?.version ||
formatFullAppVersion(appVersion, CURRENT_BUILD_NUMBER) ||
`v${appVersion}`
}.`}
</Text>
</Modal>
<Modal
title={
<Flex align='center' gap='middle'>
<ExclamationOctogonIcon />
Duplicate Installation Found
</Flex>
}
open={Boolean(duplicatePromptOpen && duplicateInstall)}
width={!removingDuplicate && !duplicateRemovalResult ? 560 : 430}
centered
closable={false}
maskClosable={false}
onCancel={removingDuplicate ? undefined : closeDuplicatePrompt}
footer={
removingDuplicate
? null
: duplicateRemovalResult
? [
<Button
key='close'
type='primary'
onClick={closeDuplicatePrompt}
>
OK
</Button>
]
: [
<Button key='no' onClick={closeDuplicatePrompt}>
No
</Button>,
<Button
key='yes'
type='primary'
onClick={handleRemoveDuplicate}
>
Yes
</Button>
]
}
>
{removingDuplicate ? (
<Space size='middle'>
<LoadingOutlined />
<Text>
Removing the duplicate installation... You may be asked for an
administrator password.
</Text>
</Space>
) : duplicateRemovalResult ? (
<Text>
{duplicateRemovalResult.ok
? 'The duplicate installation was removed.'
: duplicateRemovalResult.error ||
'Failed to remove the duplicate installation.'}
</Text>
) : (
<Space direction='vertical' size='small'>
<Text>
Farm Control is now installed in your user applications folder,
but an older copy is still installed at:{' '}
<Text code>{duplicateInstall?.duplicatePath}</Text>.{' '}
<Text>Do you want to remove the duplicate installation?</Text>
</Text>
</Space>
)}
</Modal>
</AppUpdateContext.Provider> </AppUpdateContext.Provider>
) )
} }

View File

@ -1,14 +1,33 @@
import { createContext, useCallback, useEffect, useRef, useState } from 'react' import { createContext, useCallback, useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import desktopBridge, {
isElectrobunBridgeReady,
isElectrobunDesktop
} from '../../../electrobun-bridge.js'
// Only available in Electron renderer
const electron = window.require ? window.require('electron') : null
const ipcRenderer = electron ? electron.ipcRenderer : null
// Utility to check if running in Electron
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export function isElectron() { export function isElectron() {
return isElectrobunBridgeReady() || isElectrobunDesktop() // Renderer process
if (
typeof window !== 'undefined' &&
window.process &&
window.process.type === 'renderer'
) {
return true
}
// User agent
if (
typeof navigator === 'object' &&
typeof navigator.userAgent === 'string' &&
navigator.userAgent.indexOf('Electron') >= 0
) {
return true
}
return false
} }
const ElectronContext = createContext() const ElectronContext = createContext()
@ -51,76 +70,70 @@ const ElectronProvider = ({ children }) => {
[navigate] [navigate]
) )
const applyWindowState = useCallback((state) => { // Function to open external URL via Electron
if (state && typeof state.isMaximized === 'boolean') {
setIsMaximized(state.isMaximized)
}
if (state && typeof state.isFullScreen === 'boolean') {
setIsFullScreen(state.isFullScreen)
}
}, [])
const openExternalUrl = (url) => { const openExternalUrl = (url) => {
if (!electronAvailable) return false if (electronAvailable && ipcRenderer) {
void desktopBridge.openExternalUrl(url).catch((error) => { ipcRenderer.invoke('open-external-url', url)
console.warn('[ElectronContext] Failed to open external url:', error) return true
}) }
return true return false
} }
// Function to open internal URL via Electron
const openInternalUrl = (url) => { const openInternalUrl = (url) => {
if (!electronAvailable) return false if (electronAvailable && ipcRenderer) {
void desktopBridge.openInternalUrl(url).catch((error) => { ipcRenderer.invoke('open-internal-url', url)
console.warn('[ElectronContext] Failed to open internal url:', error) return true
}) }
return true return false
} }
useEffect(() => { useEffect(() => {
if (!electronAvailable) return if (!ipcRenderer) return
document.body.classList.add('electron-body') // Get initial platform
return () => { ipcRenderer.invoke('os-info').then((info) => {
document.body.classList.remove('electron-body') if (info && info.platform) setPlatform(info.platform)
} })
}, [electronAvailable])
useEffect(() => { // Get initial window state
if (!electronAvailable) return ipcRenderer.invoke('window-state').then((state) => {
if (state && typeof state.isMaximized === 'boolean') {
desktopBridge.getOsInfo().then((info) => { setIsMaximized(state.isMaximized)
if (info?.platform) { }
setPlatform(info.platform) if (state && typeof state.isFullScreen === 'boolean') {
if (info.platform === 'darwin') { setIsFullScreen(state.isFullScreen)
document.documentElement.classList.add('macos-vibrancy')
}
} }
}) })
desktopBridge.getWindowState().then(applyWindowState) // Listen for window state changes
const windowStateHandler = (event, state) => {
const unsubWindowState = desktopBridge.onMessage( if (state && typeof state.isMaximized === 'boolean') {
'windowState', setIsMaximized(state.isMaximized)
applyWindowState
)
const unsubNavigate = desktopBridge.onMessage('navigate', (url) => {
if (url.toLowerCase() == '/favicon.ico') {
return
} }
console.log('[ElectronContext] Navigating to:', url) if (state && typeof state.isFullScreen === 'boolean') {
setIsFullScreen(state.isFullScreen)
}
}
ipcRenderer.on('window-state', windowStateHandler)
// Listen for navigate
const navigateHandler = (event, url) => {
navigate(url) navigate(url)
}) }
const unsubNavigationGesture = desktopBridge.onMessage( ipcRenderer.on('navigate', navigateHandler)
'navigationGesture',
navigateHistory const navigationGestureHandler = (_event, direction) => {
) navigateHistory(direction)
}
ipcRenderer.on('navigation-gesture', navigationGestureHandler)
return () => { return () => {
unsubWindowState() ipcRenderer.removeListener('navigate', navigateHandler)
unsubNavigate() ipcRenderer.removeListener('navigation-gesture', navigationGestureHandler)
unsubNavigationGesture() ipcRenderer.removeListener('window-state', windowStateHandler)
} }
}, [applyWindowState, electronAvailable, navigate, navigateHistory]) }, [navigate, navigateHistory])
useEffect(() => { useEffect(() => {
if (!electronAvailable || platform !== 'darwin') return if (!electronAvailable || platform !== 'darwin') return
@ -129,8 +142,7 @@ const ElectronProvider = ({ children }) => {
let resetTimer let resetTimer
const handleWheel = (event) => { const handleWheel = (event) => {
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
return
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return
if (Math.abs(event.deltaX) < 2) return if (Math.abs(event.deltaX) < 2) return
if (isInHorizontalScrollContainer(event.target)) return if (isInHorizontalScrollContainer(event.target)) return
@ -153,97 +165,69 @@ const ElectronProvider = ({ children }) => {
} }
}, [electronAvailable, navigateHistory, platform]) }, [electronAvailable, navigateHistory, platform])
// Window control handler
const handleWindowControl = (action) => { const handleWindowControl = (action) => {
if (!electronAvailable) return if (electronAvailable && ipcRenderer) {
void desktopBridge.windowControl(action) ipcRenderer.send('window-control', action)
}
} }
const getAuthSession = async () => { const getAuthSession = async () => {
if (!electronAvailable) return null if (!electronAvailable || !ipcRenderer) return null
return await desktopBridge.getAuthSession() return await ipcRenderer.invoke('auth-session-get')
} }
const setAuthSession = async (session) => { const setAuthSession = async (session) => {
if (!electronAvailable) return false if (!electronAvailable || !ipcRenderer) return false
const result = await desktopBridge.setAuthSession(session) return await ipcRenderer.invoke('auth-session-set', session)
return result?.ok ?? false
} }
const clearAuthSession = async () => { const clearAuthSession = async () => {
if (!electronAvailable) return false if (!electronAvailable || !ipcRenderer) return false
const result = await desktopBridge.clearAuthSession() return await ipcRenderer.invoke('auth-session-clear')
return result?.ok ?? false
} }
const getAppSettings = useCallback(async () => { const getAppSettings = useCallback(async () => {
if (!electronAvailable) return {} if (!electronAvailable || !ipcRenderer) return {}
return await desktopBridge.getAppSettings() return await ipcRenderer.invoke('app-settings-get')
}, [electronAvailable]) }, [electronAvailable])
const setAppSettings = useCallback( const setAppSettings = useCallback(
async (settings) => { async (settings) => {
if (!electronAvailable) return false if (!electronAvailable || !ipcRenderer) return false
const result = await desktopBridge.setAppSettings(settings) return await ipcRenderer.invoke('app-settings-set', settings)
return result?.ok ?? false
}, },
[electronAvailable] [electronAvailable]
) )
const startAppUpdate = useCallback( const startAppUpdate = useCallback(
async (update) => { async (update) => {
if (!electronAvailable) return false if (!electronAvailable || !ipcRenderer) return false
const result = await desktopBridge.startAppUpdate(update) return await ipcRenderer.invoke('app-update-start', update)
return result?.ok ?? false
},
[electronAvailable]
)
const checkAppUpdateResult = useCallback(async () => {
if (!electronAvailable) return null
return await desktopBridge.checkAppUpdateResult()
}, [electronAvailable])
const checkDuplicateInstallations = useCallback(async () => {
if (!electronAvailable) return null
return await desktopBridge.checkDuplicateInstallations()
}, [electronAvailable])
const removeDuplicateInstallations = useCallback(async () => {
if (!electronAvailable) return false
const result = await desktopBridge.removeDuplicateInstallations()
return result?.ok ?? false
}, [electronAvailable])
const onDuplicateInstallationsRemoved = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('duplicateInstallationsRemoved', handler)
}, },
[electronAvailable] [electronAvailable]
) )
const onAppUpdateProgress = useCallback( const onAppUpdateProgress = useCallback(
(handler) => { (handler) => {
if (!electronAvailable || typeof handler !== 'function') { if (!electronAvailable || !ipcRenderer || typeof handler !== 'function') {
return () => {} return () => {}
} }
return desktopBridge.onMessage('appUpdateProgress', handler)
}, const progressHandler = (event, progress) => {
[electronAvailable] handler(progress)
) }
const onCheckForUpdatesRequest = useCallback( ipcRenderer.on('app-update-progress', progressHandler)
(handler) => {
if (!electronAvailable || typeof handler !== 'function') { return () => {
return () => {} ipcRenderer.removeListener('app-update-progress', progressHandler)
} }
return desktopBridge.onMessage('checkForUpdates', handler)
}, },
[electronAvailable] [electronAvailable]
) )
// Backwards-compatible helpers
const getToken = async () => { const getToken = async () => {
const session = await getAuthSession() const session = await getAuthSession()
return session?.token || null return session?.token || null
@ -255,10 +239,9 @@ const ElectronProvider = ({ children }) => {
} }
const resizeSpotlightWindow = async (height) => { const resizeSpotlightWindow = async (height) => {
if (!electronAvailable) return false if (!electronAvailable || !ipcRenderer) return false
try { try {
const result = await desktopBridge.resizeSpotlightWindow(height) return await ipcRenderer.invoke('spotlight-window-resize', height)
return result?.ok ?? false
} catch (error) { } catch (error) {
console.warn( console.warn(
'[ElectronContext] Failed to resize spotlight window:', '[ElectronContext] Failed to resize spotlight window:',
@ -270,22 +253,15 @@ const ElectronProvider = ({ children }) => {
const setSidebarViewMenu = useCallback( const setSidebarViewMenu = useCallback(
async (sections) => { async (sections) => {
if (!electronAvailable) return false if (!electronAvailable || !ipcRenderer) return false
const result = await desktopBridge.setSidebarViewMenu(sections) return await ipcRenderer.invoke('set-sidebar-view-menu', sections)
return result?.ok ?? false
}, },
[electronAvailable] [electronAvailable]
) )
const getElectronVersion = useCallback(async () => { const getElectronVersion = useCallback(async () => {
if (!electronAvailable) return null if (!electronAvailable || !ipcRenderer) return null
return await desktopBridge.getAppVersion() return await ipcRenderer.invoke('electron-version')
}, [electronAvailable])
const getAppEngine = useCallback(async () => {
if (!electronAvailable) return 'native'
const engine = await desktopBridge.getAppEngine()
return engine === 'chromium' ? 'chromium' : 'native'
}, [electronAvailable]) }, [electronAvailable])
return ( return (
@ -304,18 +280,12 @@ const ElectronProvider = ({ children }) => {
getAppSettings, getAppSettings,
setAppSettings, setAppSettings,
startAppUpdate, startAppUpdate,
checkAppUpdateResult,
checkDuplicateInstallations,
removeDuplicateInstallations,
onDuplicateInstallationsRemoved,
onAppUpdateProgress, onAppUpdateProgress,
onCheckForUpdatesRequest,
getToken, getToken,
setToken, setToken,
resizeSpotlightWindow, resizeSpotlightWindow,
setSidebarViewMenu, setSidebarViewMenu,
getElectronVersion, getElectronVersion
getAppEngine
}} }}
> >
{children} {children}

View File

@ -1,16 +1,11 @@
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { message } from 'antd' import { message } from 'antd'
import { isElectrobunDesktop } from '../../../electrobun-bridge'
const MessageContext = createContext() const MessageContext = createContext()
// antd's default message top is 8px; push messages down an extra 40px in the
// desktop app so they clear the custom title bar / drag region.
const MESSAGE_TOP = isElectrobunDesktop() ? 48 : undefined
export const MessageProvider = ({ children }) => { export const MessageProvider = ({ children }) => {
const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP }) const [msgApi, contextHolder] = message.useMessage()
const showMessage = (type, content, options = {}) => { const showMessage = (type, content, options = {}) => {
return msgApi.open({ return msgApi.open({

Some files were not shown because too many files have changed in this diff Show More