Compare commits
117 Commits
main
...
electrobun
| Author | SHA1 | Date | |
|---|---|---|---|
| 35ce3c39af | |||
| 84a6a86bf3 | |||
| bb2b8148c7 | |||
| 235b038ecc | |||
| b254c0c94d | |||
| 7d79e0d206 | |||
| 557c334aa0 | |||
| 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 |
5
.gitignore
vendored
@ -30,3 +30,8 @@ yarn-error.log*
|
|||||||
stats.html
|
stats.html
|
||||||
|
|
||||||
test-results.xml
|
test-results.xml
|
||||||
|
|
||||||
|
dist/*
|
||||||
|
|
||||||
|
# Native macOS vibrancy (built by scripts/build-macos-effects.mjs)
|
||||||
|
src/bun/libMacWindowEffects.dylib
|
||||||
150
Jenkinsfile
vendored
@ -1,7 +1,57 @@
|
|||||||
properties([
|
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() {
|
def deploy() {
|
||||||
node('ubuntu') {
|
node('ubuntu') {
|
||||||
try {
|
try {
|
||||||
@ -30,7 +80,6 @@ def deploy() {
|
|||||||
|
|
||||||
stage('Deploy (Ubuntu)') {
|
stage('Deploy (Ubuntu)') {
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
nodejs(nodeJSInstallationName: 'Node23') {
|
||||||
// Deploy to Cloudflare Pages using wrangler
|
|
||||||
withCredentials([string(credentialsId: 'cloudflare-api-token', variable: 'CLOUDFLARE_API_TOKEN')]) {
|
withCredentials([string(credentialsId: 'cloudflare-api-token', variable: 'CLOUDFLARE_API_TOKEN')]) {
|
||||||
sh 'pnpm wrangler pages deploy build --branch main'
|
sh 'pnpm wrangler pages deploy build --branch main'
|
||||||
}
|
}
|
||||||
@ -42,76 +91,101 @@ 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 {
|
return {
|
||||||
node(label) {
|
node(label) {
|
||||||
stage("Checkout (${label})") {
|
try {
|
||||||
|
stage("Checkout (${label}/${cefLabel})") {
|
||||||
checkout scm
|
checkout scm
|
||||||
}
|
}
|
||||||
|
|
||||||
stage("Setup Node.js (${label})") {
|
withBun {
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
stage("Check Bun (${label}/${cefLabel})") {
|
||||||
|
checkBun()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('macos')) {
|
||||||
|
stage("Prepare macOS workspace (${label}/${cefLabel})") {
|
||||||
|
prepareMacBuildWorkspace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage("Install Dependencies (${label}/${cefLabel})") {
|
||||||
if (isUnix()) {
|
if (isUnix()) {
|
||||||
sh 'node -v'
|
sh 'bun install --frozen-lockfile'
|
||||||
sh 'pnpm -v'
|
|
||||||
} else {
|
} else {
|
||||||
bat 'node -v'
|
bat 'bun install --frozen-lockfile'
|
||||||
bat 'pnpm -v'
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage("Write Build Metadata (${label}/${cefLabel})") {
|
||||||
|
writeBuildMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
stage("Build (${label}/${cefLabel})") {
|
||||||
|
withEnv(["ELECTROBUN_BUNDLE_CEF=${bundleCef ? 'true' : 'false'}"]) {
|
||||||
|
runBuild(buildCommand)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage("Install Dependencies (${label})") {
|
stage("Archive Artifacts (${label}/${cefLabel})") {
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
archiveArtifacts artifacts: 'app_dist/farmcontrol-*', fingerprint: true
|
||||||
if (isUnix()) {
|
}
|
||||||
sh 'pnpm install --frozen-lockfile --production=false'
|
} finally {
|
||||||
} else {
|
cleanWs()
|
||||||
bat 'pnpm install --frozen-lockfile --production=false'
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage("Build (${label})") {
|
def buildMacOnLabel(label, targetArch, bundleCef = false) {
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
return buildOnLabel(label, "ELECTROBUN_TARGET_ARCH=${targetArch} bun run build:app:mac", bundleCef)
|
||||||
if (isUnix()) {
|
|
||||||
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production ${buildCommand}"
|
|
||||||
} else {
|
|
||||||
bat "set VITE_BUILD_NUMBER=${env.BUILD_NUMBER} && set NODE_ENV=production && ${buildCommand}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage("Archive Artifacts (${label})") {
|
|
||||||
archiveArtifacts artifacts: 'app_dist/**/farmcontrol-*.dmg, app_dist/**/farmcontrol-*.exe, app_dist/**/farmcontrol-*.pkg, app_dist/**/farmcontrol-*.msi', fingerprint: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def setBuildNameFromPackageVersion() {
|
def setBuildNameFromPackageVersion() {
|
||||||
node('ubuntu') {
|
node('ubuntu') {
|
||||||
stage('Set Build Name') {
|
stage('Set Build Name') {
|
||||||
checkout scm
|
checkout scm
|
||||||
def version
|
withBun {
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
checkBun()
|
||||||
version = sh(
|
def version = sh(
|
||||||
script: "node -p \"require('./package.json').version\"",
|
script: "bun --eval \"console.log(JSON.parse(await Bun.file('package.json').text()).version)\"",
|
||||||
returnStdout: true
|
returnStdout: true
|
||||||
).trim()
|
).trim()
|
||||||
}
|
|
||||||
def buildName = "v${version}-b${env.BUILD_NUMBER}"
|
def buildName = "v${version}-b${env.BUILD_NUMBER}"
|
||||||
currentBuild.displayName = buildName
|
currentBuild.displayName = buildName
|
||||||
echo "Build name set to: ${buildName}"
|
echo "Build name set to: ${buildName}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setBuildNameFromPackageVersion()
|
setBuildNameFromPackageVersion()
|
||||||
|
|
||||||
parallel(
|
parallel(
|
||||||
'Windows Build': buildOnLabel('windows', 'pnpm build:electron'),
|
'Windows Build': buildOnLabel('windows', 'bun run build:app', false),
|
||||||
'MacOS Build': buildOnLabel('macos', 'pnpm build:electron'),
|
'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() }
|
'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 {
|
.electron-navigation-wrapper {
|
||||||
-webkit-app-region: drag;
|
user-select: none;
|
||||||
|
--webkit-user-select: none;
|
||||||
|
z-index: 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.electron-navigation-wrapper li,
|
/* Native macOS vibrancy — transparent web content over NSVisualEffectView */
|
||||||
.electron-navigation-wrapper button,
|
html.macos-vibrancy {
|
||||||
.electron-navigation-wrapper .ant-tag {
|
--macos-window-corner-radius: 15px; /* keep in sync with MAC_WINDOW_CORNER_RADIUS */
|
||||||
-webkit-app-region: no-drag;
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy,
|
||||||
|
html.macos-vibrancy body,
|
||||||
|
html.macos-vibrancy #root {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .ant-layout.ant-layout-has-sider,
|
||||||
|
html.macos-vibrancy .main-layout {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .light-mode .ant-layout.main-content-layout {
|
||||||
|
background: rgba(255, 255, 255, 0.88) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout {
|
||||||
|
background: rgba(0, 0, 0, 0.88) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .light-mode .ant-layout-sider {
|
||||||
|
background: rgba(244, 244, 244, 0.82) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .dark-mode .ant-layout-sider {
|
||||||
|
background: rgba(10, 10, 10, 0.82) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .light-mode .ant-layout-content {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .dark-mode .ant-layout-content {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .ant-layout-sider-light,
|
||||||
|
html.macos-vibrancy .electron-sider .ant-menu-light {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy
|
||||||
|
.dark-mode
|
||||||
|
.electron-sider
|
||||||
|
.ant-menu-light
|
||||||
|
.ant-menu-item-selected {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 30%, #00000010);
|
||||||
|
color: color-mix(in srgb, var(--color-primary) 75%, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy
|
||||||
|
.light-mode
|
||||||
|
.electron-sider
|
||||||
|
.ant-menu-light
|
||||||
|
.ant-menu-item-selected {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 80%, #ffffff3d);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .dark-mode .electron-navigation-wrapper {
|
||||||
|
background: rgba(10, 10, 10, 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .light-mode .electron-navigation-wrapper {
|
||||||
|
background: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .electron-navigation {
|
||||||
|
background: rgba(0, 0, 0, 0) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.macos-vibrancy .light-mode .ant-layout.main-content-layout .ant-card {
|
||||||
|
background: rgba(255, 255, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout .ant-card {
|
||||||
|
background: rgba(31, 31, 31, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redTrafficLight {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #fe5f57;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yellowTrafficLight {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #febc2e;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greenTrafficLight {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #28c840;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.electron-navigation {
|
.electron-navigation {
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.electron-sidebar.ant-menu-inline .ant-menu-item {
|
||||||
|
padding-left: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.electron-navigation .ant-menu-overflow-item-rest {
|
.electron-navigation .ant-menu-overflow-item-rest {
|
||||||
padding-inline: 10px;
|
padding-inline: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.electron-navigation.ant-menu-horizontal .ant-menu-item {
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.electron-sidebar .ant-menu-item,
|
.electron-sidebar .ant-menu-item,
|
||||||
.electron-sidebar .ant-menu-submenu-title {
|
.electron-sidebar .ant-menu-submenu-title {
|
||||||
height: 32.5px !important;
|
height: 32.5px !important;
|
||||||
|
|||||||
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",
|
"name": "Tom Butcher",
|
||||||
"email": "tom@tombutcher.work"
|
"email": "tom@tombutcher.work"
|
||||||
},
|
},
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "./",
|
"homepage": "./",
|
||||||
@ -71,10 +71,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"simplebar-react": "^3.3.2",
|
"simplebar-react": "^3.3.2",
|
||||||
"socket.io-client": "*",
|
"socket.io-client": "*",
|
||||||
"standard": "^17.1.2",
|
|
||||||
"styled-components": "^6.1.19",
|
"styled-components": "^6.1.19",
|
||||||
"svgo": "^4.0.0",
|
|
||||||
"svgo-loader": "^4.0.0",
|
|
||||||
"three": "^0.179.1",
|
"three": "^0.179.1",
|
||||||
"tsparticles": "^3.9.1",
|
"tsparticles": "^3.9.1",
|
||||||
"web-vitals": "^5.1.0"
|
"web-vitals": "^5.1.0"
|
||||||
@ -83,13 +80,21 @@
|
|||||||
"description": "3D Printer ERP and Control Software.",
|
"description": "3D Printer ERP and Control Software.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_ENV=development vite",
|
"dev": "cross-env NODE_ENV=development vite",
|
||||||
|
"dev:app": "concurrently \"cross-env NODE_ENV=development bun run dev:renderer\" \"cross-env NODE_ENV=development electrobun dev\"",
|
||||||
|
"dev:renderer": "vite --port 5780 --no-open",
|
||||||
"electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .",
|
"electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .",
|
||||||
"start": "serve -s build",
|
"start": "serve -s build",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"build:app": "electrobun build --env=stable",
|
||||||
|
"build:app:mac": "bun scripts/build-macos.mjs",
|
||||||
"dev:electron": "concurrently \"cross-env NODE_ENV=development vite --port 5780 --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 NODE_ENV=development electron public/electron.js\"",
|
"dev:electron": "concurrently \"cross-env NODE_ENV=development vite --port 5780 --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 NODE_ENV=development electron public/electron.js\"",
|
||||||
"build:electron": "vite build && electron-builder",
|
"build:electron": "vite build && electron-builder",
|
||||||
"build:cloudflare": "cross-env VITE_DEPLOY_TARGET=cloudflare vite build",
|
"build:cloudflare": "cross-env VITE_DEPLOY_TARGET=cloudflare vite build",
|
||||||
"deploy": "npm run build:cloudflare && wrangler pages deploy --branch main"
|
"deploy": "npm run build:cloudflare && wrangler pages deploy --branch main",
|
||||||
|
"postinstall": "bun scripts/patch-electrobun-src.mjs && bun scripts/build-macos-effects.mjs",
|
||||||
|
"generate-app-icons": "bun scripts/generate-app-icons.mjs",
|
||||||
|
"build:macos-effects": "bun scripts/build-macos-effects.mjs",
|
||||||
|
"clean": "bun scripts/clean-build.mjs"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
@ -113,7 +118,10 @@
|
|||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
|
"electrobun": "1.18.1",
|
||||||
"electron": "^38.7.1",
|
"electron": "^38.7.1",
|
||||||
|
"jimp": "^1.6.1",
|
||||||
|
"png-to-ico": "^2.1.8",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"electron-packager": "^17.1.2",
|
"electron-packager": "^17.1.2",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.34.0",
|
||||||
@ -126,6 +134,7 @@
|
|||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-eslint": "^16.4.2",
|
"prettier-eslint": "^16.4.2",
|
||||||
|
"rcedit": "^4.0.1",
|
||||||
"rollup-plugin-visualizer": "^6.0.5",
|
"rollup-plugin-visualizer": "^6.0.5",
|
||||||
"serve": "^14.2.4",
|
"serve": "^14.2.4",
|
||||||
"standard": "^17.1.2",
|
"standard": "^17.1.2",
|
||||||
@ -237,13 +246,13 @@
|
|||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"include": "scripts/installer.nsh",
|
"include": "scripts/installer.nsh",
|
||||||
"perMachine": true
|
"perMachine": false
|
||||||
},
|
},
|
||||||
"msiWrapped": {
|
"msiWrapped": {
|
||||||
"upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}",
|
"upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}",
|
||||||
"perMachine": true,
|
"perMachine": false,
|
||||||
"impersonate": false,
|
"impersonate": true,
|
||||||
"wrappedInstallerArgs": "/S"
|
"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
|
||||||
412
packaging/windows/installer.nsh
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
!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 seconds for a graceful exit, then terminate the process.
|
||||||
|
System::Call 'kernel32::WaitForSingleObject(p r0, i 2000) i .r1'
|
||||||
|
${If} $1 == 258
|
||||||
|
!insertmacro progressStatus "Stopping Farm Control..."
|
||||||
|
DetailPrint "Farm Control process $UpdateParentPid did not exit; terminating it..."
|
||||||
|
; Never use taskkill /T here: this installer is a direct child of the
|
||||||
|
; updater process, so a tree kill would terminate the installer itself
|
||||||
|
; before the folder swap (CEF builds hung at "Stopping Farm Control...").
|
||||||
|
ExecWait 'taskkill /F /PID $UpdateParentPid' $R0
|
||||||
|
|
||||||
|
; Verify that taskkill actually terminated the process before swapping.
|
||||||
|
System::Call 'kernel32::WaitForSingleObject(p r0, i 10000) i .r1'
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
System::Call 'kernel32::CloseHandle(p r0)'
|
||||||
|
${If} $1 != 0
|
||||||
|
!insertmacro progressFailure "Could not stop the running Farm Control process."
|
||||||
|
Abort
|
||||||
|
${EndIf}
|
||||||
|
${EndIf}
|
||||||
|
${Else}
|
||||||
|
; Compatibility fallback for manually launched older installers.
|
||||||
|
Sleep 2000
|
||||||
|
!insertmacro quitFarmControl
|
||||||
|
|
||||||
|
; Confirm both known launcher names have stopped before continuing.
|
||||||
|
StrCpy $R7 0
|
||||||
|
restart_verify_loop:
|
||||||
|
ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq FarmControl.exe" 2>nul | find /I "FarmControl.exe"' $R0
|
||||||
|
${If} $R0 != 0
|
||||||
|
ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq launcher.exe" 2>nul | find /I "launcher.exe"' $R0
|
||||||
|
${EndIf}
|
||||||
|
${If} $R0 != 0
|
||||||
|
Goto restart_wait_done
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
IntOp $R7 $R7 + 1
|
||||||
|
${If} $R7 < 20
|
||||||
|
Sleep 500
|
||||||
|
Goto restart_verify_loop
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
!insertmacro progressFailure "Could not stop the running Farm Control process."
|
||||||
|
Abort
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
restart_wait_done:
|
||||||
|
; CEF helper processes normally exit on their own once the main process is
|
||||||
|
; gone, but sweep up any stragglers still running from the install
|
||||||
|
; directory so the rename cannot be blocked. Matching on executable path
|
||||||
|
; (not a tree kill) guarantees this installer, which runs from the Updates
|
||||||
|
; folder, is never terminated.
|
||||||
|
ExecWait `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -like '$FinalInstDir\*' } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"` $R0
|
||||||
|
|
||||||
|
; Give CEF descendants a moment to release DLLs before renaming the tree.
|
||||||
|
Sleep 3000
|
||||||
|
|
||||||
|
${If} $IsInAppUpdate == "1"
|
||||||
|
!insertmacro swapUpdateStaging
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
!insertmacro progressStatus "Starting Farm Control..."
|
||||||
|
SetOutPath "$FinalInstDir\bin"
|
||||||
|
Exec "$FinalInstDir\bin\FarmControl.exe"
|
||||||
|
FunctionEnd
|
||||||
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,262 +1,260 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { createWriteStream, promises as fs } from "node:fs";
|
||||||
import { createWriteStream, promises as fs } from 'fs'
|
import http from "node:http";
|
||||||
import http from 'http'
|
import https from "node:https";
|
||||||
import https from 'https'
|
import os from "node:os";
|
||||||
import os from 'os'
|
import path from "node:path";
|
||||||
import path from 'path'
|
import process from "node:process";
|
||||||
import process from 'process'
|
import { Utils } from "electrobun/bun";
|
||||||
import { launchMacInstaller } from './macappupdate.js'
|
import { launchMacInstaller } from "./macappupdate.js";
|
||||||
import { launchWindowsInstaller } from './winappupdate.js'
|
import { launchWindowsInstaller } from "./winappupdate.js";
|
||||||
|
import { scheduleAppRestart } from "./updater-runner.js";
|
||||||
|
|
||||||
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
|
|
||||||
const SUPPORTED_TARGETS = {
|
const SUPPORTED_TARGETS = {
|
||||||
darwin: {
|
darwin: {
|
||||||
extension: '.pkg',
|
extension: ".pkg",
|
||||||
osMatchers: ['darwin', 'mac', 'macos', 'osx']
|
osMatchers: ["darwin", "mac", "macos", "osx"],
|
||||||
},
|
},
|
||||||
win32: {
|
win32: {
|
||||||
extension: '.msi',
|
extension: ".msi",
|
||||||
osMatchers: ['win32', 'win', 'windows']
|
osMatchers: ["win32", "win", "windows"],
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
let runningUpdate = null
|
let runningUpdate = null;
|
||||||
|
|
||||||
const getArtifactName = (artifact) =>
|
const getArtifactName = (artifact) =>
|
||||||
String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
|
String(artifact?.fileName || artifact?.relativePath || artifact?.url || "");
|
||||||
|
|
||||||
const normalizeArch = (arch) => {
|
const normalizeArch = (arch) => {
|
||||||
if (arch === 'x64' || arch === 'amd64') return 'x64'
|
if (arch === "x64" || arch === "amd64") return "x64";
|
||||||
if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
|
if (arch === "arm64" || arch === "aarch64") return "arm64";
|
||||||
return arch
|
return arch;
|
||||||
}
|
};
|
||||||
|
|
||||||
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
|
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
|
||||||
const name = getArtifactName(artifact).toLowerCase()
|
const name = getArtifactName(artifact).toLowerCase();
|
||||||
const normalizedArch = normalizeArch(arch)
|
const normalizedArch = normalizeArch(arch);
|
||||||
const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
|
const artifactArch = normalizeArch(String(artifact?.arch || "").toLowerCase());
|
||||||
const artifactPlatform = String(
|
const artifactPlatform = String(
|
||||||
artifact?.platform || artifact?.os || artifact?.target || ''
|
artifact?.platform || artifact?.os || artifact?.target || "",
|
||||||
).toLowerCase()
|
).toLowerCase();
|
||||||
|
|
||||||
if (!name.endsWith(target.extension)) return false
|
if (!name.endsWith(target.extension)) return false;
|
||||||
if (!artifact?.url) return false
|
if (!artifact?.url) return false;
|
||||||
|
|
||||||
const matchesArch =
|
const matchesArch =
|
||||||
artifactArch === normalizedArch ||
|
artifactArch === normalizedArch ||
|
||||||
name.includes(`-${normalizedArch}`) ||
|
name.includes(`-${normalizedArch}`) ||
|
||||||
name.includes(`_${normalizedArch}`) ||
|
name.includes(`_${normalizedArch}`) ||
|
||||||
name.includes(`.${normalizedArch}.`) ||
|
name.includes(`.${normalizedArch}.`) ||
|
||||||
name.includes(normalizedArch)
|
name.includes(normalizedArch);
|
||||||
|
|
||||||
const matchesOs =
|
const matchesOs =
|
||||||
!artifactPlatform ||
|
!artifactPlatform ||
|
||||||
target.osMatchers.includes(artifactPlatform) ||
|
target.osMatchers.includes(artifactPlatform) ||
|
||||||
target.osMatchers.some((matcher) => name.includes(matcher)) ||
|
target.osMatchers.some((matcher) => name.includes(matcher)) ||
|
||||||
(platform === 'darwin' && name.includes('mac')) ||
|
(platform === "darwin" && name.includes("mac")) ||
|
||||||
(platform === 'win32' && name.includes('win'))
|
(platform === "win32" && name.includes("win"));
|
||||||
|
|
||||||
return matchesArch && matchesOs
|
return matchesArch && matchesOs;
|
||||||
}
|
};
|
||||||
|
|
||||||
const selectUpdateArtifact = (
|
const selectUpdateArtifact = (
|
||||||
update,
|
update,
|
||||||
platform = process.platform,
|
platform = process.platform,
|
||||||
arch = process.arch
|
arch = process.arch,
|
||||||
) => {
|
) => {
|
||||||
const target = SUPPORTED_TARGETS[platform]
|
const target = SUPPORTED_TARGETS[platform];
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error(`App updates are not supported on ${platform}.`)
|
throw new Error(`App updates are not supported on ${platform}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
|
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : [];
|
||||||
const matchingArtifact = artifacts.find((artifact) =>
|
const matchingArtifact = artifacts.find((artifact) =>
|
||||||
artifactMatchesPlatform(artifact, target, platform, arch)
|
artifactMatchesPlatform(artifact, target, platform, arch),
|
||||||
)
|
);
|
||||||
const fallbackArtifact = artifacts.find((artifact) => {
|
const fallbackArtifact = artifacts.find((artifact) => {
|
||||||
const name = getArtifactName(artifact).toLowerCase()
|
const name = getArtifactName(artifact).toLowerCase();
|
||||||
return artifact?.url && name.endsWith(target.extension)
|
return artifact?.url && name.endsWith(target.extension);
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!matchingArtifact && !fallbackArtifact) {
|
if (!matchingArtifact && !fallbackArtifact) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No ${target.extension} update artifact found for ${platform}/${arch}.`
|
`No ${target.extension} update artifact found for ${platform}/${arch}.`,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return matchingArtifact || fallbackArtifact
|
return matchingArtifact || fallbackArtifact;
|
||||||
}
|
};
|
||||||
|
|
||||||
const sendProgress = (webContents, payload) => {
|
const getInstallErrorMessage = (error, output = "") => {
|
||||||
if (!webContents || webContents.isDestroyed()) return
|
const combined = `${output}\n${error?.message || ""}`.trim();
|
||||||
webContents.send(UPDATE_PROGRESS_CHANNEL, {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...payload
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getInstallErrorMessage = (error, output = '') => {
|
|
||||||
const combined = `${output}\n${error?.message || ''}`.trim()
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
/cancel/i.test(combined) ||
|
/cancel/i.test(combined) ||
|
||||||
/did not grant permission/i.test(combined) ||
|
/did not grant permission/i.test(combined) ||
|
||||||
/user canceled/i.test(combined)
|
/user canceled/i.test(combined)
|
||||||
) {
|
) {
|
||||||
return 'Update installation was cancelled.'
|
return "Update installation was cancelled.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/incorrect/i.test(combined)) {
|
if (/incorrect/i.test(combined)) {
|
||||||
return 'The administrator password was incorrect.'
|
return "The administrator password was incorrect.";
|
||||||
}
|
}
|
||||||
|
|
||||||
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) =>
|
const getDownloadUrl = (url, redirectCount = 0) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
if (redirectCount > 5) {
|
if (redirectCount > 5) {
|
||||||
reject(new Error('Too many redirects while downloading update.'))
|
reject(new Error("Too many redirects while downloading update."));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedUrl = new URL(url)
|
const parsedUrl = new URL(url);
|
||||||
const client = parsedUrl.protocol === 'https:' ? https : http
|
const client = parsedUrl.protocol === "https:" ? https : http;
|
||||||
const request = client.get(parsedUrl, (response) => {
|
const request = client.get(parsedUrl, (response) => {
|
||||||
const location = response.headers.location
|
const location = response.headers.location;
|
||||||
|
|
||||||
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
|
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
|
||||||
response.resume()
|
response.resume();
|
||||||
resolve(
|
resolve(
|
||||||
getDownloadUrl(
|
getDownloadUrl(
|
||||||
new URL(location, parsedUrl).toString(),
|
new URL(location, parsedUrl).toString(),
|
||||||
redirectCount + 1
|
redirectCount + 1,
|
||||||
)
|
),
|
||||||
)
|
);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({ response, url: parsedUrl.toString() })
|
resolve({ response, url: parsedUrl.toString() });
|
||||||
})
|
});
|
||||||
|
|
||||||
request.on('error', reject)
|
request.on("error", reject);
|
||||||
})
|
});
|
||||||
|
|
||||||
const downloadArtifact = async (artifact, destinationPath, webContents) => {
|
const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
|
||||||
const { response } = await getDownloadUrl(artifact.url)
|
const { response } = await getDownloadUrl(artifact.url);
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
response.resume()
|
response.resume();
|
||||||
throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
|
throw new Error(`Update download failed with HTTP ${response.statusCode}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalBytes =
|
const totalBytes =
|
||||||
Number.parseInt(response.headers['content-length'], 10) || 0
|
Number.parseInt(response.headers["content-length"], 10) || 0;
|
||||||
let downloadedBytes = 0
|
let downloadedBytes = 0;
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const output = createWriteStream(destinationPath)
|
const output = createWriteStream(destinationPath);
|
||||||
|
|
||||||
response.on('data', (chunk) => {
|
response.on("data", (chunk) => {
|
||||||
downloadedBytes += chunk.length
|
downloadedBytes += chunk.length;
|
||||||
const percent = totalBytes
|
const percent = totalBytes
|
||||||
? Math.round((downloadedBytes / totalBytes) * 100)
|
? Math.round((downloadedBytes / totalBytes) * 100)
|
||||||
: null
|
: null;
|
||||||
|
|
||||||
sendProgress(webContents, {
|
sendProgress({
|
||||||
phase: 'downloading',
|
phase: "downloading",
|
||||||
percent,
|
percent,
|
||||||
downloadedBytes,
|
downloadedBytes,
|
||||||
totalBytes,
|
totalBytes,
|
||||||
message: totalBytes
|
message: totalBytes
|
||||||
? `Downloading update (${percent}%)`
|
? `Downloading update (${percent}%)`
|
||||||
: 'Downloading update'
|
: "Downloading update",
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
response.on('error', reject)
|
response.on("error", reject);
|
||||||
output.on('error', reject)
|
output.on("error", reject);
|
||||||
output.on('finish', resolve)
|
output.on("finish", resolve);
|
||||||
response.pipe(output)
|
response.pipe(output);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const restartApp = (app) => {
|
const launchInstallerAndRestart = async (
|
||||||
app.relaunch()
|
mainWindow,
|
||||||
app.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
installerPath,
|
installerPath,
|
||||||
webContents,
|
sendProgress,
|
||||||
installerHelpers
|
) => {
|
||||||
)
|
const installerHelpers = { sendProgress, getInstallErrorMessage };
|
||||||
restartApp(app)
|
|
||||||
return
|
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}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`App updates are not supported on ${process.platform}.`)
|
scheduleAppRestart();
|
||||||
}
|
Utils.quit();
|
||||||
|
};
|
||||||
|
|
||||||
const runAppUpdate = async (app, update, webContents) => {
|
const runAppUpdate = async (mainWindow, update, sendProgress) => {
|
||||||
const artifact = selectUpdateArtifact(update)
|
const artifact = selectUpdateArtifact(update);
|
||||||
const tempDirectory = await fs.mkdtemp(
|
const tempDirectory = await fs.mkdtemp(
|
||||||
path.join(os.tmpdir(), 'farmcontrol-update-')
|
path.join(os.tmpdir(), "farmcontrol-update-"),
|
||||||
)
|
);
|
||||||
const artifactName = path.basename(getArtifactName(artifact))
|
const artifactName = path.basename(getArtifactName(artifact));
|
||||||
const installerPath = path.join(tempDirectory, artifactName)
|
const installerPath = path.join(tempDirectory, artifactName);
|
||||||
|
|
||||||
sendProgress(webContents, {
|
sendProgress({
|
||||||
phase: 'preparing',
|
phase: "preparing",
|
||||||
percent: 0,
|
percent: 0,
|
||||||
artifact,
|
artifact,
|
||||||
message: 'Preparing update download'
|
message: "Preparing update download",
|
||||||
})
|
});
|
||||||
|
|
||||||
await downloadArtifact(artifact, installerPath, webContents)
|
await downloadArtifact(artifact, installerPath, sendProgress);
|
||||||
|
|
||||||
sendProgress(webContents, {
|
sendProgress({
|
||||||
phase: 'downloaded',
|
phase: "downloaded",
|
||||||
percent: 100,
|
percent: 100,
|
||||||
downloadedBytes: null,
|
downloadedBytes: null,
|
||||||
totalBytes: null,
|
totalBytes: null,
|
||||||
artifact,
|
artifact,
|
||||||
message: 'Update downloaded'
|
message: "Update downloaded",
|
||||||
})
|
});
|
||||||
|
|
||||||
await launchInstallerAndQuit(app, installerPath, webContents)
|
await launchInstallerAndRestart(mainWindow, installerPath, sendProgress);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function setupAppUpdateIPC(app) {
|
export function startAppUpdate(mainWindow, update, sendProgress) {
|
||||||
ipcMain.handle('app-update-start', async (event, update) => {
|
if (runningUpdate) return runningUpdate;
|
||||||
if (runningUpdate) return runningUpdate
|
|
||||||
|
|
||||||
const webContents = event.sender
|
runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
|
||||||
runningUpdate = runAppUpdate(app, update, webContents)
|
|
||||||
.then(() => ({ ok: true }))
|
.then(() => ({ ok: true }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
sendProgress(webContents, {
|
sendProgress({
|
||||||
phase: 'error',
|
phase: "error",
|
||||||
percent: null,
|
percent: null,
|
||||||
message: error?.message || 'Failed to update app.'
|
message: error?.message || "Failed to update app.",
|
||||||
})
|
});
|
||||||
throw error
|
throw error;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
runningUpdate = null
|
runningUpdate = null;
|
||||||
})
|
});
|
||||||
|
|
||||||
return runningUpdate
|
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 path, { dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
@ -227,6 +227,12 @@ export function createWindow() {
|
|||||||
setupWindowEvents()
|
setupWindowEvents()
|
||||||
attachKeyboardShortcuts(win)
|
attachKeyboardShortcuts(win)
|
||||||
setupNavigationGestures(win)
|
setupNavigationGestures(win)
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
win.maximize()
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
win.maximize()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWindow() {
|
export function getWindow() {
|
||||||
@ -250,7 +256,7 @@ export function setupMainWindowIPC() {
|
|||||||
|
|
||||||
// IPC handlers for window controls
|
// IPC handlers for window controls
|
||||||
ipcMain.on('window-control', (event, action) => {
|
ipcMain.on('window-control', (event, action) => {
|
||||||
if (!win) return
|
if (!win && action !== 'quit') return
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'minimize':
|
case 'minimize':
|
||||||
win.minimize()
|
win.minimize()
|
||||||
@ -265,6 +271,17 @@ export function setupMainWindowIPC() {
|
|||||||
case 'close':
|
case 'close':
|
||||||
win.close()
|
win.close()
|
||||||
break
|
break
|
||||||
|
case 'fullscreen':
|
||||||
|
win.setFullScreen(!win.isFullScreen())
|
||||||
|
break
|
||||||
|
case 'quit':
|
||||||
|
app.quit()
|
||||||
|
break
|
||||||
|
case 'toggle-devtools':
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.webContents.toggleDevTools()
|
||||||
|
}
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,18 +6,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
body {
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
body {
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* HTML: <div class="loader"></div> */
|
/* HTML: <div class="loader"></div> */
|
||||||
.fc-loader {
|
.fc-loader {
|
||||||
width: 35px;
|
width: 35px;
|
||||||
|
|||||||
@ -144,10 +144,12 @@ const isValidMsiPackage = async (filePath) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const prepareInstallerPath = async (installerPath) => {
|
export const prepareInstallerPath = async (installerPath) => {
|
||||||
const fileName = path.basename(installerPath)
|
const fileName = path.basename(installerPath)
|
||||||
const updateDir = path.join(
|
const updateDir = path.join(
|
||||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
os.homedir(),
|
||||||
|
'AppData',
|
||||||
|
'Local',
|
||||||
'FarmControl',
|
'FarmControl',
|
||||||
'Updates'
|
'Updates'
|
||||||
)
|
)
|
||||||
@ -173,11 +175,7 @@ const prepareInstallerPath = async (installerPath) => {
|
|||||||
return resolvedPath
|
return resolvedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const startWindowsInstallerProgressWatch = (
|
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
|
||||||
logPath,
|
|
||||||
webContents,
|
|
||||||
sendProgress
|
|
||||||
) => {
|
|
||||||
let installerOutput = ''
|
let installerOutput = ''
|
||||||
let lastLogSize = 0
|
let lastLogSize = 0
|
||||||
let lastPercent = null
|
let lastPercent = null
|
||||||
@ -239,7 +237,7 @@ const startWindowsInstallerProgressWatch = (
|
|||||||
|
|
||||||
lastPercent = resolvedPercent
|
lastPercent = resolvedPercent
|
||||||
lastMessage = resolvedMessage
|
lastMessage = resolvedMessage
|
||||||
sendProgress(webContents, {
|
sendProgress( {
|
||||||
phase: 'installing',
|
phase: 'installing',
|
||||||
percent: resolvedPercent,
|
percent: resolvedPercent,
|
||||||
message: resolvedMessage
|
message: resolvedMessage
|
||||||
@ -280,7 +278,7 @@ const startWindowsInstallerProgressWatch = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const launchWindowsInstaller = async (
|
export const launchWindowsInstaller = async (
|
||||||
app,
|
mainWindow,
|
||||||
installerPath,
|
installerPath,
|
||||||
webContents,
|
webContents,
|
||||||
{ sendProgress, getInstallErrorMessage }
|
{ sendProgress, getInstallErrorMessage }
|
||||||
@ -294,7 +292,7 @@ export const launchWindowsInstaller = async (
|
|||||||
logPath
|
logPath
|
||||||
})
|
})
|
||||||
|
|
||||||
sendProgress(webContents, {
|
sendProgress( {
|
||||||
phase: 'installing',
|
phase: 'installing',
|
||||||
percent: 0,
|
percent: 0,
|
||||||
message: 'Installing update...'
|
message: 'Installing update...'
|
||||||
@ -307,7 +305,6 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
const stopProgressWatch = startWindowsInstallerProgressWatch(
|
const stopProgressWatch = startWindowsInstallerProgressWatch(
|
||||||
logPath,
|
logPath,
|
||||||
webContents,
|
|
||||||
sendProgress
|
sendProgress
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -320,6 +317,9 @@ export const launchWindowsInstaller = async (
|
|||||||
resolvedPath,
|
resolvedPath,
|
||||||
'/qn',
|
'/qn',
|
||||||
'/norestart',
|
'/norestart',
|
||||||
|
'ALLUSERS=2',
|
||||||
|
'MSIINSTALLPERUSER=1',
|
||||||
|
'REBOOT=ReallySuppress',
|
||||||
'/L*v!',
|
'/L*v!',
|
||||||
logPath
|
logPath
|
||||||
]
|
]
|
||||||
@ -369,7 +369,7 @@ export const launchWindowsInstaller = async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
const message = error?.message || 'Failed to start update installer.'
|
const message = error?.message || 'Failed to start update installer.'
|
||||||
sendProgress(webContents, {
|
sendProgress( {
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
percent: null,
|
percent: null,
|
||||||
message
|
message
|
||||||
@ -396,7 +396,7 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
const message = getInstallErrorMessage(null, output)
|
const message = getInstallErrorMessage(null, output)
|
||||||
sendProgress(webContents, {
|
sendProgress( {
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
percent: null,
|
percent: null,
|
||||||
message
|
message
|
||||||
@ -418,7 +418,7 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
if (!succeeded) {
|
if (!succeeded) {
|
||||||
const message = getInstallErrorMessage(null, output)
|
const message = getInstallErrorMessage(null, output)
|
||||||
sendProgress(webContents, {
|
sendProgress( {
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
percent: null,
|
percent: null,
|
||||||
message
|
message
|
||||||
@ -429,7 +429,7 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
const { percent, message } = finalParse
|
const { percent, message } = finalParse
|
||||||
|
|
||||||
sendProgress(webContents, {
|
sendProgress( {
|
||||||
phase: 'installing',
|
phase: 'installing',
|
||||||
percent: percent ?? 100,
|
percent: percent ?? 100,
|
||||||
message: message || 'Installation complete. Restarting Farm Control...'
|
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"
|
DetailPrint "Register farmcontrol URI Handler"
|
||||||
DeleteRegKey HKCR "farmcontrol"
|
DeleteRegKey HKCU "Software\Classes\farmcontrol"
|
||||||
WriteRegStr HKCR "farmcontrol" "" "URL:farmcontrol"
|
WriteRegStr HKCU "Software\Classes\farmcontrol" "" "URL:farmcontrol"
|
||||||
WriteRegStr HKCR "farmcontrol" "URL Protocol" ""
|
WriteRegStr HKCU "Software\Classes\farmcontrol" "URL Protocol" ""
|
||||||
WriteRegStr HKCR "farmcontrol\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
|
WriteRegStr HKCU "Software\Classes\farmcontrol\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
|
||||||
WriteRegStr HKCR "farmcontrol\shell" "" ""
|
WriteRegStr HKCU "Software\Classes\farmcontrol\shell" "" ""
|
||||||
WriteRegStr HKCR "farmcontrol\shell\Open" "" ""
|
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" ""
|
||||||
WriteRegStr HKCR "farmcontrol\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1"
|
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1"
|
||||||
!macroend
|
!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>
|
<MessageProvider>
|
||||||
<Layout
|
<Layout
|
||||||
style={{ height: 'var(--unit-100vh)' }}
|
style={{ height: 'var(--unit-100vh)' }}
|
||||||
className={isDarkMode ? 'dark-mode' : 'light-mode'}
|
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout`}
|
||||||
>
|
>
|
||||||
<DashboardNavigation />
|
<DashboardNavigation />
|
||||||
<Layout>
|
<Layout>
|
||||||
@ -49,7 +49,7 @@ const DashboardLayout = ({ children }) => {
|
|||||||
) : (
|
) : (
|
||||||
<ProductionSidebar /> // Default to production sidebar
|
<ProductionSidebar /> // Default to production sidebar
|
||||||
)}
|
)}
|
||||||
<Layout style={{ padding: '24px' }}>
|
<Layout style={{ padding: '24px' }} className='main-content-layout'>
|
||||||
<Content>
|
<Content>
|
||||||
<Flex vertical style={{ height: '100%' }} gap='20px'>
|
<Flex vertical style={{ height: '100%' }} gap='20px'>
|
||||||
<Flex justify='space-between'>
|
<Flex justify='space-between'>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const About = () => {
|
|||||||
const { token } = useContext(AuthContext)
|
const { token } = useContext(AuthContext)
|
||||||
const { fetchApiServerVersion, fetchWsServerVersion } =
|
const { fetchApiServerVersion, fetchWsServerVersion } =
|
||||||
useContext(ApiServerContext)
|
useContext(ApiServerContext)
|
||||||
const { isElectron, getElectronVersion } = useContext(ElectronContext)
|
const { isElectron, getAppEngine } = useContext(ElectronContext)
|
||||||
const { checkForUpdates } = useContext(AppUpdateContext)
|
const { checkForUpdates } = useContext(AppUpdateContext)
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
|
||||||
@ -74,18 +74,18 @@ const About = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isElectron) return
|
if (!isElectron) return
|
||||||
|
|
||||||
getElectronVersion()
|
getAppEngine()
|
||||||
.then((version) => {
|
.then((engine) => {
|
||||||
setElectronVersion(version || 'unknown')
|
setAppEngine(engine === 'chromium' ? 'Chromium' : 'Native')
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setElectronVersion('unknown')
|
setAppEngine('Unknown')
|
||||||
})
|
})
|
||||||
}, [getElectronVersion, isElectron])
|
}, [getAppEngine, isElectron])
|
||||||
|
|
||||||
const [apiServerVersion, setApiServerVersion] = useState(null)
|
const [apiServerVersion, setApiServerVersion] = useState(null)
|
||||||
const [wsServerVersion, setWsServerVersion] = useState(null)
|
const [wsServerVersion, setWsServerVersion] = useState(null)
|
||||||
const [electronVersion, setElectronVersion] = useState(null)
|
const [appEngine, setAppEngine] = useState(null)
|
||||||
|
|
||||||
const apiServerVersionText = apiServerVersion ? (
|
const apiServerVersionText = apiServerVersion ? (
|
||||||
<Text>
|
<Text>
|
||||||
@ -99,8 +99,8 @@ const About = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Skeleton.Input active size='small' className='text-skeleton' />
|
<Skeleton.Input active size='small' className='text-skeleton' />
|
||||||
)
|
)
|
||||||
const electronVersionText = electronVersion ? (
|
const appEngineText = appEngine ? (
|
||||||
<Text>{`v${electronVersion}`}</Text>
|
<Text>{appEngine}</Text>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton.Input active size='small' className='text-skeleton' />
|
<Skeleton.Input active size='small' className='text-skeleton' />
|
||||||
)
|
)
|
||||||
@ -153,9 +153,7 @@ const About = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
{isElectron && (
|
{isElectron && (
|
||||||
<Text type='secondary'>
|
<Text type='secondary'>Engine: {appEngineText}</Text>
|
||||||
Electron: {electronVersionText}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text type='secondary'>REST API: {apiServerVersionText}</Text>
|
<Text type='secondary'>REST API: {apiServerVersionText}</Text>
|
||||||
|
|||||||
@ -88,6 +88,16 @@ const NewAppUpdate = ({ update, onCancel, onUpdate }) => {
|
|||||||
<Text style={{ margin: 0 }} type='secondary'>
|
<Text style={{ margin: 0 }} type='secondary'>
|
||||||
Branch: <Text>{update?.branch || 'Unknown'}</Text>
|
Branch: <Text>{update?.branch || 'Unknown'}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={{ margin: 0 }} type='secondary'>
|
||||||
|
Engine:{' '}
|
||||||
|
<Text>
|
||||||
|
{update?.engine === 'chromium'
|
||||||
|
? 'Chromium'
|
||||||
|
: update?.engine === 'native'
|
||||||
|
? 'Native'
|
||||||
|
: 'Unknown'}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Dropdown menu={actionsMenu}>
|
<Dropdown menu={actionsMenu}>
|
||||||
<Button size='small' type='text'>
|
<Button size='small' type='text'>
|
||||||
|
|||||||
@ -5,6 +5,10 @@ import { useThemeContext } from '../context/ThemeContext'
|
|||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
import {
|
||||||
|
normalizeAppUpdateEngine,
|
||||||
|
useAppUpdateContext
|
||||||
|
} from '../context/AppUpdateContext'
|
||||||
import { useMessageContext } from '../context/MessageContext'
|
import { useMessageContext } from '../context/MessageContext'
|
||||||
import useCollapseState from '../hooks/useCollapseState'
|
import useCollapseState from '../hooks/useCollapseState'
|
||||||
import InfoCollapse from '../common/InfoCollapse'
|
import InfoCollapse from '../common/InfoCollapse'
|
||||||
@ -14,13 +18,22 @@ import EditButtons from '../common/EditButtons'
|
|||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
const { Option } = Select
|
const { Option } = Select
|
||||||
const DEFAULT_UPDATE_BRANCH = 'main'
|
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||||
|
const DEFAULT_UPDATE_ENGINE = 'native'
|
||||||
|
|
||||||
|
const engineLabel = (engine) => {
|
||||||
|
const normalized = normalizeAppUpdateEngine(engine)
|
||||||
|
if (normalized === 'chromium') return 'Chromium'
|
||||||
|
if (normalized === 'native') return 'Native'
|
||||||
|
return 'Not configured'
|
||||||
|
}
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
|
const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
|
||||||
useThemeContext()
|
useThemeContext()
|
||||||
const { fetchAppUpdateBranches } = useContext(ApiServerContext)
|
const { fetchAppUpdateBranches } = useContext(ApiServerContext)
|
||||||
const { isElectron, getAppSettings, setAppSettings } =
|
const { isElectron, getAppSettings, setAppSettings, getAppEngine } =
|
||||||
useContext(ElectronContext)
|
useContext(ElectronContext)
|
||||||
|
const { recheckForUpdates } = useAppUpdateContext()
|
||||||
const { userProfile, setUserProfile } = useContext(AuthContext)
|
const { userProfile, setUserProfile } = useContext(AuthContext)
|
||||||
const { showSuccess, showError } = useMessageContext()
|
const { showSuccess, showError } = useMessageContext()
|
||||||
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
|
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
|
||||||
@ -34,20 +47,31 @@ const Settings = () => {
|
|||||||
const [draftSettings, setDraftSettings] = useState({})
|
const [draftSettings, setDraftSettings] = useState({})
|
||||||
const [branches, setBranches] = useState([])
|
const [branches, setBranches] = useState([])
|
||||||
const [branchLoading, setBranchLoading] = useState(false)
|
const [branchLoading, setBranchLoading] = useState(false)
|
||||||
|
const [runningEngine, setRunningEngine] = useState(DEFAULT_UPDATE_ENGINE)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
setSettingsLoading(true)
|
setSettingsLoading(true)
|
||||||
const storedSettings = isElectron
|
const [storedSettings, detectedEngine] = await Promise.all([
|
||||||
? await getAppSettings()
|
isElectron ? getAppSettings() : Promise.resolve(userProfile?.settings || {}),
|
||||||
: userProfile?.settings || {}
|
isElectron ? getAppEngine() : Promise.resolve(DEFAULT_UPDATE_ENGINE)
|
||||||
setAppSettingsState(storedSettings || {})
|
])
|
||||||
setDraftSettings(storedSettings || {})
|
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)
|
setSettingsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSettings()
|
loadSettings()
|
||||||
}, [getAppSettings, isElectron, userProfile?.settings])
|
}, [getAppEngine, getAppSettings, isElectron, userProfile?.settings])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsLoading || isEditing) return
|
if (settingsLoading || isEditing) return
|
||||||
@ -118,12 +142,17 @@ const Settings = () => {
|
|||||||
(branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
|
(branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
|
||||||
branches[0] ||
|
branches[0] ||
|
||||||
'Not configured'
|
'Not configured'
|
||||||
|
const currentEngine =
|
||||||
|
normalizeAppUpdateEngine(appSettings.appUpdateEngine) ||
|
||||||
|
normalizeAppUpdateEngine(runningEngine) ||
|
||||||
|
DEFAULT_UPDATE_ENGINE
|
||||||
|
|
||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
setDraftSettings({
|
setDraftSettings({
|
||||||
...appSettings,
|
...appSettings,
|
||||||
appUpdateBranch:
|
appUpdateBranch:
|
||||||
currentBranch === 'Not configured' ? undefined : currentBranch,
|
currentBranch === 'Not configured' ? undefined : currentBranch,
|
||||||
|
appUpdateEngine: currentEngine,
|
||||||
theme: currentThemeValue,
|
theme: currentThemeValue,
|
||||||
density: currentDensityValue
|
density: currentDensityValue
|
||||||
})
|
})
|
||||||
@ -139,12 +168,18 @@ const Settings = () => {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const nextEngine =
|
||||||
|
normalizeAppUpdateEngine(draftSettings.appUpdateEngine) ||
|
||||||
|
currentEngine
|
||||||
const nextSettings = {
|
const nextSettings = {
|
||||||
...appSettings,
|
...appSettings,
|
||||||
theme: draftSettings.theme,
|
theme: draftSettings.theme,
|
||||||
density: draftSettings.density,
|
density: draftSettings.density,
|
||||||
...(isElectron
|
...(isElectron
|
||||||
? { appUpdateBranch: draftSettings.appUpdateBranch }
|
? {
|
||||||
|
appUpdateBranch: draftSettings.appUpdateBranch,
|
||||||
|
appUpdateEngine: nextEngine
|
||||||
|
}
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
const saved = isElectron
|
const saved = isElectron
|
||||||
@ -173,6 +208,16 @@ const Settings = () => {
|
|||||||
setDraftSettings(nextSettings)
|
setDraftSettings(nextSettings)
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
showSuccess('Settings saved.')
|
showSuccess('Settings saved.')
|
||||||
|
|
||||||
|
const appUpdateSettingsChanged =
|
||||||
|
isElectron &&
|
||||||
|
(nextSettings.appUpdateBranch !== appSettings.appUpdateBranch ||
|
||||||
|
nextSettings.appUpdateEngine !==
|
||||||
|
normalizeAppUpdateEngine(appSettings.appUpdateEngine))
|
||||||
|
|
||||||
|
if (appUpdateSettingsChanged) {
|
||||||
|
void recheckForUpdates()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@ -196,7 +241,9 @@ const Settings = () => {
|
|||||||
startEditing={startEditing}
|
startEditing={startEditing}
|
||||||
formValid={
|
formValid={
|
||||||
Boolean(draftSettings.theme && draftSettings.density) &&
|
Boolean(draftSettings.theme && draftSettings.density) &&
|
||||||
(!isElectron || Boolean(draftSettings.appUpdateBranch))
|
(!isElectron ||
|
||||||
|
(Boolean(draftSettings.appUpdateBranch) &&
|
||||||
|
Boolean(normalizeAppUpdateEngine(draftSettings.appUpdateEngine))))
|
||||||
}
|
}
|
||||||
disabled={settingsLoading || (!isElectron && !userProfile)}
|
disabled={settingsLoading || (!isElectron && !userProfile)}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
@ -297,6 +344,29 @@ const Settings = () => {
|
|||||||
<Text>{currentBranch}</Text>
|
<Text>{currentBranch}</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label='Engine'>
|
||||||
|
{isEditing ? (
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
normalizeAppUpdateEngine(draftSettings.appUpdateEngine) ||
|
||||||
|
currentEngine
|
||||||
|
}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraftSettings((previous) => ({
|
||||||
|
...previous,
|
||||||
|
appUpdateEngine: value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder='Select an engine'
|
||||||
|
>
|
||||||
|
<Option value='native'>Native</Option>
|
||||||
|
<Option value='chromium'>Chromium</Option>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Text>{engineLabel(currentEngine)}</Text>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</InfoCollapse>
|
</InfoCollapse>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import SettingsIcon from '../../Icons/SettingsIcon'
|
|||||||
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import DashboardWindowButtons from './DashboardWindowButtons'
|
import DashboardWindowButtons from './DashboardWindowButtons'
|
||||||
|
import WindowAppMenu from './WindowAppMenu'
|
||||||
import WebAppSwitcher from './WebAppSwitcher'
|
import WebAppSwitcher from './WebAppSwitcher'
|
||||||
import {
|
import {
|
||||||
getSidebarDefaultPath,
|
getSidebarDefaultPath,
|
||||||
@ -52,7 +53,8 @@ const DashboardNavigation = () => {
|
|||||||
const { userProfile } = useContext(AuthContext)
|
const { userProfile } = useContext(AuthContext)
|
||||||
const { showSpotlight } = useContext(SpotlightContext)
|
const { showSpotlight } = useContext(SpotlightContext)
|
||||||
const { connecting, connected } = useContext(ApiServerContext)
|
const { connecting, connected } = useContext(ApiServerContext)
|
||||||
const { toggleNotificationCenter, unreadCount, notificationCenterVisible } =
|
const { authenticated } = useContext(AuthContext)
|
||||||
|
const { toggleNotificationCenter, unreadCount } =
|
||||||
useContext(NotificationContext)
|
useContext(NotificationContext)
|
||||||
const [apiServerState, setApiServerState] = useState('disconnected')
|
const [apiServerState, setApiServerState] = useState('disconnected')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -64,7 +66,7 @@ const DashboardNavigation = () => {
|
|||||||
icon: <ProductionIcon />
|
icon: <ProductionIcon />
|
||||||
})
|
})
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
const { platform, isElectron, isFullScreen, setSidebarViewMenu } =
|
const { platform, isElectron, setSidebarViewMenu, isFullScreen } =
|
||||||
useContext(ElectronContext)
|
useContext(ElectronContext)
|
||||||
const { availableUpdate, checkForUpdates } = useAppUpdateContext()
|
const { availableUpdate, checkForUpdates } = useAppUpdateContext()
|
||||||
const mainMenuItems = useMemo(
|
const mainMenuItems = useMemo(
|
||||||
@ -72,26 +74,31 @@ const DashboardNavigation = () => {
|
|||||||
{
|
{
|
||||||
key: 'production',
|
key: 'production',
|
||||||
label: 'Production',
|
label: 'Production',
|
||||||
|
className: 'electrobun-webkit-app-region-no-drag',
|
||||||
icon: <ProductionIcon />
|
icon: <ProductionIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'inventory',
|
key: 'inventory',
|
||||||
label: 'Inventory',
|
label: 'Inventory',
|
||||||
|
className: 'electrobun-webkit-app-region-no-drag',
|
||||||
icon: <InventoryIcon />
|
icon: <InventoryIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sales',
|
key: 'sales',
|
||||||
label: 'Sales',
|
label: 'Sales',
|
||||||
|
className: 'electrobun-webkit-app-region-no-drag',
|
||||||
icon: <SalesIcon />
|
icon: <SalesIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'finance',
|
key: 'finance',
|
||||||
label: 'Finance',
|
label: 'Finance',
|
||||||
|
className: 'electrobun-webkit-app-region-no-drag',
|
||||||
icon: <FinanceIcon />
|
icon: <FinanceIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'management',
|
key: 'management',
|
||||||
label: 'Management',
|
label: 'Management',
|
||||||
|
className: 'electrobun-webkit-app-region-no-drag',
|
||||||
icon: <SettingsIcon />
|
icon: <SettingsIcon />
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -135,28 +142,26 @@ const DashboardNavigation = () => {
|
|||||||
setSidebarViewMenu(sections)
|
setSidebarViewMenu(sections)
|
||||||
}, [isElectron, setSidebarViewMenu])
|
}, [isElectron, setSidebarViewMenu])
|
||||||
|
|
||||||
const showAppLogo =
|
|
||||||
(isElectron && platform == 'darwin' && isFullScreen == true) ||
|
|
||||||
(isElectron && platform != 'darwin')
|
|
||||||
const isMacOSApp = isElectron && platform == 'darwin'
|
const isMacOSApp = isElectron && platform == 'darwin'
|
||||||
const isOtherApp = isElectron && platform != 'darwin'
|
const isOtherApp = isElectron && platform != 'darwin'
|
||||||
|
|
||||||
const showDesktopLogo = !isElectron && !isMobile
|
const showDesktopLogo = !isElectron && !isMobile
|
||||||
const showMobileLogo = !isElectron && isMobile
|
const showMobileLogo = !isElectron && isMobile
|
||||||
|
|
||||||
|
const showControls = !isElectron || authenticated
|
||||||
|
|
||||||
const navigationContents = (
|
const navigationContents = (
|
||||||
<Flex style={{ width: '100%' }} align='center'>
|
<Flex style={{ width: '100%' }} align='center'>
|
||||||
{isMacOSApp ? <DashboardWindowButtons /> : null}
|
{isMacOSApp ? <DashboardWindowButtons /> : null}
|
||||||
{showAppLogo == true && (
|
{isOtherApp ? (
|
||||||
<FarmControlLogoSmall
|
<>
|
||||||
style={{
|
<WindowAppMenu />{' '}
|
||||||
fontSize: '46px',
|
<Divider
|
||||||
height: '16px',
|
type='vertical'
|
||||||
marginLeft: '13px',
|
style={{ height: '14px', margin: '3px 3px 0 1.5px' }}
|
||||||
marginRight: '0px'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
|
) : null}
|
||||||
{showDesktopLogo == true ? (
|
{showDesktopLogo == true ? (
|
||||||
<FarmControlLogo
|
<FarmControlLogo
|
||||||
style={{
|
style={{
|
||||||
@ -199,6 +204,8 @@ const DashboardNavigation = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ flexGrow: 1 }}>
|
||||||
|
{showControls && (
|
||||||
<Menu
|
<Menu
|
||||||
mode='horizontal'
|
mode='horizontal'
|
||||||
className={isElectron ? 'electron-navigation' : null}
|
className={isElectron ? 'electron-navigation' : null}
|
||||||
@ -219,12 +226,30 @@ const DashboardNavigation = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{!showControls && (
|
||||||
|
<Text
|
||||||
|
type='secondary'
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
marginLeft: '8px',
|
||||||
|
userSelect: 'none',
|
||||||
|
'--webkit-user-select': 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Farm Control
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{isMobile && <div style={{ flexGrow: 1 }} />}
|
{isMobile && <div style={{ flexGrow: 1 }} />}
|
||||||
|
|
||||||
|
<div className='electrobun-webkit-app-region-no-drag'>
|
||||||
<Flex
|
<Flex
|
||||||
gap={'small'}
|
gap={'small'}
|
||||||
align='center'
|
align='center'
|
||||||
style={{ marginTop: '-2px', marginRight: '6px' }}
|
style={{ marginTop: '-2px', marginRight: '6px' }}
|
||||||
>
|
>
|
||||||
|
{showControls && (
|
||||||
<Space style={{ paddingTop: '2px', marginRight: '8px' }}>
|
<Space style={{ paddingTop: '2px', marginRight: '8px' }}>
|
||||||
<WebAppSwitcher />
|
<WebAppSwitcher />
|
||||||
<KeyboardShortcut
|
<KeyboardShortcut
|
||||||
@ -259,6 +284,7 @@ const DashboardNavigation = () => {
|
|||||||
</KeyboardShortcut>
|
</KeyboardShortcut>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Space>
|
</Space>
|
||||||
|
)}
|
||||||
{import.meta.env.MODE === 'development' && (
|
{import.meta.env.MODE === 'development' && (
|
||||||
<Space>
|
<Space>
|
||||||
{apiServerState === 'connected' ? (
|
{apiServerState === 'connected' ? (
|
||||||
@ -300,7 +326,7 @@ const DashboardNavigation = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
{userProfile ? (
|
{showControls && userProfile ? (
|
||||||
<Space>
|
<Space>
|
||||||
<Popover
|
<Popover
|
||||||
content={userPopoverContent}
|
content={userPopoverContent}
|
||||||
@ -316,7 +342,7 @@ const DashboardNavigation = () => {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</Space>
|
</Space>
|
||||||
) : null}
|
) : null}
|
||||||
{isElectron && availableUpdate ? (
|
{showControls && isElectron && availableUpdate ? (
|
||||||
<Tag
|
<Tag
|
||||||
icon={<CloudIcon />}
|
icon={<CloudIcon />}
|
||||||
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
|
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
|
||||||
@ -327,6 +353,7 @@ const DashboardNavigation = () => {
|
|||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</div>
|
||||||
{isOtherApp ? <DashboardWindowButtons /> : null}
|
{isOtherApp ? <DashboardWindowButtons /> : null}
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
@ -335,7 +362,7 @@ const DashboardNavigation = () => {
|
|||||||
<>
|
<>
|
||||||
{isElectron ? (
|
{isElectron ? (
|
||||||
<Flex
|
<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' }}
|
style={{ lineHeight: '40px', padding: '0 2px 0 2px' }}
|
||||||
>
|
>
|
||||||
{navigationContents}
|
{navigationContents}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useContext } from 'react'
|
import { useContext } from 'react'
|
||||||
import { Flex, Button } from 'antd'
|
import { Flex, Button, Divider } from 'antd'
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||||
import MinusIcon from '../../Icons/MinusIcon'
|
import MinusIcon from '../../Icons/MinusIcon'
|
||||||
import ContractIcon from '../../Icons/ContractIcon'
|
import ContractIcon from '../../Icons/ContractIcon'
|
||||||
import ExpandIcon from '../../Icons/ExpandIcon'
|
import ExpandIcon from '../../Icons/ExpandIcon'
|
||||||
|
import MacOSTrafficLights from './MacOSTrafficLights'
|
||||||
|
|
||||||
const DashboardWindowButtons = () => {
|
const DashboardWindowButtons = () => {
|
||||||
const { isMaximized, handleWindowControl, platform, isFullScreen } =
|
const { isMaximized, handleWindowControl, platform, isFullScreen } =
|
||||||
@ -17,14 +18,21 @@ const DashboardWindowButtons = () => {
|
|||||||
onClick={() => handleWindowControl('close')}
|
onClick={() => handleWindowControl('close')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
const maximizeButton = (
|
const minimizeButton = (
|
||||||
<Button
|
<Button
|
||||||
icon={<MinusIcon />}
|
icon={<MinusIcon />}
|
||||||
type={'text'}
|
type={'text'}
|
||||||
onClick={() => handleWindowControl('minimize')}
|
onClick={() => handleWindowControl('minimize')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
const minimizeButton = (
|
const fullscreenButton = (
|
||||||
|
<Button
|
||||||
|
icon={isFullScreen ? <ContractIcon /> : <ExpandIcon />}
|
||||||
|
type={'text'}
|
||||||
|
onClick={() => handleWindowControl('fullscreen')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const maximizeButton = (
|
||||||
<Button
|
<Button
|
||||||
icon={isMaximized ? <ContractIcon /> : <ExpandIcon />}
|
icon={isMaximized ? <ContractIcon /> : <ExpandIcon />}
|
||||||
type={'text'}
|
type={'text'}
|
||||||
@ -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 (
|
return (
|
||||||
<Flex align='center'>
|
<Flex align='center'>
|
||||||
{platform == 'darwin' ? (
|
{platform == 'darwin' ? (
|
||||||
isFullScreen == false ? (
|
<>
|
||||||
<div style={{ width: '80px' }} />
|
<MacOSTrafficLights />
|
||||||
) : null
|
<Divider
|
||||||
|
type='vertical'
|
||||||
|
style={{ height: '14px', margin: '3px 6px 0 0' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: '95px', marginRight: '2.5px' }}>
|
customWindowControls
|
||||||
<Flex style={{ position: 'relative', zIndex: 9999 }}>
|
|
||||||
{maximizeButton}
|
|
||||||
{minimizeButton}
|
|
||||||
{closeButton}
|
|
||||||
</Flex>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</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
|
||||||
259
src/components/Dashboard/common/WindowAppMenu.jsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import { useContext, useMemo } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { Button, Dropdown, Flex } from 'antd'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
|
import { useAppUpdateContext } from '../context/AppUpdateContext'
|
||||||
|
import { getSidebarMenuSections } from '../../../database/Sidebars'
|
||||||
|
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
|
||||||
|
|
||||||
|
const runEditCommand = (command) => {
|
||||||
|
try {
|
||||||
|
document.execCommand(command)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[WindowAppMenu] Failed to run edit command: ${command}`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapSidebarItemsToMenuItems = (items = [], navigate) =>
|
||||||
|
items
|
||||||
|
.map((item, index) => {
|
||||||
|
if (item?.type === 'divider') {
|
||||||
|
return { type: 'divider', key: `divider-${index}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item?.children?.length) {
|
||||||
|
return {
|
||||||
|
key: item.key || `group-${item.label}-${index}`,
|
||||||
|
label: item.label,
|
||||||
|
children: mapSidebarItemsToMenuItems(item.children, navigate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item?.path) {
|
||||||
|
return {
|
||||||
|
key: item.path,
|
||||||
|
label: item.label,
|
||||||
|
onClick: () => navigate(item.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: item.key || `disabled-${item.label}-${index}`,
|
||||||
|
label: item.label,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const MenuButton = ({ label, items, logo }) => (
|
||||||
|
<Dropdown menu={{ items }} trigger={['click']} placement='bottomLeft'>
|
||||||
|
<Button
|
||||||
|
type='text'
|
||||||
|
size='small'
|
||||||
|
className='electrobun-webkit-app-region-no-drag'
|
||||||
|
style={{
|
||||||
|
height: '28px',
|
||||||
|
paddingInline: logo ? '6px' : '8px',
|
||||||
|
marginRight: logo ? '1px' : '0',
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
|
||||||
|
MenuButton.propTypes = {
|
||||||
|
label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
|
||||||
|
items: PropTypes.array.isRequired,
|
||||||
|
logo: PropTypes.bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const WindowAppMenu = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { handleWindowControl } = useContext(ElectronContext)
|
||||||
|
const { checkForUpdates } = useAppUpdateContext()
|
||||||
|
const includeDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
const viewSections = useMemo(
|
||||||
|
() => getSidebarMenuSections({ includeDev }),
|
||||||
|
[includeDev]
|
||||||
|
)
|
||||||
|
|
||||||
|
const appMenuItems = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'about',
|
||||||
|
label: 'About Farm Control',
|
||||||
|
onClick: () => navigate('/dashboard/management/about')
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'check-for-updates',
|
||||||
|
label: 'Check For Updates...',
|
||||||
|
onClick: () => checkForUpdates()
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'quit',
|
||||||
|
label: 'Quit Farm Control',
|
||||||
|
onClick: () => handleWindowControl('quit')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[checkForUpdates, handleWindowControl, navigate]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fileMenuItems = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'close',
|
||||||
|
label: 'Close Window',
|
||||||
|
onClick: () => handleWindowControl('close')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[handleWindowControl]
|
||||||
|
)
|
||||||
|
|
||||||
|
const editMenuItems = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'undo',
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: () => runEditCommand('undo')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'redo',
|
||||||
|
label: 'Redo',
|
||||||
|
onClick: () => runEditCommand('redo')
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'cut',
|
||||||
|
label: 'Cut',
|
||||||
|
onClick: () => runEditCommand('cut')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'copy',
|
||||||
|
label: 'Copy',
|
||||||
|
onClick: () => runEditCommand('copy')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'paste',
|
||||||
|
label: 'Paste',
|
||||||
|
onClick: () => runEditCommand('paste')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pasteAndMatchStyle',
|
||||||
|
label: 'Paste and Match Style',
|
||||||
|
onClick: () => runEditCommand('paste')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: () => runEditCommand('delete')
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'selectAll',
|
||||||
|
label: 'Select All',
|
||||||
|
onClick: () => runEditCommand('selectAll')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const viewMenuItems = useMemo(() => {
|
||||||
|
const sectionItems =
|
||||||
|
viewSections.length > 0
|
||||||
|
? viewSections.map((section) => ({
|
||||||
|
key: `view-section-${section.key}`,
|
||||||
|
label: section.label,
|
||||||
|
children: mapSidebarItemsToMenuItems(section.items || [], navigate)
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: 'no-sidebar-items',
|
||||||
|
label: 'No sidebar items available',
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!includeDev) {
|
||||||
|
return sectionItems
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...sectionItems,
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'toggle-devtools',
|
||||||
|
label: 'Toggle Developer Tools',
|
||||||
|
onClick: () => handleWindowControl('toggle-devtools')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, [handleWindowControl, includeDev, navigate, viewSections])
|
||||||
|
|
||||||
|
const windowMenuItems = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'minimize',
|
||||||
|
label: 'Minimize',
|
||||||
|
onClick: () => handleWindowControl('minimize')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'zoom',
|
||||||
|
label: 'Zoom',
|
||||||
|
onClick: () => handleWindowControl('maximize')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[handleWindowControl]
|
||||||
|
)
|
||||||
|
|
||||||
|
const menus = useMemo(
|
||||||
|
() => [
|
||||||
|
{ key: 'app', label: 'Farm Control', items: appMenuItems },
|
||||||
|
{ key: 'file', label: 'File', items: fileMenuItems },
|
||||||
|
{ key: 'edit', label: 'Edit', items: editMenuItems },
|
||||||
|
{ key: 'view', label: 'View', items: viewMenuItems },
|
||||||
|
{ key: 'window', label: 'Window', items: windowMenuItems }
|
||||||
|
],
|
||||||
|
[appMenuItems, editMenuItems, fileMenuItems, viewMenuItems, windowMenuItems]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
align='center'
|
||||||
|
className='electrobun-webkit-app-region-no-drag'
|
||||||
|
style={{ marginRight: '8px', flexShrink: 0, marginLeft: '4px' }}
|
||||||
|
>
|
||||||
|
{menus.map((menu) => {
|
||||||
|
if (menu.key === 'app') {
|
||||||
|
return (
|
||||||
|
<MenuButton
|
||||||
|
key={menu.key}
|
||||||
|
logo={true}
|
||||||
|
label={
|
||||||
|
<FarmControlLogoSmall
|
||||||
|
style={{
|
||||||
|
fontSize: '46px',
|
||||||
|
height: '16px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
items={menu.items}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MenuButton key={menu.key} label={menu.label} items={menu.items} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WindowAppMenu
|
||||||
@ -16,14 +16,27 @@ import { ElectronContext } from './ElectronContext'
|
|||||||
import NewAppUpdate from '../Management/AppUpdates/NewAppUpdate'
|
import NewAppUpdate from '../Management/AppUpdates/NewAppUpdate'
|
||||||
import AppUpdateProgress from '../Management/AppUpdates/AppUpdateProgress'
|
import AppUpdateProgress from '../Management/AppUpdates/AppUpdateProgress'
|
||||||
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
|
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
|
||||||
|
import ExclamationOctogonIcon from '../../Icons/ExclamationOctagonIcon'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
|
const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
const DEFAULT_MODEL_WIDTH = 710
|
||||||
const DEFAULT_UPDATE_BRANCH = 'main'
|
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||||
|
const DEFAULT_UPDATE_ENGINE = 'native'
|
||||||
const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER
|
const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER
|
||||||
const APP_UPDATE_DISMISSED_KEY = 'appUpdateDismissed'
|
const APP_UPDATE_DISMISSED_KEY = 'appUpdateDismissed'
|
||||||
|
|
||||||
|
export const normalizeAppUpdateEngine = (engine) => {
|
||||||
|
const value = String(engine || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
if (value === 'chromium' || value === 'cef') return 'chromium'
|
||||||
|
if (value === 'native') return 'native'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const getDismissedUpdate = () => {
|
const getDismissedUpdate = () => {
|
||||||
try {
|
try {
|
||||||
const stored = sessionStorage.getItem(APP_UPDATE_DISMISSED_KEY)
|
const stored = sessionStorage.getItem(APP_UPDATE_DISMISSED_KEY)
|
||||||
@ -42,7 +55,9 @@ const isUpdateDismissed = (update) => {
|
|||||||
return (
|
return (
|
||||||
dismissed.version === update.version &&
|
dismissed.version === update.version &&
|
||||||
dismissed.buildNumber === update.buildNumber &&
|
dismissed.buildNumber === update.buildNumber &&
|
||||||
dismissed.branch === update.branch
|
dismissed.branch === update.branch &&
|
||||||
|
normalizeAppUpdateEngine(dismissed.engine) ===
|
||||||
|
normalizeAppUpdateEngine(update.engine)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +69,8 @@ const saveDismissedUpdate = (update) => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: update.version,
|
version: update.version,
|
||||||
buildNumber: update.buildNumber,
|
buildNumber: update.buildNumber,
|
||||||
branch: update.branch
|
branch: update.branch,
|
||||||
|
engine: normalizeAppUpdateEngine(update.engine) || DEFAULT_UPDATE_ENGINE
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -109,23 +125,41 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
|
const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
|
||||||
useContext(ApiServerContext)
|
useContext(ApiServerContext)
|
||||||
const { token } = useContext(AuthContext)
|
const { token } = useContext(AuthContext)
|
||||||
const { isElectron, getAppSettings, startAppUpdate, onAppUpdateProgress } =
|
const {
|
||||||
useContext(ElectronContext)
|
isElectron,
|
||||||
|
getAppSettings,
|
||||||
|
setAppSettings,
|
||||||
|
getAppEngine,
|
||||||
|
startAppUpdate,
|
||||||
|
checkAppUpdateResult,
|
||||||
|
checkDuplicateInstallations,
|
||||||
|
removeDuplicateInstallations,
|
||||||
|
onDuplicateInstallationsRemoved,
|
||||||
|
onAppUpdateProgress,
|
||||||
|
onCheckForUpdatesRequest
|
||||||
|
} = useContext(ElectronContext)
|
||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
const [noUpdateOpen, setNoUpdateOpen] = useState(false)
|
const [noUpdateOpen, setNoUpdateOpen] = useState(false)
|
||||||
const [availableUpdate, setAvailableUpdate] = useState(null)
|
const [availableUpdate, setAvailableUpdate] = useState(null)
|
||||||
const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
|
const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
|
||||||
const [installingUpdate, setInstallingUpdate] = useState(null)
|
const [installingUpdate, setInstallingUpdate] = useState(null)
|
||||||
const [updateProgress, setUpdateProgress] = useState(null)
|
const [updateProgress, setUpdateProgress] = useState(null)
|
||||||
|
const [completedUpdate, setCompletedUpdate] = useState(null)
|
||||||
|
const [duplicateInstall, setDuplicateInstall] = useState(null)
|
||||||
|
const [duplicatePromptOpen, setDuplicatePromptOpen] = useState(false)
|
||||||
|
const [removingDuplicate, setRemovingDuplicate] = useState(false)
|
||||||
|
const [duplicateRemovalResult, setDuplicateRemovalResult] = useState(null)
|
||||||
const runningCheckRef = useRef(null)
|
const runningCheckRef = useRef(null)
|
||||||
const updateCheckDependenciesRef = useRef({})
|
const updateCheckDependenciesRef = useRef({})
|
||||||
|
|
||||||
const [modelWidth, setModelWidth] = useState(650)
|
const [modelWidth, setModelWidth] = useState(DEFAULT_MODEL_WIDTH)
|
||||||
|
|
||||||
updateCheckDependenciesRef.current = {
|
updateCheckDependenciesRef.current = {
|
||||||
fetchAppUpdateBranches,
|
fetchAppUpdateBranches,
|
||||||
fetchAppUpdateCurrent,
|
fetchAppUpdateCurrent,
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
|
setAppSettings,
|
||||||
|
getAppEngine,
|
||||||
isElectron,
|
isElectron,
|
||||||
token
|
token
|
||||||
}
|
}
|
||||||
@ -135,6 +169,8 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
fetchAppUpdateBranches,
|
fetchAppUpdateBranches,
|
||||||
fetchAppUpdateCurrent,
|
fetchAppUpdateCurrent,
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
|
setAppSettings,
|
||||||
|
getAppEngine,
|
||||||
isElectron,
|
isElectron,
|
||||||
token
|
token
|
||||||
} = updateCheckDependenciesRef.current
|
} = updateCheckDependenciesRef.current
|
||||||
@ -143,9 +179,10 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
if (runningCheckRef.current) return runningCheckRef.current
|
if (runningCheckRef.current) return runningCheckRef.current
|
||||||
|
|
||||||
const checkPromise = (async () => {
|
const checkPromise = (async () => {
|
||||||
const [branches, appSettings] = await Promise.all([
|
const [branches, appSettings, runningEngine] = await Promise.all([
|
||||||
fetchAppUpdateBranches(),
|
fetchAppUpdateBranches(),
|
||||||
getAppSettings()
|
getAppSettings(),
|
||||||
|
getAppEngine()
|
||||||
])
|
])
|
||||||
const configuredBranch = appSettings?.appUpdateBranch
|
const configuredBranch = appSettings?.appUpdateBranch
|
||||||
const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH)
|
const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH)
|
||||||
@ -157,11 +194,52 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
|
|
||||||
if (!selectedBranch) return null
|
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)
|
const settingsUpdates = {}
|
||||||
? update
|
if (!appSettings?.appUpdateRunningBranch) {
|
||||||
: null
|
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
|
runningCheckRef.current = checkPromise
|
||||||
@ -181,6 +259,7 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
setNoUpdateOpen(false)
|
setNoUpdateOpen(false)
|
||||||
setAvailableUpdate(update)
|
setAvailableUpdate(update)
|
||||||
if (forcePrompt || !isUpdateDismissed(update)) {
|
if (forcePrompt || !isUpdateDismissed(update)) {
|
||||||
|
setModelWidth(DEFAULT_MODEL_WIDTH)
|
||||||
setUpdatePromptOpen(true)
|
setUpdatePromptOpen(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -205,6 +284,11 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [isElectron, showUpdateIfAvailable])
|
}, [isElectron, showUpdateIfAvailable])
|
||||||
|
|
||||||
|
const recheckForUpdates = useCallback(async () => {
|
||||||
|
if (!isElectron) return null
|
||||||
|
return showUpdateIfAvailable({ forcePrompt: true })
|
||||||
|
}, [isElectron, showUpdateIfAvailable])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isElectron) return undefined
|
if (!isElectron) return undefined
|
||||||
|
|
||||||
@ -219,6 +303,54 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [isElectron, showUpdateIfAvailable])
|
}, [isElectron, showUpdateIfAvailable])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron) return undefined
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const runStartupChecks = async () => {
|
||||||
|
let result = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await checkAppUpdateResult?.()
|
||||||
|
if (!cancelled && result?.updated) {
|
||||||
|
setCompletedUpdate(result)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AppUpdateContext] Startup update check failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const installations =
|
||||||
|
result?.duplicates || (await checkDuplicateInstallations?.())
|
||||||
|
if (!cancelled && installations?.duplicatePath) {
|
||||||
|
setDuplicateInstall(installations)
|
||||||
|
setDuplicatePromptOpen(true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'[AppUpdateContext] Duplicate installation check failed:',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void runStartupChecks()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isElectron, checkAppUpdateResult, checkDuplicateInstallations])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron || !onDuplicateInstallationsRemoved) return undefined
|
||||||
|
|
||||||
|
return onDuplicateInstallationsRemoved((result) => {
|
||||||
|
setRemovingDuplicate(false)
|
||||||
|
setDuplicateRemovalResult(result || { ok: false })
|
||||||
|
})
|
||||||
|
}, [isElectron, onDuplicateInstallationsRemoved])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isElectron || !onAppUpdateProgress) return undefined
|
if (!isElectron || !onAppUpdateProgress) return undefined
|
||||||
|
|
||||||
@ -227,6 +359,14 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
})
|
})
|
||||||
}, [isElectron, onAppUpdateProgress])
|
}, [isElectron, onAppUpdateProgress])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron || !onCheckForUpdatesRequest) return undefined
|
||||||
|
|
||||||
|
return onCheckForUpdatesRequest(() => {
|
||||||
|
void checkForUpdates()
|
||||||
|
})
|
||||||
|
}, [isElectron, onCheckForUpdatesRequest, checkForUpdates])
|
||||||
|
|
||||||
const dismissUpdatePrompt = () => {
|
const dismissUpdatePrompt = () => {
|
||||||
if (availableUpdate) {
|
if (availableUpdate) {
|
||||||
saveDismissedUpdate(availableUpdate)
|
saveDismissedUpdate(availableUpdate)
|
||||||
@ -252,10 +392,20 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
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) {
|
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) {
|
} catch (error) {
|
||||||
setUpdateProgress({
|
setUpdateProgress({
|
||||||
@ -266,12 +416,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 updateModalOpen = Boolean(updatePromptOpen || installingUpdate)
|
||||||
const updateModalBusy =
|
const updateModalBusy =
|
||||||
Boolean(installingUpdate) && updateProgress?.phase !== 'error'
|
Boolean(installingUpdate) && updateProgress?.phase !== 'error'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppUpdateContext.Provider value={{ availableUpdate, checkForUpdates }}>
|
<AppUpdateContext.Provider
|
||||||
|
value={{ availableUpdate, checkForUpdates, recheckForUpdates }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<Modal
|
<Modal
|
||||||
open={checking}
|
open={checking}
|
||||||
@ -289,7 +460,12 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
title='Software Update'
|
title={
|
||||||
|
<Flex align='center' gap='middle'>
|
||||||
|
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
|
||||||
|
Software Update
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
open={noUpdateOpen}
|
open={noUpdateOpen}
|
||||||
okText='OK'
|
okText='OK'
|
||||||
style={{ maxWidth: 430 }}
|
style={{ maxWidth: 430 }}
|
||||||
@ -344,6 +520,103 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Flex align='center' gap='middle'>
|
||||||
|
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
|
||||||
|
Update Installed
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
open={Boolean(completedUpdate)}
|
||||||
|
style={{ maxWidth: 430 }}
|
||||||
|
centered
|
||||||
|
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={
|
||||||
|
<Flex align='center' gap='middle'>
|
||||||
|
<ExclamationOctogonIcon />
|
||||||
|
Duplicate Installation Found
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
open={Boolean(duplicatePromptOpen && duplicateInstall)}
|
||||||
|
width={!removingDuplicate && !duplicateRemovalResult ? 560 : 430}
|
||||||
|
centered
|
||||||
|
closable={false}
|
||||||
|
maskClosable={false}
|
||||||
|
onCancel={removingDuplicate ? undefined : closeDuplicatePrompt}
|
||||||
|
footer={
|
||||||
|
removingDuplicate
|
||||||
|
? null
|
||||||
|
: duplicateRemovalResult
|
||||||
|
? [
|
||||||
|
<Button
|
||||||
|
key='close'
|
||||||
|
type='primary'
|
||||||
|
onClick={closeDuplicatePrompt}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
<Button key='no' onClick={closeDuplicatePrompt}>
|
||||||
|
No
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key='yes'
|
||||||
|
type='primary'
|
||||||
|
onClick={handleRemoveDuplicate}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</Button>
|
||||||
|
]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{removingDuplicate ? (
|
||||||
|
<Space size='middle'>
|
||||||
|
<LoadingOutlined />
|
||||||
|
<Text>
|
||||||
|
Removing the duplicate installation... You may be asked for an
|
||||||
|
administrator password.
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
) : duplicateRemovalResult ? (
|
||||||
|
<Text>
|
||||||
|
{duplicateRemovalResult.ok
|
||||||
|
? 'The duplicate installation was removed.'
|
||||||
|
: duplicateRemovalResult.error ||
|
||||||
|
'Failed to remove the duplicate installation.'}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Space direction='vertical' size='small'>
|
||||||
|
<Text>
|
||||||
|
Farm Control is now installed in your user applications folder,
|
||||||
|
but an older copy is still installed at:{' '}
|
||||||
|
<Text code>{duplicateInstall?.duplicatePath}</Text>.{' '}
|
||||||
|
<Text>Do you want to remove the duplicate installation?</Text>
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</AppUpdateContext.Provider>
|
</AppUpdateContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,14 @@
|
|||||||
import { createContext, useCallback, useEffect, useRef, useState } from 'react'
|
import { createContext, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import desktopBridge, {
|
||||||
|
isElectrobunBridgeReady,
|
||||||
|
isElectrobunDesktop
|
||||||
|
} from '../../../electrobun-bridge.js'
|
||||||
|
|
||||||
// Only available in Electron renderer
|
|
||||||
const electron = window.require ? window.require('electron') : null
|
|
||||||
const ipcRenderer = electron ? electron.ipcRenderer : null
|
|
||||||
|
|
||||||
// Utility to check if running in Electron
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export function isElectron() {
|
export function isElectron() {
|
||||||
// Renderer process
|
return isElectrobunBridgeReady() || isElectrobunDesktop()
|
||||||
|
|
||||||
if (
|
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
window.process &&
|
|
||||||
window.process.type === 'renderer'
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// User agent
|
|
||||||
if (
|
|
||||||
typeof navigator === 'object' &&
|
|
||||||
typeof navigator.userAgent === 'string' &&
|
|
||||||
navigator.userAgent.indexOf('Electron') >= 0
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ElectronContext = createContext()
|
const ElectronContext = createContext()
|
||||||
@ -70,70 +51,76 @@ const ElectronProvider = ({ children }) => {
|
|||||||
[navigate]
|
[navigate]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Function to open external URL via Electron
|
const applyWindowState = useCallback((state) => {
|
||||||
const openExternalUrl = (url) => {
|
if (state && typeof state.isMaximized === 'boolean') {
|
||||||
if (electronAvailable && ipcRenderer) {
|
setIsMaximized(state.isMaximized)
|
||||||
ipcRenderer.invoke('open-external-url', url)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
if (state && typeof state.isFullScreen === 'boolean') {
|
||||||
|
setIsFullScreen(state.isFullScreen)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openExternalUrl = (url) => {
|
||||||
|
if (!electronAvailable) return false
|
||||||
|
void desktopBridge.openExternalUrl(url).catch((error) => {
|
||||||
|
console.warn('[ElectronContext] Failed to open external url:', error)
|
||||||
|
})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to open internal URL via Electron
|
|
||||||
const openInternalUrl = (url) => {
|
const openInternalUrl = (url) => {
|
||||||
if (electronAvailable && ipcRenderer) {
|
if (!electronAvailable) return false
|
||||||
ipcRenderer.invoke('open-internal-url', url)
|
void desktopBridge.openInternalUrl(url).catch((error) => {
|
||||||
|
console.warn('[ElectronContext] Failed to open internal url:', error)
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ipcRenderer) return
|
if (!electronAvailable) return
|
||||||
|
|
||||||
// Get initial platform
|
document.body.classList.add('electron-body')
|
||||||
ipcRenderer.invoke('os-info').then((info) => {
|
return () => {
|
||||||
if (info && info.platform) setPlatform(info.platform)
|
document.body.classList.remove('electron-body')
|
||||||
})
|
}
|
||||||
|
}, [electronAvailable])
|
||||||
// Get initial window state
|
|
||||||
ipcRenderer.invoke('window-state').then((state) => {
|
useEffect(() => {
|
||||||
if (state && typeof state.isMaximized === 'boolean') {
|
if (!electronAvailable) return
|
||||||
setIsMaximized(state.isMaximized)
|
|
||||||
|
desktopBridge.getOsInfo().then((info) => {
|
||||||
|
if (info?.platform) {
|
||||||
|
setPlatform(info.platform)
|
||||||
|
if (info.platform === 'darwin') {
|
||||||
|
document.documentElement.classList.add('macos-vibrancy')
|
||||||
}
|
}
|
||||||
if (state && typeof state.isFullScreen === 'boolean') {
|
|
||||||
setIsFullScreen(state.isFullScreen)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen for window state changes
|
desktopBridge.getWindowState().then(applyWindowState)
|
||||||
const windowStateHandler = (event, state) => {
|
|
||||||
if (state && typeof state.isMaximized === 'boolean') {
|
|
||||||
setIsMaximized(state.isMaximized)
|
|
||||||
}
|
|
||||||
if (state && typeof state.isFullScreen === 'boolean') {
|
|
||||||
setIsFullScreen(state.isFullScreen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ipcRenderer.on('window-state', windowStateHandler)
|
|
||||||
|
|
||||||
// Listen for navigate
|
const unsubWindowState = desktopBridge.onMessage(
|
||||||
const navigateHandler = (event, url) => {
|
'windowState',
|
||||||
|
applyWindowState
|
||||||
|
)
|
||||||
|
const unsubNavigate = desktopBridge.onMessage('navigate', (url) => {
|
||||||
|
if (url.toLowerCase() == '/favicon.ico') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('[ElectronContext] Navigating to:', url)
|
||||||
navigate(url)
|
navigate(url)
|
||||||
}
|
})
|
||||||
ipcRenderer.on('navigate', navigateHandler)
|
const unsubNavigationGesture = desktopBridge.onMessage(
|
||||||
|
'navigationGesture',
|
||||||
const navigationGestureHandler = (_event, direction) => {
|
navigateHistory
|
||||||
navigateHistory(direction)
|
)
|
||||||
}
|
|
||||||
ipcRenderer.on('navigation-gesture', navigationGestureHandler)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener('navigate', navigateHandler)
|
unsubWindowState()
|
||||||
ipcRenderer.removeListener('navigation-gesture', navigationGestureHandler)
|
unsubNavigate()
|
||||||
ipcRenderer.removeListener('window-state', windowStateHandler)
|
unsubNavigationGesture()
|
||||||
}
|
}
|
||||||
}, [navigate, navigateHistory])
|
}, [applyWindowState, electronAvailable, navigate, navigateHistory])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!electronAvailable || platform !== 'darwin') return
|
if (!electronAvailable || platform !== 'darwin') return
|
||||||
@ -142,7 +129,8 @@ const ElectronProvider = ({ children }) => {
|
|||||||
let resetTimer
|
let resetTimer
|
||||||
|
|
||||||
const handleWheel = (event) => {
|
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) < Math.abs(event.deltaY)) return
|
||||||
if (Math.abs(event.deltaX) < 2) return
|
if (Math.abs(event.deltaX) < 2) return
|
||||||
if (isInHorizontalScrollContainer(event.target)) return
|
if (isInHorizontalScrollContainer(event.target)) return
|
||||||
@ -165,69 +153,97 @@ const ElectronProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [electronAvailable, navigateHistory, platform])
|
}, [electronAvailable, navigateHistory, platform])
|
||||||
|
|
||||||
// Window control handler
|
|
||||||
const handleWindowControl = (action) => {
|
const handleWindowControl = (action) => {
|
||||||
if (electronAvailable && ipcRenderer) {
|
if (!electronAvailable) return
|
||||||
ipcRenderer.send('window-control', action)
|
void desktopBridge.windowControl(action)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAuthSession = async () => {
|
const getAuthSession = async () => {
|
||||||
if (!electronAvailable || !ipcRenderer) return null
|
if (!electronAvailable) return null
|
||||||
return await ipcRenderer.invoke('auth-session-get')
|
return await desktopBridge.getAuthSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
const setAuthSession = async (session) => {
|
const setAuthSession = async (session) => {
|
||||||
if (!electronAvailable || !ipcRenderer) return false
|
if (!electronAvailable) return false
|
||||||
return await ipcRenderer.invoke('auth-session-set', session)
|
const result = await desktopBridge.setAuthSession(session)
|
||||||
|
return result?.ok ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearAuthSession = async () => {
|
const clearAuthSession = async () => {
|
||||||
if (!electronAvailable || !ipcRenderer) return false
|
if (!electronAvailable) return false
|
||||||
return await ipcRenderer.invoke('auth-session-clear')
|
const result = await desktopBridge.clearAuthSession()
|
||||||
|
return result?.ok ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAppSettings = useCallback(async () => {
|
const getAppSettings = useCallback(async () => {
|
||||||
if (!electronAvailable || !ipcRenderer) return {}
|
if (!electronAvailable) return {}
|
||||||
return await ipcRenderer.invoke('app-settings-get')
|
return await desktopBridge.getAppSettings()
|
||||||
}, [electronAvailable])
|
}, [electronAvailable])
|
||||||
|
|
||||||
const setAppSettings = useCallback(
|
const setAppSettings = useCallback(
|
||||||
async (settings) => {
|
async (settings) => {
|
||||||
if (!electronAvailable || !ipcRenderer) return false
|
if (!electronAvailable) return false
|
||||||
return await ipcRenderer.invoke('app-settings-set', settings)
|
const result = await desktopBridge.setAppSettings(settings)
|
||||||
|
return result?.ok ?? false
|
||||||
},
|
},
|
||||||
[electronAvailable]
|
[electronAvailable]
|
||||||
)
|
)
|
||||||
|
|
||||||
const startAppUpdate = useCallback(
|
const startAppUpdate = useCallback(
|
||||||
async (update) => {
|
async (update) => {
|
||||||
if (!electronAvailable || !ipcRenderer) return false
|
if (!electronAvailable) return false
|
||||||
return await ipcRenderer.invoke('app-update-start', update)
|
const result = await desktopBridge.startAppUpdate(update)
|
||||||
|
return result?.ok ?? false
|
||||||
|
},
|
||||||
|
[electronAvailable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const checkAppUpdateResult = useCallback(async () => {
|
||||||
|
if (!electronAvailable) return null
|
||||||
|
return await desktopBridge.checkAppUpdateResult()
|
||||||
|
}, [electronAvailable])
|
||||||
|
|
||||||
|
const checkDuplicateInstallations = useCallback(async () => {
|
||||||
|
if (!electronAvailable) return null
|
||||||
|
return await desktopBridge.checkDuplicateInstallations()
|
||||||
|
}, [electronAvailable])
|
||||||
|
|
||||||
|
const removeDuplicateInstallations = useCallback(async () => {
|
||||||
|
if (!electronAvailable) return false
|
||||||
|
const result = await desktopBridge.removeDuplicateInstallations()
|
||||||
|
return result?.ok ?? false
|
||||||
|
}, [electronAvailable])
|
||||||
|
|
||||||
|
const onDuplicateInstallationsRemoved = useCallback(
|
||||||
|
(handler) => {
|
||||||
|
if (!electronAvailable || typeof handler !== 'function') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
return desktopBridge.onMessage('duplicateInstallationsRemoved', handler)
|
||||||
},
|
},
|
||||||
[electronAvailable]
|
[electronAvailable]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAppUpdateProgress = useCallback(
|
const onAppUpdateProgress = useCallback(
|
||||||
(handler) => {
|
(handler) => {
|
||||||
if (!electronAvailable || !ipcRenderer || typeof handler !== 'function') {
|
if (!electronAvailable || typeof handler !== 'function') {
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
return desktopBridge.onMessage('appUpdateProgress', handler)
|
||||||
const progressHandler = (event, progress) => {
|
},
|
||||||
handler(progress)
|
[electronAvailable]
|
||||||
}
|
)
|
||||||
|
|
||||||
ipcRenderer.on('app-update-progress', progressHandler)
|
const onCheckForUpdatesRequest = useCallback(
|
||||||
|
(handler) => {
|
||||||
return () => {
|
if (!electronAvailable || typeof handler !== 'function') {
|
||||||
ipcRenderer.removeListener('app-update-progress', progressHandler)
|
return () => {}
|
||||||
}
|
}
|
||||||
|
return desktopBridge.onMessage('checkForUpdates', handler)
|
||||||
},
|
},
|
||||||
[electronAvailable]
|
[electronAvailable]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Backwards-compatible helpers
|
|
||||||
const getToken = async () => {
|
const getToken = async () => {
|
||||||
const session = await getAuthSession()
|
const session = await getAuthSession()
|
||||||
return session?.token || null
|
return session?.token || null
|
||||||
@ -239,9 +255,10 @@ const ElectronProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resizeSpotlightWindow = async (height) => {
|
const resizeSpotlightWindow = async (height) => {
|
||||||
if (!electronAvailable || !ipcRenderer) return false
|
if (!electronAvailable) return false
|
||||||
try {
|
try {
|
||||||
return await ipcRenderer.invoke('spotlight-window-resize', height)
|
const result = await desktopBridge.resizeSpotlightWindow(height)
|
||||||
|
return result?.ok ?? false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[ElectronContext] Failed to resize spotlight window:',
|
'[ElectronContext] Failed to resize spotlight window:',
|
||||||
@ -253,15 +270,22 @@ const ElectronProvider = ({ children }) => {
|
|||||||
|
|
||||||
const setSidebarViewMenu = useCallback(
|
const setSidebarViewMenu = useCallback(
|
||||||
async (sections) => {
|
async (sections) => {
|
||||||
if (!electronAvailable || !ipcRenderer) return false
|
if (!electronAvailable) return false
|
||||||
return await ipcRenderer.invoke('set-sidebar-view-menu', sections)
|
const result = await desktopBridge.setSidebarViewMenu(sections)
|
||||||
|
return result?.ok ?? false
|
||||||
},
|
},
|
||||||
[electronAvailable]
|
[electronAvailable]
|
||||||
)
|
)
|
||||||
|
|
||||||
const getElectronVersion = useCallback(async () => {
|
const getElectronVersion = useCallback(async () => {
|
||||||
if (!electronAvailable || !ipcRenderer) return null
|
if (!electronAvailable) return null
|
||||||
return await ipcRenderer.invoke('electron-version')
|
return await desktopBridge.getAppVersion()
|
||||||
|
}, [electronAvailable])
|
||||||
|
|
||||||
|
const getAppEngine = useCallback(async () => {
|
||||||
|
if (!electronAvailable) return 'native'
|
||||||
|
const engine = await desktopBridge.getAppEngine()
|
||||||
|
return engine === 'chromium' ? 'chromium' : 'native'
|
||||||
}, [electronAvailable])
|
}, [electronAvailable])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -280,12 +304,18 @@ const ElectronProvider = ({ children }) => {
|
|||||||
getAppSettings,
|
getAppSettings,
|
||||||
setAppSettings,
|
setAppSettings,
|
||||||
startAppUpdate,
|
startAppUpdate,
|
||||||
|
checkAppUpdateResult,
|
||||||
|
checkDuplicateInstallations,
|
||||||
|
removeDuplicateInstallations,
|
||||||
|
onDuplicateInstallationsRemoved,
|
||||||
onAppUpdateProgress,
|
onAppUpdateProgress,
|
||||||
|
onCheckForUpdatesRequest,
|
||||||
getToken,
|
getToken,
|
||||||
setToken,
|
setToken,
|
||||||
resizeSpotlightWindow,
|
resizeSpotlightWindow,
|
||||||
setSidebarViewMenu,
|
setSidebarViewMenu,
|
||||||
getElectronVersion
|
getElectronVersion,
|
||||||
|
getAppEngine
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { ApiServerContext } from './ApiServerContext'
|
|||||||
import NotificationCenter from '../common/NotificationCenter'
|
import NotificationCenter from '../common/NotificationCenter'
|
||||||
import Notification from '../common/Notification'
|
import Notification from '../common/Notification'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import { ElectronContext } from './ElectronContext'
|
||||||
|
|
||||||
const NotificationContext = createContext()
|
const NotificationContext = createContext()
|
||||||
|
|
||||||
@ -35,6 +36,8 @@ const NotificationProvider = ({ children }) => {
|
|||||||
const [notifications, setNotifications] = useState([])
|
const [notifications, setNotifications] = useState([])
|
||||||
const [notificationsLoading, setNotificationsLoading] = useState(false)
|
const [notificationsLoading, setNotificationsLoading] = useState(false)
|
||||||
|
|
||||||
|
const { isElectron } = useContext(ElectronContext)
|
||||||
|
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
|
||||||
const fetchNotifications = useCallback(async () => {
|
const fetchNotifications = useCallback(async () => {
|
||||||
@ -180,6 +183,7 @@ const NotificationProvider = ({ children }) => {
|
|||||||
title='Notifications'
|
title='Notifications'
|
||||||
placement='right'
|
placement='right'
|
||||||
width={isMobile ? '100%' : 460}
|
width={isMobile ? '100%' : 460}
|
||||||
|
style={{ marginTop: isElectron ? '40px' : '0px' }}
|
||||||
onClose={() => setNotificationCenterVisible(false)}
|
onClose={() => setNotificationCenterVisible(false)}
|
||||||
open={notificationCenterVisible}
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||