Compare commits
110 Commits
main
...
electrobun
| Author | SHA1 | Date | |
|---|---|---|---|
| 28295f5912 | |||
| 2bbecb0d6a | |||
| 67511e8b8c | |||
| 6bf8648e1f | |||
| de2c93d82f | |||
| a0ea239aa6 | |||
| d7a7e798cb | |||
| 2216a28ce3 | |||
| 29bf25cc20 | |||
| ddaeb3cfff | |||
| d2d64ef45d | |||
| 8d689617f5 | |||
| 21a88e00d0 | |||
| c1e0921c3e | |||
| 9051b94767 | |||
| 4ee72fdbac | |||
| e32eda100a | |||
| c5d8d9cf70 | |||
| e46e12f15d | |||
| 3534a797fc | |||
| a5cad3b746 | |||
| 998084160f | |||
| 6b9e4bff63 | |||
| 643dc46402 | |||
| 2e08c454a7 | |||
| e81bc235a8 | |||
| f52ca059a1 | |||
| 482cc5dc86 | |||
| be51fecba6 | |||
| 19ce749e80 | |||
| 184a6621d9 | |||
| d28bed7136 | |||
| eb1ee1e82b | |||
| 893804a17c | |||
| 3c4dc329d0 | |||
| cfd0849cba | |||
| ee62045416 | |||
| 6f2f7ed5c2 | |||
| 4fdf3a233d | |||
| 2383d14a80 | |||
| debf6802ce | |||
| 79ee30fd25 | |||
| 98ee99ae69 | |||
| d418e4b809 | |||
| caabc62509 | |||
| c056a2b021 | |||
| 4a1badb5aa | |||
| 3d710e2d2e | |||
| 9bd581ee03 | |||
| 7d48ab6e85 | |||
| 2e7d255b20 | |||
| 634113ec5c | |||
| f641ea390d | |||
| 7d1dc22327 | |||
| 9c86737691 | |||
| add206457f | |||
| cc3846397a | |||
| 27e5ef13bc | |||
| e1051499f9 | |||
| ee35e12959 | |||
| 4cf4f5e27f | |||
| c48b8d72da | |||
| 5c1ea30e5d | |||
| 17b5068644 | |||
| 2335a64c03 | |||
| 1fdbb01ceb | |||
| 5f061f92eb | |||
| 35a50fa97d | |||
| 282d0c6630 | |||
| a196d0f26d | |||
| 09d90632f9 | |||
| 1cc21fd9e3 | |||
| 5450473f74 | |||
| 37593d73ce | |||
| 93df523b7c | |||
| 3ed7945b7c | |||
| 76ec8389c2 | |||
| 9ef02304fe | |||
| 30f0ac1559 | |||
| 4caa7a4bd1 | |||
| 37113b1b41 | |||
| fb81cfedd2 | |||
| 813b43595b | |||
| 6dad9c820c | |||
| 3b28aa4308 | |||
| 40ccacc9a8 | |||
| 1c8fc51db2 | |||
| 611cd4aad5 | |||
| 1b77428a8a | |||
| d0345fd7f3 | |||
| 8e3ce20bb6 | |||
| c334781689 | |||
| 7150639908 | |||
| 655e1803ff | |||
| 884f66cc4c | |||
| a8339aa9f8 | |||
| 8d9455eaa9 | |||
| 805361516e | |||
| f6978f44b2 | |||
| f59000778e | |||
| b1ca3818e3 | |||
| 274852b895 | |||
| f90ef10b78 | |||
| 37fe489b07 | |||
| 613cb7712d | |||
| 47c97a1771 | |||
| 3209880cc5 | |||
| 888c0fe38a | |||
| f1826548ea | |||
| 0b98d8041e |
7
.gitignore
vendored
@ -29,4 +29,9 @@ yarn-error.log*
|
||||
|
||||
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
@ -1,7 +1,57 @@
|
||||
properties([
|
||||
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||
buildDiscarder(logRotator(numToKeepStr: '10'))
|
||||
])
|
||||
|
||||
def bunBinDir() {
|
||||
if (isUnix()) {
|
||||
return "${env.HOME}/.bun/bin"
|
||||
}
|
||||
return "${env.USERPROFILE}/.bun/bin"
|
||||
}
|
||||
|
||||
def withBun(Closure body) {
|
||||
def sep = isUnix() ? ':' : ';'
|
||||
withEnv(["PATH=${bunBinDir()}${sep}${env.PATH}"]) {
|
||||
body()
|
||||
}
|
||||
}
|
||||
|
||||
def checkBun() {
|
||||
if (isUnix()) {
|
||||
sh 'bun -v'
|
||||
} else {
|
||||
bat 'bun -v'
|
||||
}
|
||||
}
|
||||
|
||||
def writeBuildMetadata() {
|
||||
if (isUnix()) {
|
||||
sh '''
|
||||
bun --eval "await Bun.write('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n')"
|
||||
'''
|
||||
} else {
|
||||
bat '''
|
||||
bun --eval "await Bun.write('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n')"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
def runBuild(buildCommand) {
|
||||
def releaseBaseUrl =
|
||||
"https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
|
||||
withEnv([
|
||||
"VITE_BUILD_NUMBER=${env.BUILD_NUMBER ?: 'dev'}",
|
||||
'NODE_ENV=production',
|
||||
"ELECTROBUN_RELEASE_BASE_URL=${releaseBaseUrl}",
|
||||
]) {
|
||||
if (isUnix()) {
|
||||
sh buildCommand
|
||||
} else {
|
||||
bat buildCommand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def deploy() {
|
||||
node('ubuntu') {
|
||||
try {
|
||||
@ -30,7 +80,6 @@ def deploy() {
|
||||
|
||||
stage('Deploy (Ubuntu)') {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
// Deploy to Cloudflare Pages using wrangler
|
||||
withCredentials([string(credentialsId: 'cloudflare-api-token', variable: 'CLOUDFLARE_API_TOKEN')]) {
|
||||
sh 'pnpm wrangler pages deploy build --branch main'
|
||||
}
|
||||
@ -42,66 +91,87 @@ def deploy() {
|
||||
}
|
||||
}
|
||||
|
||||
def buildOnLabel(label, buildCommand) {
|
||||
def prepareMacBuildWorkspace() {
|
||||
if (isUnix()) {
|
||||
sh '''
|
||||
df -h .
|
||||
for vol in /Volumes/Farm\\ Control*; do
|
||||
if [ -d "$vol" ]; then
|
||||
hdiutil detach "$vol" -force || true
|
||||
fi
|
||||
done
|
||||
rm -rf build/stable-macos-*/.dmg-staging build/stable-macos-*/.finalize-dmg-staging 2>/dev/null || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
def buildOnLabel(label, buildCommand, bundleCef = false) {
|
||||
def cefLabel = bundleCef ? 'cef' : 'native'
|
||||
return {
|
||||
node(label) {
|
||||
stage("Checkout (${label})") {
|
||||
checkout scm
|
||||
}
|
||||
try {
|
||||
stage("Checkout (${label}/${cefLabel})") {
|
||||
checkout scm
|
||||
}
|
||||
|
||||
stage("Setup Node.js (${label})") {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
if (isUnix()) {
|
||||
sh 'node -v'
|
||||
sh 'pnpm -v'
|
||||
} else {
|
||||
bat 'node -v'
|
||||
bat 'pnpm -v'
|
||||
withBun {
|
||||
stage("Check Bun (${label}/${cefLabel})") {
|
||||
checkBun()
|
||||
}
|
||||
|
||||
if (label.startsWith('macos')) {
|
||||
stage("Prepare macOS workspace (${label}/${cefLabel})") {
|
||||
prepareMacBuildWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
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("Install Dependencies (${label})") {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
if (isUnix()) {
|
||||
sh 'pnpm install --frozen-lockfile --production=false'
|
||||
} else {
|
||||
bat 'pnpm install --frozen-lockfile --production=false'
|
||||
}
|
||||
stage("Archive Artifacts (${label}/${cefLabel})") {
|
||||
archiveArtifacts artifacts: 'app_dist/farmcontrol-*', fingerprint: true
|
||||
}
|
||||
}
|
||||
|
||||
stage("Build (${label})") {
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
if (isUnix()) {
|
||||
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production ${buildCommand}"
|
||||
} else {
|
||||
bat "set VITE_BUILD_NUMBER=${env.BUILD_NUMBER} && set NODE_ENV=production && ${buildCommand}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage("Archive Artifacts (${label})") {
|
||||
archiveArtifacts artifacts: 'app_dist/**/farmcontrol-*.dmg, app_dist/**/farmcontrol-*.exe, app_dist/**/farmcontrol-*.pkg, app_dist/**/farmcontrol-*.msi', fingerprint: true
|
||||
} finally {
|
||||
cleanWs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def buildMacOnLabel(label, targetArch, bundleCef = false) {
|
||||
return buildOnLabel(label, "ELECTROBUN_TARGET_ARCH=${targetArch} bun run build:app:mac", bundleCef)
|
||||
}
|
||||
|
||||
def setBuildNameFromPackageVersion() {
|
||||
node('ubuntu') {
|
||||
stage('Set Build Name') {
|
||||
checkout scm
|
||||
def version
|
||||
nodejs(nodeJSInstallationName: 'Node23') {
|
||||
version = sh(
|
||||
script: "node -p \"require('./package.json').version\"",
|
||||
withBun {
|
||||
checkBun()
|
||||
def version = sh(
|
||||
script: "bun --eval \"console.log(JSON.parse(await Bun.file('package.json').text()).version)\"",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
def buildName = "v${version}-b${env.BUILD_NUMBER}"
|
||||
currentBuild.displayName = buildName
|
||||
echo "Build name set to: ${buildName}"
|
||||
}
|
||||
def buildName = "v${version}-b${env.BUILD_NUMBER}"
|
||||
currentBuild.displayName = buildName
|
||||
echo "Build name set to: ${buildName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -110,8 +180,12 @@ try {
|
||||
setBuildNameFromPackageVersion()
|
||||
|
||||
parallel(
|
||||
'Windows Build': buildOnLabel('windows', 'pnpm build:electron'),
|
||||
'MacOS Build': buildOnLabel('macos', 'pnpm build:electron'),
|
||||
'Windows Build': buildOnLabel('windows', 'bun run build:app', false),
|
||||
'Windows CEF Build': buildOnLabel('windows', 'bun run build:app', true),
|
||||
'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() }
|
||||
)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 26 KiB |
BIN
assets/icon.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
assets/icon.iconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
assets/icon.iconset/icon_128x128@2x.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/icon.iconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 613 B |
BIN
assets/icon.iconset/icon_16x16@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icon.iconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/icon.iconset/icon_256x256@2x.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
assets/icon.iconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icon.iconset/icon_32x32@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/icon.iconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
assets/icon.iconset/icon_512x512@2x.png
Normal file
|
After Width: | Height: | Size: 567 KiB |
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/installer.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
assets/installer.iconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
assets/installer.iconset/icon_128x128@2x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/installer.iconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 499 B |
BIN
assets/installer.iconset/icon_16x16@2x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/installer.iconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/installer.iconset/icon_256x256@2x.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/installer.iconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/installer.iconset/icon_32x32@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
assets/installer.iconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/installer.iconset/icon_512x512@2x.png
Normal file
|
After Width: | Height: | Size: 371 KiB |
BIN
assets/logos/farmcontrolinstaller.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
assets/logos/farmcontroluninstaller.png
Normal file
|
After Width: | Height: | Size: 357 KiB |
@ -105,23 +105,130 @@
|
||||
}
|
||||
|
||||
.electron-navigation-wrapper {
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
--webkit-user-select: none;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.electron-navigation-wrapper li,
|
||||
.electron-navigation-wrapper button,
|
||||
.electron-navigation-wrapper .ant-tag {
|
||||
-webkit-app-region: no-drag;
|
||||
/* 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 {
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.electron-sidebar.ant-menu-inline .ant-menu-item {
|
||||
padding-left: 20px !important;
|
||||
}
|
||||
|
||||
.electron-navigation .ant-menu-overflow-item-rest {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.electron-navigation.ant-menu-horizontal .ant-menu-item {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.electron-sidebar .ant-menu-item,
|
||||
.electron-sidebar .ant-menu-submenu-title {
|
||||
height: 32.5px !important;
|
||||
|
||||
8
assets/trafficlights/closecolored.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 804 B |
12
assets/trafficlights/closedowncolored.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
12
assets/trafficlights/closehovercolored.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
16
assets/trafficlights/exitfullscreendowncolored.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
18
assets/trafficlights/exitfullscreenhovercolored.svg
Normal file
@ -0,0 +1,18 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
8
assets/trafficlights/fullscreencolored.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 804 B |
11
assets/trafficlights/fullscreendowncolored.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
11
assets/trafficlights/fullscreenhovercolored.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
8
assets/trafficlights/minimizecolored.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 805 B |
9
assets/trafficlights/minimizedowncolored.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
9
assets/trafficlights/minimizehovercolored.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
8
assets/trafficlights/nofocus.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 839 B |
BIN
assets/uninstaller.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
88
electrobun.config.ts
Normal file
@ -0,0 +1,88 @@
|
||||
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;
|
||||
515
native/macos/window-effects.mm
Normal file
@ -0,0 +1,515 @@
|
||||
#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;
|
||||
}
|
||||
27
package.json
@ -4,7 +4,7 @@
|
||||
"name": "Tom Butcher",
|
||||
"email": "tom@tombutcher.work"
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"homepage": "./",
|
||||
@ -71,10 +71,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"simplebar-react": "^3.3.2",
|
||||
"socket.io-client": "*",
|
||||
"standard": "^17.1.2",
|
||||
"styled-components": "^6.1.19",
|
||||
"svgo": "^4.0.0",
|
||||
"svgo-loader": "^4.0.0",
|
||||
"three": "^0.179.1",
|
||||
"tsparticles": "^3.9.1",
|
||||
"web-vitals": "^5.1.0"
|
||||
@ -83,13 +80,21 @@
|
||||
"description": "3D Printer ERP and Control Software.",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development vite",
|
||||
"dev:app": "concurrently \"cross-env NODE_ENV=development bun run dev:renderer\" \"cross-env NODE_ENV=development electrobun dev\"",
|
||||
"dev:renderer": "vite --port 5780 --no-open",
|
||||
"electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .",
|
||||
"start": "serve -s build",
|
||||
"build": "vite build",
|
||||
"build:app": "electrobun build --env=stable",
|
||||
"build:app:mac": "bun scripts/build-macos.mjs",
|
||||
"dev:electron": "concurrently \"cross-env NODE_ENV=development vite --port 5780 --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 NODE_ENV=development electron public/electron.js\"",
|
||||
"build:electron": "vite build && electron-builder",
|
||||
"build:cloudflare": "cross-env VITE_DEPLOY_TARGET=cloudflare vite build",
|
||||
"deploy": "npm run build:cloudflare && wrangler pages deploy --branch main"
|
||||
"deploy": "npm run build:cloudflare && wrangler pages deploy --branch main",
|
||||
"postinstall": "bun scripts/patch-electrobun-src.mjs && 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": {
|
||||
"extends": [
|
||||
@ -113,7 +118,10 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"electrobun": "1.18.1",
|
||||
"electron": "^38.7.1",
|
||||
"jimp": "^1.6.1",
|
||||
"png-to-ico": "^2.1.8",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-packager": "^17.1.2",
|
||||
"eslint": "^9.34.0",
|
||||
@ -126,6 +134,7 @@
|
||||
"globals": "^15.12.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-eslint": "^16.4.2",
|
||||
"rcedit": "^4.0.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"serve": "^14.2.4",
|
||||
"standard": "^17.1.2",
|
||||
@ -237,13 +246,13 @@
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"include": "scripts/installer.nsh",
|
||||
"perMachine": true
|
||||
"perMachine": false
|
||||
},
|
||||
"msiWrapped": {
|
||||
"upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}",
|
||||
"perMachine": true,
|
||||
"impersonate": false,
|
||||
"wrappedInstallerArgs": "/S"
|
||||
"perMachine": false,
|
||||
"impersonate": true,
|
||||
"wrappedInstallerArgs": "/S /UPDATE /RESTARTFC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
packaging/macos/dmg/volicon.icns
Normal file
140
packaging/windows/farmcontrol.nsi
Normal file
@ -0,0 +1,140 @@
|
||||
!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 progressPercent 0
|
||||
|
||||
; 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}
|
||||
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 progressPercent 0
|
||||
|
||||
SetOverwrite try
|
||||
!insertmacro copyApplicationFilesWithProgress
|
||||
|
||||
!insertmacro progressPercent 75
|
||||
!insertmacro customInstall
|
||||
|
||||
!insertmacro progressPhase "Finalizing installation"
|
||||
!insertmacro progressStatus "Writing uninstall information..."
|
||||
!insertmacro progressPercent 90
|
||||
|
||||
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
|
||||
|
||||
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
||||
|
||||
; 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
|
||||
391
packaging/windows/installer.nsh
Normal file
@ -0,0 +1,391 @@
|
||||
!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
|
||||
|
||||
!macro progressPercent percent
|
||||
!insertmacro progressLog "installer:%${percent}"
|
||||
!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 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 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 minutes for a graceful CEF shutdown.
|
||||
System::Call 'kernel32::WaitForSingleObject(p r0, i 120000) i .r1'
|
||||
System::Call 'kernel32::CloseHandle(p r0)'
|
||||
|
||||
${If} $1 == 258
|
||||
!insertmacro progressStatus "Farm Control is taking too long to close; forcing shutdown..."
|
||||
DetailPrint "Farm Control process $UpdateParentPid did not exit; terminating its process tree..."
|
||||
ExecWait 'taskkill /F /T /PID $UpdateParentPid' $R0
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
${Else}
|
||||
; Compatibility fallback for manually launched older installers.
|
||||
StrCpy $R7 0
|
||||
restart_wait_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 < 80
|
||||
Sleep 1500
|
||||
Goto restart_wait_loop
|
||||
${EndIf}
|
||||
|
||||
!insertmacro progressStatus "Farm Control is taking too long to close; forcing shutdown..."
|
||||
!insertmacro quitFarmControl
|
||||
${EndIf}
|
||||
|
||||
restart_wait_done:
|
||||
; 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
|
||||
94
packaging/windows/msi-wrapped.wxs
Normal file
@ -0,0 +1,94 @@
|
||||
<?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="[FarmControlUpdatesDir]install.log""
|
||||
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
@ -1,270 +1,260 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { createWriteStream, promises as fs } from 'fs'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import process from 'process'
|
||||
import { launchMacInstaller } from './macappupdate.js'
|
||||
import { launchWindowsInstaller } from './winappupdate.js'
|
||||
import { createWriteStream, promises as fs } from "node:fs";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { Utils } from "electrobun/bun";
|
||||
import { launchMacInstaller } from "./macappupdate.js";
|
||||
import { launchWindowsInstaller } from "./winappupdate.js";
|
||||
import { scheduleAppRestart } from "./updater-runner.js";
|
||||
|
||||
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
|
||||
const SUPPORTED_TARGETS = {
|
||||
darwin: {
|
||||
extension: '.pkg',
|
||||
osMatchers: ['darwin', 'mac', 'macos', 'osx']
|
||||
extension: ".pkg",
|
||||
osMatchers: ["darwin", "mac", "macos", "osx"],
|
||||
},
|
||||
win32: {
|
||||
extension: '.msi',
|
||||
osMatchers: ['win32', 'win', 'windows']
|
||||
}
|
||||
}
|
||||
extension: ".msi",
|
||||
osMatchers: ["win32", "win", "windows"],
|
||||
},
|
||||
};
|
||||
|
||||
let runningUpdate = null
|
||||
let runningUpdate = null;
|
||||
|
||||
const getArtifactName = (artifact) =>
|
||||
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)
|
||||
}
|
||||
String(artifact?.fileName || artifact?.relativePath || artifact?.url || "");
|
||||
|
||||
const normalizeArch = (arch) => {
|
||||
if (arch === 'x64' || arch === 'amd64') return 'x64'
|
||||
if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
|
||||
return arch
|
||||
}
|
||||
if (arch === "x64" || arch === "amd64") return "x64";
|
||||
if (arch === "arm64" || arch === "aarch64") return "arm64";
|
||||
return arch;
|
||||
};
|
||||
|
||||
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
|
||||
const name = getArtifactName(artifact).toLowerCase()
|
||||
const normalizedArch = normalizeArch(arch)
|
||||
const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
|
||||
const name = getArtifactName(artifact).toLowerCase();
|
||||
const normalizedArch = normalizeArch(arch);
|
||||
const artifactArch = normalizeArch(String(artifact?.arch || "").toLowerCase());
|
||||
const artifactPlatform = String(
|
||||
artifact?.platform || artifact?.os || artifact?.target || ''
|
||||
).toLowerCase()
|
||||
artifact?.platform || artifact?.os || artifact?.target || "",
|
||||
).toLowerCase();
|
||||
|
||||
if (!name.endsWith(target.extension)) return false
|
||||
if (!artifact?.url) return false
|
||||
if (!name.endsWith(target.extension)) return false;
|
||||
if (!artifact?.url) return false;
|
||||
|
||||
const matchesArch =
|
||||
artifactArch === normalizedArch ||
|
||||
name.includes(`-${normalizedArch}`) ||
|
||||
name.includes(`_${normalizedArch}`) ||
|
||||
name.includes(`.${normalizedArch}.`) ||
|
||||
name.includes(normalizedArch)
|
||||
name.includes(normalizedArch);
|
||||
|
||||
const matchesOs =
|
||||
!artifactPlatform ||
|
||||
target.osMatchers.includes(artifactPlatform) ||
|
||||
target.osMatchers.some((matcher) => name.includes(matcher)) ||
|
||||
(platform === 'darwin' && name.includes('mac')) ||
|
||||
(platform === 'win32' && name.includes('win'))
|
||||
(platform === "darwin" && name.includes("mac")) ||
|
||||
(platform === "win32" && name.includes("win"));
|
||||
|
||||
return matchesArch && matchesOs
|
||||
}
|
||||
return matchesArch && matchesOs;
|
||||
};
|
||||
|
||||
const selectUpdateArtifact = (
|
||||
update,
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
arch = process.arch,
|
||||
) => {
|
||||
const target = SUPPORTED_TARGETS[platform]
|
||||
const target = SUPPORTED_TARGETS[platform];
|
||||
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 : []).filter(
|
||||
(artifact) => !isCefInstallerArtifact(artifact)
|
||||
)
|
||||
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : [];
|
||||
const matchingArtifact = artifacts.find((artifact) =>
|
||||
artifactMatchesPlatform(artifact, target, platform, arch)
|
||||
)
|
||||
artifactMatchesPlatform(artifact, target, platform, arch),
|
||||
);
|
||||
const fallbackArtifact = artifacts.find((artifact) => {
|
||||
const name = getArtifactName(artifact).toLowerCase()
|
||||
return artifact?.url && name.endsWith(target.extension)
|
||||
})
|
||||
const name = getArtifactName(artifact).toLowerCase();
|
||||
return artifact?.url && name.endsWith(target.extension);
|
||||
});
|
||||
|
||||
if (!matchingArtifact && !fallbackArtifact) {
|
||||
throw new Error(
|
||||
`No ${target.extension} update artifact found for ${platform}/${arch}.`
|
||||
)
|
||||
`No ${target.extension} update artifact found for ${platform}/${arch}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return matchingArtifact || fallbackArtifact
|
||||
}
|
||||
return matchingArtifact || fallbackArtifact;
|
||||
};
|
||||
|
||||
const sendProgress = (webContents, payload) => {
|
||||
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()
|
||||
const getInstallErrorMessage = (error, output = "") => {
|
||||
const combined = `${output}\n${error?.message || ""}`.trim();
|
||||
|
||||
if (
|
||||
/cancel/i.test(combined) ||
|
||||
/did not grant permission/i.test(combined) ||
|
||||
/user canceled/i.test(combined)
|
||||
) {
|
||||
return 'Update installation was cancelled.'
|
||||
return "Update installation was cancelled.";
|
||||
}
|
||||
|
||||
if (/incorrect/i.test(combined)) {
|
||||
return 'The administrator password was incorrect.'
|
||||
return "The administrator password was incorrect.";
|
||||
}
|
||||
|
||||
return combined || 'Failed to install update.'
|
||||
}
|
||||
if (
|
||||
/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.";
|
||||
}
|
||||
|
||||
const installerHelpers = { sendProgress, getInstallErrorMessage }
|
||||
return combined || "Failed to install update.";
|
||||
};
|
||||
|
||||
const getDownloadUrl = (url, redirectCount = 0) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects while downloading update.'))
|
||||
return
|
||||
reject(new Error("Too many redirects while downloading update."));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url)
|
||||
const client = parsedUrl.protocol === 'https:' ? https : http
|
||||
const parsedUrl = new URL(url);
|
||||
const client = parsedUrl.protocol === "https:" ? https : http;
|
||||
const request = client.get(parsedUrl, (response) => {
|
||||
const location = response.headers.location
|
||||
const location = response.headers.location;
|
||||
|
||||
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
|
||||
response.resume()
|
||||
response.resume();
|
||||
resolve(
|
||||
getDownloadUrl(
|
||||
new URL(location, parsedUrl).toString(),
|
||||
redirectCount + 1
|
||||
)
|
||||
)
|
||||
return
|
||||
redirectCount + 1,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ response, url: parsedUrl.toString() })
|
||||
})
|
||||
resolve({ response, url: parsedUrl.toString() });
|
||||
});
|
||||
|
||||
request.on('error', reject)
|
||||
})
|
||||
request.on("error", reject);
|
||||
});
|
||||
|
||||
const downloadArtifact = async (artifact, destinationPath, webContents) => {
|
||||
const { response } = await getDownloadUrl(artifact.url)
|
||||
const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
|
||||
const { response } = await getDownloadUrl(artifact.url);
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
response.resume()
|
||||
throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
|
||||
response.resume();
|
||||
throw new Error(`Update download failed with HTTP ${response.statusCode}.`);
|
||||
}
|
||||
|
||||
const totalBytes =
|
||||
Number.parseInt(response.headers['content-length'], 10) || 0
|
||||
let downloadedBytes = 0
|
||||
Number.parseInt(response.headers["content-length"], 10) || 0;
|
||||
let downloadedBytes = 0;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(destinationPath)
|
||||
const output = createWriteStream(destinationPath);
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length
|
||||
response.on("data", (chunk) => {
|
||||
downloadedBytes += chunk.length;
|
||||
const percent = totalBytes
|
||||
? Math.round((downloadedBytes / totalBytes) * 100)
|
||||
: null
|
||||
: null;
|
||||
|
||||
sendProgress(webContents, {
|
||||
phase: 'downloading',
|
||||
sendProgress({
|
||||
phase: "downloading",
|
||||
percent,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
message: totalBytes
|
||||
? `Downloading update (${percent}%)`
|
||||
: 'Downloading update'
|
||||
})
|
||||
})
|
||||
: "Downloading update",
|
||||
});
|
||||
});
|
||||
|
||||
response.on('error', reject)
|
||||
output.on('error', reject)
|
||||
output.on('finish', resolve)
|
||||
response.pipe(output)
|
||||
})
|
||||
}
|
||||
response.on("error", reject);
|
||||
output.on("error", reject);
|
||||
output.on("finish", resolve);
|
||||
response.pipe(output);
|
||||
});
|
||||
};
|
||||
|
||||
const restartApp = (app) => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
}
|
||||
const launchInstallerAndRestart = async (
|
||||
mainWindow,
|
||||
installerPath,
|
||||
sendProgress,
|
||||
) => {
|
||||
const installerHelpers = { sendProgress, getInstallErrorMessage };
|
||||
|
||||
const launchInstallerAndQuit = async (app, installerPath, webContents) => {
|
||||
if (process.platform === 'darwin') {
|
||||
await launchMacInstaller(app, installerPath, webContents, installerHelpers)
|
||||
restartApp(app)
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
await launchWindowsInstaller(
|
||||
app,
|
||||
if (process.platform === "darwin") {
|
||||
await launchMacInstaller(
|
||||
mainWindow,
|
||||
installerPath,
|
||||
webContents,
|
||||
installerHelpers
|
||||
)
|
||||
restartApp(app)
|
||||
return
|
||||
sendProgress,
|
||||
installerHelpers,
|
||||
);
|
||||
} else if (process.platform === "win32") {
|
||||
await launchWindowsInstaller(
|
||||
mainWindow,
|
||||
installerPath,
|
||||
sendProgress,
|
||||
installerHelpers,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`App updates are not supported on ${process.platform}.`);
|
||||
}
|
||||
|
||||
throw new Error(`App updates are not supported on ${process.platform}.`)
|
||||
}
|
||||
scheduleAppRestart();
|
||||
Utils.quit();
|
||||
};
|
||||
|
||||
const runAppUpdate = async (app, update, webContents) => {
|
||||
const artifact = selectUpdateArtifact(update)
|
||||
const runAppUpdate = async (mainWindow, update, sendProgress) => {
|
||||
const artifact = selectUpdateArtifact(update);
|
||||
const tempDirectory = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'farmcontrol-update-')
|
||||
)
|
||||
const artifactName = path.basename(getArtifactName(artifact))
|
||||
const installerPath = path.join(tempDirectory, artifactName)
|
||||
path.join(os.tmpdir(), "farmcontrol-update-"),
|
||||
);
|
||||
const artifactName = path.basename(getArtifactName(artifact));
|
||||
const installerPath = path.join(tempDirectory, artifactName);
|
||||
|
||||
sendProgress(webContents, {
|
||||
phase: 'preparing',
|
||||
sendProgress({
|
||||
phase: "preparing",
|
||||
percent: 0,
|
||||
artifact,
|
||||
message: 'Preparing update download'
|
||||
})
|
||||
message: "Preparing update download",
|
||||
});
|
||||
|
||||
await downloadArtifact(artifact, installerPath, webContents)
|
||||
await downloadArtifact(artifact, installerPath, sendProgress);
|
||||
|
||||
sendProgress(webContents, {
|
||||
phase: 'downloaded',
|
||||
sendProgress({
|
||||
phase: "downloaded",
|
||||
percent: 100,
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
artifact,
|
||||
message: 'Update downloaded'
|
||||
})
|
||||
message: "Update downloaded",
|
||||
});
|
||||
|
||||
await launchInstallerAndQuit(app, installerPath, webContents)
|
||||
}
|
||||
|
||||
export function setupAppUpdateIPC(app) {
|
||||
ipcMain.handle('app-update-start', async (event, update) => {
|
||||
if (runningUpdate) return runningUpdate
|
||||
|
||||
const webContents = event.sender
|
||||
runningUpdate = runAppUpdate(app, update, webContents)
|
||||
.then(() => ({ ok: true }))
|
||||
.catch((error) => {
|
||||
sendProgress(webContents, {
|
||||
phase: 'error',
|
||||
percent: null,
|
||||
message: error?.message || 'Failed to update app.'
|
||||
})
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
runningUpdate = null
|
||||
})
|
||||
|
||||
return runningUpdate
|
||||
})
|
||||
await launchInstallerAndRestart(mainWindow, installerPath, sendProgress);
|
||||
};
|
||||
|
||||
export function startAppUpdate(mainWindow, update, sendProgress) {
|
||||
if (runningUpdate) return runningUpdate;
|
||||
|
||||
runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
|
||||
.then(() => ({ ok: true }))
|
||||
.catch((error) => {
|
||||
sendProgress({
|
||||
phase: "error",
|
||||
percent: null,
|
||||
message: error?.message || "Failed to update app.",
|
||||
});
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
runningUpdate = null;
|
||||
});
|
||||
|
||||
return runningUpdate;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, ipcMain, Menu } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, Menu } from 'electron'
|
||||
import path, { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
@ -227,6 +227,12 @@ export function createWindow() {
|
||||
setupWindowEvents()
|
||||
attachKeyboardShortcuts(win)
|
||||
setupNavigationGestures(win)
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
win.maximize()
|
||||
} else if (process.platform === 'win32') {
|
||||
win.maximize()
|
||||
}
|
||||
}
|
||||
|
||||
export function getWindow() {
|
||||
@ -250,7 +256,7 @@ export function setupMainWindowIPC() {
|
||||
|
||||
// IPC handlers for window controls
|
||||
ipcMain.on('window-control', (event, action) => {
|
||||
if (!win) return
|
||||
if (!win && action !== 'quit') return
|
||||
switch (action) {
|
||||
case 'minimize':
|
||||
win.minimize()
|
||||
@ -265,6 +271,17 @@ export function setupMainWindowIPC() {
|
||||
case 'close':
|
||||
win.close()
|
||||
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:
|
||||
break
|
||||
}
|
||||
|
||||
@ -6,18 +6,6 @@
|
||||
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> */
|
||||
.fc-loader {
|
||||
width: 35px;
|
||||
|
||||
@ -144,10 +144,12 @@ const isValidMsiPackage = async (filePath) => {
|
||||
}
|
||||
}
|
||||
|
||||
const prepareInstallerPath = async (installerPath) => {
|
||||
export const prepareInstallerPath = async (installerPath) => {
|
||||
const fileName = path.basename(installerPath)
|
||||
const updateDir = path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
os.homedir(),
|
||||
'AppData',
|
||||
'Local',
|
||||
'FarmControl',
|
||||
'Updates'
|
||||
)
|
||||
@ -173,11 +175,7 @@ const prepareInstallerPath = async (installerPath) => {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
const startWindowsInstallerProgressWatch = (
|
||||
logPath,
|
||||
webContents,
|
||||
sendProgress
|
||||
) => {
|
||||
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
|
||||
let installerOutput = ''
|
||||
let lastLogSize = 0
|
||||
let lastPercent = null
|
||||
@ -239,7 +237,7 @@ const startWindowsInstallerProgressWatch = (
|
||||
|
||||
lastPercent = resolvedPercent
|
||||
lastMessage = resolvedMessage
|
||||
sendProgress(webContents, {
|
||||
sendProgress( {
|
||||
phase: 'installing',
|
||||
percent: resolvedPercent,
|
||||
message: resolvedMessage
|
||||
@ -280,7 +278,7 @@ const startWindowsInstallerProgressWatch = (
|
||||
}
|
||||
|
||||
export const launchWindowsInstaller = async (
|
||||
app,
|
||||
mainWindow,
|
||||
installerPath,
|
||||
webContents,
|
||||
{ sendProgress, getInstallErrorMessage }
|
||||
@ -294,7 +292,7 @@ export const launchWindowsInstaller = async (
|
||||
logPath
|
||||
})
|
||||
|
||||
sendProgress(webContents, {
|
||||
sendProgress( {
|
||||
phase: 'installing',
|
||||
percent: 0,
|
||||
message: 'Installing update...'
|
||||
@ -307,7 +305,6 @@ export const launchWindowsInstaller = async (
|
||||
|
||||
const stopProgressWatch = startWindowsInstallerProgressWatch(
|
||||
logPath,
|
||||
webContents,
|
||||
sendProgress
|
||||
)
|
||||
|
||||
@ -320,6 +317,9 @@ export const launchWindowsInstaller = async (
|
||||
resolvedPath,
|
||||
'/qn',
|
||||
'/norestart',
|
||||
'ALLUSERS=2',
|
||||
'MSIINSTALLPERUSER=1',
|
||||
'REBOOT=ReallySuppress',
|
||||
'/L*v!',
|
||||
logPath
|
||||
]
|
||||
@ -369,7 +369,7 @@ export const launchWindowsInstaller = async (
|
||||
})
|
||||
|
||||
const message = error?.message || 'Failed to start update installer.'
|
||||
sendProgress(webContents, {
|
||||
sendProgress( {
|
||||
phase: 'error',
|
||||
percent: null,
|
||||
message
|
||||
@ -396,7 +396,7 @@ export const launchWindowsInstaller = async (
|
||||
|
||||
if (code !== 0) {
|
||||
const message = getInstallErrorMessage(null, output)
|
||||
sendProgress(webContents, {
|
||||
sendProgress( {
|
||||
phase: 'error',
|
||||
percent: null,
|
||||
message
|
||||
@ -418,7 +418,7 @@ export const launchWindowsInstaller = async (
|
||||
|
||||
if (!succeeded) {
|
||||
const message = getInstallErrorMessage(null, output)
|
||||
sendProgress(webContents, {
|
||||
sendProgress( {
|
||||
phase: 'error',
|
||||
percent: null,
|
||||
message
|
||||
@ -429,7 +429,7 @@ export const launchWindowsInstaller = async (
|
||||
|
||||
const { percent, message } = finalParse
|
||||
|
||||
sendProgress(webContents, {
|
||||
sendProgress( {
|
||||
phase: 'installing',
|
||||
percent: percent ?? 100,
|
||||
message: message || 'Installation complete. Restarting Farm Control...'
|
||||
|
||||
121
scripts/build-macos-effects.mjs
Normal file
@ -0,0 +1,121 @@
|
||||
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})`,
|
||||
);
|
||||
111
scripts/build-macos.mjs
Normal file
@ -0,0 +1,111 @@
|
||||
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(", ")}`);
|
||||
36
scripts/build-renderer.mjs
Normal file
@ -0,0 +1,36 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const result = spawnSync("bun", ["x", "vite", "build"], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: process.env.NODE_ENV || "production",
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const syncResult = spawnSync(
|
||||
"bun",
|
||||
[path.join(rootDir, "scripts/sync-electrobun-views.mjs")],
|
||||
{
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
|
||||
if (syncResult.status !== 0) {
|
||||
process.exit(syncResult.status ?? 1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`build-renderer: output at ${path.join(rootDir, "dist/mainview")}`,
|
||||
);
|
||||
56
scripts/build-windows-deeplink.mjs
Normal file
@ -0,0 +1,56 @@
|
||||
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))
|
||||
}
|
||||
130
scripts/build-windows-msi.ps1
Normal file
@ -0,0 +1,130 @@
|
||||
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"
|
||||
149
scripts/build-windows-nsis.ps1
Normal file
@ -0,0 +1,149 @@
|
||||
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"
|
||||
11
scripts/clean-build.mjs
Normal file
@ -0,0 +1,11 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
for (const dir of ["build", "dist", "app_dist", "artifacts"]) {
|
||||
rmSync(path.join(rootDir, dir), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("clean: removed build, dist, app_dist, and artifacts");
|
||||
89
scripts/codesign-macos-app.mjs
Normal file
@ -0,0 +1,89 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function getCodesignArgs() {
|
||||
const identity = process.env.ELECTROBUN_DEVELOPER_ID || "-";
|
||||
const args = ["--force", "--deep"];
|
||||
|
||||
if (identity !== "-") {
|
||||
args.push("--options", "runtime", "--timestamp");
|
||||
}
|
||||
|
||||
args.push("--sign", identity);
|
||||
return { identity, args };
|
||||
}
|
||||
|
||||
export function codesignMacAppBundle(appBundlePath) {
|
||||
if (process.platform !== "darwin") {
|
||||
console.log("codesign-macos-app: skipping (not macOS)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!appBundlePath || !existsSync(appBundlePath)) {
|
||||
throw new Error(`codesign-macos-app: app bundle not found: ${appBundlePath}`);
|
||||
}
|
||||
|
||||
const contentsPath = path.join(appBundlePath, "Contents");
|
||||
if (!existsSync(contentsPath)) {
|
||||
throw new Error(`codesign-macos-app: invalid app bundle: ${appBundlePath}`);
|
||||
}
|
||||
|
||||
const { identity, args } = getCodesignArgs();
|
||||
const result = spawnSync("codesign", [...args, appBundlePath], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`codesign failed with exit code ${result.status ?? 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
const verify = spawnSync(
|
||||
"codesign",
|
||||
["--verify", "--deep", "--strict", "--verbose=2", appBundlePath],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
if (verify.status !== 0) {
|
||||
throw new Error(
|
||||
`codesign verify failed: ${verify.stderr || verify.stdout || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const label = identity === "-" ? "ad-hoc" : identity;
|
||||
console.log(`codesign-macos-app: signed ${appBundlePath} (${label})`);
|
||||
}
|
||||
|
||||
function resolveAppBundlePath() {
|
||||
const fromEnv = process.env.ELECTROBUN_WRAPPER_BUNDLE_PATH;
|
||||
if (fromEnv && existsSync(fromEnv)) {
|
||||
return path.resolve(fromEnv);
|
||||
}
|
||||
|
||||
const fromArgv = process.argv[2];
|
||||
if (fromArgv && existsSync(fromArgv)) {
|
||||
return path.resolve(fromArgv);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
? path.resolve(process.argv[1])
|
||||
: null;
|
||||
const modulePath = fileURLToPath(import.meta.url);
|
||||
|
||||
if (invokedPath === modulePath) {
|
||||
const appBundlePath = resolveAppBundlePath();
|
||||
if (!appBundlePath) {
|
||||
console.error(
|
||||
"codesign-macos-app: set ELECTROBUN_WRAPPER_BUNDLE_PATH or pass app bundle path",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
codesignMacAppBundle(appBundlePath);
|
||||
}
|
||||
131
scripts/ensure-electrobun-core.mjs
Normal file
@ -0,0 +1,131 @@
|
||||
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);
|
||||
}
|
||||
152
scripts/expand-macos-bundle.mjs
Normal file
@ -0,0 +1,152 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
|
||||
const targetOs = process.env.ELECTROBUN_OS || "macos";
|
||||
const wrapperPath = process.env.ELECTROBUN_WRAPPER_BUNDLE_PATH;
|
||||
|
||||
if (buildEnv === "dev" || targetOs !== "macos") {
|
||||
console.log("expand-macos-bundle: skipping (dev or non-macOS build)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!wrapperPath || !existsSync(wrapperPath)) {
|
||||
console.error("expand-macos-bundle: ELECTROBUN_WRAPPER_BUNDLE_PATH not set or missing");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const resolvedWrapperPath = path.resolve(wrapperPath);
|
||||
const contentsPath = path.join(resolvedWrapperPath, "Contents");
|
||||
const resourcesPath = path.join(contentsPath, "Resources");
|
||||
const metadataPath = path.join(resourcesPath, "metadata.json");
|
||||
|
||||
if (!existsSync(metadataPath)) {
|
||||
console.log("expand-macos-bundle: no extractor metadata, assuming already expanded");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
const hash = metadata.hash;
|
||||
const appName = metadata.name || "Farm Control";
|
||||
const tarZstPath = path.resolve(resourcesPath, `${hash}.tar.zst`);
|
||||
|
||||
if (!existsSync(tarZstPath)) {
|
||||
console.log(`expand-macos-bundle: ${hash}.tar.zst not found, skipping`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function decompressTarZst(inputPath, outputPath) {
|
||||
const systemZstd = spawnSync("zstd", ["--version"], { encoding: "utf8" });
|
||||
if (systemZstd.status === 0) {
|
||||
return spawnSync("zstd", ["-d", "-f", "-o", outputPath, inputPath], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
const hostArch = process.arch === "arm64" ? "arm64" : "x64";
|
||||
const zigZstd = path.join(
|
||||
rootDir,
|
||||
"node_modules/electrobun/dist-macos-" + hostArch,
|
||||
"zig-zstd",
|
||||
);
|
||||
if (existsSync(zigZstd)) {
|
||||
return spawnSync(
|
||||
zigZstd,
|
||||
["decompress", "-i", inputPath, "-o", outputPath, "--no-timing"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
}
|
||||
|
||||
console.error(
|
||||
"expand-macos-bundle: zstd not found (install zstd or use electrobun dist binaries)",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workDir = path.join(tmpdir(), `farmcontrol-expand-${hash}`);
|
||||
const tarPath = path.join(workDir, `${hash}.tar`);
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
mkdirSync(workDir, { recursive: true });
|
||||
|
||||
const decompress = decompressTarZst(tarZstPath, tarPath);
|
||||
|
||||
if (decompress.status !== 0) {
|
||||
console.error("expand-macos-bundle: zstd decompression failed");
|
||||
process.exit(decompress.status ?? 1);
|
||||
}
|
||||
|
||||
const extractTar = spawnSync("tar", ["-xf", tarPath, "-C", workDir], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (extractTar.status !== 0) {
|
||||
console.error("expand-macos-bundle: tar extraction failed");
|
||||
process.exit(extractTar.status ?? 1);
|
||||
}
|
||||
|
||||
const innerAppPath = path.join(workDir, `${appName}.app`);
|
||||
if (!existsSync(innerAppPath)) {
|
||||
const appBundle = readdirSync(workDir).find((entry) => entry.endsWith(".app"));
|
||||
if (!appBundle) {
|
||||
console.error("expand-macos-bundle: extracted app bundle not found");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedInnerApp = existsSync(innerAppPath)
|
||||
? innerAppPath
|
||||
: path.join(workDir, readdirSync(workDir).find((e) => e.endsWith(".app")));
|
||||
const innerContentsPath = path.join(resolvedInnerApp, "Contents");
|
||||
|
||||
function replaceDirectory(sourceDir, destinationDir) {
|
||||
rmSync(destinationDir, { recursive: true, force: true });
|
||||
const result = spawnSync("ditto", [sourceDir, destinationDir], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
console.error("expand-macos-bundle: ditto copy failed");
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
replaceDirectory(
|
||||
path.join(innerContentsPath, "MacOS"),
|
||||
path.join(contentsPath, "MacOS"),
|
||||
);
|
||||
|
||||
rmSync(resourcesPath, { recursive: true, force: true });
|
||||
replaceDirectory(path.join(innerContentsPath, "Resources"), resourcesPath);
|
||||
|
||||
const innerFrameworks = path.join(innerContentsPath, "Frameworks");
|
||||
const wrapperFrameworks = path.join(contentsPath, "Frameworks");
|
||||
if (existsSync(innerFrameworks)) {
|
||||
replaceDirectory(innerFrameworks, wrapperFrameworks);
|
||||
}
|
||||
|
||||
const infoPlistResult = spawnSync(
|
||||
"ditto",
|
||||
[path.join(innerContentsPath, "Info.plist"), path.join(contentsPath, "Info.plist")],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (infoPlistResult.status !== 0) {
|
||||
console.error("expand-macos-bundle: Info.plist copy failed");
|
||||
process.exit(infoPlistResult.status ?? 1);
|
||||
}
|
||||
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
spawnSync("xattr", ["-cr", resolvedWrapperPath], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
console.log(`expand-macos-bundle: pre-expanded ${resolvedWrapperPath}`);
|
||||
144
scripts/expand-windows-installer.mjs
Normal file
@ -0,0 +1,144 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const electrobunDir = path.join(rootDir, "node_modules/electrobun");
|
||||
|
||||
function findZstdDecompressCommand() {
|
||||
const hostArch = process.arch === "arm64" ? "arm64" : "x64";
|
||||
const zigCandidates = [
|
||||
path.join(electrobunDir, `dist-win-${hostArch}`, "zig-zstd.exe"),
|
||||
path.join(electrobunDir, "dist-win-x64", "zig-zstd.exe"),
|
||||
path.join(electrobunDir, `dist-macos-${hostArch}`, "zig-zstd"),
|
||||
path.join(electrobunDir, "dist-macos-x64", "zig-zstd"),
|
||||
path.join(electrobunDir, "dist-win-x64", "zig-zstd", "x64", "zig-zstd.exe"),
|
||||
path.join(electrobunDir, "dist-win-x64", "zig-zstd", "arm64", "zig-zstd.exe"),
|
||||
path.join(electrobunDir, "vendors", "zig-zstd", "x64", "zig-zstd.exe"),
|
||||
path.join(electrobunDir, "vendors", "zig-zstd", "arm64", "zig-zstd.exe"),
|
||||
];
|
||||
|
||||
for (const candidate of zigCandidates) {
|
||||
if (!existsSync(candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "zig-zstd",
|
||||
command: candidate,
|
||||
args: (inputPath, outputPath) => [
|
||||
"decompress",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-o",
|
||||
outputPath,
|
||||
"--no-timing",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const systemZstd = spawnSync("zstd", ["--version"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
if (systemZstd.status === 0) {
|
||||
return {
|
||||
kind: "system-zstd",
|
||||
command: "zstd",
|
||||
args: (inputPath, outputPath) => [
|
||||
"-d",
|
||||
"-f",
|
||||
"-o",
|
||||
outputPath,
|
||||
inputPath,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function decompressTarZst(inputPath, outputPath) {
|
||||
const zstd = findZstdDecompressCommand();
|
||||
if (!zstd) {
|
||||
throw new Error(
|
||||
"expand-windows-installer: zstd not found (install zstd or use electrobun dist binaries)",
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedInput = path.resolve(inputPath);
|
||||
const resolvedOutput = path.resolve(outputPath);
|
||||
const args = zstd.args(resolvedInput, resolvedOutput);
|
||||
|
||||
console.log(
|
||||
`expand-windows-installer: decompressing with ${zstd.command} (${zstd.kind})`,
|
||||
);
|
||||
|
||||
const result = spawnSync(zstd.command, args, {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`expand-windows-installer: decompression failed (exit ${result.status ?? 1}) using ${zstd.command} ${args.join(" ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function expandWindowsAppFromArchive(setupArchivePath, parentDir) {
|
||||
const resolvedArchive = path.resolve(setupArchivePath);
|
||||
if (!existsSync(resolvedArchive)) {
|
||||
throw new Error(`expand-windows-installer: archive not found: ${resolvedArchive}`);
|
||||
}
|
||||
|
||||
const workDir = path.join(path.resolve(parentDir), ".expanded-app");
|
||||
const tarPath = path.join(workDir, "app.tar");
|
||||
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
mkdirSync(workDir, { recursive: true });
|
||||
|
||||
const archiveForDecompress = path.join(workDir, "setup.tar.zst");
|
||||
cpSync(resolvedArchive, archiveForDecompress);
|
||||
decompressTarZst(archiveForDecompress, tarPath);
|
||||
|
||||
const extractTar = spawnSync("tar", ["-xf", tarPath, "-C", workDir], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
if (extractTar.status !== 0) {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
throw new Error(
|
||||
`expand-windows-installer: tar extraction failed (exit ${extractTar.status ?? 1})`,
|
||||
);
|
||||
}
|
||||
|
||||
const appDir = readdirSync(workDir)
|
||||
.filter((entry) => entry !== "app.tar" && entry !== "__MACOSX")
|
||||
.map((entry) => path.join(workDir, entry))
|
||||
.find((entryPath) => statSync(entryPath).isDirectory());
|
||||
|
||||
if (!appDir) {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
throw new Error("expand-windows-installer: app folder not found in archive");
|
||||
}
|
||||
|
||||
console.log(`expand-windows-installer: expanded ${appDir}`);
|
||||
return appDir;
|
||||
}
|
||||
|
||||
export function cleanExpandedWindowsApp(parentDir) {
|
||||
const workDir = path.join(path.resolve(parentDir), ".expanded-app");
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
681
scripts/finalize-desktop-artifacts.mjs
Normal file
@ -0,0 +1,681 @@
|
||||
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()
|
||||
152
scripts/fix-macos-headerpad.mjs
Normal file
@ -0,0 +1,152 @@
|
||||
// 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));
|
||||
}
|
||||
27
scripts/generate-app-icons.mjs
Normal file
@ -0,0 +1,27 @@
|
||||
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')
|
||||
62
scripts/generate-nsis-file-list.ps1
Normal file
@ -0,0 +1,62 @@
|
||||
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)."
|
||||
@ -1,10 +1,10 @@
|
||||
!macro customInstall
|
||||
!macro customInstall
|
||||
DetailPrint "Register farmcontrol URI Handler"
|
||||
DeleteRegKey HKCR "farmcontrol"
|
||||
WriteRegStr HKCR "farmcontrol" "" "URL:farmcontrol"
|
||||
WriteRegStr HKCR "farmcontrol" "URL Protocol" ""
|
||||
WriteRegStr HKCR "farmcontrol\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
|
||||
WriteRegStr HKCR "farmcontrol\shell" "" ""
|
||||
WriteRegStr HKCR "farmcontrol\shell\Open" "" ""
|
||||
WriteRegStr HKCR "farmcontrol\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1"
|
||||
!macroend
|
||||
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" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
|
||||
WriteRegStr HKCU "Software\Classes\farmcontrol\shell" "" ""
|
||||
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" ""
|
||||
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1"
|
||||
!macroend
|
||||
|
||||
350
scripts/patch-electrobun-src.mjs
Normal file
@ -0,0 +1,350 @@
|
||||
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'
|
||||
)
|
||||
45
scripts/patch-windows-binaries.mjs
Normal file
@ -0,0 +1,45 @@
|
||||
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))
|
||||
}
|
||||
145
scripts/pre-build.mjs
Normal file
@ -0,0 +1,145 @@
|
||||
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})`);
|
||||
104
scripts/prepare-app-icons.mjs
Normal file
@ -0,0 +1,104 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Jimp } from "jimp";
|
||||
import pngToIco from "png-to-ico";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const MAC_ICONSET_ENTRIES = [
|
||||
{ name: "icon_16x16.png", size: 16 },
|
||||
{ name: "icon_16x16@2x.png", size: 32 },
|
||||
{ name: "icon_32x32.png", size: 32 },
|
||||
{ name: "icon_32x32@2x.png", size: 64 },
|
||||
{ name: "icon_128x128.png", size: 128 },
|
||||
{ name: "icon_128x128@2x.png", size: 256 },
|
||||
{ name: "icon_256x256.png", size: 256 },
|
||||
{ name: "icon_256x256@2x.png", size: 512 },
|
||||
{ name: "icon_512x512.png", size: 512 },
|
||||
{ name: "icon_512x512@2x.png", size: 1024 },
|
||||
];
|
||||
|
||||
const WIN_ICO_SIZES = [16, 32, 48, 256];
|
||||
const LINUX_ICON_SIZE = 256;
|
||||
|
||||
function resolveSourcePath(arg) {
|
||||
if (!arg) {
|
||||
return path.join(rootDir, "assets/farmcontrolhosticon.png");
|
||||
}
|
||||
|
||||
const candidate = path.isAbsolute(arg) ? arg : path.resolve(rootDir, arg);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function resizePng(sourceImage, size) {
|
||||
return sourceImage
|
||||
.clone()
|
||||
.resize({ w: size, h: size })
|
||||
.getBuffer("image/png");
|
||||
}
|
||||
|
||||
async function writePng(filePath, sourceImage, size) {
|
||||
const png = await resizePng(sourceImage, size);
|
||||
await Bun.write(filePath, png);
|
||||
return png;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const sourceArg = argv.find((arg) => !arg.startsWith("-"));
|
||||
const outDirArgIndex = argv.indexOf("--out-dir");
|
||||
const outDir =
|
||||
outDirArgIndex >= 0 ? argv[outDirArgIndex + 1] : path.join(rootDir, "assets");
|
||||
|
||||
return {
|
||||
sourcePath: resolveSourcePath(sourceArg),
|
||||
outDir: path.isAbsolute(outDir) ? outDir : path.resolve(rootDir, outDir),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { sourcePath, outDir } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
console.error(`prepare-app-icons: source image not found: ${sourcePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sourceImage = await Jimp.read(sourcePath);
|
||||
if (sourceImage.width < 256 || sourceImage.height < 256) {
|
||||
console.warn(
|
||||
`prepare-app-icons: source image is ${sourceImage.width}x${sourceImage.height}; Electrobun recommends at least 256x256`,
|
||||
);
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const iconsetDir = path.join(outDir, "icon.iconset");
|
||||
rmSync(iconsetDir, { recursive: true, force: true });
|
||||
mkdirSync(iconsetDir, { recursive: true });
|
||||
|
||||
for (const entry of MAC_ICONSET_ENTRIES) {
|
||||
const iconPath = path.join(iconsetDir, entry.name);
|
||||
await writePng(iconPath, sourceImage, entry.size);
|
||||
}
|
||||
|
||||
const icoPngs = [];
|
||||
for (const size of WIN_ICO_SIZES) {
|
||||
icoPngs.push(await resizePng(sourceImage, size));
|
||||
}
|
||||
|
||||
const icoPath = path.join(outDir, "icon.ico");
|
||||
await Bun.write(icoPath, await pngToIco(icoPngs));
|
||||
|
||||
const linuxIconPath = path.join(outDir, "icon.png");
|
||||
await writePng(linuxIconPath, sourceImage, LINUX_ICON_SIZE);
|
||||
|
||||
console.log(`prepare-app-icons: wrote ${iconsetDir}`);
|
||||
console.log(`prepare-app-icons: wrote ${icoPath}`);
|
||||
console.log(`prepare-app-icons: wrote ${linuxIconPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`prepare-app-icons: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
110
scripts/prepare-installer-icons.mjs
Normal file
@ -0,0 +1,110 @@
|
||||
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)
|
||||
})
|
||||
28
scripts/release-artifact-utils.mjs
Normal file
@ -0,0 +1,28 @@
|
||||
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}`;
|
||||
}
|
||||
101
scripts/run-electrobun-build.mjs
Normal file
@ -0,0 +1,101 @@
|
||||
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);
|
||||
}
|
||||
13
scripts/sync-electrobun-views.mjs
Normal file
@ -0,0 +1,13 @@
|
||||
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}`);
|
||||
15
scripts/write-build-info.mjs
Normal file
@ -0,0 +1,15 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const buildNumber =
|
||||
process.env.BUILD_NUMBER || process.env.VITE_BUILD_NUMBER || "dev";
|
||||
|
||||
const buildInfoPath = path.join(rootDir, "src/buildInfo.json");
|
||||
|
||||
await Bun.write(
|
||||
buildInfoPath,
|
||||
`${JSON.stringify({ buildNumber }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
console.log(`write-build-info: ${buildInfoPath} (buildNumber=${buildNumber})`);
|
||||
3
src/buildInfo.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"buildNumber": "dev"
|
||||
}
|
||||
50
src/bun/deeplink.js
Normal file
@ -0,0 +1,50 @@
|
||||
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)
|
||||
43
src/bun/index.js
Normal file
@ -0,0 +1,43 @@
|
||||
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()
|
||||
})
|
||||
@ -30,7 +30,7 @@ const DashboardLayout = ({ children }) => {
|
||||
<MessageProvider>
|
||||
<Layout
|
||||
style={{ height: 'var(--unit-100vh)' }}
|
||||
className={isDarkMode ? 'dark-mode' : 'light-mode'}
|
||||
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout`}
|
||||
>
|
||||
<DashboardNavigation />
|
||||
<Layout>
|
||||
@ -49,7 +49,7 @@ const DashboardLayout = ({ children }) => {
|
||||
) : (
|
||||
<ProductionSidebar /> // Default to production sidebar
|
||||
)}
|
||||
<Layout style={{ padding: '24px' }}>
|
||||
<Layout style={{ padding: '24px' }} className='main-content-layout'>
|
||||
<Content>
|
||||
<Flex vertical style={{ height: '100%' }} gap='20px'>
|
||||
<Flex justify='space-between'>
|
||||
|
||||
@ -30,7 +30,7 @@ const About = () => {
|
||||
const { token } = useContext(AuthContext)
|
||||
const { fetchApiServerVersion, fetchWsServerVersion } =
|
||||
useContext(ApiServerContext)
|
||||
const { isElectron, getElectronVersion } = useContext(ElectronContext)
|
||||
const { isElectron, getAppEngine } = useContext(ElectronContext)
|
||||
const { checkForUpdates } = useContext(AppUpdateContext)
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
@ -74,18 +74,18 @@ const About = () => {
|
||||
useEffect(() => {
|
||||
if (!isElectron) return
|
||||
|
||||
getElectronVersion()
|
||||
.then((version) => {
|
||||
setElectronVersion(version || 'unknown')
|
||||
getAppEngine()
|
||||
.then((engine) => {
|
||||
setAppEngine(engine === 'chromium' ? 'Chromium' : 'Native')
|
||||
})
|
||||
.catch(() => {
|
||||
setElectronVersion('unknown')
|
||||
setAppEngine('Unknown')
|
||||
})
|
||||
}, [getElectronVersion, isElectron])
|
||||
}, [getAppEngine, isElectron])
|
||||
|
||||
const [apiServerVersion, setApiServerVersion] = useState(null)
|
||||
const [wsServerVersion, setWsServerVersion] = useState(null)
|
||||
const [electronVersion, setElectronVersion] = useState(null)
|
||||
const [appEngine, setAppEngine] = useState(null)
|
||||
|
||||
const apiServerVersionText = apiServerVersion ? (
|
||||
<Text>
|
||||
@ -99,8 +99,8 @@ const About = () => {
|
||||
) : (
|
||||
<Skeleton.Input active size='small' className='text-skeleton' />
|
||||
)
|
||||
const electronVersionText = electronVersion ? (
|
||||
<Text>{`v${electronVersion}`}</Text>
|
||||
const appEngineText = appEngine ? (
|
||||
<Text>{appEngine}</Text>
|
||||
) : (
|
||||
<Skeleton.Input active size='small' className='text-skeleton' />
|
||||
)
|
||||
@ -153,9 +153,7 @@ const About = () => {
|
||||
</Text>
|
||||
</Text>
|
||||
{isElectron && (
|
||||
<Text type='secondary'>
|
||||
Electron: {electronVersionText}
|
||||
</Text>
|
||||
<Text type='secondary'>Engine: {appEngineText}</Text>
|
||||
)}
|
||||
|
||||
<Text type='secondary'>REST API: {apiServerVersionText}</Text>
|
||||
|
||||
@ -88,6 +88,16 @@ const NewAppUpdate = ({ update, onCancel, onUpdate }) => {
|
||||
<Text style={{ margin: 0 }} type='secondary'>
|
||||
Branch: <Text>{update?.branch || 'Unknown'}</Text>
|
||||
</Text>
|
||||
<Text style={{ margin: 0 }} type='secondary'>
|
||||
Engine:{' '}
|
||||
<Text>
|
||||
{update?.engine === 'chromium'
|
||||
? 'Chromium'
|
||||
: update?.engine === 'native'
|
||||
? 'Native'
|
||||
: 'Unknown'}
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
<Dropdown menu={actionsMenu}>
|
||||
<Button size='small' type='text'>
|
||||
|
||||
@ -5,6 +5,10 @@ import { useThemeContext } from '../context/ThemeContext'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import { ElectronContext } from '../context/ElectronContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import {
|
||||
normalizeAppUpdateEngine,
|
||||
useAppUpdateContext
|
||||
} from '../context/AppUpdateContext'
|
||||
import { useMessageContext } from '../context/MessageContext'
|
||||
import useCollapseState from '../hooks/useCollapseState'
|
||||
import InfoCollapse from '../common/InfoCollapse'
|
||||
@ -14,13 +18,22 @@ import EditButtons from '../common/EditButtons'
|
||||
const { Text } = Typography
|
||||
const { Option } = Select
|
||||
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 { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
|
||||
useThemeContext()
|
||||
const { fetchAppUpdateBranches } = useContext(ApiServerContext)
|
||||
const { isElectron, getAppSettings, setAppSettings } =
|
||||
const { isElectron, getAppSettings, setAppSettings, getAppEngine } =
|
||||
useContext(ElectronContext)
|
||||
const { recheckForUpdates } = useAppUpdateContext()
|
||||
const { userProfile, setUserProfile } = useContext(AuthContext)
|
||||
const { showSuccess, showError } = useMessageContext()
|
||||
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
|
||||
@ -34,20 +47,31 @@ const Settings = () => {
|
||||
const [draftSettings, setDraftSettings] = useState({})
|
||||
const [branches, setBranches] = useState([])
|
||||
const [branchLoading, setBranchLoading] = useState(false)
|
||||
const [runningEngine, setRunningEngine] = useState(DEFAULT_UPDATE_ENGINE)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
setSettingsLoading(true)
|
||||
const storedSettings = isElectron
|
||||
? await getAppSettings()
|
||||
: userProfile?.settings || {}
|
||||
setAppSettingsState(storedSettings || {})
|
||||
setDraftSettings(storedSettings || {})
|
||||
const [storedSettings, detectedEngine] = await Promise.all([
|
||||
isElectron ? getAppSettings() : Promise.resolve(userProfile?.settings || {}),
|
||||
isElectron ? getAppEngine() : Promise.resolve(DEFAULT_UPDATE_ENGINE)
|
||||
])
|
||||
const nextEngine =
|
||||
normalizeAppUpdateEngine(detectedEngine) || DEFAULT_UPDATE_ENGINE
|
||||
setRunningEngine(nextEngine)
|
||||
|
||||
const nextSettings = { ...(storedSettings || {}) }
|
||||
if (isElectron && !normalizeAppUpdateEngine(nextSettings.appUpdateEngine)) {
|
||||
nextSettings.appUpdateEngine = nextEngine
|
||||
}
|
||||
|
||||
setAppSettingsState(nextSettings)
|
||||
setDraftSettings(nextSettings)
|
||||
setSettingsLoading(false)
|
||||
}
|
||||
|
||||
loadSettings()
|
||||
}, [getAppSettings, isElectron, userProfile?.settings])
|
||||
}, [getAppEngine, getAppSettings, isElectron, userProfile?.settings])
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoading || isEditing) return
|
||||
@ -118,12 +142,17 @@ const Settings = () => {
|
||||
(branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
|
||||
branches[0] ||
|
||||
'Not configured'
|
||||
const currentEngine =
|
||||
normalizeAppUpdateEngine(appSettings.appUpdateEngine) ||
|
||||
normalizeAppUpdateEngine(runningEngine) ||
|
||||
DEFAULT_UPDATE_ENGINE
|
||||
|
||||
const startEditing = () => {
|
||||
setDraftSettings({
|
||||
...appSettings,
|
||||
appUpdateBranch:
|
||||
currentBranch === 'Not configured' ? undefined : currentBranch,
|
||||
appUpdateEngine: currentEngine,
|
||||
theme: currentThemeValue,
|
||||
density: currentDensityValue
|
||||
})
|
||||
@ -139,12 +168,18 @@ const Settings = () => {
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const nextEngine =
|
||||
normalizeAppUpdateEngine(draftSettings.appUpdateEngine) ||
|
||||
currentEngine
|
||||
const nextSettings = {
|
||||
...appSettings,
|
||||
theme: draftSettings.theme,
|
||||
density: draftSettings.density,
|
||||
...(isElectron
|
||||
? { appUpdateBranch: draftSettings.appUpdateBranch }
|
||||
? {
|
||||
appUpdateBranch: draftSettings.appUpdateBranch,
|
||||
appUpdateEngine: nextEngine
|
||||
}
|
||||
: {})
|
||||
}
|
||||
const saved = isElectron
|
||||
@ -173,6 +208,16 @@ const Settings = () => {
|
||||
setDraftSettings(nextSettings)
|
||||
setIsEditing(false)
|
||||
showSuccess('Settings saved.')
|
||||
|
||||
const appUpdateSettingsChanged =
|
||||
isElectron &&
|
||||
(nextSettings.appUpdateBranch !== appSettings.appUpdateBranch ||
|
||||
nextSettings.appUpdateEngine !==
|
||||
normalizeAppUpdateEngine(appSettings.appUpdateEngine))
|
||||
|
||||
if (appUpdateSettingsChanged) {
|
||||
void recheckForUpdates()
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@ -196,7 +241,9 @@ const Settings = () => {
|
||||
startEditing={startEditing}
|
||||
formValid={
|
||||
Boolean(draftSettings.theme && draftSettings.density) &&
|
||||
(!isElectron || Boolean(draftSettings.appUpdateBranch))
|
||||
(!isElectron ||
|
||||
(Boolean(draftSettings.appUpdateBranch) &&
|
||||
Boolean(normalizeAppUpdateEngine(draftSettings.appUpdateEngine))))
|
||||
}
|
||||
disabled={settingsLoading || (!isElectron && !userProfile)}
|
||||
loading={saving}
|
||||
@ -297,6 +344,29 @@ const Settings = () => {
|
||||
<Text>{currentBranch}</Text>
|
||||
)}
|
||||
</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>
|
||||
</InfoCollapse>
|
||||
)}
|
||||
|
||||
@ -38,6 +38,7 @@ import SettingsIcon from '../../Icons/SettingsIcon'
|
||||
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
||||
import { ElectronContext } from '../context/ElectronContext'
|
||||
import DashboardWindowButtons from './DashboardWindowButtons'
|
||||
import WindowAppMenu from './WindowAppMenu'
|
||||
import WebAppSwitcher from './WebAppSwitcher'
|
||||
import {
|
||||
getSidebarDefaultPath,
|
||||
@ -52,7 +53,8 @@ const DashboardNavigation = () => {
|
||||
const { userProfile } = useContext(AuthContext)
|
||||
const { showSpotlight } = useContext(SpotlightContext)
|
||||
const { connecting, connected } = useContext(ApiServerContext)
|
||||
const { toggleNotificationCenter, unreadCount, notificationCenterVisible } =
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
const { toggleNotificationCenter, unreadCount } =
|
||||
useContext(NotificationContext)
|
||||
const [apiServerState, setApiServerState] = useState('disconnected')
|
||||
const navigate = useNavigate()
|
||||
@ -64,7 +66,7 @@ const DashboardNavigation = () => {
|
||||
icon: <ProductionIcon />
|
||||
})
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { platform, isElectron, isFullScreen, setSidebarViewMenu } =
|
||||
const { platform, isElectron, setSidebarViewMenu, isFullScreen } =
|
||||
useContext(ElectronContext)
|
||||
const { availableUpdate, checkForUpdates } = useAppUpdateContext()
|
||||
const mainMenuItems = useMemo(
|
||||
@ -72,26 +74,31 @@ const DashboardNavigation = () => {
|
||||
{
|
||||
key: 'production',
|
||||
label: 'Production',
|
||||
className: 'electrobun-webkit-app-region-no-drag',
|
||||
icon: <ProductionIcon />
|
||||
},
|
||||
{
|
||||
key: 'inventory',
|
||||
label: 'Inventory',
|
||||
className: 'electrobun-webkit-app-region-no-drag',
|
||||
icon: <InventoryIcon />
|
||||
},
|
||||
{
|
||||
key: 'sales',
|
||||
label: 'Sales',
|
||||
className: 'electrobun-webkit-app-region-no-drag',
|
||||
icon: <SalesIcon />
|
||||
},
|
||||
{
|
||||
key: 'finance',
|
||||
label: 'Finance',
|
||||
className: 'electrobun-webkit-app-region-no-drag',
|
||||
icon: <FinanceIcon />
|
||||
},
|
||||
{
|
||||
key: 'management',
|
||||
label: 'Management',
|
||||
className: 'electrobun-webkit-app-region-no-drag',
|
||||
icon: <SettingsIcon />
|
||||
}
|
||||
],
|
||||
@ -135,28 +142,26 @@ const DashboardNavigation = () => {
|
||||
setSidebarViewMenu(sections)
|
||||
}, [isElectron, setSidebarViewMenu])
|
||||
|
||||
const showAppLogo =
|
||||
(isElectron && platform == 'darwin' && isFullScreen == true) ||
|
||||
(isElectron && platform != 'darwin')
|
||||
const isMacOSApp = isElectron && platform == 'darwin'
|
||||
const isOtherApp = isElectron && platform != 'darwin'
|
||||
|
||||
const showDesktopLogo = !isElectron && !isMobile
|
||||
const showMobileLogo = !isElectron && isMobile
|
||||
|
||||
const showControls = !isElectron || authenticated
|
||||
|
||||
const navigationContents = (
|
||||
<Flex style={{ width: '100%' }} align='center'>
|
||||
{isMacOSApp ? <DashboardWindowButtons /> : null}
|
||||
{showAppLogo == true && (
|
||||
<FarmControlLogoSmall
|
||||
style={{
|
||||
fontSize: '46px',
|
||||
height: '16px',
|
||||
marginLeft: '13px',
|
||||
marginRight: '0px'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isOtherApp ? (
|
||||
<>
|
||||
<WindowAppMenu />{' '}
|
||||
<Divider
|
||||
type='vertical'
|
||||
style={{ height: '14px', margin: '3px 3px 0 1.5px' }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{showDesktopLogo == true ? (
|
||||
<FarmControlLogo
|
||||
style={{
|
||||
@ -199,134 +204,156 @@ const DashboardNavigation = () => {
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Menu
|
||||
mode='horizontal'
|
||||
className={isElectron ? 'electron-navigation' : null}
|
||||
items={mainMenuItems}
|
||||
style={{
|
||||
flexWrap: 'wrap',
|
||||
flexGrow: isMobile ? 0 : 1,
|
||||
border: 0,
|
||||
width: isMobile ? '64px' : 'unset'
|
||||
}}
|
||||
onClick={handleMainMenuClick}
|
||||
selectedKeys={[selectedKey]}
|
||||
overflowedIndicator={
|
||||
<Button
|
||||
type='text'
|
||||
icon={<MenuIcon />}
|
||||
style={{ marginBottom: '4px' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{isMobile && <div style={{ flexGrow: 1 }} />}
|
||||
<Flex
|
||||
gap={'small'}
|
||||
align='center'
|
||||
style={{ marginTop: '-2px', marginRight: '6px' }}
|
||||
>
|
||||
<Space style={{ paddingTop: '2px', marginRight: '8px' }}>
|
||||
<WebAppSwitcher />
|
||||
<KeyboardShortcut
|
||||
shortcut='alt+q'
|
||||
hint='ALT Q'
|
||||
onTrigger={() => showSpotlight()}
|
||||
>
|
||||
<Button
|
||||
icon={<SearchIcon />}
|
||||
type='text'
|
||||
style={{ marginTop: '4px' }}
|
||||
onClick={() => showSpotlight()}
|
||||
/>
|
||||
</KeyboardShortcut>
|
||||
<Badge
|
||||
count={unreadCount}
|
||||
size='small'
|
||||
offset={[-5, 8]}
|
||||
style={{ padding: 0, fontWeight: 600 }}
|
||||
>
|
||||
<KeyboardShortcut
|
||||
shortcut='alt+n'
|
||||
hint='ALT N'
|
||||
onTrigger={() => toggleNotificationCenter()}
|
||||
>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
{showControls && (
|
||||
<Menu
|
||||
mode='horizontal'
|
||||
className={isElectron ? 'electron-navigation' : null}
|
||||
items={mainMenuItems}
|
||||
style={{
|
||||
flexWrap: 'wrap',
|
||||
flexGrow: isMobile ? 0 : 1,
|
||||
border: 0,
|
||||
width: isMobile ? '64px' : 'unset'
|
||||
}}
|
||||
onClick={handleMainMenuClick}
|
||||
selectedKeys={[selectedKey]}
|
||||
overflowedIndicator={
|
||||
<Button
|
||||
icon={<BellIcon />}
|
||||
type='text'
|
||||
style={{ marginTop: '2px' }}
|
||||
onClick={() => toggleNotificationCenter()}
|
||||
icon={<MenuIcon />}
|
||||
style={{ marginBottom: '4px' }}
|
||||
/>
|
||||
</KeyboardShortcut>
|
||||
</Badge>
|
||||
</Space>
|
||||
{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
|
||||
color='yellow'
|
||||
style={{ marginRight: 0 }}
|
||||
icon={<DeveloperIcon />}
|
||||
onClick={() => {
|
||||
navigate('/dashboard/developer/sessionstorage')
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{userProfile ? (
|
||||
<Space>
|
||||
<Popover
|
||||
content={userPopoverContent}
|
||||
placement='bottomRight'
|
||||
trigger='hover'
|
||||
open={userPopoverOpen}
|
||||
onOpenChange={setUserPopoverOpen}
|
||||
arrow={false}
|
||||
>
|
||||
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
|
||||
{!isMobile && (userProfile?.name || userProfile.username)}
|
||||
</Tag>
|
||||
</Popover>
|
||||
</Space>
|
||||
) : null}
|
||||
{isElectron && availableUpdate ? (
|
||||
<Tag
|
||||
icon={<CloudIcon />}
|
||||
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
|
||||
color='cyan'
|
||||
onClick={() => checkForUpdates()}
|
||||
{!showControls && (
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
marginLeft: '8px',
|
||||
userSelect: 'none',
|
||||
'--webkit-user-select': 'none'
|
||||
}}
|
||||
>
|
||||
Update Available
|
||||
</Tag>
|
||||
) : null}
|
||||
</Flex>
|
||||
Farm Control
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{isMobile && <div style={{ flexGrow: 1 }} />}
|
||||
|
||||
<div className='electrobun-webkit-app-region-no-drag'>
|
||||
<Flex
|
||||
gap={'small'}
|
||||
align='center'
|
||||
style={{ marginTop: '-2px', marginRight: '6px' }}
|
||||
>
|
||||
{showControls && (
|
||||
<Space style={{ paddingTop: '2px', marginRight: '8px' }}>
|
||||
<WebAppSwitcher />
|
||||
<KeyboardShortcut
|
||||
shortcut='alt+q'
|
||||
hint='ALT Q'
|
||||
onTrigger={() => showSpotlight()}
|
||||
>
|
||||
<Button
|
||||
icon={<SearchIcon />}
|
||||
type='text'
|
||||
style={{ marginTop: '4px' }}
|
||||
onClick={() => showSpotlight()}
|
||||
/>
|
||||
</KeyboardShortcut>
|
||||
<Badge
|
||||
count={unreadCount}
|
||||
size='small'
|
||||
offset={[-5, 8]}
|
||||
style={{ padding: 0, fontWeight: 600 }}
|
||||
>
|
||||
<KeyboardShortcut
|
||||
shortcut='alt+n'
|
||||
hint='ALT N'
|
||||
onTrigger={() => toggleNotificationCenter()}
|
||||
>
|
||||
<Button
|
||||
icon={<BellIcon />}
|
||||
type='text'
|
||||
style={{ marginTop: '2px' }}
|
||||
onClick={() => toggleNotificationCenter()}
|
||||
/>
|
||||
</KeyboardShortcut>
|
||||
</Badge>
|
||||
</Space>
|
||||
)}
|
||||
{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
|
||||
color='yellow'
|
||||
style={{ marginRight: 0 }}
|
||||
icon={<DeveloperIcon />}
|
||||
onClick={() => {
|
||||
navigate('/dashboard/developer/sessionstorage')
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
{showControls && userProfile ? (
|
||||
<Space>
|
||||
<Popover
|
||||
content={userPopoverContent}
|
||||
placement='bottomRight'
|
||||
trigger='hover'
|
||||
open={userPopoverOpen}
|
||||
onOpenChange={setUserPopoverOpen}
|
||||
arrow={false}
|
||||
>
|
||||
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
|
||||
{!isMobile && (userProfile?.name || userProfile.username)}
|
||||
</Tag>
|
||||
</Popover>
|
||||
</Space>
|
||||
) : null}
|
||||
{showControls && isElectron && availableUpdate ? (
|
||||
<Tag
|
||||
icon={<CloudIcon />}
|
||||
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
|
||||
color='cyan'
|
||||
onClick={() => checkForUpdates()}
|
||||
>
|
||||
Update Available
|
||||
</Tag>
|
||||
) : null}
|
||||
</Flex>
|
||||
</div>
|
||||
{isOtherApp ? <DashboardWindowButtons /> : null}
|
||||
</Flex>
|
||||
)
|
||||
@ -335,7 +362,7 @@ const DashboardNavigation = () => {
|
||||
<>
|
||||
{isElectron ? (
|
||||
<Flex
|
||||
className={`ant-menu-horizontal ant-menu-light${!notificationCenterVisible ? ' electron-navigation-wrapper' : ''}`}
|
||||
className={`ant-menu-horizontal electron-navigation-wrapper ant-menu-light ${!isFullScreen ? 'electrobun-webkit-app-region-drag' : 'electrobun-webkit-app-region-no-drag'}`}
|
||||
style={{ lineHeight: '40px', padding: '0 2px 0 2px' }}
|
||||
>
|
||||
{navigationContents}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { useContext } from 'react'
|
||||
import { Flex, Button } from 'antd'
|
||||
import { Flex, Button, Divider } from 'antd'
|
||||
import { ElectronContext } from '../context/ElectronContext'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import MinusIcon from '../../Icons/MinusIcon'
|
||||
import ContractIcon from '../../Icons/ContractIcon'
|
||||
import ExpandIcon from '../../Icons/ExpandIcon'
|
||||
import MacOSTrafficLights from './MacOSTrafficLights'
|
||||
|
||||
const DashboardWindowButtons = () => {
|
||||
const { isMaximized, handleWindowControl, platform, isFullScreen } =
|
||||
@ -17,14 +18,21 @@ const DashboardWindowButtons = () => {
|
||||
onClick={() => handleWindowControl('close')}
|
||||
/>
|
||||
)
|
||||
const maximizeButton = (
|
||||
const minimizeButton = (
|
||||
<Button
|
||||
icon={<MinusIcon />}
|
||||
type={'text'}
|
||||
onClick={() => handleWindowControl('minimize')}
|
||||
/>
|
||||
)
|
||||
const minimizeButton = (
|
||||
const fullscreenButton = (
|
||||
<Button
|
||||
icon={isFullScreen ? <ContractIcon /> : <ExpandIcon />}
|
||||
type={'text'}
|
||||
onClick={() => handleWindowControl('fullscreen')}
|
||||
/>
|
||||
)
|
||||
const maximizeButton = (
|
||||
<Button
|
||||
icon={isMaximized ? <ContractIcon /> : <ExpandIcon />}
|
||||
type={'text'}
|
||||
@ -32,20 +40,41 @@ 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 (
|
||||
<Flex align='center'>
|
||||
{platform == 'darwin' ? (
|
||||
isFullScreen == false ? (
|
||||
<div style={{ width: '80px' }} />
|
||||
) : null
|
||||
<>
|
||||
<MacOSTrafficLights />
|
||||
<Divider
|
||||
type='vertical'
|
||||
style={{ height: '14px', margin: '3px 6px 0 0' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ width: '95px', marginRight: '2.5px' }}>
|
||||
<Flex style={{ position: 'relative', zIndex: 9999 }}>
|
||||
{maximizeButton}
|
||||
{minimizeButton}
|
||||
{closeButton}
|
||||
</Flex>
|
||||
</div>
|
||||
customWindowControls
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
|
||||
212
src/components/Dashboard/common/MacOSTrafficLights.jsx
Normal file
@ -0,0 +1,212 @@
|
||||
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
|
||||
256
src/components/Dashboard/common/WindowAppMenu.jsx
Normal file
@ -0,0 +1,256 @@
|
||||
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 }) => (
|
||||
<Dropdown menu={{ items }} trigger={['click']} placement='bottomLeft'>
|
||||
<Button
|
||||
type='text'
|
||||
size='small'
|
||||
className='electrobun-webkit-app-region-no-drag'
|
||||
style={{
|
||||
height: '28px',
|
||||
paddingInline: '8px',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)
|
||||
|
||||
MenuButton.propTypes = {
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
|
||||
items: PropTypes.array.isRequired
|
||||
}
|
||||
|
||||
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}
|
||||
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
|
||||
@ -20,10 +20,22 @@ import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
|
||||
const { Text } = Typography
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
|
||||
const DEFAULT_MODEL_WIDTH = 710
|
||||
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||
const DEFAULT_UPDATE_ENGINE = 'native'
|
||||
const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER
|
||||
const APP_UPDATE_DISMISSED_KEY = 'appUpdateDismissed'
|
||||
|
||||
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 = () => {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(APP_UPDATE_DISMISSED_KEY)
|
||||
@ -42,7 +54,9 @@ const isUpdateDismissed = (update) => {
|
||||
return (
|
||||
dismissed.version === update.version &&
|
||||
dismissed.buildNumber === update.buildNumber &&
|
||||
dismissed.branch === update.branch
|
||||
dismissed.branch === update.branch &&
|
||||
normalizeAppUpdateEngine(dismissed.engine) ===
|
||||
normalizeAppUpdateEngine(update.engine)
|
||||
)
|
||||
}
|
||||
|
||||
@ -54,7 +68,8 @@ const saveDismissedUpdate = (update) => {
|
||||
JSON.stringify({
|
||||
version: update.version,
|
||||
buildNumber: update.buildNumber,
|
||||
branch: update.branch
|
||||
branch: update.branch,
|
||||
engine: normalizeAppUpdateEngine(update.engine) || DEFAULT_UPDATE_ENGINE
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -109,23 +124,41 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
|
||||
useContext(ApiServerContext)
|
||||
const { token } = useContext(AuthContext)
|
||||
const { isElectron, getAppSettings, startAppUpdate, onAppUpdateProgress } =
|
||||
useContext(ElectronContext)
|
||||
const {
|
||||
isElectron,
|
||||
getAppSettings,
|
||||
setAppSettings,
|
||||
getAppEngine,
|
||||
startAppUpdate,
|
||||
checkAppUpdateResult,
|
||||
checkDuplicateInstallations,
|
||||
removeDuplicateInstallations,
|
||||
onDuplicateInstallationsRemoved,
|
||||
onAppUpdateProgress,
|
||||
onCheckForUpdatesRequest
|
||||
} = useContext(ElectronContext)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [noUpdateOpen, setNoUpdateOpen] = useState(false)
|
||||
const [availableUpdate, setAvailableUpdate] = useState(null)
|
||||
const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
|
||||
const [installingUpdate, setInstallingUpdate] = 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 updateCheckDependenciesRef = useRef({})
|
||||
|
||||
const [modelWidth, setModelWidth] = useState(650)
|
||||
const [modelWidth, setModelWidth] = useState(DEFAULT_MODEL_WIDTH)
|
||||
|
||||
updateCheckDependenciesRef.current = {
|
||||
fetchAppUpdateBranches,
|
||||
fetchAppUpdateCurrent,
|
||||
getAppSettings,
|
||||
setAppSettings,
|
||||
getAppEngine,
|
||||
isElectron,
|
||||
token
|
||||
}
|
||||
@ -135,6 +168,8 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
fetchAppUpdateBranches,
|
||||
fetchAppUpdateCurrent,
|
||||
getAppSettings,
|
||||
setAppSettings,
|
||||
getAppEngine,
|
||||
isElectron,
|
||||
token
|
||||
} = updateCheckDependenciesRef.current
|
||||
@ -143,9 +178,10 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
if (runningCheckRef.current) return runningCheckRef.current
|
||||
|
||||
const checkPromise = (async () => {
|
||||
const [branches, appSettings] = await Promise.all([
|
||||
const [branches, appSettings, runningEngine] = await Promise.all([
|
||||
fetchAppUpdateBranches(),
|
||||
getAppSettings()
|
||||
getAppSettings(),
|
||||
getAppEngine()
|
||||
])
|
||||
const configuredBranch = appSettings?.appUpdateBranch
|
||||
const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH)
|
||||
@ -157,11 +193,52 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
|
||||
if (!selectedBranch) return null
|
||||
|
||||
const update = await fetchAppUpdateCurrent(selectedBranch)
|
||||
const selectedEngine =
|
||||
normalizeAppUpdateEngine(appSettings?.appUpdateEngine) ||
|
||||
normalizeAppUpdateEngine(runningEngine) ||
|
||||
DEFAULT_UPDATE_ENGINE
|
||||
const currentRunningEngine =
|
||||
normalizeAppUpdateEngine(runningEngine) || DEFAULT_UPDATE_ENGINE
|
||||
|
||||
return isAppUpdateAvailable(update, appVersion, CURRENT_BUILD_NUMBER)
|
||||
? update
|
||||
: null
|
||||
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)
|
||||
if (!update) return null
|
||||
|
||||
const newerVersionAvailable = isAppUpdateAvailable(
|
||||
update,
|
||||
appVersion,
|
||||
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
|
||||
@ -181,6 +258,7 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
setNoUpdateOpen(false)
|
||||
setAvailableUpdate(update)
|
||||
if (forcePrompt || !isUpdateDismissed(update)) {
|
||||
setModelWidth(DEFAULT_MODEL_WIDTH)
|
||||
setUpdatePromptOpen(true)
|
||||
}
|
||||
}
|
||||
@ -205,6 +283,11 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
}
|
||||
}, [isElectron, showUpdateIfAvailable])
|
||||
|
||||
const recheckForUpdates = useCallback(async () => {
|
||||
if (!isElectron) return null
|
||||
return showUpdateIfAvailable({ forcePrompt: true })
|
||||
}, [isElectron, showUpdateIfAvailable])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron) return undefined
|
||||
|
||||
@ -219,6 +302,54 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!isElectron || !onAppUpdateProgress) return undefined
|
||||
|
||||
@ -227,6 +358,14 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
})
|
||||
}, [isElectron, onAppUpdateProgress])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron || !onCheckForUpdatesRequest) return undefined
|
||||
|
||||
return onCheckForUpdatesRequest(() => {
|
||||
void checkForUpdates()
|
||||
})
|
||||
}, [isElectron, onCheckForUpdatesRequest, checkForUpdates])
|
||||
|
||||
const dismissUpdatePrompt = () => {
|
||||
if (availableUpdate) {
|
||||
saveDismissedUpdate(availableUpdate)
|
||||
@ -252,10 +391,20 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await startAppUpdate(update)
|
||||
const appSettings = await getAppSettings()
|
||||
const engine =
|
||||
normalizeAppUpdateEngine(update?.engine) ||
|
||||
normalizeAppUpdateEngine(appSettings?.appUpdateEngine) ||
|
||||
DEFAULT_UPDATE_ENGINE
|
||||
const result = await startAppUpdate({
|
||||
...update,
|
||||
engine
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
throw new Error('App updates are only available in the desktop app.')
|
||||
throw new Error(
|
||||
'Failed to start the app update. Please restart the app and try again.'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
setUpdateProgress({
|
||||
@ -266,12 +415,33 @@ 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 updateModalBusy =
|
||||
Boolean(installingUpdate) && updateProgress?.phase !== 'error'
|
||||
|
||||
return (
|
||||
<AppUpdateContext.Provider value={{ availableUpdate, checkForUpdates }}>
|
||||
<AppUpdateContext.Provider
|
||||
value={{ availableUpdate, checkForUpdates, recheckForUpdates }}
|
||||
>
|
||||
{children}
|
||||
<Modal
|
||||
open={checking}
|
||||
@ -344,6 +514,98 @@ export const AppUpdateProvider = ({ children }) => {
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title={
|
||||
<Flex align='center' gap='middle'>
|
||||
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
|
||||
Update Installed
|
||||
</Flex>
|
||||
}
|
||||
open={Boolean(completedUpdate)}
|
||||
style={{ maxWidth: 430 }}
|
||||
centered
|
||||
onCancel={() => setCompletedUpdate(null)}
|
||||
footer={[
|
||||
<Button
|
||||
key='ok'
|
||||
type='primary'
|
||||
onClick={() => setCompletedUpdate(null)}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Text>
|
||||
Farm Control was successfully updated to version{' '}
|
||||
{completedUpdate?.current?.version || appVersion}
|
||||
{completedUpdate?.previous?.version &&
|
||||
completedUpdate.previous.version !== completedUpdate?.current?.version
|
||||
? ` (previously ${completedUpdate.previous.version})`
|
||||
: ''}
|
||||
.
|
||||
</Text>
|
||||
</Modal>
|
||||
<Modal
|
||||
title='Duplicate Installation Found'
|
||||
open={Boolean(duplicatePromptOpen && duplicateInstall)}
|
||||
style={{ maxWidth: 480 }}
|
||||
centered
|
||||
closable={!removingDuplicate}
|
||||
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>
|
||||
<Text code>{duplicateInstall?.duplicatePath}</Text>
|
||||
<Text>Do you want to remove the duplicate installation?</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</AppUpdateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
import { createContext, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
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
|
||||
export function isElectron() {
|
||||
// Renderer process
|
||||
if (isElectrobunDesktop()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
@ -19,7 +23,6 @@ export function isElectron() {
|
||||
return true
|
||||
}
|
||||
|
||||
// User agent
|
||||
if (
|
||||
typeof navigator === 'object' &&
|
||||
typeof navigator.userAgent === 'string' &&
|
||||
@ -27,6 +30,7 @@ export function isElectron() {
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@ -52,6 +56,7 @@ const ElectronProvider = ({ children }) => {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
const [isFullScreen, setIsFullScreen] = useState(false)
|
||||
const [electronAvailable] = useState(isElectron())
|
||||
const useElectrobun = isElectrobunBridgeReady() || isElectrobunDesktop()
|
||||
const navigate = useNavigate()
|
||||
const lastNavigationAtRef = useRef(0)
|
||||
|
||||
@ -70,8 +75,22 @@ const ElectronProvider = ({ children }) => {
|
||||
[navigate]
|
||||
)
|
||||
|
||||
// Function to open external URL via Electron
|
||||
const applyWindowState = useCallback((state) => {
|
||||
if (state && typeof state.isMaximized === 'boolean') {
|
||||
setIsMaximized(state.isMaximized)
|
||||
}
|
||||
if (state && typeof state.isFullScreen === 'boolean') {
|
||||
setIsFullScreen(state.isFullScreen)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openExternalUrl = (url) => {
|
||||
if (useElectrobun) {
|
||||
void desktopBridge.openExternalUrl(url).catch((error) => {
|
||||
console.warn('[ElectronContext] Failed to open external url:', error)
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (electronAvailable && ipcRenderer) {
|
||||
ipcRenderer.invoke('open-external-url', url)
|
||||
return true
|
||||
@ -79,8 +98,13 @@ const ElectronProvider = ({ children }) => {
|
||||
return false
|
||||
}
|
||||
|
||||
// Function to open internal URL via Electron
|
||||
const openInternalUrl = (url) => {
|
||||
if (useElectrobun) {
|
||||
void desktopBridge.openInternalUrl(url).catch((error) => {
|
||||
console.warn('[ElectronContext] Failed to open internal url:', error)
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (electronAvailable && ipcRenderer) {
|
||||
ipcRenderer.invoke('open-internal-url', url)
|
||||
return true
|
||||
@ -89,36 +113,66 @@ const ElectronProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAvailable) return
|
||||
|
||||
if (useElectrobun) {
|
||||
desktopBridge.getOsInfo().then((info) => {
|
||||
if (info?.platform) {
|
||||
setPlatform(info.platform)
|
||||
if (info.platform === 'darwin') {
|
||||
document.documentElement.classList.add('macos-vibrancy')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
desktopBridge.getWindowState().then(applyWindowState)
|
||||
|
||||
const unsubWindowState = desktopBridge.onMessage(
|
||||
'windowState',
|
||||
applyWindowState
|
||||
)
|
||||
const unsubNavigate = desktopBridge.onMessage('navigate', (url) => {
|
||||
if (url.toLowerCase() == '/favicon.ico') {
|
||||
return
|
||||
}
|
||||
console.log('[ElectronContext] Navigating to:', url)
|
||||
navigate(url)
|
||||
})
|
||||
const unsubNavigationGesture = desktopBridge.onMessage(
|
||||
'navigationGesture',
|
||||
navigateHistory
|
||||
)
|
||||
|
||||
return () => {
|
||||
unsubWindowState()
|
||||
unsubNavigate()
|
||||
unsubNavigationGesture()
|
||||
}
|
||||
}
|
||||
|
||||
if (!ipcRenderer) return
|
||||
|
||||
// Get initial platform
|
||||
ipcRenderer.invoke('os-info').then((info) => {
|
||||
if (info && info.platform) setPlatform(info.platform)
|
||||
})
|
||||
|
||||
// Get initial window state
|
||||
ipcRenderer.invoke('window-state').then((state) => {
|
||||
if (state && typeof state.isMaximized === 'boolean') {
|
||||
setIsMaximized(state.isMaximized)
|
||||
}
|
||||
if (state && typeof state.isFullScreen === 'boolean') {
|
||||
setIsFullScreen(state.isFullScreen)
|
||||
if (info?.platform) {
|
||||
setPlatform(info.platform)
|
||||
if (info.platform === 'darwin') {
|
||||
document.documentElement.classList.add('macos-vibrancy')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for window state changes
|
||||
const windowStateHandler = (event, state) => {
|
||||
if (state && typeof state.isMaximized === 'boolean') {
|
||||
setIsMaximized(state.isMaximized)
|
||||
}
|
||||
if (state && typeof state.isFullScreen === 'boolean') {
|
||||
setIsFullScreen(state.isFullScreen)
|
||||
}
|
||||
ipcRenderer.invoke('window-state').then(applyWindowState)
|
||||
|
||||
const windowStateHandler = (_event, state) => {
|
||||
applyWindowState(state)
|
||||
}
|
||||
ipcRenderer.on('window-state', windowStateHandler)
|
||||
|
||||
// Listen for navigate
|
||||
const navigateHandler = (event, url) => {
|
||||
const navigateHandler = (_event, url) => {
|
||||
if (url.toLowerCase() == '/favicon.ico') {
|
||||
return
|
||||
}
|
||||
console.log('[ElectronContext] Navigating to:', url)
|
||||
navigate(url)
|
||||
}
|
||||
ipcRenderer.on('navigate', navigateHandler)
|
||||
@ -133,7 +187,13 @@ const ElectronProvider = ({ children }) => {
|
||||
ipcRenderer.removeListener('navigation-gesture', navigationGestureHandler)
|
||||
ipcRenderer.removeListener('window-state', windowStateHandler)
|
||||
}
|
||||
}, [navigate, navigateHistory])
|
||||
}, [
|
||||
applyWindowState,
|
||||
electronAvailable,
|
||||
navigate,
|
||||
navigateHistory,
|
||||
useElectrobun
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAvailable || platform !== 'darwin') return
|
||||
@ -142,7 +202,8 @@ const ElectronProvider = ({ children }) => {
|
||||
let resetTimer
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)
|
||||
return
|
||||
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return
|
||||
if (Math.abs(event.deltaX) < 2) return
|
||||
if (isInHorizontalScrollContainer(event.target)) return
|
||||
@ -165,56 +226,119 @@ const ElectronProvider = ({ children }) => {
|
||||
}
|
||||
}, [electronAvailable, navigateHistory, platform])
|
||||
|
||||
// Window control handler
|
||||
const handleWindowControl = (action) => {
|
||||
if (useElectrobun) {
|
||||
void desktopBridge.windowControl(action)
|
||||
return
|
||||
}
|
||||
if (electronAvailable && ipcRenderer) {
|
||||
ipcRenderer.send('window-control', action)
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthSession = async () => {
|
||||
if (!electronAvailable || !ipcRenderer) return null
|
||||
if (!electronAvailable) return null
|
||||
if (useElectrobun) return await desktopBridge.getAuthSession()
|
||||
if (!ipcRenderer) return null
|
||||
return await ipcRenderer.invoke('auth-session-get')
|
||||
}
|
||||
|
||||
const setAuthSession = async (session) => {
|
||||
if (!electronAvailable || !ipcRenderer) return false
|
||||
if (!electronAvailable) return false
|
||||
if (useElectrobun) {
|
||||
const result = await desktopBridge.setAuthSession(session)
|
||||
return result?.ok ?? false
|
||||
}
|
||||
if (!ipcRenderer) return false
|
||||
return await ipcRenderer.invoke('auth-session-set', session)
|
||||
}
|
||||
|
||||
const clearAuthSession = async () => {
|
||||
if (!electronAvailable || !ipcRenderer) return false
|
||||
if (!electronAvailable) return false
|
||||
if (useElectrobun) {
|
||||
const result = await desktopBridge.clearAuthSession()
|
||||
return result?.ok ?? false
|
||||
}
|
||||
if (!ipcRenderer) return false
|
||||
return await ipcRenderer.invoke('auth-session-clear')
|
||||
}
|
||||
|
||||
const getAppSettings = useCallback(async () => {
|
||||
if (!electronAvailable || !ipcRenderer) return {}
|
||||
if (!electronAvailable) return {}
|
||||
if (useElectrobun) return await desktopBridge.getAppSettings()
|
||||
if (!ipcRenderer) return {}
|
||||
return await ipcRenderer.invoke('app-settings-get')
|
||||
}, [electronAvailable])
|
||||
}, [electronAvailable, useElectrobun])
|
||||
|
||||
const setAppSettings = useCallback(
|
||||
async (settings) => {
|
||||
if (!electronAvailable || !ipcRenderer) return false
|
||||
if (!electronAvailable) return false
|
||||
if (useElectrobun) {
|
||||
const result = await desktopBridge.setAppSettings(settings)
|
||||
return result?.ok ?? false
|
||||
}
|
||||
if (!ipcRenderer) return false
|
||||
return await ipcRenderer.invoke('app-settings-set', settings)
|
||||
},
|
||||
[electronAvailable]
|
||||
[electronAvailable, useElectrobun]
|
||||
)
|
||||
|
||||
const startAppUpdate = useCallback(
|
||||
async (update) => {
|
||||
if (!electronAvailable || !ipcRenderer) return false
|
||||
if (!electronAvailable) return false
|
||||
if (useElectrobun) {
|
||||
const result = await desktopBridge.startAppUpdate(update)
|
||||
return result?.ok ?? false
|
||||
}
|
||||
if (!ipcRenderer) return false
|
||||
return await ipcRenderer.invoke('app-update-start', update)
|
||||
},
|
||||
[electronAvailable]
|
||||
[electronAvailable, useElectrobun]
|
||||
)
|
||||
|
||||
const checkAppUpdateResult = useCallback(async () => {
|
||||
if (!electronAvailable || !useElectrobun) return null
|
||||
return await desktopBridge.checkAppUpdateResult()
|
||||
}, [electronAvailable, useElectrobun])
|
||||
|
||||
const checkDuplicateInstallations = useCallback(async () => {
|
||||
if (!electronAvailable || !useElectrobun) return null
|
||||
return await desktopBridge.checkDuplicateInstallations()
|
||||
}, [electronAvailable, useElectrobun])
|
||||
|
||||
const removeDuplicateInstallations = useCallback(async () => {
|
||||
if (!electronAvailable || !useElectrobun) return false
|
||||
const result = await desktopBridge.removeDuplicateInstallations()
|
||||
return result?.ok ?? false
|
||||
}, [electronAvailable, useElectrobun])
|
||||
|
||||
const onDuplicateInstallationsRemoved = useCallback(
|
||||
(handler) => {
|
||||
if (
|
||||
!electronAvailable ||
|
||||
!useElectrobun ||
|
||||
typeof handler !== 'function'
|
||||
) {
|
||||
return () => {}
|
||||
}
|
||||
return desktopBridge.onMessage('duplicateInstallationsRemoved', handler)
|
||||
},
|
||||
[electronAvailable, useElectrobun]
|
||||
)
|
||||
|
||||
const onAppUpdateProgress = useCallback(
|
||||
(handler) => {
|
||||
if (!electronAvailable || !ipcRenderer || typeof handler !== 'function') {
|
||||
if (!electronAvailable || typeof handler !== 'function') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const progressHandler = (event, progress) => {
|
||||
if (useElectrobun) {
|
||||
return desktopBridge.onMessage('appUpdateProgress', handler)
|
||||
}
|
||||
|
||||
if (!ipcRenderer) return () => {}
|
||||
|
||||
const progressHandler = (_event, progress) => {
|
||||
handler(progress)
|
||||
}
|
||||
|
||||
@ -224,10 +348,34 @@ const ElectronProvider = ({ children }) => {
|
||||
ipcRenderer.removeListener('app-update-progress', progressHandler)
|
||||
}
|
||||
},
|
||||
[electronAvailable]
|
||||
[electronAvailable, useElectrobun]
|
||||
)
|
||||
|
||||
const onCheckForUpdatesRequest = useCallback(
|
||||
(handler) => {
|
||||
if (!electronAvailable || typeof handler !== 'function') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
if (useElectrobun) {
|
||||
return desktopBridge.onMessage('checkForUpdates', handler)
|
||||
}
|
||||
|
||||
if (!ipcRenderer) return () => {}
|
||||
|
||||
const checkHandler = () => {
|
||||
handler()
|
||||
}
|
||||
|
||||
ipcRenderer.on('check-for-updates', checkHandler)
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener('check-for-updates', checkHandler)
|
||||
}
|
||||
},
|
||||
[electronAvailable, useElectrobun]
|
||||
)
|
||||
|
||||
// Backwards-compatible helpers
|
||||
const getToken = async () => {
|
||||
const session = await getAuthSession()
|
||||
return session?.token || null
|
||||
@ -239,8 +387,13 @@ const ElectronProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
const resizeSpotlightWindow = async (height) => {
|
||||
if (!electronAvailable || !ipcRenderer) return false
|
||||
if (!electronAvailable) return false
|
||||
try {
|
||||
if (useElectrobun) {
|
||||
const result = await desktopBridge.resizeSpotlightWindow(height)
|
||||
return result?.ok ?? false
|
||||
}
|
||||
if (!ipcRenderer) return false
|
||||
return await ipcRenderer.invoke('spotlight-window-resize', height)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@ -253,16 +406,32 @@ const ElectronProvider = ({ children }) => {
|
||||
|
||||
const setSidebarViewMenu = useCallback(
|
||||
async (sections) => {
|
||||
if (!electronAvailable || !ipcRenderer) return false
|
||||
if (!electronAvailable) return false
|
||||
if (useElectrobun) {
|
||||
const result = await desktopBridge.setSidebarViewMenu(sections)
|
||||
return result?.ok ?? false
|
||||
}
|
||||
if (!ipcRenderer) return false
|
||||
return await ipcRenderer.invoke('set-sidebar-view-menu', sections)
|
||||
},
|
||||
[electronAvailable]
|
||||
[electronAvailable, useElectrobun]
|
||||
)
|
||||
|
||||
const getElectronVersion = useCallback(async () => {
|
||||
if (!electronAvailable || !ipcRenderer) return null
|
||||
if (!electronAvailable) return null
|
||||
if (useElectrobun) return await desktopBridge.getAppVersion()
|
||||
if (!ipcRenderer) return null
|
||||
return await ipcRenderer.invoke('electron-version')
|
||||
}, [electronAvailable])
|
||||
}, [electronAvailable, useElectrobun])
|
||||
|
||||
const getAppEngine = useCallback(async () => {
|
||||
if (!electronAvailable) return 'native'
|
||||
if (useElectrobun) {
|
||||
const engine = await desktopBridge.getAppEngine()
|
||||
return engine === 'chromium' ? 'chromium' : 'native'
|
||||
}
|
||||
return 'native'
|
||||
}, [electronAvailable, useElectrobun])
|
||||
|
||||
return (
|
||||
<ElectronContext.Provider
|
||||
@ -280,12 +449,18 @@ const ElectronProvider = ({ children }) => {
|
||||
getAppSettings,
|
||||
setAppSettings,
|
||||
startAppUpdate,
|
||||
checkAppUpdateResult,
|
||||
checkDuplicateInstallations,
|
||||
removeDuplicateInstallations,
|
||||
onDuplicateInstallationsRemoved,
|
||||
onAppUpdateProgress,
|
||||
onCheckForUpdatesRequest,
|
||||
getToken,
|
||||
setToken,
|
||||
resizeSpotlightWindow,
|
||||
setSidebarViewMenu,
|
||||
getElectronVersion
|
||||
getElectronVersion,
|
||||
getAppEngine
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -13,6 +13,7 @@ import { ApiServerContext } from './ApiServerContext'
|
||||
import NotificationCenter from '../common/NotificationCenter'
|
||||
import Notification from '../common/Notification'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { ElectronContext } from './ElectronContext'
|
||||
|
||||
const NotificationContext = createContext()
|
||||
|
||||
@ -35,6 +36,8 @@ const NotificationProvider = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState([])
|
||||
const [notificationsLoading, setNotificationsLoading] = useState(false)
|
||||
|
||||
const { isElectron } = useContext(ElectronContext)
|
||||
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
@ -180,6 +183,7 @@ const NotificationProvider = ({ children }) => {
|
||||
title='Notifications'
|
||||
placement='right'
|
||||
width={isMobile ? '100%' : 460}
|
||||
style={{ marginTop: isElectron ? '40px' : '0px' }}
|
||||
onClose={() => setNotificationCenterVisible(false)}
|
||||
open={notificationCenterVisible}
|
||||
>
|
||||
|
||||
397
src/desktop/appupdate.js
Normal file
@ -0,0 +1,397 @@
|
||||
import { createWriteStream, promises as fs } from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { Utils } from 'electrobun/bun'
|
||||
import { launchMacInstaller } from './macappupdate.js'
|
||||
import { launchWindowsInstaller } from './winappupdate.js'
|
||||
import { checkForDuplicateInstallations } from './check-duplicate-installations.js'
|
||||
import { scheduleAppRestart } from './updater-runner.js'
|
||||
import { getAppSettings, setAppSettings } from './store.js'
|
||||
|
||||
const SUPPORTED_TARGETS = {
|
||||
darwin: {
|
||||
extension: '.pkg',
|
||||
osMatchers: ['darwin', 'mac', 'macos', 'osx']
|
||||
},
|
||||
win32: {
|
||||
extension: '.exe',
|
||||
osMatchers: ['win32', 'win', 'windows']
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_UPDATE_ENGINE = 'native'
|
||||
|
||||
let runningUpdate = null
|
||||
|
||||
const getArtifactName = (artifact) =>
|
||||
String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
|
||||
|
||||
const normalizeArch = (arch) => {
|
||||
if (arch === 'x64' || arch === 'amd64') return 'x64'
|
||||
if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
|
||||
return arch
|
||||
}
|
||||
|
||||
const normalizeEngine = (engine) => {
|
||||
const value = String(engine || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (value === 'chromium' || value === 'cef') return 'chromium'
|
||||
if (value === 'native') return 'native'
|
||||
return null
|
||||
}
|
||||
|
||||
const artifactIsChromium = (artifact) => {
|
||||
const explicit = String(
|
||||
artifact?.engine || artifact?.renderer || ''
|
||||
).toLowerCase()
|
||||
|
||||
if (explicit === 'cef' || explicit === 'chromium') return true
|
||||
if (explicit === 'native') return false
|
||||
|
||||
const name = getArtifactName(artifact).toLowerCase()
|
||||
return /[-_.]cef(?:[-_.]|$)/.test(name)
|
||||
}
|
||||
|
||||
const artifactMatchesEngine = (artifact, engine) => {
|
||||
const wantsChromium = normalizeEngine(engine) === 'chromium'
|
||||
return artifactIsChromium(artifact) === wantsChromium
|
||||
}
|
||||
|
||||
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
|
||||
const name = getArtifactName(artifact).toLowerCase()
|
||||
const normalizedArch = normalizeArch(arch)
|
||||
const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
|
||||
const artifactPlatform = String(
|
||||
artifact?.platform || artifact?.os || artifact?.target || ''
|
||||
).toLowerCase()
|
||||
|
||||
if (!name.endsWith(target.extension)) return false
|
||||
if (!artifact?.url) return false
|
||||
|
||||
const matchesArch =
|
||||
artifactArch === normalizedArch ||
|
||||
name.includes(`-${normalizedArch}`) ||
|
||||
name.includes(`_${normalizedArch}`) ||
|
||||
name.includes(`.${normalizedArch}.`) ||
|
||||
name.includes(normalizedArch)
|
||||
|
||||
const matchesOs =
|
||||
!artifactPlatform ||
|
||||
target.osMatchers.includes(artifactPlatform) ||
|
||||
target.osMatchers.some((matcher) => name.includes(matcher)) ||
|
||||
(platform === 'darwin' && name.includes('mac')) ||
|
||||
(platform === 'win32' && name.includes('win'))
|
||||
|
||||
return matchesArch && matchesOs
|
||||
}
|
||||
|
||||
const selectUpdateArtifact = (
|
||||
update,
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
) => {
|
||||
const target = SUPPORTED_TARGETS[platform]
|
||||
if (!target) {
|
||||
throw new Error(`App updates are not supported on ${platform}.`)
|
||||
}
|
||||
|
||||
const engine = normalizeEngine(update?.engine) || DEFAULT_UPDATE_ENGINE
|
||||
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
|
||||
const matchingArtifact = artifacts.find(
|
||||
(artifact) =>
|
||||
artifactMatchesPlatform(artifact, target, platform, arch) &&
|
||||
artifactMatchesEngine(artifact, engine)
|
||||
)
|
||||
|
||||
if (!matchingArtifact) {
|
||||
const engineLabel = engine === 'chromium' ? 'Chromium (cef)' : 'Native'
|
||||
throw new Error(
|
||||
`No ${target.extension} ${engineLabel} update artifact found for ${platform}/${arch}.`
|
||||
)
|
||||
}
|
||||
|
||||
return matchingArtifact
|
||||
}
|
||||
|
||||
const getInstallErrorMessage = (error, output = '') => {
|
||||
const combined = `${output}\n${error?.message || ''}`.trim()
|
||||
|
||||
if (
|
||||
/cancel/i.test(combined) ||
|
||||
/did not grant permission/i.test(combined) ||
|
||||
/user canceled/i.test(combined)
|
||||
) {
|
||||
return 'Update installation was cancelled.'
|
||||
}
|
||||
|
||||
if (/incorrect/i.test(combined)) {
|
||||
return 'The administrator password was incorrect.'
|
||||
}
|
||||
|
||||
if (
|
||||
/1625/.test(combined) ||
|
||||
/forbidden by system policy/i.test(combined) ||
|
||||
/Non-assigned apps are disabled/i.test(combined)
|
||||
) {
|
||||
return 'Update installation was blocked by system policy.'
|
||||
}
|
||||
|
||||
return combined || 'Failed to install update.'
|
||||
}
|
||||
|
||||
const getDownloadUrl = (url, redirectCount = 0) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects while downloading update.'))
|
||||
return
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url)
|
||||
const client = parsedUrl.protocol === 'https:' ? https : http
|
||||
const request = client.get(parsedUrl, (response) => {
|
||||
const location = response.headers.location
|
||||
|
||||
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
|
||||
response.resume()
|
||||
resolve(
|
||||
getDownloadUrl(
|
||||
new URL(location, parsedUrl).toString(),
|
||||
redirectCount + 1
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
resolve({ response, url: parsedUrl.toString() })
|
||||
})
|
||||
|
||||
request.on('error', reject)
|
||||
})
|
||||
|
||||
const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
|
||||
const { response } = await getDownloadUrl(artifact.url)
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
response.resume()
|
||||
throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
|
||||
}
|
||||
|
||||
const totalBytes =
|
||||
Number.parseInt(response.headers['content-length'], 10) || 0
|
||||
let downloadedBytes = 0
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(destinationPath)
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length
|
||||
const percent = totalBytes
|
||||
? Math.round((downloadedBytes / totalBytes) * 100)
|
||||
: null
|
||||
|
||||
sendProgress({
|
||||
phase: 'downloading',
|
||||
percent,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
message: totalBytes
|
||||
? `Downloading update (${percent}%)`
|
||||
: 'Downloading update'
|
||||
})
|
||||
})
|
||||
|
||||
response.on('error', reject)
|
||||
output.on('error', reject)
|
||||
output.on('finish', resolve)
|
||||
response.pipe(output)
|
||||
})
|
||||
}
|
||||
|
||||
const getRunningEngine = (mainWindow) =>
|
||||
mainWindow?.renderer === 'cef' ? 'chromium' : 'native'
|
||||
|
||||
const getRunningAppState = (mainWindow, settings) => ({
|
||||
version: process.env.ELECTROBUN_VERSION || null,
|
||||
branch: settings?.appUpdateRunningBranch || null,
|
||||
engine: getRunningEngine(mainWindow)
|
||||
})
|
||||
|
||||
// Snapshot the running version/branch/engine so the next launch can tell
|
||||
// whether an update actually completed.
|
||||
const persistCurrentAppState = async (mainWindow) => {
|
||||
try {
|
||||
const settings = await getAppSettings()
|
||||
await setAppSettings({
|
||||
...settings,
|
||||
current: getRunningAppState(mainWindow, settings)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[app-update] Failed to persist current app state.', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore missing values: a field only counts as changed when it was recorded
|
||||
// both before and after the update.
|
||||
const stateValueChanged = (previous, next) =>
|
||||
Boolean(previous) && Boolean(next) && previous !== next
|
||||
|
||||
let completedUpdateResult = null
|
||||
|
||||
export const checkForCompletedUpdate = async (mainWindow) => {
|
||||
// Cache per process so repeated renderer calls (e.g. remounts) get the same
|
||||
// answer instead of a false negative after `current` has been rewritten.
|
||||
if (completedUpdateResult) return completedUpdateResult
|
||||
|
||||
try {
|
||||
const settings = await getAppSettings()
|
||||
const previous =
|
||||
settings?.current && typeof settings.current === 'object'
|
||||
? settings.current
|
||||
: null
|
||||
const current = getRunningAppState(mainWindow, settings)
|
||||
|
||||
await setAppSettings({ ...settings, current })
|
||||
|
||||
const updated =
|
||||
Boolean(previous) &&
|
||||
(stateValueChanged(previous.version, current.version) ||
|
||||
stateValueChanged(previous.branch, current.branch) ||
|
||||
stateValueChanged(
|
||||
normalizeEngine(previous.engine),
|
||||
normalizeEngine(current.engine)
|
||||
))
|
||||
|
||||
const duplicates = checkForDuplicateInstallations()
|
||||
|
||||
completedUpdateResult = { updated, previous, current, duplicates }
|
||||
} catch (error) {
|
||||
console.warn('[app-update] Failed to check for a completed update.', error)
|
||||
completedUpdateResult = {
|
||||
updated: false,
|
||||
previous: null,
|
||||
current: null,
|
||||
duplicates: checkForDuplicateInstallations()
|
||||
}
|
||||
}
|
||||
|
||||
return completedUpdateResult
|
||||
}
|
||||
|
||||
const persistInstalledUpdateSettings = async (update) => {
|
||||
try {
|
||||
const settings = await getAppSettings()
|
||||
const engine =
|
||||
normalizeEngine(update?.engine) ||
|
||||
normalizeEngine(settings?.appUpdateEngine) ||
|
||||
DEFAULT_UPDATE_ENGINE
|
||||
|
||||
await setAppSettings({
|
||||
...settings,
|
||||
appUpdateEngine: engine,
|
||||
...(update?.branch ? { appUpdateRunningBranch: update.branch } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[app-update] Failed to persist installed update settings.',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const launchInstallerAndRestart = async (
|
||||
mainWindow,
|
||||
installerPath,
|
||||
sendProgress,
|
||||
update
|
||||
) => {
|
||||
const installerHelpers = { sendProgress, getInstallErrorMessage }
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
await launchMacInstaller(
|
||||
mainWindow,
|
||||
installerPath,
|
||||
sendProgress,
|
||||
installerHelpers
|
||||
)
|
||||
} else if (process.platform === 'win32') {
|
||||
await launchWindowsInstaller(
|
||||
mainWindow,
|
||||
installerPath,
|
||||
sendProgress,
|
||||
installerHelpers
|
||||
)
|
||||
} else {
|
||||
throw new Error(`App updates are not supported on ${process.platform}.`)
|
||||
}
|
||||
|
||||
await persistInstalledUpdateSettings(update)
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
scheduleAppRestart()
|
||||
}
|
||||
|
||||
// Give the UI a moment to show completion before the app exits.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
Utils.quit()
|
||||
}
|
||||
|
||||
const runAppUpdate = async (mainWindow, update, sendProgress) => {
|
||||
await persistCurrentAppState(mainWindow)
|
||||
|
||||
const artifact = selectUpdateArtifact(update)
|
||||
const tempDirectory = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'farmcontrol-update-')
|
||||
)
|
||||
const artifactName = path.basename(getArtifactName(artifact))
|
||||
const installerPath = path.join(tempDirectory, artifactName)
|
||||
|
||||
sendProgress({
|
||||
phase: 'preparing',
|
||||
percent: 0,
|
||||
artifact,
|
||||
message: 'Preparing update download'
|
||||
})
|
||||
|
||||
await downloadArtifact(artifact, installerPath, sendProgress)
|
||||
|
||||
sendProgress({
|
||||
phase: 'downloaded',
|
||||
percent: 100,
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
artifact,
|
||||
message: 'Update downloaded'
|
||||
})
|
||||
|
||||
await launchInstallerAndRestart(
|
||||
mainWindow,
|
||||
installerPath,
|
||||
sendProgress,
|
||||
update
|
||||
)
|
||||
}
|
||||
|
||||
export function startAppUpdate(mainWindow, update, sendProgress) {
|
||||
if (runningUpdate) return runningUpdate
|
||||
|
||||
runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
|
||||
.then(() => ({ ok: true }))
|
||||
.catch((error) => {
|
||||
sendProgress({
|
||||
phase: 'error',
|
||||
percent: null,
|
||||
message: error?.message || 'Failed to update app.'
|
||||
})
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
runningUpdate = null
|
||||
})
|
||||
|
||||
return runningUpdate
|
||||
}
|
||||
294
src/desktop/check-duplicate-installations.js
Normal file
@ -0,0 +1,294 @@
|
||||
import { execSync, spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { unlink, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import sudo from '@vscode/sudo-prompt'
|
||||
import { findMacAppBundle } from './updater-runner.js'
|
||||
|
||||
const MAC_APP_NAME = 'Farm Control.app'
|
||||
const MAC_SYSTEM_APP_PATH = path.join('/Applications', MAC_APP_NAME)
|
||||
const MAC_PKG_IDENTIFIER = 'com.tombutcher.farmcontrol'
|
||||
|
||||
const WINDOWS_APP_DIR_NAME = 'Farm Control'
|
||||
const WINDOWS_LAUNCHER_EXE = 'FarmControl.exe'
|
||||
const WINDOWS_LEGACY_LAUNCHER_EXE = 'launcher.exe'
|
||||
const WINDOWS_REGISTRY_APP_KEY = 'Software\\Tom Butcher\\Farm Control'
|
||||
const WINDOWS_REGISTRY_UNINSTALL_KEY =
|
||||
'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Farm Control'
|
||||
|
||||
const UNSUPPORTED_RESULT = {
|
||||
supported: false,
|
||||
runningInstall: 'unknown',
|
||||
runningPath: null,
|
||||
duplicatePath: null,
|
||||
}
|
||||
|
||||
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
|
||||
|
||||
const isPathInside = (childPath, parentPath) => {
|
||||
if (!childPath || !parentPath) return false
|
||||
const relative = path.relative(parentPath, childPath)
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
)
|
||||
}
|
||||
|
||||
const samePath = (left, right) => {
|
||||
if (!left || !right) return false
|
||||
if (process.platform === 'win32') {
|
||||
return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase()
|
||||
}
|
||||
return path.resolve(left) === path.resolve(right)
|
||||
}
|
||||
|
||||
const checkMacInstallations = () => {
|
||||
const runningBundle = findMacAppBundle(process.execPath)
|
||||
const userAppPath = path.join(os.homedir(), 'Applications', MAC_APP_NAME)
|
||||
|
||||
let runningInstall = 'unknown'
|
||||
if (runningBundle) {
|
||||
if (isPathInside(runningBundle, path.join(os.homedir(), 'Applications'))) {
|
||||
runningInstall = 'user'
|
||||
} else if (isPathInside(runningBundle, '/Applications')) {
|
||||
runningInstall = 'system'
|
||||
}
|
||||
}
|
||||
|
||||
const systemCopyExists =
|
||||
existsSync(MAC_SYSTEM_APP_PATH) &&
|
||||
!samePath(runningBundle, MAC_SYSTEM_APP_PATH)
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
runningInstall,
|
||||
runningPath: runningBundle,
|
||||
userPath: userAppPath,
|
||||
duplicatePath:
|
||||
runningInstall === 'user' && systemCopyExists
|
||||
? MAC_SYSTEM_APP_PATH
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
const readWindowsInstallDir = (hive) => {
|
||||
try {
|
||||
const output = execSync(
|
||||
`reg query "${hive}\\${WINDOWS_REGISTRY_APP_KEY}" /v InstallDir`,
|
||||
{ encoding: 'utf8', windowsHide: true },
|
||||
)
|
||||
const match = output.match(/InstallDir\s+REG_\w+\s+(.+)/i)
|
||||
return match?.[1]?.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const windowsDirLooksLikeInstall = (dir) =>
|
||||
Boolean(dir) &&
|
||||
(existsSync(path.join(dir, 'bin', WINDOWS_LAUNCHER_EXE)) ||
|
||||
existsSync(path.join(dir, 'bin', WINDOWS_LEGACY_LAUNCHER_EXE)) ||
|
||||
existsSync(path.join(dir, WINDOWS_LAUNCHER_EXE)) ||
|
||||
existsSync(path.join(dir, 'Uninstall.exe')) ||
|
||||
existsSync(path.join(dir, `Uninstall ${WINDOWS_APP_DIR_NAME}.exe`)))
|
||||
|
||||
const getWindowsUserInstallDir = () =>
|
||||
readWindowsInstallDir('HKCU') ||
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
WINDOWS_APP_DIR_NAME,
|
||||
)
|
||||
|
||||
const getWindowsSystemInstallCandidates = () => {
|
||||
const candidates = [
|
||||
readWindowsInstallDir('HKLM'),
|
||||
process.env.ProgramFiles &&
|
||||
path.join(process.env.ProgramFiles, WINDOWS_APP_DIR_NAME),
|
||||
process.env['ProgramFiles(x86)'] &&
|
||||
path.join(process.env['ProgramFiles(x86)'], WINDOWS_APP_DIR_NAME),
|
||||
process.env.ProgramW6432 &&
|
||||
path.join(process.env.ProgramW6432, WINDOWS_APP_DIR_NAME),
|
||||
].filter(Boolean)
|
||||
|
||||
const unique = []
|
||||
for (const candidate of candidates) {
|
||||
if (!unique.some((existing) => samePath(existing, candidate))) {
|
||||
unique.push(candidate)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
const checkWindowsInstallations = () => {
|
||||
const runningDir = path.dirname(process.execPath)
|
||||
const userInstallDir = getWindowsUserInstallDir()
|
||||
const systemCandidates = getWindowsSystemInstallCandidates()
|
||||
const localAppData =
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
|
||||
|
||||
let runningInstall = 'unknown'
|
||||
if (
|
||||
isPathInside(runningDir, userInstallDir) ||
|
||||
isPathInside(runningDir, localAppData)
|
||||
) {
|
||||
runningInstall = 'user'
|
||||
} else if (
|
||||
systemCandidates.some((candidate) => isPathInside(runningDir, candidate))
|
||||
) {
|
||||
runningInstall = 'system'
|
||||
}
|
||||
|
||||
const duplicatePath =
|
||||
runningInstall === 'user'
|
||||
? systemCandidates.find(
|
||||
(candidate) =>
|
||||
windowsDirLooksLikeInstall(candidate) &&
|
||||
!isPathInside(runningDir, candidate),
|
||||
) || null
|
||||
: null
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
runningInstall,
|
||||
runningPath: runningDir,
|
||||
userPath: userInstallDir,
|
||||
duplicatePath,
|
||||
}
|
||||
}
|
||||
|
||||
export const checkForDuplicateInstallations = () => {
|
||||
try {
|
||||
if (process.platform === 'darwin') return checkMacInstallations()
|
||||
if (process.platform === 'win32') return checkWindowsInstallations()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[duplicate-install] Failed to check for duplicate installations.',
|
||||
error,
|
||||
)
|
||||
}
|
||||
return UNSUPPORTED_RESULT
|
||||
}
|
||||
|
||||
const removeMacDuplicate = (duplicatePath) =>
|
||||
new Promise((resolve, reject) => {
|
||||
// Removing from /Applications typically needs admin rights; also forget
|
||||
// the pkg receipt so future installer runs start clean.
|
||||
const script = [
|
||||
`/bin/rm -rf ${quoteShellArg(duplicatePath)}`,
|
||||
`/usr/sbin/pkgutil --forget ${quoteShellArg(MAC_PKG_IDENTIFIER)} || true`,
|
||||
].join(' && ')
|
||||
|
||||
sudo.exec(script, { name: 'farmcontrol' }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const combined = `${stderr || ''}\n${error?.message || ''}`
|
||||
const message =
|
||||
/cancel/i.test(combined) || /did not grant permission/i.test(combined)
|
||||
? 'Removal was cancelled.'
|
||||
: 'Failed to remove the duplicate installation.'
|
||||
reject(new Error(message))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const psQuote = (value) => `'${String(value).replaceAll("'", "''")}'`
|
||||
|
||||
// Intentionally does NOT run the old install's Uninstall.exe: it taskkills
|
||||
// FarmControl.exe, which would terminate the running app. Instead the old
|
||||
// files, HKLM registry keys and machine-level shortcuts are removed directly.
|
||||
const buildWindowsRemovalScript = (duplicatePath) =>
|
||||
[
|
||||
`$ErrorActionPreference = 'SilentlyContinue'`,
|
||||
`$installDir = ${psQuote(duplicatePath)}`,
|
||||
`if (Test-Path -LiteralPath $installDir) {`,
|
||||
` Remove-Item -LiteralPath $installDir -Recurse -Force`,
|
||||
`}`,
|
||||
`& reg.exe delete ${psQuote(`HKLM\\${WINDOWS_REGISTRY_APP_KEY}`)} /f 2>$null | Out-Null`,
|
||||
`& reg.exe delete ${psQuote(`HKLM\\${WINDOWS_REGISTRY_UNINSTALL_KEY}`)} /f 2>$null | Out-Null`,
|
||||
`$commonStartMenu = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\${WINDOWS_APP_DIR_NAME}'`,
|
||||
`if (Test-Path -LiteralPath $commonStartMenu) {`,
|
||||
` Remove-Item -LiteralPath $commonStartMenu -Recurse -Force`,
|
||||
`}`,
|
||||
`$publicDesktopShortcut = 'C:\\Users\\Public\\Desktop\\${WINDOWS_APP_DIR_NAME}.lnk'`,
|
||||
`if (Test-Path -LiteralPath $publicDesktopShortcut) {`,
|
||||
` Remove-Item -LiteralPath $publicDesktopShortcut -Force`,
|
||||
`}`,
|
||||
`if (Test-Path -LiteralPath $installDir) { exit 1 }`,
|
||||
`exit 0`,
|
||||
].join('\r\n')
|
||||
|
||||
const removeWindowsDuplicate = async (duplicatePath) => {
|
||||
const scriptPath = path.join(
|
||||
os.tmpdir(),
|
||||
`farmcontrol-remove-duplicate-${Date.now()}.ps1`,
|
||||
)
|
||||
await writeFile(scriptPath, buildWindowsRemovalScript(duplicatePath), 'utf8')
|
||||
|
||||
// Pre-quote the -File argument: PowerShell 5.1's Start-Process does not
|
||||
// quote ArgumentList entries containing spaces.
|
||||
const elevateCommand = [
|
||||
`$p = Start-Process -FilePath 'powershell.exe'`,
|
||||
`-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','"${scriptPath.replaceAll("'", "''")}"')`,
|
||||
`-Verb RunAs -Wait -PassThru; exit $p.ExitCode`,
|
||||
].join(' ')
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', elevateCommand],
|
||||
{ stdio: 'ignore', windowsHide: true },
|
||||
)
|
||||
|
||||
child.on('error', reject)
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
// Start-Process throws when the UAC prompt is declined, which exits
|
||||
// the outer PowerShell with a non-zero code and no script output.
|
||||
reject(
|
||||
new Error(
|
||||
code === 1
|
||||
? 'Failed to remove the duplicate installation.'
|
||||
: 'Removal was cancelled.',
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await unlink(scriptPath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export const removeDuplicateInstallations = async () => {
|
||||
// Re-detect instead of trusting a path from the renderer so this can never
|
||||
// be used to delete an arbitrary directory.
|
||||
const check = checkForDuplicateInstallations()
|
||||
|
||||
if (!check.duplicatePath) {
|
||||
return { ok: false, error: 'No duplicate installation was found.' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
await removeMacDuplicate(check.duplicatePath)
|
||||
} else if (process.platform === 'win32') {
|
||||
await removeWindowsDuplicate(check.duplicatePath)
|
||||
} else {
|
||||
return { ok: false, error: 'Not supported on this platform.' }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error?.message || 'Failed to remove the duplicate installation.',
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, removedPath: check.duplicatePath }
|
||||
}
|
||||
220
src/desktop/deeplink-ipc.js
Normal file
@ -0,0 +1,220 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export const PROTOCOL_PREFIX = 'farmcontrol://'
|
||||
export const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance'
|
||||
export const SIGNAL_FILE = 'instance-signal.json'
|
||||
export const LOCK_FILE = 'primary.lock'
|
||||
const FORWARD_TIMEOUT_MS = 750
|
||||
const PIPE_RETRY_ATTEMPTS = 3
|
||||
const PIPE_RETRY_DELAY_MS = 100
|
||||
|
||||
export function getInstanceDir() {
|
||||
if (process.platform === 'win32') {
|
||||
return join(
|
||||
os.homedir(),
|
||||
'AppData',
|
||||
'Local',
|
||||
'com.tombutcher.farmcontrol',
|
||||
'instance'
|
||||
)
|
||||
}
|
||||
|
||||
return join(process.env.TMPDIR || '/tmp', 'com.tombutcher.farmcontrol', 'instance')
|
||||
}
|
||||
|
||||
export function getSignalPath() {
|
||||
return join(getInstanceDir(), SIGNAL_FILE)
|
||||
}
|
||||
|
||||
export function getLockPath() {
|
||||
return join(getInstanceDir(), LOCK_FILE)
|
||||
}
|
||||
|
||||
export function isProcessAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isPrimaryInstanceRunning() {
|
||||
const lockPath = getLockPath()
|
||||
if (!existsSync(lockPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const existingPid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10)
|
||||
return isProcessAlive(existingPid)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function findProtocolUrl(args) {
|
||||
const directMatch = args.find(
|
||||
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
|
||||
)
|
||||
|
||||
if (directMatch) {
|
||||
return directMatch
|
||||
}
|
||||
|
||||
const sources = args.filter((arg) => typeof arg === 'string')
|
||||
|
||||
for (const arg of sources) {
|
||||
const trimmed = arg.trim().replace(/^['"]+|['"]+$/g, '')
|
||||
const match = trimmed.match(/farmcontrol:\/\/\S+/i)
|
||||
if (match) {
|
||||
const rawUrl = match[0].replace(/['"]+$/g, '')
|
||||
try {
|
||||
return decodeURI(rawUrl)
|
||||
} catch {
|
||||
return rawUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const combinedMatch = sources.join(' ').match(/farmcontrol:\/\/\S+/i)
|
||||
if (combinedMatch) {
|
||||
const rawUrl = combinedMatch[0].replace(/['"]+$/g, '')
|
||||
try {
|
||||
return decodeURI(rawUrl)
|
||||
} catch {
|
||||
return rawUrl
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function buildDeeplinkPayload(url, argv = process.argv) {
|
||||
const commandLine = [...argv]
|
||||
|
||||
return url
|
||||
? { type: 'deeplink', url, argv: commandLine }
|
||||
: { type: 'focus', argv: commandLine }
|
||||
}
|
||||
|
||||
function readSignalQueue({ clear = true } = {}) {
|
||||
const signalPath = getSignalPath()
|
||||
if (!existsSync(signalPath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(signalPath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (clear) {
|
||||
unlinkSync(signalPath)
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return parsed ? [parsed] : []
|
||||
} catch {
|
||||
if (clear) {
|
||||
try {
|
||||
unlinkSync(signalPath)
|
||||
} catch {
|
||||
// Ignore cleanup failures.
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDeeplinkSignal(payload) {
|
||||
const signalPath = getSignalPath()
|
||||
mkdirSync(getInstanceDir(), { recursive: true })
|
||||
|
||||
const queue = readSignalQueue({ clear: false })
|
||||
queue.push({ ...payload, timestamp: Date.now() })
|
||||
|
||||
const tempPath = `${signalPath}.${process.pid}.${Date.now()}.tmp`
|
||||
writeFileSync(tempPath, JSON.stringify(queue), 'utf8')
|
||||
writeFileSync(signalPath, readFileSync(tempPath))
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
|
||||
function tryForwardViaPipe(payload) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (forwarded) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(forwarded)
|
||||
}
|
||||
|
||||
const client = net.connect({ path: WINDOWS_PIPE_NAME })
|
||||
const message = JSON.stringify(payload)
|
||||
|
||||
client.on('connect', () => {
|
||||
client.write(message)
|
||||
client.end()
|
||||
finish(true)
|
||||
})
|
||||
|
||||
client.on('error', () => {
|
||||
finish(false)
|
||||
})
|
||||
|
||||
client.setTimeout(FORWARD_TIMEOUT_MS, () => {
|
||||
client.destroy()
|
||||
finish(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
async function tryForwardViaPipeWithRetries(payload) {
|
||||
for (let attempt = 0; attempt < PIPE_RETRY_ATTEMPTS; attempt += 1) {
|
||||
if (await tryForwardViaPipe(payload)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (attempt < PIPE_RETRY_ATTEMPTS - 1) {
|
||||
await delay(PIPE_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function forwardDeeplinkToRunningInstance(payload) {
|
||||
if (process.platform === 'win32') {
|
||||
if (await tryForwardViaPipeWithRetries(payload)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (isPrimaryInstanceRunning()) {
|
||||
writeDeeplinkSignal(payload)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
167
src/desktop/macappupdate.js
Normal file
@ -0,0 +1,167 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import sudo from '@vscode/sudo-prompt'
|
||||
|
||||
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
|
||||
|
||||
const parseMacInstallerProgress = (output) => {
|
||||
const lines = String(output || '').split('\n')
|
||||
let percent = null
|
||||
let message = 'Installing update...'
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('installer:PHASE:')) {
|
||||
message = line.slice('installer:PHASE:'.length).trim() || message
|
||||
} else if (line.startsWith('installer:STATUS:')) {
|
||||
const status = line.slice('installer:STATUS:'.length).trim()
|
||||
if (status) message = status
|
||||
} else if (line.startsWith('installer:%')) {
|
||||
const value = Number.parseFloat(line.slice('installer:%'.length))
|
||||
if (Number.isFinite(value)) {
|
||||
percent = Math.min(100, Math.round(value <= 1 ? value * 100 : value))
|
||||
}
|
||||
} else if (
|
||||
line.startsWith('installer: ') &&
|
||||
!line.startsWith('installer:PHASE:') &&
|
||||
!line.startsWith('installer:STATUS:') &&
|
||||
!line.startsWith('installer:%')
|
||||
) {
|
||||
const text = line.slice('installer: '.length).trim()
|
||||
if (text) message = text
|
||||
}
|
||||
}
|
||||
|
||||
return { percent, message }
|
||||
}
|
||||
|
||||
const isMacInstallSuccessful = (output) =>
|
||||
/installer: The (install|upgrade) was successful\./i.test(output)
|
||||
|
||||
const isMacInstallFailed = (output) =>
|
||||
/installer: The install failed/i.test(output)
|
||||
|
||||
const buildMacInstallScript = (installerPath, logPath) =>
|
||||
`sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg(
|
||||
installerPath
|
||||
)} -target / -verboseR 2>&1 | /usr/bin/tee ${quoteShellArg(logPath)}`
|
||||
|
||||
const startMacInstallerProgressWatch = (logPath, sendProgress) => {
|
||||
let installerOutput = ''
|
||||
let offset = 0
|
||||
let lastPercent = null
|
||||
let lastMessage = null
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const stat = await fs.stat(logPath)
|
||||
if (stat.size <= offset) return
|
||||
|
||||
const handle = await fs.open(logPath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(stat.size - offset)
|
||||
await handle.read(buffer, 0, buffer.length, offset)
|
||||
offset = stat.size
|
||||
installerOutput += buffer.toString('utf8')
|
||||
|
||||
const { percent, message } = parseMacInstallerProgress(installerOutput)
|
||||
const resolvedMessage = message || 'Installing update...'
|
||||
|
||||
if (percent !== lastPercent || resolvedMessage !== lastMessage) {
|
||||
lastPercent = percent
|
||||
lastMessage = resolvedMessage
|
||||
sendProgress( {
|
||||
phase: 'installing',
|
||||
percent,
|
||||
message: resolvedMessage
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
console.error('[app-update] installer log poll error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
poll().catch((error) => {
|
||||
console.error('[app-update] installer log poll error:', error)
|
||||
})
|
||||
}, 300)
|
||||
|
||||
return async () => {
|
||||
clearInterval(intervalId)
|
||||
await poll()
|
||||
return installerOutput
|
||||
}
|
||||
}
|
||||
|
||||
export const launchMacInstaller = (
|
||||
mainWindow,
|
||||
installerPath,
|
||||
webContents,
|
||||
{ sendProgress, getInstallErrorMessage }
|
||||
) => {
|
||||
const logPath = path.join(path.dirname(installerPath), 'install.log')
|
||||
const installScript = buildMacInstallScript(installerPath, logPath)
|
||||
const promptName = 'farmcontrol'
|
||||
|
||||
sendProgress( {
|
||||
phase: 'installing',
|
||||
percent: 0,
|
||||
message: 'Enter your Mac password when prompted.'
|
||||
})
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed?.()) {
|
||||
mainWindow.focus?.();
|
||||
mainWindow.show?.();
|
||||
}
|
||||
|
||||
const stopProgressWatch = startMacInstallerProgressWatch(logPath, sendProgress)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
sudo.exec(installScript, { name: promptName }, async (error, stdout, stderr) => {
|
||||
const watchedOutput = await stopProgressWatch()
|
||||
const output = `${stdout || ''}${stderr || ''}` || watchedOutput
|
||||
|
||||
await fs.unlink(logPath).catch(() => {})
|
||||
|
||||
if (stderr) console.error('[app-update] installer stderr:', stderr)
|
||||
|
||||
if (error) {
|
||||
console.error('[app-update] installer error:', error)
|
||||
const message = getInstallErrorMessage(error, output)
|
||||
sendProgress( {
|
||||
phase: 'error',
|
||||
percent: null,
|
||||
message
|
||||
})
|
||||
reject(new Error(message))
|
||||
return
|
||||
}
|
||||
|
||||
if (isMacInstallFailed(output) || !isMacInstallSuccessful(output)) {
|
||||
const message = getInstallErrorMessage(null, output)
|
||||
sendProgress( {
|
||||
phase: 'error',
|
||||
percent: null,
|
||||
message
|
||||
})
|
||||
reject(new Error(message))
|
||||
return
|
||||
}
|
||||
|
||||
const { percent, message } = parseMacInstallerProgress(output)
|
||||
|
||||
sendProgress( {
|
||||
phase: 'installing',
|
||||
percent: percent ?? 100,
|
||||
message: message || 'Installation complete. Restarting Farm Control...'
|
||||
})
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
148
src/desktop/macos-window-effects.js
Normal file
@ -0,0 +1,148 @@
|
||||
import { dlopen, FFIType } from 'bun:ffi'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const MAC_WINDOW_CORNER_RADIUS = 15
|
||||
export const MAC_TRAFFIC_LIGHT_OFFSET = { x: 14, y: 12 }
|
||||
|
||||
const DYLIB_NAME = 'libMacWindowEffects.dylib'
|
||||
const MIN_DYLIB_BYTES = 10_000
|
||||
|
||||
function resolveDylibPath() {
|
||||
const candidates = new Set()
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
candidates.add(path.join(moduleDir, DYLIB_NAME))
|
||||
candidates.add(path.join(moduleDir, '../bun', DYLIB_NAME))
|
||||
candidates.add(path.join(moduleDir, '../../bun', DYLIB_NAME))
|
||||
candidates.add(path.join(process.cwd(), 'src/bun', DYLIB_NAME))
|
||||
|
||||
for (const execPath of [process.execPath, process.argv0].filter(Boolean)) {
|
||||
const execDir = path.dirname(execPath)
|
||||
candidates.add(
|
||||
path.join(execDir, '../Resources/app/bun', DYLIB_NAME)
|
||||
)
|
||||
candidates.add(path.join(execDir, 'Resources/app/bun', DYLIB_NAME))
|
||||
candidates.add(path.join(execDir, DYLIB_NAME))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const resolved = path.resolve(candidate)
|
||||
if (!existsSync(resolved)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (statSync(resolved).size >= MIN_DYLIB_BYTES) {
|
||||
return resolved
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable paths and keep searching.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function loadMacWindowEffectsLibrary() {
|
||||
const dylibPath = resolveDylibPath()
|
||||
if (!dylibPath) {
|
||||
console.warn(
|
||||
'macOS vibrancy: native effects library not found; using transparent window only.'
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
path: dylibPath,
|
||||
lib: dlopen(dylibPath, {
|
||||
enableWindowVibrancy: {
|
||||
args: [FFIType.ptr, FFIType.f64],
|
||||
returns: FFIType.bool
|
||||
},
|
||||
ensureWindowShadow: {
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.bool
|
||||
},
|
||||
setWindowCornerRadius: {
|
||||
args: [FFIType.ptr, FFIType.f64],
|
||||
returns: FFIType.bool
|
||||
},
|
||||
setWindowTrafficLightsPosition: {
|
||||
args: [FFIType.ptr, FFIType.f64, FFIType.f64],
|
||||
returns: FFIType.bool
|
||||
},
|
||||
setNativeWindowDragRegion: {
|
||||
args: [FFIType.ptr, FFIType.f64, FFIType.f64],
|
||||
returns: FFIType.bool
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`macOS vibrancy: failed to load native effects library (${dylibPath}):`,
|
||||
error
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function applyTrafficLightsPosition(lib, mainWindow, attempt = 0) {
|
||||
const applied = lib.symbols.setWindowTrafficLightsPosition(
|
||||
mainWindow.ptr,
|
||||
MAC_TRAFFIC_LIGHT_OFFSET.x,
|
||||
MAC_TRAFFIC_LIGHT_OFFSET.y
|
||||
)
|
||||
|
||||
if (applied || attempt >= 10) {
|
||||
return applied
|
||||
}
|
||||
|
||||
setTimeout(() => applyTrafficLightsPosition(lib, mainWindow, attempt + 1), 50)
|
||||
return false
|
||||
}
|
||||
|
||||
export function applyMacOSWindowEffects(mainWindow) {
|
||||
if (process.platform !== 'darwin' || !mainWindow?.ptr) {
|
||||
return
|
||||
}
|
||||
|
||||
const loaded = loadMacWindowEffectsLibrary()
|
||||
if (!loaded) {
|
||||
return
|
||||
}
|
||||
|
||||
const { path: dylibPath, lib } = loaded
|
||||
|
||||
try {
|
||||
const vibrancyEnabled = lib.symbols.enableWindowVibrancy(
|
||||
mainWindow.ptr,
|
||||
MAC_WINDOW_CORNER_RADIUS
|
||||
)
|
||||
const shadowEnabled = lib.symbols.ensureWindowShadow(mainWindow.ptr)
|
||||
const trafficLightsScheduled = applyTrafficLightsPosition(lib, mainWindow)
|
||||
|
||||
mainWindow.on?.('resize', () => {
|
||||
lib.symbols.setWindowCornerRadius(
|
||||
mainWindow.ptr,
|
||||
MAC_WINDOW_CORNER_RADIUS
|
||||
)
|
||||
lib.symbols.setWindowTrafficLightsPosition(
|
||||
mainWindow.ptr,
|
||||
MAC_TRAFFIC_LIGHT_OFFSET.x,
|
||||
MAC_TRAFFIC_LIGHT_OFFSET.y
|
||||
)
|
||||
})
|
||||
|
||||
console.log(
|
||||
`macOS vibrancy applied (dylib=${dylibPath}, vibrancy=${vibrancyEnabled}, shadow=${shadowEnabled}, trafficLights=${trafficLightsScheduled}, cornerRadius=${MAC_WINDOW_CORNER_RADIUS})`
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'macOS vibrancy: failed to apply native window effects:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||