Compare commits
2 Commits
electrobun
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9f4c1f3d3 | |||
| 1d741d8932 |
5
.gitignore
vendored
@ -30,8 +30,3 @@ yarn-error.log*
|
|||||||
stats.html
|
stats.html
|
||||||
|
|
||||||
test-results.xml
|
test-results.xml
|
||||||
|
|
||||||
dist/*
|
|
||||||
|
|
||||||
# Native macOS vibrancy (built by scripts/build-macos-effects.mjs)
|
|
||||||
src/bun/libMacWindowEffects.dylib
|
|
||||||
150
Jenkinsfile
vendored
@ -1,57 +1,7 @@
|
|||||||
properties([
|
properties([
|
||||||
buildDiscarder(logRotator(numToKeepStr: '10'))
|
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||||
])
|
])
|
||||||
|
|
||||||
def bunBinDir() {
|
|
||||||
if (isUnix()) {
|
|
||||||
return "${env.HOME}/.bun/bin"
|
|
||||||
}
|
|
||||||
return "${env.USERPROFILE}/.bun/bin"
|
|
||||||
}
|
|
||||||
|
|
||||||
def withBun(Closure body) {
|
|
||||||
def sep = isUnix() ? ':' : ';'
|
|
||||||
withEnv(["PATH=${bunBinDir()}${sep}${env.PATH}"]) {
|
|
||||||
body()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def checkBun() {
|
|
||||||
if (isUnix()) {
|
|
||||||
sh 'bun -v'
|
|
||||||
} else {
|
|
||||||
bat 'bun -v'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def writeBuildMetadata() {
|
|
||||||
if (isUnix()) {
|
|
||||||
sh '''
|
|
||||||
bun --eval "await Bun.write('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n')"
|
|
||||||
'''
|
|
||||||
} else {
|
|
||||||
bat '''
|
|
||||||
bun --eval "await Bun.write('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n')"
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def runBuild(buildCommand) {
|
|
||||||
def releaseBaseUrl =
|
|
||||||
"https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
|
|
||||||
withEnv([
|
|
||||||
"VITE_BUILD_NUMBER=${env.BUILD_NUMBER ?: 'dev'}",
|
|
||||||
'NODE_ENV=production',
|
|
||||||
"ELECTROBUN_RELEASE_BASE_URL=${releaseBaseUrl}",
|
|
||||||
]) {
|
|
||||||
if (isUnix()) {
|
|
||||||
sh buildCommand
|
|
||||||
} else {
|
|
||||||
bat buildCommand
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def deploy() {
|
def deploy() {
|
||||||
node('ubuntu') {
|
node('ubuntu') {
|
||||||
try {
|
try {
|
||||||
@ -80,6 +30,7 @@ def deploy() {
|
|||||||
|
|
||||||
stage('Deploy (Ubuntu)') {
|
stage('Deploy (Ubuntu)') {
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
nodejs(nodeJSInstallationName: 'Node23') {
|
||||||
|
// Deploy to Cloudflare Pages using wrangler
|
||||||
withCredentials([string(credentialsId: 'cloudflare-api-token', variable: 'CLOUDFLARE_API_TOKEN')]) {
|
withCredentials([string(credentialsId: 'cloudflare-api-token', variable: 'CLOUDFLARE_API_TOKEN')]) {
|
||||||
sh 'pnpm wrangler pages deploy build --branch main'
|
sh 'pnpm wrangler pages deploy build --branch main'
|
||||||
}
|
}
|
||||||
@ -91,101 +42,76 @@ def deploy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def prepareMacBuildWorkspace() {
|
def buildOnLabel(label, buildCommand) {
|
||||||
if (isUnix()) {
|
|
||||||
sh '''
|
|
||||||
df -h .
|
|
||||||
for vol in /Volumes/Farm\\ Control*; do
|
|
||||||
if [ -d "$vol" ]; then
|
|
||||||
hdiutil detach "$vol" -force || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
rm -rf build/stable-macos-*/.dmg-staging build/stable-macos-*/.finalize-dmg-staging 2>/dev/null || true
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def buildOnLabel(label, buildCommand, bundleCef = false) {
|
|
||||||
def cefLabel = bundleCef ? 'cef' : 'native'
|
|
||||||
return {
|
return {
|
||||||
node(label) {
|
node(label) {
|
||||||
try {
|
stage("Checkout (${label})") {
|
||||||
stage("Checkout (${label}/${cefLabel})") {
|
|
||||||
checkout scm
|
checkout scm
|
||||||
}
|
}
|
||||||
|
|
||||||
withBun {
|
stage("Setup Node.js (${label})") {
|
||||||
stage("Check Bun (${label}/${cefLabel})") {
|
nodejs(nodeJSInstallationName: 'Node23') {
|
||||||
checkBun()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('macos')) {
|
|
||||||
stage("Prepare macOS workspace (${label}/${cefLabel})") {
|
|
||||||
prepareMacBuildWorkspace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage("Install Dependencies (${label}/${cefLabel})") {
|
|
||||||
if (isUnix()) {
|
if (isUnix()) {
|
||||||
sh 'bun install --frozen-lockfile'
|
sh 'node -v'
|
||||||
|
sh 'pnpm -v'
|
||||||
} else {
|
} else {
|
||||||
bat 'bun install --frozen-lockfile'
|
bat 'node -v'
|
||||||
}
|
bat 'pnpm -v'
|
||||||
}
|
|
||||||
|
|
||||||
stage("Write Build Metadata (${label}/${cefLabel})") {
|
|
||||||
writeBuildMetadata()
|
|
||||||
}
|
|
||||||
|
|
||||||
stage("Build (${label}/${cefLabel})") {
|
|
||||||
withEnv(["ELECTROBUN_BUNDLE_CEF=${bundleCef ? 'true' : 'false'}"]) {
|
|
||||||
runBuild(buildCommand)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage("Archive Artifacts (${label}/${cefLabel})") {
|
stage("Install Dependencies (${label})") {
|
||||||
archiveArtifacts artifacts: 'app_dist/farmcontrol-*', fingerprint: true
|
nodejs(nodeJSInstallationName: 'Node23') {
|
||||||
}
|
if (isUnix()) {
|
||||||
} finally {
|
sh 'pnpm install --frozen-lockfile --production=false'
|
||||||
cleanWs()
|
} else {
|
||||||
}
|
bat 'pnpm install --frozen-lockfile --production=false'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def buildMacOnLabel(label, targetArch, bundleCef = false) {
|
stage("Build (${label})") {
|
||||||
return buildOnLabel(label, "ELECTROBUN_TARGET_ARCH=${targetArch} bun run build:app:mac", bundleCef)
|
nodejs(nodeJSInstallationName: 'Node23') {
|
||||||
|
if (isUnix()) {
|
||||||
|
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production ${buildCommand}"
|
||||||
|
} else {
|
||||||
|
bat "set VITE_BUILD_NUMBER=${env.BUILD_NUMBER} && set NODE_ENV=production && ${buildCommand}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage("Archive Artifacts (${label})") {
|
||||||
|
archiveArtifacts artifacts: 'app_dist/**/farmcontrol-*.dmg, app_dist/**/farmcontrol-*.exe, app_dist/**/farmcontrol-*.pkg, app_dist/**/farmcontrol-*.msi', fingerprint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def setBuildNameFromPackageVersion() {
|
def setBuildNameFromPackageVersion() {
|
||||||
node('ubuntu') {
|
node('ubuntu') {
|
||||||
stage('Set Build Name') {
|
stage('Set Build Name') {
|
||||||
checkout scm
|
checkout scm
|
||||||
withBun {
|
def version
|
||||||
checkBun()
|
nodejs(nodeJSInstallationName: 'Node23') {
|
||||||
def version = sh(
|
version = sh(
|
||||||
script: "bun --eval \"console.log(JSON.parse(await Bun.file('package.json').text()).version)\"",
|
script: "node -p \"require('./package.json').version\"",
|
||||||
returnStdout: true
|
returnStdout: true
|
||||||
).trim()
|
).trim()
|
||||||
|
}
|
||||||
def buildName = "v${version}-b${env.BUILD_NUMBER}"
|
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', 'bun run build:app', false),
|
'Windows Build': buildOnLabel('windows', 'pnpm build:electron'),
|
||||||
'Windows CEF Build': buildOnLabel('windows', 'bun run build:app', true),
|
'MacOS Build': buildOnLabel('macos', 'pnpm build:electron'),
|
||||||
'MacOS x64 Build': buildMacOnLabel('macos', 'x64', false),
|
|
||||||
'MacOS x64 CEF Build': buildMacOnLabel('macos', 'x64', true),
|
|
||||||
'MacOS arm64 Build': buildMacOnLabel('macos', 'arm64', false),
|
|
||||||
'MacOS arm64 CEF Build': buildMacOnLabel('macos', 'arm64', true),
|
|
||||||
'Ubuntu Deploy': { deploy() }
|
'Ubuntu Deploy': { deploy() }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 29 KiB |
BIN
assets/icon.ico
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 613 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 567 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 499 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 357 KiB |
@ -105,146 +105,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.electron-navigation-wrapper {
|
.electron-navigation-wrapper {
|
||||||
user-select: none;
|
-webkit-app-region: drag;
|
||||||
--webkit-user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.electron-body * {
|
.electron-navigation-wrapper li,
|
||||||
-webkit-user-select: none;
|
.electron-navigation-wrapper button,
|
||||||
-khtml-user-select: none;
|
.electron-navigation-wrapper .ant-tag {
|
||||||
-moz-user-select: none;
|
-webkit-app-region: no-drag;
|
||||||
-o-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.electron-body .ant-descriptions-item-content *,
|
|
||||||
.electron-body .ant-table-cell * {
|
|
||||||
-webkit-user-select: text;
|
|
||||||
-khtml-user-select: text;
|
|
||||||
-moz-user-select: text;
|
|
||||||
-o-user-select: text;
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Native macOS vibrancy — transparent web content over NSVisualEffectView */
|
|
||||||
html.macos-vibrancy {
|
|
||||||
--macos-window-corner-radius: 15px; /* keep in sync with MAC_WINDOW_CORNER_RADIUS */
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy,
|
|
||||||
html.macos-vibrancy body,
|
|
||||||
html.macos-vibrancy #root {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .ant-layout.ant-layout-has-sider,
|
|
||||||
html.macos-vibrancy .main-layout {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .light-mode .ant-layout.main-content-layout {
|
|
||||||
background: rgba(255, 255, 255, 0.88) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout {
|
|
||||||
background: rgba(0, 0, 0, 0.88) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .light-mode .ant-layout-sider {
|
|
||||||
background: rgba(244, 244, 244, 0.82) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .dark-mode .ant-layout-sider {
|
|
||||||
background: rgba(10, 10, 10, 0.82) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .light-mode .ant-layout-content {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .dark-mode .ant-layout-content {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .ant-layout-sider-light,
|
|
||||||
html.macos-vibrancy .electron-sider .ant-menu-light {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy
|
|
||||||
.dark-mode
|
|
||||||
.electron-sider
|
|
||||||
.ant-menu-light
|
|
||||||
.ant-menu-item-selected {
|
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 30%, #00000010);
|
|
||||||
color: color-mix(in srgb, var(--color-primary) 75%, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy
|
|
||||||
.light-mode
|
|
||||||
.electron-sider
|
|
||||||
.ant-menu-light
|
|
||||||
.ant-menu-item-selected {
|
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 80%, #ffffff3d);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .dark-mode .electron-navigation-wrapper {
|
|
||||||
background: rgba(10, 10, 10, 0.9) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .light-mode .electron-navigation-wrapper {
|
|
||||||
background: rgba(255, 255, 255, 0.9) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .electron-navigation {
|
|
||||||
background: rgba(0, 0, 0, 0) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.macos-vibrancy .light-mode .ant-layout.main-content-layout .ant-card {
|
|
||||||
background: rgba(255, 255, 255, 0.5) !important;
|
|
||||||
}
|
|
||||||
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout .ant-card {
|
|
||||||
background: rgba(31, 31, 31, 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.redTrafficLight {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background-color: #fe5f57;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yellowTrafficLight {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background-color: #febc2e;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.greenTrafficLight {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background-color: #28c840;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.electron-navigation {
|
.electron-navigation {
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.electron-sidebar.ant-menu-inline .ant-menu-item {
|
|
||||||
padding-left: 20px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.electron-navigation .ant-menu-overflow-item-rest {
|
.electron-navigation .ant-menu-overflow-item-rest {
|
||||||
padding-inline: 10px;
|
padding-inline: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.electron-navigation.ant-menu-horizontal .ant-menu-item {
|
|
||||||
padding-inline: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.electron-sidebar .ant-menu-item,
|
.electron-sidebar .ant-menu-item,
|
||||||
.electron-sidebar .ant-menu-submenu-title {
|
.electron-sidebar .ant-menu-submenu-title {
|
||||||
height: 32.5px !important;
|
height: 32.5px !important;
|
||||||
@ -965,124 +842,3 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.electron-body .ant-modal-wrap,
|
|
||||||
.electron-body .ant-modal-mask {
|
|
||||||
top: 41px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes h-progress-macos-wave {
|
|
||||||
0% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
/* One gradient period (40cqw of a 200cqw-wide element) for a seamless loop */
|
|
||||||
transform: translateX(20%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress {
|
|
||||||
display: inline-block;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-outer {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-inner {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 100%;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
vertical-align: middle;
|
|
||||||
/* Lets the wave size itself against the full track via cqw units */
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-bg,
|
|
||||||
.h-progress-success-bg {
|
|
||||||
position: relative;
|
|
||||||
transition: all 0.1s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-success-bg {
|
|
||||||
position: absolute;
|
|
||||||
inset-block-start: 0;
|
|
||||||
inset-inline-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mask that clips the wave to the filled portion of the bar. Its width
|
|
||||||
tracks the fill, while the wave inside keeps a constant width so Safari
|
|
||||||
doesn't restart/freeze the animation when progress updates. */
|
|
||||||
.h-progress-bg-mask {
|
|
||||||
position: absolute;
|
|
||||||
inset-block: 0;
|
|
||||||
inset-inline-start: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: width 0.1s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-bg-wave {
|
|
||||||
position: absolute;
|
|
||||||
inset-block: 0;
|
|
||||||
/* Constant width relative to the full track (.h-progress-inner),
|
|
||||||
independent of the mask's changing width. Starts one period early so
|
|
||||||
the pattern always covers the track while sliding right. */
|
|
||||||
inset-inline-start: -40cqw;
|
|
||||||
width: 200cqw;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(255, 255, 255, 0) 0,
|
|
||||||
rgba(255, 255, 255, 0.35) 20cqw,
|
|
||||||
rgba(255, 255, 255, 0) 40cqw
|
|
||||||
);
|
|
||||||
animation: h-progress-macos-wave 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-text {
|
|
||||||
display: inline-block;
|
|
||||||
margin-inline-start: 8px;
|
|
||||||
line-height: 1;
|
|
||||||
width: 2em;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-align: start;
|
|
||||||
vertical-align: middle;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-text-start {
|
|
||||||
width: max-content;
|
|
||||||
margin-inline-start: 0;
|
|
||||||
margin-inline-end: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-text-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
margin-inline-start: 0;
|
|
||||||
padding: 0 4px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-layout-bottom {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-progress-layout-bottom .h-progress-text {
|
|
||||||
width: max-content;
|
|
||||||
margin-inline-start: 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(241,0,27);"/>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(255,92,96);"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 804 B |
@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(202,2,24);"/>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(212,76,80);"/>
|
|
||||||
<g>
|
|
||||||
<path d="M22.5,57.8L57.8,22.5C59.2,21.1 61.4,21.1 62.8,22.5L62.9,22.6C64.3,24 64.3,26.2 62.9,27.6L27.6,62.9C26.2,64.3 24,64.3 22.6,62.9L22.5,62.8C21.2,61.4 21.2,59.2 22.5,57.8Z" style="fill:rgb(128,47,49);stroke:rgb(128,47,49);stroke-width:2.5px;"/>
|
|
||||||
<path d="M27.6,22.5L62.9,57.8C64.3,59.2 64.3,61.4 62.9,62.8L62.8,62.9C61.4,64.3 59.2,64.3 57.8,62.9L22.5,27.6C21.1,26.2 21.1,24 22.5,22.6L22.6,22.5C24,21.2 26.2,21.2 27.6,22.5Z" style="fill:rgb(128,47,49);stroke:rgb(128,47,49);stroke-width:2.5px;"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(241,0,27);"/>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(255,92,96);"/>
|
|
||||||
<g>
|
|
||||||
<path d="M22.5,57.8L57.8,22.5C59.2,21.1 61.4,21.1 62.8,22.5L62.9,22.6C64.3,24 64.3,26.2 62.9,27.6L27.6,62.9C26.2,64.3 24,64.3 22.6,62.9L22.5,62.8C21.2,61.4 21.2,59.2 22.5,57.8Z" style="fill:rgb(128,47,49);stroke:rgb(128,47,49);stroke-width:2.5px;"/>
|
|
||||||
<path d="M27.6,22.5L62.9,57.8C64.3,59.2 64.3,61.4 62.9,62.8L62.8,62.9C61.4,64.3 59.2,64.3 57.8,62.9L22.5,27.6C21.1,26.2 21.1,24 22.5,22.6L22.6,22.5C24,21.2 26.2,21.2 27.6,22.5Z" style="fill:rgb(128,47,49);stroke:rgb(128,47,49);stroke-width:2.5px;"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(66,130,52);"/>
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(46,164,75);"/>
|
|
||||||
<g transform="matrix(0,-0.842628,0.842628,0,6.811922,78.99493)">
|
|
||||||
<g transform="matrix(-1,0,-0,-1,106.637317,63.746683)">
|
|
||||||
<path d="M28.052,20.855L57.9,20.8C61.5,20.8 64.4,23.7 64.4,27.3L64.444,57.088L28.052,20.855Z" style="fill:rgb(17,49,7);"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(-1,0,-0,-1,63.293317,107.454683)">
|
|
||||||
<path d="M56.994,64.5L27.6,64.5C24,64.5 21.1,61.6 21.1,58L21.1,28.154L56.994,64.5Z" style="fill:rgb(17,49,7);"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(0,183,30);"/>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(54,198,89);"/>
|
|
||||||
<g transform="matrix(0,-0.842628,0.842628,0,6.811922,78.99493)">
|
|
||||||
<g>
|
|
||||||
<g transform="matrix(-1,0,0,-1,106.637317,63.746683)">
|
|
||||||
<path d="M28.052,20.855L57.9,20.8C61.5,20.8 64.4,23.7 64.4,27.3L64.444,57.088L28.052,20.855Z" style="fill:rgb(1,97,0);"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(-1,0,0,-1,63.293317,107.454683)">
|
|
||||||
<path d="M56.994,64.5L27.6,64.5C24,64.5 21.1,61.6 21.1,58L21.1,28.154L56.994,64.5Z" style="fill:rgb(1,97,0);"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(0,183,30);"/>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(54,198,89);"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 804 B |
@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(66,130,52);"/>
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(46,164,75);"/>
|
|
||||||
<g transform="matrix(0,-0.842628,0.842628,0,6.811922,78.99493)">
|
|
||||||
<path d="M28.052,20.855L57.9,20.8C61.5,20.8 64.4,23.7 64.4,27.3L64.444,57.088L28.052,20.855ZM56.994,64.5L27.6,64.5C24,64.5 21.1,61.6 21.1,58L21.1,28.154L56.994,64.5Z" style="fill:rgb(17,49,7);"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(0,183,30);"/>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.2 21.1,81.8 42.7,81.8Z" style="fill:rgb(54,198,89);"/>
|
|
||||||
<g transform="matrix(0,-0.842628,0.842628,0,6.811922,78.99493)">
|
|
||||||
<path d="M28.052,20.855L57.9,20.8C61.5,20.8 64.4,23.7 64.4,27.3L64.444,57.088L28.052,20.855ZM56.994,64.5L27.6,64.5C24,64.5 21.1,61.6 21.1,58L21.1,28.154L56.994,64.5Z" style="fill:rgb(17,49,7);"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(239,174,0);"/>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(250,200,0);"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 805 B |
@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(200,146,0);"/>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(208,165,5);"/>
|
|
||||||
<path d="M22.154,39.1L63.792,39.1C65.692,39.1 67.292,40.7 67.292,42.6L67.292,42.7C67.292,44.6 65.692,46.2 63.792,46.2L22.154,46.2C20.254,46.2 18.654,44.6 18.654,42.7L18.654,42.6C18.654,40.7 20.154,39.1 22.154,39.1Z" style="fill:rgb(126,100,12);stroke:rgb(126,100,12);stroke-width:3.5px;"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(239,174,0);"/>
|
|
||||||
<path d="M42.7,81.8C64.3,81.8 81.8,64.3 81.8,42.7C81.8,21.1 64.3,3.6 42.7,3.6C21.1,3.6 3.6,21.1 3.6,42.7C3.6,64.3 21.1,81.8 42.7,81.8Z" style="fill:rgb(250,200,0);"/>
|
|
||||||
<path d="M22.154,39.1L63.792,39.1C65.692,39.1 67.292,40.7 67.292,42.6L67.292,42.7C67.292,44.6 65.692,46.2 63.792,46.2L22.154,46.2C20.254,46.2 18.654,44.6 18.654,42.7L18.654,42.6C18.654,40.7 20.154,39.1 22.154,39.1Z" style="fill:rgb(126,100,12);stroke:rgb(126,100,12);stroke-width:3.5px;"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 86 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M42.7,85.4C66.3,85.4 85.4,66.3 85.4,42.7C85.4,19.1 66.3,0 42.7,0C19.1,0 0,19.1 0,42.7C0,66.3 19.1,85.4 42.7,85.4Z" style="fill:rgb(209,208,210);fill-opacity:0.3;"/>
|
|
||||||
<path d="M42.7,81.7C64.3,81.7 81.8,64.2 81.8,42.6C81.8,21 64.3,3.5 42.7,3.5C21.1,3.5 3.6,21 3.6,42.6C3.6,64.2 21.1,81.7 42.7,81.7Z" style="fill:rgb(199,199,199);fill-opacity:0.3;"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 839 B |
|
Before Width: | Height: | Size: 279 KiB |
@ -1,88 +0,0 @@
|
|||||||
import type { ElectrobunConfig } from "electrobun";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const packageJson = JSON.parse(
|
|
||||||
readFileSync(path.join(rootDir, "package.json"), "utf8"),
|
|
||||||
);
|
|
||||||
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
|
|
||||||
const isStable = buildEnv === "stable";
|
|
||||||
const canCodesign = Boolean(process.env.ELECTROBUN_DEVELOPER_ID);
|
|
||||||
const bundleCEF = ["1", "true", "yes"].includes(
|
|
||||||
String(process.env.ELECTROBUN_BUNDLE_CEF || "").toLowerCase(),
|
|
||||||
);
|
|
||||||
const defaultRenderer = bundleCEF ? "cef" : "native";
|
|
||||||
const targetOs =
|
|
||||||
process.env.ELECTROBUN_OS ||
|
|
||||||
(process.platform === "darwin"
|
|
||||||
? "macos"
|
|
||||||
: process.platform === "win32"
|
|
||||||
? "win"
|
|
||||||
: "linux");
|
|
||||||
|
|
||||||
export default {
|
|
||||||
app: {
|
|
||||||
name: "Farm Control",
|
|
||||||
identifier: "com.tombutcher.farmcontrol",
|
|
||||||
version: packageJson.version,
|
|
||||||
description: "3D Printer ERP and Control Software.",
|
|
||||||
urlSchemes: ["farmcontrol"],
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
exitOnLastWindowClosed: true,
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
buildFolder: "build",
|
|
||||||
artifactFolder: "app_dist",
|
|
||||||
// macOS WebKit loads views:// assets reliably from a flat app folder;
|
|
||||||
// ASAR-packed views can fail to load module scripts on first launch.
|
|
||||||
useAsar: isStable && targetOs !== "macos",
|
|
||||||
asarUnpack: [
|
|
||||||
"*.node",
|
|
||||||
"*.dll",
|
|
||||||
"*.dylib",
|
|
||||||
"*.so",
|
|
||||||
"views/**",
|
|
||||||
],
|
|
||||||
bun: {
|
|
||||||
entrypoint: "src/bun/index.js",
|
|
||||||
},
|
|
||||||
copy: {
|
|
||||||
"dist/mainview": "views/mainview",
|
|
||||||
"src/bun/libMacWindowEffects.dylib": "bun/libMacWindowEffects.dylib",
|
|
||||||
},
|
|
||||||
watch: ["scripts", "src"],
|
|
||||||
watchIgnore: ["dist/**", "build/**", "app_dist/**"],
|
|
||||||
mac: {
|
|
||||||
bundleCEF,
|
|
||||||
defaultRenderer,
|
|
||||||
icons: "assets/icon.iconset",
|
|
||||||
createDmg: false,
|
|
||||||
codesign: isStable && canCodesign,
|
|
||||||
notarize: isStable && canCodesign,
|
|
||||||
},
|
|
||||||
linux: {
|
|
||||||
bundleCEF: false,
|
|
||||||
defaultRenderer: "native",
|
|
||||||
icon: "assets/icon.png",
|
|
||||||
},
|
|
||||||
win: {
|
|
||||||
bundleCEF,
|
|
||||||
defaultRenderer,
|
|
||||||
icon: "assets/icon.iconset/icon_256x256.png",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scripts: {
|
|
||||||
preBuild: "scripts/pre-build.mjs",
|
|
||||||
postWrap: "scripts/expand-macos-bundle.mjs",
|
|
||||||
postPackage: "scripts/finalize-desktop-artifacts.mjs",
|
|
||||||
},
|
|
||||||
release: {
|
|
||||||
baseUrl:
|
|
||||||
process.env.ELECTROBUN_RELEASE_BASE_URL ||
|
|
||||||
process.env.RELEASE_BASE_URL ||
|
|
||||||
"",
|
|
||||||
},
|
|
||||||
} satisfies ElectrobunConfig;
|
|
||||||
@ -1,515 +0,0 @@
|
|||||||
#import <Cocoa/Cocoa.h>
|
|
||||||
#import <objc/runtime.h>
|
|
||||||
|
|
||||||
static NSString *const kElectrobunVibrancyViewIdentifier =
|
|
||||||
@"ElectrobunVibrancyView";
|
|
||||||
static NSString *const kElectrobunNativeDragViewIdentifier =
|
|
||||||
@"ElectrobunNativeDragView";
|
|
||||||
static NSString *const kElectrobunWindowBorderViewIdentifier =
|
|
||||||
@"ElectrobunWindowBorderView";
|
|
||||||
|
|
||||||
static CGFloat gWindowCornerRadius = 15.0;
|
|
||||||
static CGFloat gTrafficLightsX = 16.0;
|
|
||||||
static CGFloat gTrafficLightsY = 12.0;
|
|
||||||
|
|
||||||
static const void *kElectrobunChromeObserverKey = &kElectrobunChromeObserverKey;
|
|
||||||
|
|
||||||
@interface ElectrobunWindowBorderView : NSView
|
|
||||||
@property(nonatomic) CGFloat cornerRadius;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation ElectrobunWindowBorderView
|
|
||||||
@synthesize cornerRadius = _cornerRadius;
|
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(NSRect)frameRect {
|
|
||||||
self = [super initWithFrame:frameRect];
|
|
||||||
if (self) {
|
|
||||||
_cornerRadius = gWindowCornerRadius;
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)isOpaque {
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)acceptsFirstMouse:(NSEvent *)event {
|
|
||||||
(void)event;
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSView *)hitTest:(NSPoint)point {
|
|
||||||
(void)point;
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)setFrame:(NSRect)frame {
|
|
||||||
[super setFrame:frame];
|
|
||||||
[self setNeedsDisplay:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)drawRect:(NSRect)dirtyRect {
|
|
||||||
(void)dirtyRect;
|
|
||||||
|
|
||||||
NSRect bounds = [self bounds];
|
|
||||||
CGFloat radius = MAX(0.0, self.cornerRadius);
|
|
||||||
NSBezierPath *borderPath = [NSBezierPath
|
|
||||||
bezierPathWithRoundedRect:NSInsetRect(bounds, 0.5, 0.5)
|
|
||||||
xRadius:radius
|
|
||||||
yRadius:radius];
|
|
||||||
|
|
||||||
if (@available(macOS 10.14, *)) {
|
|
||||||
NSAppearance *appearance = [self effectiveAppearance];
|
|
||||||
BOOL isDark =
|
|
||||||
[[appearance bestMatchFromAppearancesWithNames:@[
|
|
||||||
NSAppearanceNameAqua, NSAppearanceNameDarkAqua
|
|
||||||
]] isEqualToString:NSAppearanceNameDarkAqua];
|
|
||||||
|
|
||||||
if (isDark) {
|
|
||||||
[[NSColor colorWithWhite:1.0 alpha:0.18] setStroke];
|
|
||||||
} else {
|
|
||||||
[[NSColor colorWithWhite:1.0 alpha:0.72] setStroke];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
[[NSColor colorWithWhite:1.0 alpha:0.72] setStroke];
|
|
||||||
}
|
|
||||||
|
|
||||||
borderPath.lineWidth = 1.0;
|
|
||||||
[borderPath stroke];
|
|
||||||
}
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface ElectrobunNativeDragView : NSView
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation ElectrobunNativeDragView
|
|
||||||
- (BOOL)isOpaque {
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)drawRect:(NSRect)dirtyRect {
|
|
||||||
(void)dirtyRect;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)mouseDown:(NSEvent *)event {
|
|
||||||
NSWindow *window = [self window];
|
|
||||||
if (window != nil && event != nil) {
|
|
||||||
[window performWindowDragWithEvent:event];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface ElectrobunWindowChromeObserver : NSObject
|
|
||||||
@property(nonatomic, weak) NSWindow *window;
|
|
||||||
@end
|
|
||||||
|
|
||||||
static bool applyTrafficLightsPosition(NSWindow *window, CGFloat x,
|
|
||||||
CGFloat yFromTop);
|
|
||||||
static void refreshWindowChrome(NSWindow *window);
|
|
||||||
static BOOL isWindowFullScreen(NSWindow *window);
|
|
||||||
|
|
||||||
@implementation ElectrobunWindowChromeObserver
|
|
||||||
|
|
||||||
- (instancetype)initWithWindow:(NSWindow *)window {
|
|
||||||
self = [super init];
|
|
||||||
if (self) {
|
|
||||||
_window = window;
|
|
||||||
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
|
|
||||||
[center addObserver:self
|
|
||||||
selector:@selector(refreshChrome)
|
|
||||||
name:NSWindowDidResizeNotification
|
|
||||||
object:window];
|
|
||||||
[center addObserver:self
|
|
||||||
selector:@selector(refreshChrome)
|
|
||||||
name:NSWindowDidEndLiveResizeNotification
|
|
||||||
object:window];
|
|
||||||
[center addObserver:self
|
|
||||||
selector:@selector(refreshChrome)
|
|
||||||
name:NSWindowDidEnterFullScreenNotification
|
|
||||||
object:window];
|
|
||||||
[center addObserver:self
|
|
||||||
selector:@selector(refreshChrome)
|
|
||||||
name:NSWindowDidExitFullScreenNotification
|
|
||||||
object:window];
|
|
||||||
[center addObserver:self
|
|
||||||
selector:@selector(refreshChrome)
|
|
||||||
name:NSWindowDidBecomeKeyNotification
|
|
||||||
object:window];
|
|
||||||
[center addObserver:self
|
|
||||||
selector:@selector(refreshChrome)
|
|
||||||
name:NSWindowWillEnterFullScreenNotification
|
|
||||||
object:window];
|
|
||||||
}
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)refreshChrome {
|
|
||||||
if (self.window != nil) {
|
|
||||||
refreshWindowChrome(self.window);
|
|
||||||
applyTrafficLightsPosition(self.window, gTrafficLightsX, gTrafficLightsY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)dealloc {
|
|
||||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
static NSVisualEffectView *findVibrancyView(NSView *contentView) {
|
|
||||||
for (NSView *subview in [contentView subviews]) {
|
|
||||||
if ([subview isKindOfClass:[NSVisualEffectView class]] &&
|
|
||||||
[[subview identifier]
|
|
||||||
isEqualToString:kElectrobunVibrancyViewIdentifier]) {
|
|
||||||
return (NSVisualEffectView *)subview;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
static ElectrobunNativeDragView *findNativeDragView(NSView *contentView) {
|
|
||||||
for (NSView *subview in [contentView subviews]) {
|
|
||||||
if ([subview isKindOfClass:[ElectrobunNativeDragView class]] &&
|
|
||||||
[[subview identifier]
|
|
||||||
isEqualToString:kElectrobunNativeDragViewIdentifier]) {
|
|
||||||
return (ElectrobunNativeDragView *)subview;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
static ElectrobunWindowBorderView *findWindowBorderView(NSView *contentView) {
|
|
||||||
for (NSView *subview in [contentView subviews]) {
|
|
||||||
if ([subview isKindOfClass:[ElectrobunWindowBorderView class]] &&
|
|
||||||
[[subview identifier]
|
|
||||||
isEqualToString:kElectrobunWindowBorderViewIdentifier]) {
|
|
||||||
return (ElectrobunWindowBorderView *)subview;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
static BOOL isWindowFullScreen(NSWindow *window) {
|
|
||||||
return (window.styleMask & NSWindowStyleMaskFullScreen) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void refreshWindowChrome(NSWindow *window) {
|
|
||||||
BOOL fullScreen = isWindowFullScreen(window);
|
|
||||||
CGFloat radius = fullScreen ? 0.0 : gWindowCornerRadius;
|
|
||||||
|
|
||||||
NSView *contentView = [window contentView];
|
|
||||||
if (contentView == nil) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
contentView.wantsLayer = YES;
|
|
||||||
contentView.layer.cornerRadius = radius;
|
|
||||||
contentView.layer.masksToBounds = YES;
|
|
||||||
|
|
||||||
NSVisualEffectView *effectView = findVibrancyView(contentView);
|
|
||||||
if (effectView != nil) {
|
|
||||||
effectView.wantsLayer = YES;
|
|
||||||
effectView.layer.cornerRadius = radius;
|
|
||||||
effectView.layer.masksToBounds = YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView);
|
|
||||||
if (borderView != nil) {
|
|
||||||
borderView.hidden = fullScreen;
|
|
||||||
if (!fullScreen) {
|
|
||||||
borderView.cornerRadius = gWindowCornerRadius;
|
|
||||||
[borderView setNeedsDisplay:YES];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) {
|
|
||||||
gWindowCornerRadius = MAX(0.0, radius);
|
|
||||||
refreshWindowChrome(window);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void ensureWindowBorder(NSWindow *window) {
|
|
||||||
NSView *contentView = [window contentView];
|
|
||||||
if (contentView == nil) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView);
|
|
||||||
if (borderView == nil) {
|
|
||||||
borderView = [[ElectrobunWindowBorderView alloc]
|
|
||||||
initWithFrame:[contentView bounds]];
|
|
||||||
[borderView setIdentifier:kElectrobunWindowBorderViewIdentifier];
|
|
||||||
[borderView
|
|
||||||
setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)];
|
|
||||||
}
|
|
||||||
|
|
||||||
[borderView setFrame:[contentView bounds]];
|
|
||||||
|
|
||||||
if ([borderView superview] == nil) {
|
|
||||||
[contentView addSubview:borderView
|
|
||||||
positioned:NSWindowAbove
|
|
||||||
relativeTo:nil];
|
|
||||||
} else {
|
|
||||||
[borderView removeFromSuperview];
|
|
||||||
[contentView addSubview:borderView
|
|
||||||
positioned:NSWindowAbove
|
|
||||||
relativeTo:nil];
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshWindowChrome(window);
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool applyTrafficLightsPosition(NSWindow *window, CGFloat x,
|
|
||||||
CGFloat yFromTop) {
|
|
||||||
NSButton *closeButton = [window standardWindowButton:NSWindowCloseButton];
|
|
||||||
NSButton *minimizeButton =
|
|
||||||
[window standardWindowButton:NSWindowMiniaturizeButton];
|
|
||||||
NSButton *zoomButton = [window standardWindowButton:NSWindowZoomButton];
|
|
||||||
|
|
||||||
if (closeButton == nil || minimizeButton == nil || zoomButton == nil) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSView *buttonContainer = [closeButton superview];
|
|
||||||
if (buttonContainer == nil) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
CGFloat spacing = NSMinX(minimizeButton.frame) - NSMinX(closeButton.frame);
|
|
||||||
if (spacing <= 0) {
|
|
||||||
spacing = closeButton.frame.size.width + 6.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL flipped = [buttonContainer isFlipped];
|
|
||||||
CGFloat targetY = yFromTop;
|
|
||||||
if (!flipped) {
|
|
||||||
targetY = buttonContainer.frame.size.height - yFromTop -
|
|
||||||
closeButton.frame.size.height;
|
|
||||||
}
|
|
||||||
targetY = MAX(0.0, targetY);
|
|
||||||
|
|
||||||
CGFloat currentX = x;
|
|
||||||
NSArray *buttons = @[ closeButton, minimizeButton, zoomButton ];
|
|
||||||
for (NSButton *button in buttons) {
|
|
||||||
[button setAutoresizingMask:NSViewNotSizable];
|
|
||||||
[button setFrameOrigin:NSMakePoint(currentX, targetY)];
|
|
||||||
currentX += spacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void ensureTrafficLightsObserver(NSWindow *window) {
|
|
||||||
ElectrobunWindowChromeObserver *existingObserver =
|
|
||||||
objc_getAssociatedObject(window, kElectrobunChromeObserverKey);
|
|
||||||
if (existingObserver != nil) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ElectrobunWindowChromeObserver *observer =
|
|
||||||
[[ElectrobunWindowChromeObserver alloc] initWithWindow:window];
|
|
||||||
objc_setAssociatedObject(window, kElectrobunChromeObserverKey, observer,
|
|
||||||
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void scheduleTrafficLightsPosition(NSWindow *window, NSInteger attempt) {
|
|
||||||
if (applyTrafficLightsPosition(window, gTrafficLightsX, gTrafficLightsY)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt >= 10) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch_after(
|
|
||||||
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(50 * NSEC_PER_MSEC)),
|
|
||||||
dispatch_get_main_queue(), ^{
|
|
||||||
scheduleTrafficLightsPosition(window, attempt + 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" bool enableWindowVibrancy(void *windowPtr, double cornerRadius) {
|
|
||||||
if (windowPtr == nullptr) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
__block BOOL success = NO;
|
|
||||||
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
||||||
NSWindow *window = (__bridge NSWindow *)windowPtr;
|
|
||||||
if (![window isKindOfClass:[NSWindow class]]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyWindowCornerRadius(window, (CGFloat)cornerRadius);
|
|
||||||
|
|
||||||
[window setOpaque:NO];
|
|
||||||
[window setBackgroundColor:[NSColor clearColor]];
|
|
||||||
[window setTitlebarAppearsTransparent:YES];
|
|
||||||
[window setHasShadow:YES];
|
|
||||||
|
|
||||||
NSView *contentView = [window contentView];
|
|
||||||
if (contentView == nil) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSVisualEffectView *effectView = findVibrancyView(contentView);
|
|
||||||
|
|
||||||
if (effectView == nil) {
|
|
||||||
effectView = [[NSVisualEffectView alloc]
|
|
||||||
initWithFrame:[contentView bounds]];
|
|
||||||
[effectView setIdentifier:kElectrobunVibrancyViewIdentifier];
|
|
||||||
[effectView
|
|
||||||
setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (@available(macOS 10.14, *)) {
|
|
||||||
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
|
|
||||||
} else {
|
|
||||||
[effectView setMaterial:NSVisualEffectMaterialSidebar];
|
|
||||||
}
|
|
||||||
[effectView setBlendingMode:NSVisualEffectBlendingModeBehindWindow];
|
|
||||||
[effectView setState:NSVisualEffectStateActive];
|
|
||||||
|
|
||||||
if ([effectView superview] == nil) {
|
|
||||||
NSView *relativeView = [[contentView subviews] firstObject];
|
|
||||||
if (relativeView != nil) {
|
|
||||||
[contentView addSubview:effectView
|
|
||||||
positioned:NSWindowBelow
|
|
||||||
relativeTo:relativeView];
|
|
||||||
} else {
|
|
||||||
[contentView addSubview:effectView];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureWindowBorder(window);
|
|
||||||
applyWindowCornerRadius(window, gWindowCornerRadius);
|
|
||||||
ensureTrafficLightsObserver(window);
|
|
||||||
scheduleTrafficLightsPosition(window, 0);
|
|
||||||
|
|
||||||
[window invalidateShadow];
|
|
||||||
success = YES;
|
|
||||||
});
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" bool setWindowCornerRadius(void *windowPtr, double cornerRadius) {
|
|
||||||
if (windowPtr == nullptr) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
__block BOOL success = NO;
|
|
||||||
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
||||||
NSWindow *window = (__bridge NSWindow *)windowPtr;
|
|
||||||
if (![window isKindOfClass:[NSWindow class]]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyWindowCornerRadius(window, (CGFloat)cornerRadius);
|
|
||||||
ensureWindowBorder(window);
|
|
||||||
[window invalidateShadow];
|
|
||||||
success = YES;
|
|
||||||
});
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" bool ensureWindowShadow(void *windowPtr) {
|
|
||||||
if (windowPtr == nullptr) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
__block BOOL success = NO;
|
|
||||||
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
||||||
NSWindow *window = (__bridge NSWindow *)windowPtr;
|
|
||||||
if (![window isKindOfClass:[NSWindow class]]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
[window setHasShadow:YES];
|
|
||||||
[window invalidateShadow];
|
|
||||||
ensureWindowBorder(window);
|
|
||||||
applyWindowCornerRadius(window, gWindowCornerRadius);
|
|
||||||
success = YES;
|
|
||||||
});
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" bool setWindowTrafficLightsPosition(void *windowPtr, double x,
|
|
||||||
double yFromTop) {
|
|
||||||
if (windowPtr == nullptr) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
__block BOOL success = NO;
|
|
||||||
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
||||||
NSWindow *window = (__bridge NSWindow *)windowPtr;
|
|
||||||
if (![window isKindOfClass:[NSWindow class]]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
gTrafficLightsX = (CGFloat)x;
|
|
||||||
gTrafficLightsY = (CGFloat)yFromTop;
|
|
||||||
ensureTrafficLightsObserver(window);
|
|
||||||
success = applyTrafficLightsPosition(window, gTrafficLightsX, gTrafficLightsY);
|
|
||||||
if (success) {
|
|
||||||
[window invalidateShadow];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" bool setNativeWindowDragRegion(void *windowPtr, double x,
|
|
||||||
double height) {
|
|
||||||
if (windowPtr == nullptr) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
__block BOOL success = NO;
|
|
||||||
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
||||||
NSWindow *window = (__bridge NSWindow *)windowPtr;
|
|
||||||
if (![window isKindOfClass:[NSWindow class]]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSView *contentView = [window contentView];
|
|
||||||
if (contentView == nil) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CGFloat dragX = MAX(0.0, x);
|
|
||||||
CGFloat dragHeight = MAX(0.0, height);
|
|
||||||
CGFloat dragWidth = MAX(0.0, contentView.bounds.size.width - dragX);
|
|
||||||
if (dragHeight <= 0.0 || dragWidth <= 0.0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL flipped = [contentView isFlipped];
|
|
||||||
CGFloat dragY = flipped ? 0.0 : contentView.bounds.size.height - dragHeight;
|
|
||||||
dragY = MAX(0.0, dragY);
|
|
||||||
|
|
||||||
ElectrobunNativeDragView *dragView = findNativeDragView(contentView);
|
|
||||||
if (dragView == nil) {
|
|
||||||
dragView = [[ElectrobunNativeDragView alloc] initWithFrame:NSZeroRect];
|
|
||||||
[dragView setIdentifier:kElectrobunNativeDragViewIdentifier];
|
|
||||||
}
|
|
||||||
|
|
||||||
[dragView setFrame:NSMakeRect(dragX, dragY, dragWidth, dragHeight)];
|
|
||||||
[dragView setAutoresizingMask:NSViewWidthSizable];
|
|
||||||
|
|
||||||
if ([dragView superview] == nil) {
|
|
||||||
[contentView addSubview:dragView
|
|
||||||
positioned:NSWindowAbove
|
|
||||||
relativeTo:nil];
|
|
||||||
}
|
|
||||||
|
|
||||||
success = YES;
|
|
||||||
});
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
27
package.json
@ -4,7 +4,7 @@
|
|||||||
"name": "Tom Butcher",
|
"name": "Tom Butcher",
|
||||||
"email": "tom@tombutcher.work"
|
"email": "tom@tombutcher.work"
|
||||||
},
|
},
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "./",
|
"homepage": "./",
|
||||||
@ -71,7 +71,10 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"simplebar-react": "^3.3.2",
|
"simplebar-react": "^3.3.2",
|
||||||
"socket.io-client": "*",
|
"socket.io-client": "*",
|
||||||
|
"standard": "^17.1.2",
|
||||||
"styled-components": "^6.1.19",
|
"styled-components": "^6.1.19",
|
||||||
|
"svgo": "^4.0.0",
|
||||||
|
"svgo-loader": "^4.0.0",
|
||||||
"three": "^0.179.1",
|
"three": "^0.179.1",
|
||||||
"tsparticles": "^3.9.1",
|
"tsparticles": "^3.9.1",
|
||||||
"web-vitals": "^5.1.0"
|
"web-vitals": "^5.1.0"
|
||||||
@ -80,21 +83,13 @@
|
|||||||
"description": "3D Printer ERP and Control Software.",
|
"description": "3D Printer ERP and Control Software.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_ENV=development vite",
|
"dev": "cross-env NODE_ENV=development vite",
|
||||||
"dev:app": "concurrently \"cross-env NODE_ENV=development bun run dev:renderer\" \"cross-env NODE_ENV=development electrobun dev\"",
|
|
||||||
"dev:renderer": "vite --port 5780 --no-open",
|
|
||||||
"electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .",
|
"electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .",
|
||||||
"start": "serve -s build",
|
"start": "serve -s build",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:app": "electrobun build --env=stable",
|
|
||||||
"build:app:mac": "bun scripts/build-macos.mjs",
|
|
||||||
"dev:electron": "concurrently \"cross-env NODE_ENV=development vite --port 5780 --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 NODE_ENV=development electron public/electron.js\"",
|
"dev:electron": "concurrently \"cross-env NODE_ENV=development vite --port 5780 --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 NODE_ENV=development electron public/electron.js\"",
|
||||||
"build:electron": "vite build && electron-builder",
|
"build:electron": "vite build && electron-builder",
|
||||||
"build:cloudflare": "cross-env VITE_DEPLOY_TARGET=cloudflare vite build",
|
"build:cloudflare": "cross-env VITE_DEPLOY_TARGET=cloudflare vite build",
|
||||||
"deploy": "npm run build:cloudflare && wrangler pages deploy --branch main",
|
"deploy": "npm run build:cloudflare && wrangler pages deploy --branch main"
|
||||||
"postinstall": "bun scripts/patch-electrobun-src.mjs && bun scripts/build-macos-effects.mjs",
|
|
||||||
"generate-app-icons": "bun scripts/generate-app-icons.mjs",
|
|
||||||
"build:macos-effects": "bun scripts/build-macos-effects.mjs",
|
|
||||||
"clean": "bun scripts/clean-build.mjs"
|
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
@ -118,10 +113,7 @@
|
|||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"electrobun": "1.18.1",
|
|
||||||
"electron": "^38.7.1",
|
"electron": "^38.7.1",
|
||||||
"jimp": "^1.6.1",
|
|
||||||
"png-to-ico": "^2.1.8",
|
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"electron-packager": "^17.1.2",
|
"electron-packager": "^17.1.2",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.34.0",
|
||||||
@ -134,7 +126,6 @@
|
|||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-eslint": "^16.4.2",
|
"prettier-eslint": "^16.4.2",
|
||||||
"rcedit": "^4.0.1",
|
|
||||||
"rollup-plugin-visualizer": "^6.0.5",
|
"rollup-plugin-visualizer": "^6.0.5",
|
||||||
"serve": "^14.2.4",
|
"serve": "^14.2.4",
|
||||||
"standard": "^17.1.2",
|
"standard": "^17.1.2",
|
||||||
@ -246,13 +237,13 @@
|
|||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"include": "scripts/installer.nsh",
|
"include": "scripts/installer.nsh",
|
||||||
"perMachine": false
|
"perMachine": true
|
||||||
},
|
},
|
||||||
"msiWrapped": {
|
"msiWrapped": {
|
||||||
"upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}",
|
"upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}",
|
||||||
"perMachine": false,
|
"perMachine": true,
|
||||||
"impersonate": true,
|
"impersonate": false,
|
||||||
"wrappedInstallerArgs": "/S /UPDATE /RESTARTFC"
|
"wrappedInstallerArgs": "/S"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,148 +0,0 @@
|
|||||||
!include "MUI2.nsh"
|
|
||||||
!include "LogicLib.nsh"
|
|
||||||
!include "installer.nsh"
|
|
||||||
|
|
||||||
!ifndef OUTFILE
|
|
||||||
!define OUTFILE "farmcontrol-installer.exe"
|
|
||||||
!endif
|
|
||||||
|
|
||||||
!ifndef VERSION
|
|
||||||
!define VERSION "0.1.0"
|
|
||||||
!endif
|
|
||||||
|
|
||||||
!ifndef BUILD_NUMBER
|
|
||||||
!define BUILD_NUMBER "dev"
|
|
||||||
!endif
|
|
||||||
|
|
||||||
!ifndef APP_SOURCE_DIR
|
|
||||||
!define APP_SOURCE_DIR "app"
|
|
||||||
!endif
|
|
||||||
|
|
||||||
!ifndef APP_FILE_LIST_GENERATOR
|
|
||||||
!define APP_FILE_LIST_GENERATOR "..\..\scripts\generate-nsis-file-list.ps1"
|
|
||||||
!endif
|
|
||||||
|
|
||||||
; Enumerate APP_SOURCE_DIR now, while compiling, so the generated installer
|
|
||||||
; knows the exact unpacked byte total and can log each extracted file.
|
|
||||||
!insertmacro compileProgressFileCopy "${APP_SOURCE_DIR}" "${APP_FILE_LIST_GENERATOR}"
|
|
||||||
|
|
||||||
Name "Farm Control"
|
|
||||||
OutFile "${OUTFILE}"
|
|
||||||
InstallDir "$LOCALAPPDATA\Programs\Farm Control"
|
|
||||||
InstallDirRegKey HKCU "Software\Tom Butcher\Farm Control" "InstallDir"
|
|
||||||
RequestExecutionLevel user
|
|
||||||
SilentInstall normal
|
|
||||||
|
|
||||||
!define MUI_ABORTWARNING
|
|
||||||
|
|
||||||
!ifndef INSTALLER_ICON
|
|
||||||
!define INSTALLER_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico"
|
|
||||||
!endif
|
|
||||||
|
|
||||||
!ifndef UNINSTALLER_ICON
|
|
||||||
!define UNINSTALLER_ICON "${INSTALLER_ICON}"
|
|
||||||
!endif
|
|
||||||
|
|
||||||
!define MUI_ICON "${INSTALLER_ICON}"
|
|
||||||
!define MUI_UNICON "${UNINSTALLER_ICON}"
|
|
||||||
|
|
||||||
Icon "${INSTALLER_ICON}"
|
|
||||||
UninstallIcon "${UNINSTALLER_ICON}"
|
|
||||||
|
|
||||||
BrandingText "Farm Control v${VERSION}-b${BUILD_NUMBER} Installer"
|
|
||||||
|
|
||||||
!insertmacro MUI_PAGE_DIRECTORY
|
|
||||||
!insertmacro MUI_PAGE_INSTFILES
|
|
||||||
!insertmacro MUI_UNPAGE_CONFIRM
|
|
||||||
!insertmacro MUI_UNPAGE_INSTFILES
|
|
||||||
!insertmacro MUI_LANGUAGE "English"
|
|
||||||
|
|
||||||
Function .onInit
|
|
||||||
!insertmacro initProgressLog
|
|
||||||
|
|
||||||
${If} ${Silent}
|
|
||||||
SetAutoClose true
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
!insertmacro progressPhase "Starting installation"
|
|
||||||
!insertmacro progressStatus "Starting Farm Control installation..."
|
|
||||||
!insertmacro progressCopyTotal "${APP_COPY_TOTAL_BYTES}"
|
|
||||||
!insertmacro progressStart
|
|
||||||
|
|
||||||
; In-app updates install into Farm Control.new while the running process
|
|
||||||
; keeps using Farm Control; the folders are swapped after exit (/RESTARTFC).
|
|
||||||
; Interactive / silent fresh installs still remove the previous version first.
|
|
||||||
${If} $IsInAppUpdate == "1"
|
|
||||||
!insertmacro prepareSideBySideUpdate
|
|
||||||
${Else}
|
|
||||||
StrCpy $FinalInstDir $INSTDIR
|
|
||||||
!insertmacro uninstallPreviousFarmControl
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
!insertmacro progressPrepare
|
|
||||||
FunctionEnd
|
|
||||||
|
|
||||||
Function .onInstFailed
|
|
||||||
!insertmacro progressFailure "Installation failed."
|
|
||||||
FunctionEnd
|
|
||||||
|
|
||||||
Function .onInstSuccess
|
|
||||||
Call restartFarmControlAfterUpdate
|
|
||||||
FunctionEnd
|
|
||||||
|
|
||||||
Section "Farm Control" SecMain
|
|
||||||
SectionIn RO
|
|
||||||
|
|
||||||
!insertmacro progressPhase "Copying application files"
|
|
||||||
!insertmacro progressStatus "Copying application files..."
|
|
||||||
!insertmacro progressCopyStart
|
|
||||||
|
|
||||||
SetOverwrite try
|
|
||||||
!insertmacro copyApplicationFilesWithProgress
|
|
||||||
|
|
||||||
!insertmacro progressCopyComplete
|
|
||||||
!insertmacro customInstall
|
|
||||||
|
|
||||||
!insertmacro progressFinalizeInstall
|
|
||||||
!insertmacro progressPhase "Finalizing installation"
|
|
||||||
!insertmacro progressStatus "Writing installation registry entries..."
|
|
||||||
|
|
||||||
WriteRegStr HKCU "Software\Tom Butcher\Farm Control" "InstallDir" $FinalInstDir
|
|
||||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
|
|
||||||
"DisplayName" "Farm Control"
|
|
||||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
|
|
||||||
"DisplayVersion" "${VERSION}"
|
|
||||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
|
|
||||||
"Publisher" "Tom Butcher"
|
|
||||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
|
|
||||||
"UninstallString" "$FinalInstDir\Uninstall.exe"
|
|
||||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
|
|
||||||
"NoModify" 1
|
|
||||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
|
|
||||||
"NoRepair" 1
|
|
||||||
|
|
||||||
!insertmacro progressWriteUninstaller
|
|
||||||
!insertmacro progressStatus "Writing uninstall information..."
|
|
||||||
|
|
||||||
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
|
||||||
|
|
||||||
!insertmacro progressFinalize
|
|
||||||
|
|
||||||
|
|
||||||
; For in-app updates (/RESTARTFC) the success signal is emitted from
|
|
||||||
; restartFarmControlAfterUpdate, right before the install directory swap,
|
|
||||||
; so the running app quits while this installer stays alive.
|
|
||||||
${If} $RestartAfterInstall != "1"
|
|
||||||
!insertmacro progressSuccess
|
|
||||||
${EndIf}
|
|
||||||
SectionEnd
|
|
||||||
|
|
||||||
Section "Uninstall"
|
|
||||||
!insertmacro customUnInstall
|
|
||||||
|
|
||||||
Delete "$INSTDIR\Uninstall.exe"
|
|
||||||
RMDir /r "$INSTDIR"
|
|
||||||
|
|
||||||
DeleteRegKey HKCU "Software\Tom Butcher\Farm Control"
|
|
||||||
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control"
|
|
||||||
SectionEnd
|
|
||||||
@ -1,460 +0,0 @@
|
|||||||
!include "FileFunc.nsh"
|
|
||||||
!insertmacro GetParameters
|
|
||||||
!insertmacro GetOptions
|
|
||||||
|
|
||||||
Var ProgressLogFile
|
|
||||||
Var IsInAppUpdate
|
|
||||||
Var RestartAfterInstall
|
|
||||||
Var FinalInstDir
|
|
||||||
Var UpdateParentPid
|
|
||||||
|
|
||||||
; Generate an include at compile time that defines APP_COPY_TOTAL_BYTES and
|
|
||||||
; copyApplicationFilesWithProgress. The generated macro contains one File
|
|
||||||
; command and one byte-progress log entry for every file in the source tree.
|
|
||||||
!macro compileProgressFileCopy sourceDir generator
|
|
||||||
!tempfile GENERATED_FILE_COPY_INCLUDE
|
|
||||||
!system 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${generator}" -SourceDir "${sourceDir}" -OutputPath "${GENERATED_FILE_COPY_INCLUDE}"' = 0
|
|
||||||
!include "${GENERATED_FILE_COPY_INCLUDE}"
|
|
||||||
!delfile "${GENERATED_FILE_COPY_INCLUDE}"
|
|
||||||
!undef GENERATED_FILE_COPY_INCLUDE
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro initProgressLog
|
|
||||||
StrCpy $ProgressLogFile ""
|
|
||||||
StrCpy $IsInAppUpdate "0"
|
|
||||||
StrCpy $RestartAfterInstall "0"
|
|
||||||
StrCpy $FinalInstDir ""
|
|
||||||
StrCpy $UpdateParentPid "0"
|
|
||||||
|
|
||||||
${GetParameters} $R9
|
|
||||||
|
|
||||||
ClearErrors
|
|
||||||
${GetOptions} $R9 "/UPDATE" $R8
|
|
||||||
${IfNot} ${Errors}
|
|
||||||
StrCpy $IsInAppUpdate "1"
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
ClearErrors
|
|
||||||
${GetOptions} $R9 "/RESTARTFC" $R8
|
|
||||||
${IfNot} ${Errors}
|
|
||||||
StrCpy $RestartAfterInstall "1"
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
ClearErrors
|
|
||||||
${GetOptions} $R9 "/PARENTPID=" $R8
|
|
||||||
${IfNot} ${Errors}
|
|
||||||
; Convert to an integer before it is used in Win32 calls or taskkill.
|
|
||||||
IntOp $UpdateParentPid $R8 + 0
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
; Prefer env var so paths with spaces are reliable; /LOG= remains supported.
|
|
||||||
ReadEnvStr $ProgressLogFile "FARMCONTROL_INSTALL_LOG"
|
|
||||||
${If} $ProgressLogFile == ""
|
|
||||||
ClearErrors
|
|
||||||
${GetOptions} $R9 "/LOG=" $ProgressLogFile
|
|
||||||
${If} ${Errors}
|
|
||||||
StrCpy $ProgressLogFile ""
|
|
||||||
${EndIf}
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
${If} $ProgressLogFile != ""
|
|
||||||
; Strip surrounding quotes from /LOG="C:\path with spaces\log.log"
|
|
||||||
StrCpy $R8 $ProgressLogFile 1
|
|
||||||
${If} $R8 == '"'
|
|
||||||
StrCpy $ProgressLogFile $ProgressLogFile "" 1
|
|
||||||
StrLen $R8 $ProgressLogFile
|
|
||||||
IntOp $R8 $R8 - 1
|
|
||||||
${If} $R8 > 0
|
|
||||||
StrCpy $ProgressLogFile $ProgressLogFile $R8
|
|
||||||
${EndIf}
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
; Truncate any previous log so progress starts clean.
|
|
||||||
Push $0
|
|
||||||
FileOpen $0 "$ProgressLogFile" w
|
|
||||||
${If} $0 != ""
|
|
||||||
FileClose $0
|
|
||||||
${EndIf}
|
|
||||||
Pop $0
|
|
||||||
${EndIf}
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
; Open/append/close each line so the updater can read the log concurrently
|
|
||||||
; (avoids an exclusive lock for the whole install; LogEx SHARE_READ is an alternative).
|
|
||||||
!macro progressLog line
|
|
||||||
DetailPrint "${line}"
|
|
||||||
${If} $ProgressLogFile != ""
|
|
||||||
Push $0
|
|
||||||
FileOpen $0 "$ProgressLogFile" a
|
|
||||||
${If} $0 != ""
|
|
||||||
FileSeek $0 0 END
|
|
||||||
FileWrite $0 "${line}$\r$\n"
|
|
||||||
FileClose $0
|
|
||||||
${EndIf}
|
|
||||||
Pop $0
|
|
||||||
${EndIf}
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!define PROGRESS_START 1
|
|
||||||
!define PREPARE_PROGRESS 3
|
|
||||||
!define COPY_PROGRESS_START 5
|
|
||||||
!define COPY_PROGRESS_END 90
|
|
||||||
!define CONFIGURE_PROGRESS 91
|
|
||||||
!define SHORTCUTS_PROGRESS 93
|
|
||||||
!define FINALIZE_PROGRESS 95
|
|
||||||
!define UNINSTALLER_PROGRESS 97
|
|
||||||
!define COMPLETE_PROGRESS 99
|
|
||||||
|
|
||||||
!macro progressPercent percent
|
|
||||||
!insertmacro progressLog "installer:%${percent}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressStart
|
|
||||||
!insertmacro progressPercent "${PROGRESS_START}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressPrepare
|
|
||||||
!insertmacro progressPercent "${PREPARE_PROGRESS}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressCopyStart
|
|
||||||
!insertmacro progressPercent "${COPY_PROGRESS_START}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressCopyComplete
|
|
||||||
!insertmacro progressPercent "${COPY_PROGRESS_END}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressConfigure
|
|
||||||
!insertmacro progressPercent "${CONFIGURE_PROGRESS}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressShortcuts
|
|
||||||
!insertmacro progressPercent "${SHORTCUTS_PROGRESS}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressFinalizeInstall
|
|
||||||
!insertmacro progressPercent "${FINALIZE_PROGRESS}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressWriteUninstaller
|
|
||||||
!insertmacro progressPercent "${UNINSTALLER_PROGRESS}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressFinalize
|
|
||||||
!insertmacro progressPercent "${COMPLETE_PROGRESS}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressPhase phase
|
|
||||||
!insertmacro progressLog "installer:PHASE:${phase}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressStatus status
|
|
||||||
!insertmacro progressLog "installer:STATUS:${status}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressCopyTotal bytes
|
|
||||||
!insertmacro progressLog "installer:COPY_TOTAL:${bytes}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressCopyFile bytes relativePath
|
|
||||||
!insertmacro progressLog "installer:COPY_FILE:${bytes}:${relativePath}"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressSuccess
|
|
||||||
!insertmacro progressPercent 100
|
|
||||||
!insertmacro progressStatus "Installation complete. Restarting Farm Control..."
|
|
||||||
!insertmacro progressLog "installer: The install was successful."
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro progressFailure message
|
|
||||||
!insertmacro progressLog "installer:STATUS:${message}"
|
|
||||||
!insertmacro progressLog "installer: The install failed."
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro quitFarmControl
|
|
||||||
!insertmacro progressPhase "Stopping Farm Control"
|
|
||||||
!insertmacro progressStatus "Stopping running Farm Control processes..."
|
|
||||||
DetailPrint "Stopping running Farm Control processes..."
|
|
||||||
ExecWait 'taskkill /F /IM FarmControl.exe /T' $R0
|
|
||||||
ExecWait 'taskkill /F /IM launcher.exe /T' $R0
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
; Side-by-side update: install into Farm Control.new while the running app
|
|
||||||
; keeps using Farm Control, then swap folders after the process exits.
|
|
||||||
!macro prepareSideBySideUpdate
|
|
||||||
!insertmacro progressPhase "Preparing update"
|
|
||||||
!insertmacro progressStatus "Preparing staging folder for update..."
|
|
||||||
|
|
||||||
StrCpy $FinalInstDir $INSTDIR
|
|
||||||
StrCpy $INSTDIR "$FinalInstDir.new"
|
|
||||||
|
|
||||||
${If} ${FileExists} "$INSTDIR"
|
|
||||||
DetailPrint "Removing leftover staging folder..."
|
|
||||||
RMDir /r "$INSTDIR"
|
|
||||||
${EndIf}
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
; Swap order: Farm Control -> .old, Farm Control.new -> Farm Control, delete .old.
|
|
||||||
!macro swapUpdateStaging
|
|
||||||
!insertmacro progressPhase "Applying update"
|
|
||||||
!insertmacro progressStatus "Replacing Farm Control with the new version..."
|
|
||||||
DetailPrint "Replacing Farm Control with staged update..."
|
|
||||||
|
|
||||||
; The install section ran SetOutPath into the install tree; a directory that
|
|
||||||
; is any process's working directory cannot be renamed, so move out first.
|
|
||||||
SetOutPath "$TEMP"
|
|
||||||
|
|
||||||
Sleep 3000
|
|
||||||
|
|
||||||
; Clear a leftover .old from a previous interrupted update.
|
|
||||||
${If} ${FileExists} "$FinalInstDir.old"
|
|
||||||
DetailPrint "Removing leftover Farm Control.old..."
|
|
||||||
RMDir /r "$FinalInstDir.old"
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
${If} ${FileExists} "$FinalInstDir"
|
|
||||||
!insertmacro progressStatus "Waiting for Farm Control to release install files..."
|
|
||||||
DetailPrint "Waiting to rename install directory to Farm Control.old..."
|
|
||||||
|
|
||||||
; Retry every 500ms; success is verified on disk rather than via the
|
|
||||||
; error flag, which Rename does not set reliably.
|
|
||||||
StrCpy $R7 0
|
|
||||||
swap_rename_old_retry:
|
|
||||||
Rename "$FinalInstDir" "$FinalInstDir.old"
|
|
||||||
${IfNot} ${FileExists} "$FinalInstDir"
|
|
||||||
Goto swap_rename_old_done
|
|
||||||
${EndIf}
|
|
||||||
IntOp $R7 $R7 + 1
|
|
||||||
${If} $R7 < 120
|
|
||||||
Sleep 500
|
|
||||||
Goto swap_rename_old_retry
|
|
||||||
${EndIf}
|
|
||||||
!insertmacro progressFailure "Could not move the previous Farm Control installation aside."
|
|
||||||
Abort
|
|
||||||
swap_rename_old_done:
|
|
||||||
DetailPrint "Install directory renamed to Farm Control.old"
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
!insertmacro progressStatus "Activating new Farm Control installation..."
|
|
||||||
DetailPrint "Renaming Farm Control.new to Farm Control..."
|
|
||||||
|
|
||||||
StrCpy $R7 0
|
|
||||||
swap_rename_new_retry:
|
|
||||||
Rename "$INSTDIR" "$FinalInstDir"
|
|
||||||
${If} ${FileExists} "$FinalInstDir"
|
|
||||||
Goto swap_rename_new_done
|
|
||||||
${EndIf}
|
|
||||||
IntOp $R7 $R7 + 1
|
|
||||||
${If} $R7 < 20
|
|
||||||
Sleep 500
|
|
||||||
Goto swap_rename_new_retry
|
|
||||||
${EndIf}
|
|
||||||
; Best-effort rollback so the previous install is usable again.
|
|
||||||
${If} ${FileExists} "$FinalInstDir.old"
|
|
||||||
Rename "$FinalInstDir.old" "$FinalInstDir"
|
|
||||||
${EndIf}
|
|
||||||
!insertmacro progressFailure "Could not activate the new Farm Control installation."
|
|
||||||
Abort
|
|
||||||
swap_rename_new_done:
|
|
||||||
|
|
||||||
StrCpy $INSTDIR $FinalInstDir
|
|
||||||
!insertmacro progressLog "installer:STATUS:Update files activated"
|
|
||||||
|
|
||||||
${If} ${FileExists} "$FinalInstDir.old"
|
|
||||||
!insertmacro progressStatus "Removing previous Farm Control installation..."
|
|
||||||
DetailPrint "Removing Farm Control.old..."
|
|
||||||
|
|
||||||
StrCpy $R7 0
|
|
||||||
swap_delete_old_retry:
|
|
||||||
RMDir /r "$FinalInstDir.old"
|
|
||||||
${If} ${FileExists} "$FinalInstDir.old"
|
|
||||||
IntOp $R7 $R7 + 1
|
|
||||||
${If} $R7 < 20
|
|
||||||
Sleep 500
|
|
||||||
Goto swap_delete_old_retry
|
|
||||||
${EndIf}
|
|
||||||
; New version is already active; leftover .old is non-fatal.
|
|
||||||
DetailPrint "Warning: could not fully remove Farm Control.old"
|
|
||||||
!insertmacro progressLog "installer:STATUS:Warning: could not fully remove previous installation"
|
|
||||||
${EndIf}
|
|
||||||
${EndIf}
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro uninstallPreviousFarmControl
|
|
||||||
ClearErrors
|
|
||||||
ReadRegStr $R0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString"
|
|
||||||
StrCmp $R0 "" check_install_dir run_uninstall
|
|
||||||
|
|
||||||
check_install_dir:
|
|
||||||
ReadRegStr $R0 HKCU "Software\Tom Butcher\Farm Control" "InstallDir"
|
|
||||||
StrCmp $R0 "" check_legacy_uninstall
|
|
||||||
StrCpy $R0 "$R0\Uninstall.exe"
|
|
||||||
Goto run_uninstall
|
|
||||||
|
|
||||||
check_legacy_uninstall:
|
|
||||||
ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString"
|
|
||||||
StrCmp $R0 "" check_legacy_install_dir run_uninstall
|
|
||||||
|
|
||||||
check_legacy_install_dir:
|
|
||||||
ReadRegStr $R0 HKLM "Software\Tom Butcher\Farm Control" "InstallDir"
|
|
||||||
StrCmp $R0 "" check_default_dir
|
|
||||||
StrCpy $R0 "$R0\Uninstall.exe"
|
|
||||||
Goto run_uninstall
|
|
||||||
|
|
||||||
check_default_dir:
|
|
||||||
StrCpy $R0 "$LOCALAPPDATA\Programs\Farm Control\Uninstall.exe"
|
|
||||||
IfFileExists $R0 run_uninstall check_legacy_default_dir
|
|
||||||
|
|
||||||
check_legacy_default_dir:
|
|
||||||
StrCpy $R0 "$PROGRAMFILES64\Farm Control\Uninstall.exe"
|
|
||||||
IfFileExists $R0 run_uninstall done_uninstall
|
|
||||||
|
|
||||||
run_uninstall:
|
|
||||||
IfFileExists $R0 0 done_uninstall
|
|
||||||
!insertmacro progressPhase "Removing previous version"
|
|
||||||
!insertmacro progressStatus "Removing previous Farm Control installation..."
|
|
||||||
DetailPrint "Removing previous Farm Control installation..."
|
|
||||||
!insertmacro quitFarmControl
|
|
||||||
ExecWait '"$R0" /S' $R1
|
|
||||||
DetailPrint "Previous installation removed (exit code: $R1)"
|
|
||||||
!insertmacro progressLog "installer:STATUS:Previous installation removed"
|
|
||||||
|
|
||||||
done_uninstall:
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro createDesktopShortcut
|
|
||||||
SetShellVarContext current
|
|
||||||
SetOutPath "$FinalInstDir\bin"
|
|
||||||
CreateShortCut "$DESKTOP\Farm Control.lnk" "$FinalInstDir\bin\FarmControl.exe" "" "$FinalInstDir\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro createStartMenuShortcut
|
|
||||||
SetShellVarContext current
|
|
||||||
CreateDirectory "$SMPROGRAMS\Farm Control"
|
|
||||||
SetOutPath "$FinalInstDir\bin"
|
|
||||||
CreateShortCut "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$FinalInstDir\bin\FarmControl.exe" "" "$FinalInstDir\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro removeDesktopShortcut
|
|
||||||
SetShellVarContext current
|
|
||||||
Delete "$DESKTOP\Farm Control.lnk"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro removeStartMenuShortcut
|
|
||||||
SetShellVarContext current
|
|
||||||
Delete "$SMPROGRAMS\Farm Control\Farm Control.lnk"
|
|
||||||
RMDir "$SMPROGRAMS\Farm Control"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro customInstall
|
|
||||||
!insertmacro progressConfigure
|
|
||||||
!insertmacro progressPhase "Configuring Farm Control"
|
|
||||||
!insertmacro progressStatus "Registering farmcontrol URI handler..."
|
|
||||||
DetailPrint "Register farmcontrol URI Handler"
|
|
||||||
DeleteRegKey HKCU "Software\Classes\farmcontrol"
|
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol" "" "URL:farmcontrol"
|
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol" "URL Protocol" ""
|
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol\DefaultIcon" "" "$FinalInstDir\bin\FarmControl.exe"
|
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol\shell" "" ""
|
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" ""
|
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" '"$FinalInstDir\bin\bun.exe" "$FinalInstDir\bin\deeplink.js" "%1"'
|
|
||||||
|
|
||||||
!insertmacro progressShortcuts
|
|
||||||
!insertmacro progressStatus "Creating shortcuts..."
|
|
||||||
DetailPrint "Creating shortcuts"
|
|
||||||
!insertmacro createDesktopShortcut
|
|
||||||
!insertmacro createStartMenuShortcut
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro customUnInstall
|
|
||||||
!insertmacro removeDesktopShortcut
|
|
||||||
!insertmacro removeStartMenuShortcut
|
|
||||||
DeleteRegKey HKCU "Software\Classes\farmcontrol"
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
; Wait for the running app to exit, swap staged update into place, then relaunch.
|
|
||||||
; Only used when /RESTARTFC is passed.
|
|
||||||
Function restartFarmControlAfterUpdate
|
|
||||||
${If} $RestartAfterInstall != "1"
|
|
||||||
Return
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
; All files are copied and we are about to rename the install directory.
|
|
||||||
; Emit the success line (matched by isWindowsInstallSuccessful in
|
|
||||||
; winappupdate.js) so the app knows to quit and restart; this installer
|
|
||||||
; stays alive to swap the folders and relaunch Farm Control.
|
|
||||||
!insertmacro progressSuccess
|
|
||||||
|
|
||||||
!insertmacro progressStatus "Waiting for Farm Control to close..."
|
|
||||||
|
|
||||||
${If} $UpdateParentPid > 0
|
|
||||||
; Wait for the exact updater process rather than an executable name. CEF
|
|
||||||
; builds can keep bun/renderer processes alive after the launcher exits.
|
|
||||||
System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $UpdateParentPid) p .r0'
|
|
||||||
${If} $0 != 0
|
|
||||||
; Allow two seconds for a graceful exit, then terminate the process.
|
|
||||||
System::Call 'kernel32::WaitForSingleObject(p r0, i 2000) i .r1'
|
|
||||||
${If} $1 == 258
|
|
||||||
!insertmacro progressStatus "Stopping Farm Control..."
|
|
||||||
DetailPrint "Farm Control process $UpdateParentPid did not exit; terminating it..."
|
|
||||||
; Never use taskkill /T here: this installer is a direct child of the
|
|
||||||
; updater process, so a tree kill would terminate the installer itself
|
|
||||||
; before the folder swap (CEF builds hung at "Stopping Farm Control...").
|
|
||||||
ExecWait 'taskkill /F /PID $UpdateParentPid' $R0
|
|
||||||
|
|
||||||
; Verify that taskkill actually terminated the process before swapping.
|
|
||||||
System::Call 'kernel32::WaitForSingleObject(p r0, i 10000) i .r1'
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
System::Call 'kernel32::CloseHandle(p r0)'
|
|
||||||
${If} $1 != 0
|
|
||||||
!insertmacro progressFailure "Could not stop the running Farm Control process."
|
|
||||||
Abort
|
|
||||||
${EndIf}
|
|
||||||
${EndIf}
|
|
||||||
${Else}
|
|
||||||
; Compatibility fallback for manually launched older installers.
|
|
||||||
Sleep 2000
|
|
||||||
!insertmacro quitFarmControl
|
|
||||||
|
|
||||||
; Confirm both known launcher names have stopped before continuing.
|
|
||||||
StrCpy $R7 0
|
|
||||||
restart_verify_loop:
|
|
||||||
ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq FarmControl.exe" 2>nul | find /I "FarmControl.exe"' $R0
|
|
||||||
${If} $R0 != 0
|
|
||||||
ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq launcher.exe" 2>nul | find /I "launcher.exe"' $R0
|
|
||||||
${EndIf}
|
|
||||||
${If} $R0 != 0
|
|
||||||
Goto restart_wait_done
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
IntOp $R7 $R7 + 1
|
|
||||||
${If} $R7 < 20
|
|
||||||
Sleep 500
|
|
||||||
Goto restart_verify_loop
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
!insertmacro progressFailure "Could not stop the running Farm Control process."
|
|
||||||
Abort
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
restart_wait_done:
|
|
||||||
; CEF helper processes normally exit on their own once the main process is
|
|
||||||
; gone, but sweep up any stragglers still running from the install
|
|
||||||
; directory so the rename cannot be blocked. Matching on executable path
|
|
||||||
; (not a tree kill) guarantees this installer, which runs from the Updates
|
|
||||||
; folder, is never terminated.
|
|
||||||
ExecWait `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -like '$FinalInstDir\*' } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"` $R0
|
|
||||||
|
|
||||||
; Give CEF descendants a moment to release DLLs before renaming the tree.
|
|
||||||
Sleep 3000
|
|
||||||
|
|
||||||
${If} $IsInAppUpdate == "1"
|
|
||||||
!insertmacro swapUpdateStaging
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
!insertmacro progressStatus "Starting Farm Control..."
|
|
||||||
SetOutPath "$FinalInstDir\bin"
|
|
||||||
Exec "$FinalInstDir\bin\FarmControl.exe"
|
|
||||||
FunctionEnd
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
|
||||||
<!--
|
|
||||||
MSI wrapper around the compiled NSIS setup.exe.
|
|
||||||
Runs the bundled installer with the same silent in-app update flags used by
|
|
||||||
src/desktop/winappupdate.js: /S /UPDATE /RESTARTFC /LOG=...
|
|
||||||
|
|
||||||
ALLUSERS=2 + MSIINSTALLPERUSER=1 defaults to per-user (matching NSIS) and is
|
|
||||||
not blocked by DisableMsi=1 / error 1625 the way a pure InstallScope=perUser
|
|
||||||
package is.
|
|
||||||
-->
|
|
||||||
<Product
|
|
||||||
Id="*"
|
|
||||||
Name="Farm Control"
|
|
||||||
Language="1033"
|
|
||||||
Version="__VERSION__"
|
|
||||||
Manufacturer="Tom Butcher"
|
|
||||||
UpgradeCode="__UPGRADE_CODE__">
|
|
||||||
<Package
|
|
||||||
InstallerVersion="500"
|
|
||||||
Compressed="yes"
|
|
||||||
InstallPrivileges="limited"
|
|
||||||
Platform="x64" />
|
|
||||||
|
|
||||||
<Condition Message="Windows 7 and above is required"><![CDATA[Installed OR VersionNT >= 601]]></Condition>
|
|
||||||
|
|
||||||
<MajorUpgrade
|
|
||||||
AllowSameVersionUpgrades="yes"
|
|
||||||
DowngradeErrorMessage="A newer version of [ProductName] is already installed." />
|
|
||||||
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
|
||||||
|
|
||||||
<Icon Id="InstallerIcon" SourceFile="__INSTALLER_ICON__" />
|
|
||||||
<Property Id="ARPPRODUCTICON" Value="InstallerIcon" />
|
|
||||||
|
|
||||||
<Property Id="DISABLEADVTSHORTCUTS" Value="1" />
|
|
||||||
<Property Id="ALLUSERS" Secure="yes" Value="2" />
|
|
||||||
<Property Id="MSIINSTALLPERUSER" Secure="yes" Value="1" />
|
|
||||||
<Property Id="REBOOT" Value="ReallySuppress" />
|
|
||||||
|
|
||||||
<Binary Id="WrappedExe" SourceFile="__SETUP_EXE__" />
|
|
||||||
|
|
||||||
<!-- ExeCommand is a Formatted field, so [FarmControlUpdatesDir] resolves to the
|
|
||||||
real per-user path here. An intermediate Property would not resolve it
|
|
||||||
(property values are not recursively formatted). -->
|
|
||||||
<CustomAction
|
|
||||||
Id="RunInstaller"
|
|
||||||
BinaryKey="WrappedExe"
|
|
||||||
ExeCommand="/S /UPDATE /RESTARTFC /LOG="[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,260 +1,270 @@
|
|||||||
import { createWriteStream, promises as fs } from "node:fs";
|
import { ipcMain } from 'electron'
|
||||||
import http from "node:http";
|
import { createWriteStream, promises as fs } from 'fs'
|
||||||
import https from "node:https";
|
import http from 'http'
|
||||||
import os from "node:os";
|
import https from 'https'
|
||||||
import path from "node:path";
|
import os from 'os'
|
||||||
import process from "node:process";
|
import path from 'path'
|
||||||
import { Utils } from "electrobun/bun";
|
import process from 'process'
|
||||||
import { launchMacInstaller } from "./macappupdate.js";
|
import { launchMacInstaller } from './macappupdate.js'
|
||||||
import { launchWindowsInstaller } from "./winappupdate.js";
|
import { launchWindowsInstaller } from './winappupdate.js'
|
||||||
import { scheduleAppRestart } from "./updater-runner.js";
|
|
||||||
|
|
||||||
|
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
|
||||||
const SUPPORTED_TARGETS = {
|
const SUPPORTED_TARGETS = {
|
||||||
darwin: {
|
darwin: {
|
||||||
extension: ".pkg",
|
extension: '.pkg',
|
||||||
osMatchers: ["darwin", "mac", "macos", "osx"],
|
osMatchers: ['darwin', 'mac', 'macos', 'osx']
|
||||||
},
|
},
|
||||||
win32: {
|
win32: {
|
||||||
extension: ".msi",
|
extension: '.msi',
|
||||||
osMatchers: ["win32", "win", "windows"],
|
osMatchers: ['win32', 'win', 'windows']
|
||||||
},
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
let runningUpdate = null;
|
let runningUpdate = null
|
||||||
|
|
||||||
const getArtifactName = (artifact) =>
|
const getArtifactName = (artifact) =>
|
||||||
String(artifact?.fileName || artifact?.relativePath || artifact?.url || "");
|
String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
|
||||||
|
|
||||||
|
const isCefInstallerArtifact = (artifact) => {
|
||||||
|
const name = getArtifactName(artifact).toLowerCase()
|
||||||
|
// CEF builds are named like farmcontrol-0.1.1-x64-cef.msi / -cef.pkg / -cef.exe
|
||||||
|
return /-cef\.[a-z0-9]+$/.test(name)
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeArch = (arch) => {
|
const normalizeArch = (arch) => {
|
||||||
if (arch === "x64" || arch === "amd64") return "x64";
|
if (arch === 'x64' || arch === 'amd64') return 'x64'
|
||||||
if (arch === "arm64" || arch === "aarch64") return "arm64";
|
if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
|
||||||
return arch;
|
return arch
|
||||||
};
|
}
|
||||||
|
|
||||||
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
|
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
|
||||||
const name = getArtifactName(artifact).toLowerCase();
|
const name = getArtifactName(artifact).toLowerCase()
|
||||||
const normalizedArch = normalizeArch(arch);
|
const normalizedArch = normalizeArch(arch)
|
||||||
const artifactArch = normalizeArch(String(artifact?.arch || "").toLowerCase());
|
const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
|
||||||
const artifactPlatform = String(
|
const artifactPlatform = String(
|
||||||
artifact?.platform || artifact?.os || artifact?.target || "",
|
artifact?.platform || artifact?.os || artifact?.target || ''
|
||||||
).toLowerCase();
|
).toLowerCase()
|
||||||
|
|
||||||
if (!name.endsWith(target.extension)) return false;
|
if (!name.endsWith(target.extension)) return false
|
||||||
if (!artifact?.url) return false;
|
if (!artifact?.url) return false
|
||||||
|
|
||||||
const matchesArch =
|
const matchesArch =
|
||||||
artifactArch === normalizedArch ||
|
artifactArch === normalizedArch ||
|
||||||
name.includes(`-${normalizedArch}`) ||
|
name.includes(`-${normalizedArch}`) ||
|
||||||
name.includes(`_${normalizedArch}`) ||
|
name.includes(`_${normalizedArch}`) ||
|
||||||
name.includes(`.${normalizedArch}.`) ||
|
name.includes(`.${normalizedArch}.`) ||
|
||||||
name.includes(normalizedArch);
|
name.includes(normalizedArch)
|
||||||
|
|
||||||
const matchesOs =
|
const matchesOs =
|
||||||
!artifactPlatform ||
|
!artifactPlatform ||
|
||||||
target.osMatchers.includes(artifactPlatform) ||
|
target.osMatchers.includes(artifactPlatform) ||
|
||||||
target.osMatchers.some((matcher) => name.includes(matcher)) ||
|
target.osMatchers.some((matcher) => name.includes(matcher)) ||
|
||||||
(platform === "darwin" && name.includes("mac")) ||
|
(platform === 'darwin' && name.includes('mac')) ||
|
||||||
(platform === "win32" && name.includes("win"));
|
(platform === 'win32' && name.includes('win'))
|
||||||
|
|
||||||
return matchesArch && matchesOs;
|
return matchesArch && matchesOs
|
||||||
};
|
}
|
||||||
|
|
||||||
const selectUpdateArtifact = (
|
const selectUpdateArtifact = (
|
||||||
update,
|
update,
|
||||||
platform = process.platform,
|
platform = process.platform,
|
||||||
arch = process.arch,
|
arch = process.arch
|
||||||
) => {
|
) => {
|
||||||
const target = SUPPORTED_TARGETS[platform];
|
const target = SUPPORTED_TARGETS[platform]
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error(`App updates are not supported on ${platform}.`);
|
throw new Error(`App updates are not supported on ${platform}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : [];
|
const artifacts = (Array.isArray(update?.artifacts) ? update.artifacts : []).filter(
|
||||||
|
(artifact) => !isCefInstallerArtifact(artifact)
|
||||||
|
)
|
||||||
const matchingArtifact = artifacts.find((artifact) =>
|
const matchingArtifact = artifacts.find((artifact) =>
|
||||||
artifactMatchesPlatform(artifact, target, platform, arch),
|
artifactMatchesPlatform(artifact, target, platform, arch)
|
||||||
);
|
)
|
||||||
const fallbackArtifact = artifacts.find((artifact) => {
|
const fallbackArtifact = artifacts.find((artifact) => {
|
||||||
const name = getArtifactName(artifact).toLowerCase();
|
const name = getArtifactName(artifact).toLowerCase()
|
||||||
return artifact?.url && name.endsWith(target.extension);
|
return artifact?.url && name.endsWith(target.extension)
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!matchingArtifact && !fallbackArtifact) {
|
if (!matchingArtifact && !fallbackArtifact) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No ${target.extension} update artifact found for ${platform}/${arch}.`,
|
`No ${target.extension} update artifact found for ${platform}/${arch}.`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return matchingArtifact || fallbackArtifact;
|
return matchingArtifact || fallbackArtifact
|
||||||
};
|
}
|
||||||
|
|
||||||
const getInstallErrorMessage = (error, output = "") => {
|
const sendProgress = (webContents, payload) => {
|
||||||
const combined = `${output}\n${error?.message || ""}`.trim();
|
if (!webContents || webContents.isDestroyed()) return
|
||||||
|
webContents.send(UPDATE_PROGRESS_CHANNEL, {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...payload
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInstallErrorMessage = (error, output = '') => {
|
||||||
|
const combined = `${output}\n${error?.message || ''}`.trim()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
/cancel/i.test(combined) ||
|
/cancel/i.test(combined) ||
|
||||||
/did not grant permission/i.test(combined) ||
|
/did not grant permission/i.test(combined) ||
|
||||||
/user canceled/i.test(combined)
|
/user canceled/i.test(combined)
|
||||||
) {
|
) {
|
||||||
return "Update installation was cancelled.";
|
return 'Update installation was cancelled.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/incorrect/i.test(combined)) {
|
if (/incorrect/i.test(combined)) {
|
||||||
return "The administrator password was incorrect.";
|
return 'The administrator password was incorrect.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
return combined || 'Failed to install update.'
|
||||||
/1625/.test(combined) ||
|
|
||||||
/forbidden by system policy/i.test(combined) ||
|
|
||||||
/Non-assigned apps are disabled/i.test(combined)
|
|
||||||
) {
|
|
||||||
return "Update installation was blocked by Windows Installer policy.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return combined || "Failed to install update.";
|
const installerHelpers = { sendProgress, getInstallErrorMessage }
|
||||||
};
|
|
||||||
|
|
||||||
const getDownloadUrl = (url, redirectCount = 0) =>
|
const getDownloadUrl = (url, redirectCount = 0) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
if (redirectCount > 5) {
|
if (redirectCount > 5) {
|
||||||
reject(new Error("Too many redirects while downloading update."));
|
reject(new Error('Too many redirects while downloading update.'))
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url)
|
||||||
const client = parsedUrl.protocol === "https:" ? https : http;
|
const client = parsedUrl.protocol === 'https:' ? https : http
|
||||||
const request = client.get(parsedUrl, (response) => {
|
const request = client.get(parsedUrl, (response) => {
|
||||||
const location = response.headers.location;
|
const location = response.headers.location
|
||||||
|
|
||||||
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
|
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
|
||||||
response.resume();
|
response.resume()
|
||||||
resolve(
|
resolve(
|
||||||
getDownloadUrl(
|
getDownloadUrl(
|
||||||
new URL(location, parsedUrl).toString(),
|
new URL(location, parsedUrl).toString(),
|
||||||
redirectCount + 1,
|
redirectCount + 1
|
||||||
),
|
)
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({ response, url: parsedUrl.toString() });
|
resolve({ response, url: parsedUrl.toString() })
|
||||||
});
|
})
|
||||||
|
|
||||||
request.on("error", reject);
|
request.on('error', reject)
|
||||||
});
|
})
|
||||||
|
|
||||||
const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
|
const downloadArtifact = async (artifact, destinationPath, webContents) => {
|
||||||
const { response } = await getDownloadUrl(artifact.url);
|
const { response } = await getDownloadUrl(artifact.url)
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
response.resume();
|
response.resume()
|
||||||
throw new Error(`Update download failed with HTTP ${response.statusCode}.`);
|
throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalBytes =
|
const totalBytes =
|
||||||
Number.parseInt(response.headers["content-length"], 10) || 0;
|
Number.parseInt(response.headers['content-length'], 10) || 0
|
||||||
let downloadedBytes = 0;
|
let downloadedBytes = 0
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const output = createWriteStream(destinationPath);
|
const output = createWriteStream(destinationPath)
|
||||||
|
|
||||||
response.on("data", (chunk) => {
|
response.on('data', (chunk) => {
|
||||||
downloadedBytes += chunk.length;
|
downloadedBytes += chunk.length
|
||||||
const percent = totalBytes
|
const percent = totalBytes
|
||||||
? Math.round((downloadedBytes / totalBytes) * 100)
|
? Math.round((downloadedBytes / totalBytes) * 100)
|
||||||
: null;
|
: null
|
||||||
|
|
||||||
sendProgress({
|
sendProgress(webContents, {
|
||||||
phase: "downloading",
|
phase: 'downloading',
|
||||||
percent,
|
percent,
|
||||||
downloadedBytes,
|
downloadedBytes,
|
||||||
totalBytes,
|
totalBytes,
|
||||||
message: totalBytes
|
message: totalBytes
|
||||||
? `Downloading update (${percent}%)`
|
? `Downloading update (${percent}%)`
|
||||||
: "Downloading update",
|
: 'Downloading update'
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
response.on("error", reject);
|
response.on('error', reject)
|
||||||
output.on("error", reject);
|
output.on('error', reject)
|
||||||
output.on("finish", resolve);
|
output.on('finish', resolve)
|
||||||
response.pipe(output);
|
response.pipe(output)
|
||||||
});
|
})
|
||||||
};
|
|
||||||
|
|
||||||
const launchInstallerAndRestart = async (
|
|
||||||
mainWindow,
|
|
||||||
installerPath,
|
|
||||||
sendProgress,
|
|
||||||
) => {
|
|
||||||
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}.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleAppRestart();
|
const restartApp = (app) => {
|
||||||
Utils.quit();
|
app.relaunch()
|
||||||
};
|
app.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
const runAppUpdate = async (mainWindow, update, sendProgress) => {
|
const launchInstallerAndQuit = async (app, installerPath, webContents) => {
|
||||||
const artifact = selectUpdateArtifact(update);
|
if (process.platform === 'darwin') {
|
||||||
|
await launchMacInstaller(app, installerPath, webContents, installerHelpers)
|
||||||
|
restartApp(app)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
await launchWindowsInstaller(
|
||||||
|
app,
|
||||||
|
installerPath,
|
||||||
|
webContents,
|
||||||
|
installerHelpers
|
||||||
|
)
|
||||||
|
restartApp(app)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`App updates are not supported on ${process.platform}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAppUpdate = async (app, update, webContents) => {
|
||||||
|
const artifact = selectUpdateArtifact(update)
|
||||||
const tempDirectory = await fs.mkdtemp(
|
const tempDirectory = await fs.mkdtemp(
|
||||||
path.join(os.tmpdir(), "farmcontrol-update-"),
|
path.join(os.tmpdir(), 'farmcontrol-update-')
|
||||||
);
|
)
|
||||||
const artifactName = path.basename(getArtifactName(artifact));
|
const artifactName = path.basename(getArtifactName(artifact))
|
||||||
const installerPath = path.join(tempDirectory, artifactName);
|
const installerPath = path.join(tempDirectory, artifactName)
|
||||||
|
|
||||||
sendProgress({
|
sendProgress(webContents, {
|
||||||
phase: "preparing",
|
phase: 'preparing',
|
||||||
percent: 0,
|
percent: 0,
|
||||||
artifact,
|
artifact,
|
||||||
message: "Preparing update download",
|
message: 'Preparing update download'
|
||||||
});
|
})
|
||||||
|
|
||||||
await downloadArtifact(artifact, installerPath, sendProgress);
|
await downloadArtifact(artifact, installerPath, webContents)
|
||||||
|
|
||||||
sendProgress({
|
sendProgress(webContents, {
|
||||||
phase: "downloaded",
|
phase: 'downloaded',
|
||||||
percent: 100,
|
percent: 100,
|
||||||
downloadedBytes: null,
|
downloadedBytes: null,
|
||||||
totalBytes: null,
|
totalBytes: null,
|
||||||
artifact,
|
artifact,
|
||||||
message: "Update downloaded",
|
message: 'Update downloaded'
|
||||||
});
|
})
|
||||||
|
|
||||||
await launchInstallerAndRestart(mainWindow, installerPath, sendProgress);
|
await launchInstallerAndQuit(app, installerPath, webContents)
|
||||||
};
|
}
|
||||||
|
|
||||||
export function startAppUpdate(mainWindow, update, sendProgress) {
|
export function setupAppUpdateIPC(app) {
|
||||||
if (runningUpdate) return runningUpdate;
|
ipcMain.handle('app-update-start', async (event, update) => {
|
||||||
|
if (runningUpdate) return runningUpdate
|
||||||
|
|
||||||
runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
|
const webContents = event.sender
|
||||||
|
runningUpdate = runAppUpdate(app, update, webContents)
|
||||||
.then(() => ({ ok: true }))
|
.then(() => ({ ok: true }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
sendProgress({
|
sendProgress(webContents, {
|
||||||
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 { app, BrowserWindow, ipcMain, Menu } from 'electron'
|
import { BrowserWindow, ipcMain, Menu } from 'electron'
|
||||||
import path, { dirname } from 'path'
|
import path, { dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
@ -227,12 +227,6 @@ export function createWindow() {
|
|||||||
setupWindowEvents()
|
setupWindowEvents()
|
||||||
attachKeyboardShortcuts(win)
|
attachKeyboardShortcuts(win)
|
||||||
setupNavigationGestures(win)
|
setupNavigationGestures(win)
|
||||||
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
win.maximize()
|
|
||||||
} else if (process.platform === 'win32') {
|
|
||||||
win.maximize()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWindow() {
|
export function getWindow() {
|
||||||
@ -256,7 +250,7 @@ export function setupMainWindowIPC() {
|
|||||||
|
|
||||||
// IPC handlers for window controls
|
// IPC handlers for window controls
|
||||||
ipcMain.on('window-control', (event, action) => {
|
ipcMain.on('window-control', (event, action) => {
|
||||||
if (!win && action !== 'quit') return
|
if (!win) return
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'minimize':
|
case 'minimize':
|
||||||
win.minimize()
|
win.minimize()
|
||||||
@ -271,17 +265,6 @@ export function setupMainWindowIPC() {
|
|||||||
case 'close':
|
case 'close':
|
||||||
win.close()
|
win.close()
|
||||||
break
|
break
|
||||||
case 'fullscreen':
|
|
||||||
win.setFullScreen(!win.isFullScreen())
|
|
||||||
break
|
|
||||||
case 'quit':
|
|
||||||
app.quit()
|
|
||||||
break
|
|
||||||
case 'toggle-devtools':
|
|
||||||
if (win && !win.isDestroyed()) {
|
|
||||||
win.webContents.toggleDevTools()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
body {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* HTML: <div class="loader"></div> */
|
/* HTML: <div class="loader"></div> */
|
||||||
.fc-loader {
|
.fc-loader {
|
||||||
width: 35px;
|
width: 35px;
|
||||||
|
|||||||
@ -144,12 +144,10 @@ const isValidMsiPackage = async (filePath) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const prepareInstallerPath = async (installerPath) => {
|
const prepareInstallerPath = async (installerPath) => {
|
||||||
const fileName = path.basename(installerPath)
|
const fileName = path.basename(installerPath)
|
||||||
const updateDir = path.join(
|
const updateDir = path.join(
|
||||||
os.homedir(),
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
'AppData',
|
|
||||||
'Local',
|
|
||||||
'FarmControl',
|
'FarmControl',
|
||||||
'Updates'
|
'Updates'
|
||||||
)
|
)
|
||||||
@ -175,7 +173,11 @@ export const prepareInstallerPath = async (installerPath) => {
|
|||||||
return resolvedPath
|
return resolvedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
|
const startWindowsInstallerProgressWatch = (
|
||||||
|
logPath,
|
||||||
|
webContents,
|
||||||
|
sendProgress
|
||||||
|
) => {
|
||||||
let installerOutput = ''
|
let installerOutput = ''
|
||||||
let lastLogSize = 0
|
let lastLogSize = 0
|
||||||
let lastPercent = null
|
let lastPercent = null
|
||||||
@ -237,7 +239,7 @@ const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
|
|||||||
|
|
||||||
lastPercent = resolvedPercent
|
lastPercent = resolvedPercent
|
||||||
lastMessage = resolvedMessage
|
lastMessage = resolvedMessage
|
||||||
sendProgress( {
|
sendProgress(webContents, {
|
||||||
phase: 'installing',
|
phase: 'installing',
|
||||||
percent: resolvedPercent,
|
percent: resolvedPercent,
|
||||||
message: resolvedMessage
|
message: resolvedMessage
|
||||||
@ -278,7 +280,7 @@ const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const launchWindowsInstaller = async (
|
export const launchWindowsInstaller = async (
|
||||||
mainWindow,
|
app,
|
||||||
installerPath,
|
installerPath,
|
||||||
webContents,
|
webContents,
|
||||||
{ sendProgress, getInstallErrorMessage }
|
{ sendProgress, getInstallErrorMessage }
|
||||||
@ -292,7 +294,7 @@ export const launchWindowsInstaller = async (
|
|||||||
logPath
|
logPath
|
||||||
})
|
})
|
||||||
|
|
||||||
sendProgress( {
|
sendProgress(webContents, {
|
||||||
phase: 'installing',
|
phase: 'installing',
|
||||||
percent: 0,
|
percent: 0,
|
||||||
message: 'Installing update...'
|
message: 'Installing update...'
|
||||||
@ -305,6 +307,7 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
const stopProgressWatch = startWindowsInstallerProgressWatch(
|
const stopProgressWatch = startWindowsInstallerProgressWatch(
|
||||||
logPath,
|
logPath,
|
||||||
|
webContents,
|
||||||
sendProgress
|
sendProgress
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -317,9 +320,6 @@ export const launchWindowsInstaller = async (
|
|||||||
resolvedPath,
|
resolvedPath,
|
||||||
'/qn',
|
'/qn',
|
||||||
'/norestart',
|
'/norestart',
|
||||||
'ALLUSERS=2',
|
|
||||||
'MSIINSTALLPERUSER=1',
|
|
||||||
'REBOOT=ReallySuppress',
|
|
||||||
'/L*v!',
|
'/L*v!',
|
||||||
logPath
|
logPath
|
||||||
]
|
]
|
||||||
@ -369,7 +369,7 @@ export const launchWindowsInstaller = async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
const message = error?.message || 'Failed to start update installer.'
|
const message = error?.message || 'Failed to start update installer.'
|
||||||
sendProgress( {
|
sendProgress(webContents, {
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
percent: null,
|
percent: null,
|
||||||
message
|
message
|
||||||
@ -396,7 +396,7 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
const message = getInstallErrorMessage(null, output)
|
const message = getInstallErrorMessage(null, output)
|
||||||
sendProgress( {
|
sendProgress(webContents, {
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
percent: null,
|
percent: null,
|
||||||
message
|
message
|
||||||
@ -418,7 +418,7 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
if (!succeeded) {
|
if (!succeeded) {
|
||||||
const message = getInstallErrorMessage(null, output)
|
const message = getInstallErrorMessage(null, output)
|
||||||
sendProgress( {
|
sendProgress(webContents, {
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
percent: null,
|
percent: null,
|
||||||
message
|
message
|
||||||
@ -429,7 +429,7 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
const { percent, message } = finalParse
|
const { percent, message } = finalParse
|
||||||
|
|
||||||
sendProgress( {
|
sendProgress(webContents, {
|
||||||
phase: 'installing',
|
phase: 'installing',
|
||||||
percent: percent ?? 100,
|
percent: percent ?? 100,
|
||||||
message: message || 'Installation complete. Restarting Farm Control...'
|
message: message || 'Installation complete. Restarting Farm Control...'
|
||||||
|
|||||||
@ -1,121 +0,0 @@
|
|||||||
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const srcFile = path.join(rootDir, "native/macos/window-effects.mm");
|
|
||||||
export const outFile = path.join(rootDir, "src/bun/libMacWindowEffects.dylib");
|
|
||||||
const minDylibBytes = 10_000;
|
|
||||||
|
|
||||||
function normalizeArch(value) {
|
|
||||||
if (value === "x64" || value === "amd64" || value === "x86_64") {
|
|
||||||
return "x86_64";
|
|
||||||
}
|
|
||||||
if (value === "arm64" || value === "aarch64") {
|
|
||||||
return "arm64";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveTargetArch() {
|
|
||||||
return (
|
|
||||||
normalizeArch(process.env.ELECTROBUN_TARGET_ARCH) ||
|
|
||||||
normalizeArch(process.env.ELECTROBUN_ARCH) ||
|
|
||||||
normalizeArch(process.arch) ||
|
|
||||||
"arm64"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPlaceholder() {
|
|
||||||
mkdirSync(path.dirname(outFile), { recursive: true });
|
|
||||||
writeFileSync(outFile, "");
|
|
||||||
console.log(`build-macos-effects: created placeholder dylib at ${outFile}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDylibArch(dylibPath) {
|
|
||||||
const lipo = spawnSync("lipo", ["-info", dylibPath], { encoding: "utf8" });
|
|
||||||
if (lipo.status === 0) {
|
|
||||||
return lipo.stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = spawnSync("file", ["-b", dylibPath], { encoding: "utf8" });
|
|
||||||
return file.stdout || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateMacosEffectsDylib({
|
|
||||||
dylibPath = outFile,
|
|
||||||
expectedArch = resolveTargetArch(),
|
|
||||||
required = process.platform === "darwin",
|
|
||||||
} = {}) {
|
|
||||||
if (!required) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(dylibPath)) {
|
|
||||||
console.error(`build-macos-effects: missing dylib at ${dylibPath}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { size } = statSync(dylibPath);
|
|
||||||
if (size < minDylibBytes) {
|
|
||||||
console.error(
|
|
||||||
`build-macos-effects: dylib at ${dylibPath} is too small (${size} bytes)`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const archInfo = readDylibArch(dylibPath);
|
|
||||||
if (!archInfo.includes(expectedArch)) {
|
|
||||||
console.error(
|
|
||||||
`build-macos-effects: dylib architecture mismatch (expected ${expectedArch}): ${archInfo.trim()}`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
createPlaceholder();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(srcFile)) {
|
|
||||||
console.error(`build-macos-effects: missing source file ${srcFile}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(path.dirname(outFile), { recursive: true });
|
|
||||||
|
|
||||||
const targetArch = resolveTargetArch();
|
|
||||||
const result = spawnSync(
|
|
||||||
"xcrun",
|
|
||||||
[
|
|
||||||
"clang++",
|
|
||||||
"-dynamiclib",
|
|
||||||
"-fobjc-arc",
|
|
||||||
"-arch",
|
|
||||||
targetArch,
|
|
||||||
"-mmacosx-version-min=11.0",
|
|
||||||
"-framework",
|
|
||||||
"Cocoa",
|
|
||||||
srcFile,
|
|
||||||
"-o",
|
|
||||||
outFile,
|
|
||||||
],
|
|
||||||
{ cwd: rootDir, stdio: "inherit" },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateMacosEffectsDylib({ dylibPath: outFile, expectedArch: targetArch })) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { size } = statSync(outFile);
|
|
||||||
console.log(
|
|
||||||
`build-macos-effects: built ${outFile} (${size} bytes, arch=${targetArch})`,
|
|
||||||
);
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import path from "node:path";
|
|
||||||
import { getReleaseArch, isBundleCefEnabled } from "./release-artifact-utils.mjs";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const hostArch = getReleaseArch(process.arch);
|
|
||||||
const targetArchs = process.env.ELECTROBUN_TARGET_ARCH
|
|
||||||
? [getReleaseArch(process.env.ELECTROBUN_TARGET_ARCH)]
|
|
||||||
: ["arm64", "x64"];
|
|
||||||
const bundleCef = isBundleCefEnabled();
|
|
||||||
|
|
||||||
function canRunUnderArch(archFlag) {
|
|
||||||
const result = spawnSync("arch", [archFlag, "true"], {
|
|
||||||
encoding: "utf8",
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
return result.status === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
|
||||||
const result = spawnSync(command, args, {
|
|
||||||
cwd: rootDir,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: process.env,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildElectrobunEnv(targetArch) {
|
|
||||||
return {
|
|
||||||
...process.env,
|
|
||||||
ELECTROBUN_BUILD_ENV: "stable",
|
|
||||||
ELECTROBUN_OS: "macos",
|
|
||||||
ELECTROBUN_ARCH: targetArch,
|
|
||||||
ELECTROBUN_FORCE_ARCH: targetArch,
|
|
||||||
ELECTROBUN_TARGET_ARCH: targetArch,
|
|
||||||
NODE_ENV: "production",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInvocation(targetArch) {
|
|
||||||
const env = buildElectrobunEnv(targetArch);
|
|
||||||
const runScript = path.join(rootDir, "scripts/run-electrobun-build.mjs");
|
|
||||||
|
|
||||||
if (targetArch === hostArch) {
|
|
||||||
return { command: "bun", args: [runScript], env };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetArch === "x64" && hostArch === "arm64" && canRunUnderArch("-x86_64")) {
|
|
||||||
return {
|
|
||||||
command: "arch",
|
|
||||||
args: ["-x86_64", "bun", runScript],
|
|
||||||
env,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetArch === "arm64" && hostArch === "x64") {
|
|
||||||
return { command: "bun", args: [runScript], env };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderedTargets = [...targetArchs].sort((a, b) => {
|
|
||||||
if (a === hostArch) return -1;
|
|
||||||
if (b === hostArch) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Host architecture: ${hostArch}`);
|
|
||||||
console.log(`CEF bundling: ${bundleCef ? "enabled" : "disabled"}`);
|
|
||||||
console.log(`Build order: ${orderedTargets.join(", ")}`);
|
|
||||||
|
|
||||||
let builtCount = 0;
|
|
||||||
|
|
||||||
for (const targetArch of orderedTargets) {
|
|
||||||
const invocation = getInvocation(targetArch);
|
|
||||||
if (!invocation) {
|
|
||||||
console.error(
|
|
||||||
`Cannot build macOS ${targetArch} from host ${hostArch}.`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n=== Building macOS ${targetArch} (host ${hostArch}, cef=${bundleCef}) ===`);
|
|
||||||
console.log(
|
|
||||||
`ELECTROBUN_BUILD_ENV=stable ELECTROBUN_OS=macos ELECTROBUN_ARCH=${targetArch} ELECTROBUN_BUNDLE_CEF=${bundleCef}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
run(invocation.command, invocation.args, { env: invocation.env });
|
|
||||||
builtCount += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (builtCount === 0) {
|
|
||||||
console.error("No macOS architectures were built.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (builtCount < targetArchs.length) {
|
|
||||||
console.error(
|
|
||||||
`Built ${builtCount}/${targetArchs.length} macOS architectures.`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nBuilt macOS architectures: ${orderedTargets.join(", ")}`);
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
|
|
||||||
const result = spawnSync("bun", ["x", "vite", "build"], {
|
|
||||||
cwd: rootDir,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
NODE_ENV: process.env.NODE_ENV || "production",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncResult = spawnSync(
|
|
||||||
"bun",
|
|
||||||
[path.join(rootDir, "scripts/sync-electrobun-views.mjs")],
|
|
||||||
{
|
|
||||||
cwd: rootDir,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: process.env,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (syncResult.status !== 0) {
|
|
||||||
process.exit(syncResult.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`build-renderer: output at ${path.join(rootDir, "dist/mainview")}`,
|
|
||||||
);
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import { existsSync, mkdirSync } from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { spawnSync } from 'node:child_process'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
||||||
const entrypoint = path.join(rootDir, 'src/bun/deeplink.js')
|
|
||||||
|
|
||||||
export function stageWindowsDeeplink(appDir) {
|
|
||||||
if (!existsSync(entrypoint)) {
|
|
||||||
throw new Error(`stage-windows-deeplink: entrypoint not found: ${entrypoint}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputPath = path.join(appDir, 'bin', 'deeplink.js')
|
|
||||||
mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
||||||
|
|
||||||
const result = spawnSync(
|
|
||||||
'bun',
|
|
||||||
[
|
|
||||||
'build',
|
|
||||||
'--minify',
|
|
||||||
'--target=bun',
|
|
||||||
entrypoint,
|
|
||||||
'--outfile',
|
|
||||||
outputPath
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: rootDir,
|
|
||||||
stdio: 'inherit',
|
|
||||||
env: process.env
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`stage-windows-deeplink: bun build failed with exit code ${result.status ?? 1}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(outputPath)) {
|
|
||||||
throw new Error(`stage-windows-deeplink: output not created: ${outputPath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`stage-windows-deeplink: created ${outputPath}`)
|
|
||||||
return outputPath
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
||||||
const appDirArg = process.argv[2]
|
|
||||||
if (!appDirArg) {
|
|
||||||
console.error('Usage: bun scripts/build-windows-deeplink.mjs <app-dir>')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
stageWindowsDeeplink(path.resolve(appDirArg))
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$SetupExe,
|
|
||||||
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$OutputMsi,
|
|
||||||
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$Version,
|
|
||||||
|
|
||||||
[string]$UpgradeCode = "735812DB-E33B-57A0-8FBC-5FC3155925AA"
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
function Get-MsiVersion {
|
|
||||||
param([string]$InputVersion)
|
|
||||||
|
|
||||||
$parts = $InputVersion.Split(".")
|
|
||||||
while ($parts.Count -lt 4) {
|
|
||||||
$parts += "0"
|
|
||||||
}
|
|
||||||
|
|
||||||
return ($parts[0..3] -join ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
function Find-WixToolset {
|
|
||||||
$searchRoots = @(
|
|
||||||
$env:WIX,
|
|
||||||
$env:WIX_TOOLSET_PATH,
|
|
||||||
"${env:ProgramFiles(x86)}\WiX Toolset v3.14\bin",
|
|
||||||
"${env:ProgramFiles}\WiX Toolset v3.14\bin",
|
|
||||||
"${env:ProgramFiles(x86)}\WiX Toolset v3.11\bin",
|
|
||||||
"${env:ProgramFiles}\WiX Toolset v3.11\bin"
|
|
||||||
)
|
|
||||||
|
|
||||||
foreach ($root in $searchRoots) {
|
|
||||||
if (-not $root) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
$candle = Join-Path $root "candle.exe"
|
|
||||||
$light = Join-Path $root "light.exe"
|
|
||||||
if ((Test-Path $candle) -and (Test-Path $light)) {
|
|
||||||
return @{
|
|
||||||
Candle = $candle
|
|
||||||
Light = $light
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$candleCommand = Get-Command candle.exe -ErrorAction SilentlyContinue
|
|
||||||
$lightCommand = Get-Command light.exe -ErrorAction SilentlyContinue
|
|
||||||
if ($candleCommand -and $lightCommand) {
|
|
||||||
return @{
|
|
||||||
Candle = $candleCommand.Source
|
|
||||||
Light = $lightCommand.Source
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw @"
|
|
||||||
WiX Toolset not found. Install WiX Toolset 3.11+ on the Windows build agent and ensure candle.exe and light.exe are on PATH.
|
|
||||||
Example: choco install wixtoolset --version=3.14.0.4118
|
|
||||||
"@
|
|
||||||
}
|
|
||||||
|
|
||||||
function Escape-WixSourcePath {
|
|
||||||
param([string]$Path)
|
|
||||||
|
|
||||||
return $Path.Replace("\", "\\")
|
|
||||||
}
|
|
||||||
|
|
||||||
$rootDir = Split-Path -Parent $PSScriptRoot
|
|
||||||
$templatePath = Join-Path $rootDir "packaging/windows/msi-wrapped.wxs"
|
|
||||||
$workDir = Join-Path $env:TEMP "farmcontrol-wix"
|
|
||||||
$wix = Find-WixToolset
|
|
||||||
|
|
||||||
if (Test-Path $workDir) {
|
|
||||||
Remove-Item $workDir -Recurse -Force
|
|
||||||
}
|
|
||||||
New-Item -ItemType Directory -Path $workDir | Out-Null
|
|
||||||
|
|
||||||
$setupExePath = (Resolve-Path -LiteralPath $SetupExe).Path
|
|
||||||
$outputMsiPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputMsi)
|
|
||||||
$outputDir = Split-Path $outputMsiPath -Parent
|
|
||||||
if ($outputDir -and -not (Test-Path $outputDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
$installerIconPath = Join-Path $rootDir "assets\installer.ico"
|
|
||||||
if (-not (Test-Path $installerIconPath)) {
|
|
||||||
throw "Installer icon not found at $installerIconPath. Run 'bun run generate-app-icons' first."
|
|
||||||
}
|
|
||||||
|
|
||||||
$installerIconWorkPath = Join-Path $workDir "installer.ico"
|
|
||||||
Copy-Item -LiteralPath $installerIconPath -Destination $installerIconWorkPath -Force
|
|
||||||
|
|
||||||
$msiVersion = Get-MsiVersion $Version
|
|
||||||
$wxsContent = Get-Content -LiteralPath $templatePath -Raw
|
|
||||||
$wxsContent = $wxsContent.Replace("__UPGRADE_CODE__", $UpgradeCode)
|
|
||||||
$wxsContent = $wxsContent.Replace("__VERSION__", $msiVersion)
|
|
||||||
$wxsContent = $wxsContent.Replace("__SETUP_EXE__", (Escape-WixSourcePath $setupExePath))
|
|
||||||
$wxsContent = $wxsContent.Replace("__INSTALLER_ICON__", (Escape-WixSourcePath $installerIconWorkPath))
|
|
||||||
|
|
||||||
$projectWxs = Join-Path $workDir "project.wxs"
|
|
||||||
Set-Content -LiteralPath $projectWxs -Value $wxsContent -Encoding UTF8
|
|
||||||
|
|
||||||
$wixObj = Join-Path $workDir "project.wixobj"
|
|
||||||
|
|
||||||
Push-Location $workDir
|
|
||||||
try {
|
|
||||||
& $wix.Candle "-arch" "x64" $projectWxs
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "candle.exe failed with exit code $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
|
|
||||||
& $wix.Light "-out" $outputMsiPath "-spdb" "-sw1076" $wixObj
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "light.exe failed with exit code $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
Pop-Location
|
|
||||||
Remove-Item $workDir -Recurse -Force
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $outputMsiPath)) {
|
|
||||||
throw "MSI installer was not created at $outputMsiPath"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Created MSI installer at $outputMsiPath"
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$AppDir,
|
|
||||||
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$OutputExe,
|
|
||||||
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$Version,
|
|
||||||
|
|
||||||
[string]$BuildNumber = "dev",
|
|
||||||
|
|
||||||
[long]$MinInstallerBytes = 10485760
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
function Find-Makensis() {
|
|
||||||
$command = Get-Command makensis -ErrorAction SilentlyContinue
|
|
||||||
if ($command) {
|
|
||||||
return $command.Source
|
|
||||||
}
|
|
||||||
|
|
||||||
$searchRoots = @(
|
|
||||||
"${env:ProgramFiles(x86)}\NSIS\makensis.exe",
|
|
||||||
"${env:ProgramFiles}\NSIS\makensis.exe"
|
|
||||||
)
|
|
||||||
|
|
||||||
foreach ($candidate in $searchRoots) {
|
|
||||||
if (Test-Path $candidate) {
|
|
||||||
return $candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw "Could not find makensis. Install NSIS 3.x on the Windows build agent."
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-DirectoryBytes {
|
|
||||||
param([string]$Path)
|
|
||||||
|
|
||||||
$total = 0
|
|
||||||
foreach ($item in Get-ChildItem -LiteralPath $Path -Recurse -File -Force) {
|
|
||||||
$total += $item.Length
|
|
||||||
}
|
|
||||||
return $total
|
|
||||||
}
|
|
||||||
|
|
||||||
$rootDir = Split-Path -Parent $PSScriptRoot
|
|
||||||
$nsiPath = Join-Path $rootDir "packaging/windows/farmcontrol.nsi"
|
|
||||||
$installerInclude = Join-Path $rootDir "packaging/windows/installer.nsh"
|
|
||||||
$fileListGenerator = Join-Path $rootDir "scripts/generate-nsis-file-list.ps1"
|
|
||||||
$workDir = Join-Path $env:TEMP "farmcontrol-nsis"
|
|
||||||
$localInstallerName = "farmcontrol-installer.exe"
|
|
||||||
|
|
||||||
if (Test-Path $workDir) {
|
|
||||||
Remove-Item $workDir -Recurse -Force
|
|
||||||
}
|
|
||||||
New-Item -ItemType Directory -Path $workDir | Out-Null
|
|
||||||
|
|
||||||
$appDirPath = (Resolve-Path -LiteralPath $AppDir).Path
|
|
||||||
$stagingAppDir = Join-Path $workDir "app"
|
|
||||||
|
|
||||||
Write-Host "Staging application files for NSIS from $appDirPath"
|
|
||||||
Copy-Item -LiteralPath $appDirPath -Destination $stagingAppDir -Recurse -Force
|
|
||||||
|
|
||||||
$stagedAppBytes = Get-DirectoryBytes $stagingAppDir
|
|
||||||
Write-Host "Staged application size: $([math]::Round($stagedAppBytes / 1MB, 2)) MB"
|
|
||||||
|
|
||||||
if ($stagedAppBytes -lt 5242880) {
|
|
||||||
throw "Staged application is only $([math]::Round($stagedAppBytes / 1KB, 1)) KiB. Expected a full Electrobun app bundle before building the installer."
|
|
||||||
}
|
|
||||||
|
|
||||||
$requiredExe = Join-Path $stagingAppDir "bin\FarmControl.exe"
|
|
||||||
$requiredDeeplinkScript = Join-Path $stagingAppDir "bin\deeplink.js"
|
|
||||||
if (-not (Test-Path $requiredExe)) {
|
|
||||||
throw "Staged application is missing bin\FarmControl.exe"
|
|
||||||
}
|
|
||||||
if (-not (Test-Path $requiredDeeplinkScript)) {
|
|
||||||
throw "Staged application is missing bin\deeplink.js"
|
|
||||||
}
|
|
||||||
|
|
||||||
Copy-Item -LiteralPath $nsiPath -Destination (Join-Path $workDir "farmcontrol.nsi")
|
|
||||||
Copy-Item -LiteralPath $installerInclude -Destination (Join-Path $workDir "installer.nsh")
|
|
||||||
Copy-Item -LiteralPath $fileListGenerator -Destination (Join-Path $workDir "generate-nsis-file-list.ps1")
|
|
||||||
|
|
||||||
$iconPath = Join-Path $rootDir "assets\installer.ico"
|
|
||||||
if (Test-Path $iconPath) {
|
|
||||||
Copy-Item -LiteralPath $iconPath -Destination (Join-Path $workDir "icon.ico") -Force
|
|
||||||
Write-Host "Using installer icon from $iconPath"
|
|
||||||
}
|
|
||||||
|
|
||||||
$uninstallerIconPath = Join-Path $rootDir "assets\uninstaller.ico"
|
|
||||||
if (Test-Path $uninstallerIconPath) {
|
|
||||||
Copy-Item -LiteralPath $uninstallerIconPath -Destination (Join-Path $workDir "uninstaller.ico") -Force
|
|
||||||
Write-Host "Using uninstaller icon from $uninstallerIconPath"
|
|
||||||
}
|
|
||||||
|
|
||||||
$makensis = Find-Makensis
|
|
||||||
$outputExePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputExe)
|
|
||||||
$outputDir = Split-Path $outputExePath -Parent
|
|
||||||
if (-not (Test-Path $outputDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $outputDir | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
$makensisArgs = @(
|
|
||||||
"/V3"
|
|
||||||
"/NOCD"
|
|
||||||
"/DOUTFILE=$localInstallerName"
|
|
||||||
"/DVERSION=$Version"
|
|
||||||
"/DBUILD_NUMBER=$BuildNumber"
|
|
||||||
"/DAPP_SOURCE_DIR=app"
|
|
||||||
"/DAPP_FILE_LIST_GENERATOR=generate-nsis-file-list.ps1"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (Test-Path (Join-Path $workDir "icon.ico")) {
|
|
||||||
$makensisArgs += "/DINSTALLER_ICON=icon.ico"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Test-Path (Join-Path $workDir "uninstaller.ico")) {
|
|
||||||
$makensisArgs += "/DUNINSTALLER_ICON=uninstaller.ico"
|
|
||||||
}
|
|
||||||
|
|
||||||
$makensisArgs += (Join-Path $workDir "farmcontrol.nsi")
|
|
||||||
|
|
||||||
Push-Location $workDir
|
|
||||||
try {
|
|
||||||
& $makensis @makensisArgs
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "makensis failed with exit code $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
|
|
||||||
$localInstallerPath = Join-Path $workDir $localInstallerName
|
|
||||||
if (-not (Test-Path $localInstallerPath)) {
|
|
||||||
throw "NSIS installer was not created at $localInstallerPath"
|
|
||||||
}
|
|
||||||
|
|
||||||
$installerBytes = (Get-Item -LiteralPath $localInstallerPath).Length
|
|
||||||
Write-Host "NSIS installer size: $([math]::Round($installerBytes / 1MB, 2)) MB"
|
|
||||||
|
|
||||||
if ($installerBytes -lt $MinInstallerBytes) {
|
|
||||||
throw "NSIS installer is only $([math]::Round($installerBytes / 1KB, 1)) KiB at $localInstallerPath. The application files were not packaged. Check NSIS logs above."
|
|
||||||
}
|
|
||||||
|
|
||||||
Move-Item -LiteralPath $localInstallerPath -Destination $outputExePath -Force
|
|
||||||
|
|
||||||
Write-Host "Created NSIS installer at $outputExePath"
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { rmSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
|
|
||||||
for (const dir of ["build", "dist", "app_dist", "artifacts"]) {
|
|
||||||
rmSync(path.join(rootDir, dir), { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("clean: removed build, dist, app_dist, and artifacts");
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import { existsSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
function getCodesignArgs() {
|
|
||||||
const identity = process.env.ELECTROBUN_DEVELOPER_ID || "-";
|
|
||||||
const args = ["--force", "--deep"];
|
|
||||||
|
|
||||||
if (identity !== "-") {
|
|
||||||
args.push("--options", "runtime", "--timestamp");
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("--sign", identity);
|
|
||||||
return { identity, args };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function codesignMacAppBundle(appBundlePath) {
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
console.log("codesign-macos-app: skipping (not macOS)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appBundlePath || !existsSync(appBundlePath)) {
|
|
||||||
throw new Error(`codesign-macos-app: app bundle not found: ${appBundlePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentsPath = path.join(appBundlePath, "Contents");
|
|
||||||
if (!existsSync(contentsPath)) {
|
|
||||||
throw new Error(`codesign-macos-app: invalid app bundle: ${appBundlePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { identity, args } = getCodesignArgs();
|
|
||||||
const result = spawnSync("codesign", [...args, appBundlePath], {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`codesign failed with exit code ${result.status ?? 1}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const verify = spawnSync(
|
|
||||||
"codesign",
|
|
||||||
["--verify", "--deep", "--strict", "--verbose=2", appBundlePath],
|
|
||||||
{ encoding: "utf8" },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (verify.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`codesign verify failed: ${verify.stderr || verify.stdout || "unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = identity === "-" ? "ad-hoc" : identity;
|
|
||||||
console.log(`codesign-macos-app: signed ${appBundlePath} (${label})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAppBundlePath() {
|
|
||||||
const fromEnv = process.env.ELECTROBUN_WRAPPER_BUNDLE_PATH;
|
|
||||||
if (fromEnv && existsSync(fromEnv)) {
|
|
||||||
return path.resolve(fromEnv);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromArgv = process.argv[2];
|
|
||||||
if (fromArgv && existsSync(fromArgv)) {
|
|
||||||
return path.resolve(fromArgv);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokedPath = process.argv[1]
|
|
||||||
? path.resolve(process.argv[1])
|
|
||||||
: null;
|
|
||||||
const modulePath = fileURLToPath(import.meta.url);
|
|
||||||
|
|
||||||
if (invokedPath === modulePath) {
|
|
||||||
const appBundlePath = resolveAppBundlePath();
|
|
||||||
if (!appBundlePath) {
|
|
||||||
console.error(
|
|
||||||
"codesign-macos-app: set ELECTROBUN_WRAPPER_BUNDLE_PATH or pass app bundle path",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
codesignMacAppBundle(appBundlePath);
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import {
|
|
||||||
createWriteStream,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readdirSync,
|
|
||||||
unlinkSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { getReleaseArch } from "./release-artifact-utils.mjs";
|
|
||||||
import { fixMacosHeaderpad } from "./fix-macos-headerpad.mjs";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const electrobunDir = path.join(rootDir, "node_modules/electrobun");
|
|
||||||
const electrobunVersion = JSON.parse(
|
|
||||||
await Bun.file(path.join(electrobunDir, "package.json")).text(),
|
|
||||||
).version;
|
|
||||||
|
|
||||||
function getPlatformPaths(targetOS, targetArch) {
|
|
||||||
const binExt = targetOS === "win" ? ".exe" : "";
|
|
||||||
const platformDistDir = path.join(
|
|
||||||
electrobunDir,
|
|
||||||
`dist-${targetOS}-${targetArch}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
platformDistDir,
|
|
||||||
bunBinary: path.join(platformDistDir, "bun") + binExt,
|
|
||||||
bsdiff: path.join(platformDistDir, "bsdiff") + binExt,
|
|
||||||
bspatch: path.join(platformDistDir, "bspatch") + binExt,
|
|
||||||
launcher: path.join(platformDistDir, "launcher") + binExt,
|
|
||||||
nativeWrapperMacos: path.join(platformDistDir, "libNativeWrapper.dylib"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadFile(url, destination) {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(path.dirname(destination), { recursive: true });
|
|
||||||
const fileStream = createWriteStream(destination);
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
if (!reader) {
|
|
||||||
throw new Error(`No response body for ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
fileStream.write(Buffer.from(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
fileStream.end((error) => (error ? reject(error) : resolve()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTarGz(tarPath, destination) {
|
|
||||||
mkdirSync(destination, { recursive: true });
|
|
||||||
const result = spawnSync("tar", ["-xzf", tarPath, "-C", destination], {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(`tar extraction failed for ${tarPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureCoreDependencies(targetOS, targetArch) {
|
|
||||||
const paths = getPlatformPaths(targetOS, targetArch);
|
|
||||||
const required = [
|
|
||||||
paths.bunBinary,
|
|
||||||
paths.bsdiff,
|
|
||||||
paths.bspatch,
|
|
||||||
paths.launcher,
|
|
||||||
paths.nativeWrapperMacos,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (required.every((filePath) => existsSync(filePath))) {
|
|
||||||
console.log(`ensure-electrobun-core: ${targetOS}-${targetArch} already present`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformName =
|
|
||||||
targetOS === "macos" ? "darwin" : targetOS === "win" ? "win" : "linux";
|
|
||||||
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${electrobunVersion}/electrobun-core-${platformName}-${targetArch}.tar.gz`;
|
|
||||||
const tempFile = path.join(
|
|
||||||
electrobunDir,
|
|
||||||
`core-${targetOS}-${targetArch}-temp.tar.gz`,
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`ensure-electrobun-core: downloading ${targetOS}-${targetArch}`);
|
|
||||||
console.log(url);
|
|
||||||
|
|
||||||
await downloadFile(url, tempFile);
|
|
||||||
extractTarGz(tempFile, paths.platformDistDir);
|
|
||||||
|
|
||||||
if (existsSync(tempFile)) {
|
|
||||||
unlinkSync(tempFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
const missing = required.filter((filePath) => !existsSync(filePath));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
const extracted = existsSync(paths.platformDistDir)
|
|
||||||
? readdirSync(paths.platformDistDir)
|
|
||||||
: [];
|
|
||||||
throw new Error(
|
|
||||||
`Missing ${targetOS}-${targetArch} binaries after extract: ${missing.join(", ")}; extracted=${extracted.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`ensure-electrobun-core: ${targetOS}-${targetArch} ready`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetOS = "macos";
|
|
||||||
const hostArch = getReleaseArch(process.arch);
|
|
||||||
const targetArch = getReleaseArch(
|
|
||||||
process.env.ELECTROBUN_TARGET_ARCH ||
|
|
||||||
process.env.ELECTROBUN_FORCE_ARCH ||
|
|
||||||
process.arch,
|
|
||||||
);
|
|
||||||
|
|
||||||
const archesToEnsure = new Set([hostArch, targetArch]);
|
|
||||||
for (const arch of archesToEnsure) {
|
|
||||||
await ensureCoreDependencies(targetOS, arch);
|
|
||||||
// electrobun#485: x64 core binaries lack Mach-O headerpad and get corrupted
|
|
||||||
// by codesign; free a load-command slot before anything copies or signs them.
|
|
||||||
fixMacosHeaderpad(getPlatformPaths(targetOS, arch).platformDistDir);
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
import {
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
readdirSync,
|
|
||||||
rmSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
|
|
||||||
const targetOs = process.env.ELECTROBUN_OS || "macos";
|
|
||||||
const wrapperPath = process.env.ELECTROBUN_WRAPPER_BUNDLE_PATH;
|
|
||||||
|
|
||||||
if (buildEnv === "dev" || targetOs !== "macos") {
|
|
||||||
console.log("expand-macos-bundle: skipping (dev or non-macOS build)");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wrapperPath || !existsSync(wrapperPath)) {
|
|
||||||
console.error("expand-macos-bundle: ELECTROBUN_WRAPPER_BUNDLE_PATH not set or missing");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedWrapperPath = path.resolve(wrapperPath);
|
|
||||||
const contentsPath = path.join(resolvedWrapperPath, "Contents");
|
|
||||||
const resourcesPath = path.join(contentsPath, "Resources");
|
|
||||||
const metadataPath = path.join(resourcesPath, "metadata.json");
|
|
||||||
|
|
||||||
if (!existsSync(metadataPath)) {
|
|
||||||
console.log("expand-macos-bundle: no extractor metadata, assuming already expanded");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
||||||
const hash = metadata.hash;
|
|
||||||
const appName = metadata.name || "Farm Control";
|
|
||||||
const tarZstPath = path.resolve(resourcesPath, `${hash}.tar.zst`);
|
|
||||||
|
|
||||||
if (!existsSync(tarZstPath)) {
|
|
||||||
console.log(`expand-macos-bundle: ${hash}.tar.zst not found, skipping`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function decompressTarZst(inputPath, outputPath) {
|
|
||||||
const systemZstd = spawnSync("zstd", ["--version"], { encoding: "utf8" });
|
|
||||||
if (systemZstd.status === 0) {
|
|
||||||
return spawnSync("zstd", ["-d", "-f", "-o", outputPath, inputPath], {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostArch = process.arch === "arm64" ? "arm64" : "x64";
|
|
||||||
const zigZstd = path.join(
|
|
||||||
rootDir,
|
|
||||||
"node_modules/electrobun/dist-macos-" + hostArch,
|
|
||||||
"zig-zstd",
|
|
||||||
);
|
|
||||||
if (existsSync(zigZstd)) {
|
|
||||||
return spawnSync(
|
|
||||||
zigZstd,
|
|
||||||
["decompress", "-i", inputPath, "-o", outputPath, "--no-timing"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(
|
|
||||||
"expand-macos-bundle: zstd not found (install zstd or use electrobun dist binaries)",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workDir = path.join(tmpdir(), `farmcontrol-expand-${hash}`);
|
|
||||||
const tarPath = path.join(workDir, `${hash}.tar`);
|
|
||||||
rmSync(workDir, { recursive: true, force: true });
|
|
||||||
mkdirSync(workDir, { recursive: true });
|
|
||||||
|
|
||||||
const decompress = decompressTarZst(tarZstPath, tarPath);
|
|
||||||
|
|
||||||
if (decompress.status !== 0) {
|
|
||||||
console.error("expand-macos-bundle: zstd decompression failed");
|
|
||||||
process.exit(decompress.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractTar = spawnSync("tar", ["-xf", tarPath, "-C", workDir], {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (extractTar.status !== 0) {
|
|
||||||
console.error("expand-macos-bundle: tar extraction failed");
|
|
||||||
process.exit(extractTar.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const innerAppPath = path.join(workDir, `${appName}.app`);
|
|
||||||
if (!existsSync(innerAppPath)) {
|
|
||||||
const appBundle = readdirSync(workDir).find((entry) => entry.endsWith(".app"));
|
|
||||||
if (!appBundle) {
|
|
||||||
console.error("expand-macos-bundle: extracted app bundle not found");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedInnerApp = existsSync(innerAppPath)
|
|
||||||
? innerAppPath
|
|
||||||
: path.join(workDir, readdirSync(workDir).find((e) => e.endsWith(".app")));
|
|
||||||
const innerContentsPath = path.join(resolvedInnerApp, "Contents");
|
|
||||||
|
|
||||||
function replaceDirectory(sourceDir, destinationDir) {
|
|
||||||
rmSync(destinationDir, { recursive: true, force: true });
|
|
||||||
const result = spawnSync("ditto", [sourceDir, destinationDir], {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
if (result.status !== 0) {
|
|
||||||
console.error("expand-macos-bundle: ditto copy failed");
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceDirectory(
|
|
||||||
path.join(innerContentsPath, "MacOS"),
|
|
||||||
path.join(contentsPath, "MacOS"),
|
|
||||||
);
|
|
||||||
|
|
||||||
rmSync(resourcesPath, { recursive: true, force: true });
|
|
||||||
replaceDirectory(path.join(innerContentsPath, "Resources"), resourcesPath);
|
|
||||||
|
|
||||||
const innerFrameworks = path.join(innerContentsPath, "Frameworks");
|
|
||||||
const wrapperFrameworks = path.join(contentsPath, "Frameworks");
|
|
||||||
if (existsSync(innerFrameworks)) {
|
|
||||||
replaceDirectory(innerFrameworks, wrapperFrameworks);
|
|
||||||
}
|
|
||||||
|
|
||||||
const infoPlistResult = spawnSync(
|
|
||||||
"ditto",
|
|
||||||
[path.join(innerContentsPath, "Info.plist"), path.join(contentsPath, "Info.plist")],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
if (infoPlistResult.status !== 0) {
|
|
||||||
console.error("expand-macos-bundle: Info.plist copy failed");
|
|
||||||
process.exit(infoPlistResult.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
rmSync(workDir, { recursive: true, force: true });
|
|
||||||
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
spawnSync("xattr", ["-cr", resolvedWrapperPath], { stdio: "inherit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`expand-macos-bundle: pre-expanded ${resolvedWrapperPath}`);
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
import {
|
|
||||||
cpSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readdirSync,
|
|
||||||
rmSync,
|
|
||||||
statSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const electrobunDir = path.join(rootDir, "node_modules/electrobun");
|
|
||||||
|
|
||||||
function findZstdDecompressCommand() {
|
|
||||||
const hostArch = process.arch === "arm64" ? "arm64" : "x64";
|
|
||||||
const zigCandidates = [
|
|
||||||
path.join(electrobunDir, `dist-win-${hostArch}`, "zig-zstd.exe"),
|
|
||||||
path.join(electrobunDir, "dist-win-x64", "zig-zstd.exe"),
|
|
||||||
path.join(electrobunDir, `dist-macos-${hostArch}`, "zig-zstd"),
|
|
||||||
path.join(electrobunDir, "dist-macos-x64", "zig-zstd"),
|
|
||||||
path.join(electrobunDir, "dist-win-x64", "zig-zstd", "x64", "zig-zstd.exe"),
|
|
||||||
path.join(electrobunDir, "dist-win-x64", "zig-zstd", "arm64", "zig-zstd.exe"),
|
|
||||||
path.join(electrobunDir, "vendors", "zig-zstd", "x64", "zig-zstd.exe"),
|
|
||||||
path.join(electrobunDir, "vendors", "zig-zstd", "arm64", "zig-zstd.exe"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const candidate of zigCandidates) {
|
|
||||||
if (!existsSync(candidate)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: "zig-zstd",
|
|
||||||
command: candidate,
|
|
||||||
args: (inputPath, outputPath) => [
|
|
||||||
"decompress",
|
|
||||||
"-i",
|
|
||||||
inputPath,
|
|
||||||
"-o",
|
|
||||||
outputPath,
|
|
||||||
"--no-timing",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemZstd = spawnSync("zstd", ["--version"], {
|
|
||||||
encoding: "utf8",
|
|
||||||
shell: process.platform === "win32",
|
|
||||||
});
|
|
||||||
if (systemZstd.status === 0) {
|
|
||||||
return {
|
|
||||||
kind: "system-zstd",
|
|
||||||
command: "zstd",
|
|
||||||
args: (inputPath, outputPath) => [
|
|
||||||
"-d",
|
|
||||||
"-f",
|
|
||||||
"-o",
|
|
||||||
outputPath,
|
|
||||||
inputPath,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decompressTarZst(inputPath, outputPath) {
|
|
||||||
const zstd = findZstdDecompressCommand();
|
|
||||||
if (!zstd) {
|
|
||||||
throw new Error(
|
|
||||||
"expand-windows-installer: zstd not found (install zstd or use electrobun dist binaries)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedInput = path.resolve(inputPath);
|
|
||||||
const resolvedOutput = path.resolve(outputPath);
|
|
||||||
const args = zstd.args(resolvedInput, resolvedOutput);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`expand-windows-installer: decompressing with ${zstd.command} (${zstd.kind})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = spawnSync(zstd.command, args, {
|
|
||||||
stdio: "inherit",
|
|
||||||
shell: false,
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`expand-windows-installer: decompression failed (exit ${result.status ?? 1}) using ${zstd.command} ${args.join(" ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandWindowsAppFromArchive(setupArchivePath, parentDir) {
|
|
||||||
const resolvedArchive = path.resolve(setupArchivePath);
|
|
||||||
if (!existsSync(resolvedArchive)) {
|
|
||||||
throw new Error(`expand-windows-installer: archive not found: ${resolvedArchive}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workDir = path.join(path.resolve(parentDir), ".expanded-app");
|
|
||||||
const tarPath = path.join(workDir, "app.tar");
|
|
||||||
|
|
||||||
rmSync(workDir, { recursive: true, force: true });
|
|
||||||
mkdirSync(workDir, { recursive: true });
|
|
||||||
|
|
||||||
const archiveForDecompress = path.join(workDir, "setup.tar.zst");
|
|
||||||
cpSync(resolvedArchive, archiveForDecompress);
|
|
||||||
decompressTarZst(archiveForDecompress, tarPath);
|
|
||||||
|
|
||||||
const extractTar = spawnSync("tar", ["-xf", tarPath, "-C", workDir], {
|
|
||||||
stdio: "inherit",
|
|
||||||
shell: false,
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (extractTar.status !== 0) {
|
|
||||||
rmSync(workDir, { recursive: true, force: true });
|
|
||||||
throw new Error(
|
|
||||||
`expand-windows-installer: tar extraction failed (exit ${extractTar.status ?? 1})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const appDir = readdirSync(workDir)
|
|
||||||
.filter((entry) => entry !== "app.tar" && entry !== "__MACOSX")
|
|
||||||
.map((entry) => path.join(workDir, entry))
|
|
||||||
.find((entryPath) => statSync(entryPath).isDirectory());
|
|
||||||
|
|
||||||
if (!appDir) {
|
|
||||||
rmSync(workDir, { recursive: true, force: true });
|
|
||||||
throw new Error("expand-windows-installer: app folder not found in archive");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`expand-windows-installer: expanded ${appDir}`);
|
|
||||||
return appDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cleanExpandedWindowsApp(parentDir) {
|
|
||||||
const workDir = path.join(path.resolve(parentDir), ".expanded-app");
|
|
||||||
rmSync(workDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
@ -1,681 +0,0 @@
|
|||||||
import {
|
|
||||||
cpSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
statSync
|
|
||||||
} from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { spawnSync } from 'node:child_process'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import {
|
|
||||||
getReleaseArch,
|
|
||||||
getReleaseArtifactName,
|
|
||||||
getReleaseVersion,
|
|
||||||
isBundleCefEnabled
|
|
||||||
} from './release-artifact-utils.mjs'
|
|
||||||
import {
|
|
||||||
cleanExpandedWindowsApp,
|
|
||||||
expandWindowsAppFromArchive
|
|
||||||
} from './expand-windows-installer.mjs'
|
|
||||||
import { stageWindowsDeeplink } from './build-windows-deeplink.mjs'
|
|
||||||
import { patchWindowsBinaries } from './patch-windows-binaries.mjs'
|
|
||||||
import { codesignMacAppBundle } from './codesign-macos-app.mjs'
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
||||||
const packageJson = JSON.parse(
|
|
||||||
readFileSync(path.join(rootDir, 'package.json'), 'utf8')
|
|
||||||
)
|
|
||||||
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || 'stable'
|
|
||||||
const targetOs =
|
|
||||||
process.env.ELECTROBUN_OS ||
|
|
||||||
(process.platform === 'darwin'
|
|
||||||
? 'macos'
|
|
||||||
: process.platform === 'win32'
|
|
||||||
? 'win'
|
|
||||||
: process.platform === 'linux'
|
|
||||||
? 'linux'
|
|
||||||
: null)
|
|
||||||
const buildArch = getReleaseArch(process.env.ELECTROBUN_ARCH || process.arch)
|
|
||||||
const version =
|
|
||||||
process.env.ELECTROBUN_APP_VERSION || getReleaseVersion(packageJson)
|
|
||||||
const bundleCef = isBundleCefEnabled()
|
|
||||||
const artifactDir =
|
|
||||||
process.env.ELECTROBUN_ARTIFACT_DIR || path.join(rootDir, 'app_dist')
|
|
||||||
const identifier =
|
|
||||||
process.env.ELECTROBUN_APP_IDENTIFIER || 'com.tombutcher.farmcontrol'
|
|
||||||
const artifactPrefix = `farmcontrol-${version}-`
|
|
||||||
|
|
||||||
function artifactName(arch, ext) {
|
|
||||||
return getReleaseArtifactName(version, arch, ext, { cef: bundleCef })
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBuildRoot() {
|
|
||||||
const electrobunBuildDir = process.env.ELECTROBUN_BUILD_DIR
|
|
||||||
if (!electrobunBuildDir) {
|
|
||||||
return path.join(rootDir, 'build')
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseName = path.basename(electrobunBuildDir)
|
|
||||||
if (baseName.startsWith('stable-')) {
|
|
||||||
return path.dirname(electrobunBuildDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
return electrobunBuildDir
|
|
||||||
}
|
|
||||||
|
|
||||||
function walkFiles(dir) {
|
|
||||||
const files = []
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of readdirSync(dir)) {
|
|
||||||
const fullPath = path.join(dir, entry)
|
|
||||||
const stats = statSync(fullPath)
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
files.push(...walkFiles(fullPath))
|
|
||||||
} else {
|
|
||||||
files.push(fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
function findByExtension(root, extension) {
|
|
||||||
return walkFiles(root).find((filePath) =>
|
|
||||||
filePath.toLowerCase().endsWith(extension.toLowerCase())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMacAppBundle(arch) {
|
|
||||||
const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`)
|
|
||||||
if (!existsSync(platformDir)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of readdirSync(platformDir)) {
|
|
||||||
if (child.endsWith('.app')) {
|
|
||||||
return path.join(platformDir, child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMacDmgSource(arch) {
|
|
||||||
const prefixedArtifact = walkFiles(artifactDir).find(
|
|
||||||
(filePath) =>
|
|
||||||
filePath.includes(`stable-macos-${arch}`) &&
|
|
||||||
filePath.toLowerCase().endsWith('.dmg')
|
|
||||||
)
|
|
||||||
if (prefixedArtifact) {
|
|
||||||
return prefixedArtifact
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildDmg = findByExtension(
|
|
||||||
path.join(getBuildRoot(), `stable-macos-${arch}`),
|
|
||||||
'.dmg'
|
|
||||||
)
|
|
||||||
if (buildDmg) {
|
|
||||||
return buildDmg
|
|
||||||
}
|
|
||||||
|
|
||||||
return findByExtension(artifactDir, '.dmg')
|
|
||||||
}
|
|
||||||
|
|
||||||
function findWindowsInstallerFiles() {
|
|
||||||
const buildRoot = getBuildRoot()
|
|
||||||
if (!existsSync(buildRoot)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of readdirSync(buildRoot)) {
|
|
||||||
if (!entry.startsWith('stable-win-')) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformDir = path.join(buildRoot, entry)
|
|
||||||
const files = walkFiles(platformDir)
|
|
||||||
const setupExe = files.find((filePath) => /-setup\.exe$/i.test(filePath))
|
|
||||||
const setupArchive = files.find((filePath) =>
|
|
||||||
/-setup\.tar\.zst$/i.test(filePath)
|
|
||||||
)
|
|
||||||
const setupMetadata = files.find((filePath) =>
|
|
||||||
/-setup\.metadata\.json$/i.test(filePath)
|
|
||||||
)
|
|
||||||
const setupZip = files.find((filePath) => /-setup\.zip$/i.test(filePath))
|
|
||||||
|
|
||||||
if (setupExe && setupArchive && setupMetadata) {
|
|
||||||
return { setupExe, setupArchive, setupMetadata, setupZip }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function publishArtifact(sourcePath, arch, ext) {
|
|
||||||
if (!sourcePath || !existsSync(sourcePath)) {
|
|
||||||
throw new Error(
|
|
||||||
`Missing source artifact for ${arch}.${ext}: ${sourcePath ?? 'not found'}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(artifactDir, { recursive: true })
|
|
||||||
const destination = path.join(artifactDir, artifactName(arch, ext))
|
|
||||||
cpSync(sourcePath, destination)
|
|
||||||
console.log(`Published ${destination}`)
|
|
||||||
return destination
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanStagingArtifacts(keepNames) {
|
|
||||||
if (!existsSync(artifactDir)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of readdirSync(artifactDir)) {
|
|
||||||
if (keepNames.includes(entry) || entry.startsWith(artifactPrefix)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
entry.includes('stable-macos-') ||
|
|
||||||
entry.includes('stable-win-') ||
|
|
||||||
entry.endsWith('-update.json') ||
|
|
||||||
entry.endsWith('.tar.gz')
|
|
||||||
) {
|
|
||||||
rmSync(path.join(artifactDir, entry), { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAC_DMG_ASSETS_DIR = path.join(rootDir, 'packaging/macos/dmg')
|
|
||||||
const MAC_INSTALLER_ICONSET_PATH = path.join(rootDir, 'assets/installer.iconset')
|
|
||||||
const MAC_DMG_BACKGROUND_PATH = path.join(rootDir, 'assets/dmg/background.png')
|
|
||||||
const MAC_DMG_BACKGROUND_RETINA_PATH = path.join(
|
|
||||||
rootDir,
|
|
||||||
'assets/dmg/background@2x.png'
|
|
||||||
)
|
|
||||||
const MAC_DMG_APP_NAME = 'Farm Control.app'
|
|
||||||
const MAC_DMG_WINDOW_WIDTH = 540
|
|
||||||
const MAC_DMG_WINDOW_HEIGHT = 380
|
|
||||||
|
|
||||||
function findCommand(command) {
|
|
||||||
const which = spawnSync('which', [command], { encoding: 'utf8' })
|
|
||||||
if (which.status === 0 && which.stdout.trim()) {
|
|
||||||
return which.stdout.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInstallerIcns(destinationPath) {
|
|
||||||
if (!existsSync(MAC_INSTALLER_ICONSET_PATH)) {
|
|
||||||
throw new Error(
|
|
||||||
`Installer iconset not found at ${MAC_INSTALLER_ICONSET_PATH}. Run \`bun run generate-app-icons\` first.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(path.dirname(destinationPath), { recursive: true })
|
|
||||||
rmSync(destinationPath, { force: true })
|
|
||||||
|
|
||||||
const iconutil = spawnSync(
|
|
||||||
'iconutil',
|
|
||||||
['-c', 'icns', '-o', destinationPath, MAC_INSTALLER_ICONSET_PATH],
|
|
||||||
{ stdio: 'inherit' }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (iconutil.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`iconutil failed to create installer icns with exit code ${iconutil.status ?? 1}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return destinationPath
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMacInstallerFileIcon(targetPath, icnsPath) {
|
|
||||||
if (!existsSync(icnsPath)) {
|
|
||||||
console.warn(
|
|
||||||
`finalize-desktop-artifacts: installer icns not found at ${icnsPath}, skipping custom icon`
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileicon = findCommand('fileicon')
|
|
||||||
if (fileicon) {
|
|
||||||
const result = spawnSync(fileicon, ['set', targetPath, icnsPath], {
|
|
||||||
stdio: 'inherit'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.status === 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapedTarget = targetPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
||||||
const escapedIcon = icnsPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
||||||
const result = spawnSync(
|
|
||||||
'osascript',
|
|
||||||
[
|
|
||||||
'-e',
|
|
||||||
`tell application "Finder" to set icon of (POSIX file "${escapedTarget}") to (read (POSIX file "${escapedIcon}") as picture)`
|
|
||||||
],
|
|
||||||
{ stdio: 'inherit' }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.status === 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(
|
|
||||||
'finalize-desktop-artifacts: could not apply installer icon; install fileicon or run on macOS with Finder access'
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureMacDmgAssets() {
|
|
||||||
mkdirSync(MAC_DMG_ASSETS_DIR, { recursive: true })
|
|
||||||
|
|
||||||
const voliconPath = path.join(MAC_DMG_ASSETS_DIR, 'volicon.icns')
|
|
||||||
buildInstallerIcns(voliconPath)
|
|
||||||
|
|
||||||
if (!existsSync(MAC_DMG_BACKGROUND_PATH)) {
|
|
||||||
throw new Error(`DMG background not found at ${MAC_DMG_BACKGROUND_PATH}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(MAC_DMG_BACKGROUND_RETINA_PATH)) {
|
|
||||||
console.warn(
|
|
||||||
`finalize-desktop-artifacts: retina DMG background not found at ${MAC_DMG_BACKGROUND_RETINA_PATH}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
volicon: voliconPath,
|
|
||||||
background: MAC_DMG_BACKGROUND_PATH
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findCreateDmgCommand() {
|
|
||||||
const which = spawnSync('which', ['create-dmg'], { encoding: 'utf8' })
|
|
||||||
if (which.status === 0 && which.stdout.trim()) {
|
|
||||||
return which.stdout.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of [
|
|
||||||
'/opt/homebrew/bin/create-dmg',
|
|
||||||
'/usr/local/bin/create-dmg'
|
|
||||||
]) {
|
|
||||||
if (existsSync(candidate)) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMacDmgWithCreateDmg(
|
|
||||||
createDmg,
|
|
||||||
dmgPath,
|
|
||||||
sourceFolder,
|
|
||||||
appBundleName,
|
|
||||||
dmgAssets
|
|
||||||
) {
|
|
||||||
rmSync(dmgPath, { force: true })
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'--volname',
|
|
||||||
'Farm Control',
|
|
||||||
'--window-pos',
|
|
||||||
'200',
|
|
||||||
'120',
|
|
||||||
'--window-size',
|
|
||||||
String(MAC_DMG_WINDOW_WIDTH),
|
|
||||||
String(MAC_DMG_WINDOW_HEIGHT),
|
|
||||||
'--icon-size',
|
|
||||||
'100',
|
|
||||||
'--icon',
|
|
||||||
appBundleName,
|
|
||||||
'130',
|
|
||||||
'212',
|
|
||||||
'--hide-extension',
|
|
||||||
appBundleName,
|
|
||||||
'--app-drop-link',
|
|
||||||
'410',
|
|
||||||
'212',
|
|
||||||
'--format',
|
|
||||||
'UDZO'
|
|
||||||
]
|
|
||||||
|
|
||||||
if (dmgAssets.volicon) {
|
|
||||||
args.push('--volicon', dmgAssets.volicon)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dmgAssets.background) {
|
|
||||||
args.push('--background', dmgAssets.background)
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push(dmgPath, sourceFolder)
|
|
||||||
|
|
||||||
const result = spawnSync(createDmg, args, { stdio: 'inherit' })
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(`create-dmg failed with exit code ${result.status ?? 1}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(dmgPath)) {
|
|
||||||
throw new Error(`create-dmg did not produce ${dmgPath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dmgPath
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyMacAppBundle(sourcePath, destinationPath) {
|
|
||||||
rmSync(destinationPath, { recursive: true, force: true })
|
|
||||||
mkdirSync(path.dirname(destinationPath), { recursive: true })
|
|
||||||
|
|
||||||
const result = spawnSync('ditto', [sourcePath, destinationPath], {
|
|
||||||
stdio: 'inherit'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(`ditto failed with exit code ${result.status ?? 1}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function canUseDirectDmgSourceFolder(appBundlePath) {
|
|
||||||
const platformDir = path.dirname(appBundlePath)
|
|
||||||
const appName = path.basename(appBundlePath)
|
|
||||||
const entries = readdirSync(platformDir).filter(
|
|
||||||
(entry) => !entry.startsWith('.') && entry !== '.finalize-dmg-staging'
|
|
||||||
)
|
|
||||||
|
|
||||||
return entries.length === 1 && entries[0] === appName
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildMacDmg(appBundlePath, arch) {
|
|
||||||
const createDmg = findCreateDmgCommand()
|
|
||||||
if (!createDmg) {
|
|
||||||
throw new Error(
|
|
||||||
'create-dmg not found. Install with: brew install create-dmg'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const appBundleName = path.basename(appBundlePath)
|
|
||||||
if (appBundleName !== MAC_DMG_APP_NAME) {
|
|
||||||
console.warn(
|
|
||||||
`finalize-desktop-artifacts: expected ${MAC_DMG_APP_NAME}, found ${appBundleName}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const dmgPath = path.join(artifactDir, artifactName(arch, 'dmg'))
|
|
||||||
|
|
||||||
mkdirSync(artifactDir, { recursive: true })
|
|
||||||
|
|
||||||
const stagingDir = path.join(
|
|
||||||
path.dirname(appBundlePath),
|
|
||||||
'.finalize-dmg-staging'
|
|
||||||
)
|
|
||||||
const useDirectSource = canUseDirectDmgSourceFolder(appBundlePath)
|
|
||||||
const sourceFolder = useDirectSource
|
|
||||||
? path.dirname(appBundlePath)
|
|
||||||
: stagingDir
|
|
||||||
|
|
||||||
if (!useDirectSource) {
|
|
||||||
rmSync(stagingDir, { recursive: true, force: true })
|
|
||||||
mkdirSync(stagingDir, { recursive: true })
|
|
||||||
const stagedAppPath = path.join(stagingDir, path.basename(appBundlePath))
|
|
||||||
copyMacAppBundle(appBundlePath, stagedAppPath)
|
|
||||||
codesignMacAppBundle(stagedAppPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
let builtDmgPath
|
|
||||||
const dmgAssets = ensureMacDmgAssets()
|
|
||||||
|
|
||||||
try {
|
|
||||||
builtDmgPath = buildMacDmgWithCreateDmg(
|
|
||||||
createDmg,
|
|
||||||
dmgPath,
|
|
||||||
sourceFolder,
|
|
||||||
appBundleName,
|
|
||||||
dmgAssets
|
|
||||||
)
|
|
||||||
applyMacInstallerFileIcon(builtDmgPath, dmgAssets.volicon)
|
|
||||||
} finally {
|
|
||||||
if (!useDirectSource) {
|
|
||||||
rmSync(stagingDir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Published ${builtDmgPath}`)
|
|
||||||
return builtDmgPath
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanMacBuildDir(arch) {
|
|
||||||
const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`)
|
|
||||||
if (existsSync(platformDir)) {
|
|
||||||
rmSync(platformDir, { recursive: true, force: true })
|
|
||||||
console.log(`Removed build output ${platformDir}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMacPkg(appBundlePath, arch) {
|
|
||||||
const pkgPath = path.join(artifactDir, artifactName(arch, 'pkg'))
|
|
||||||
|
|
||||||
const result = spawnSync(
|
|
||||||
'pkgbuild',
|
|
||||||
[
|
|
||||||
'--component',
|
|
||||||
appBundlePath,
|
|
||||||
'--install-location',
|
|
||||||
'/Applications',
|
|
||||||
'--identifier',
|
|
||||||
identifier,
|
|
||||||
'--version',
|
|
||||||
version,
|
|
||||||
pkgPath
|
|
||||||
],
|
|
||||||
{ stdio: 'inherit' }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(`pkgbuild failed with exit code ${result.status ?? 1}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Published ${pkgPath}`)
|
|
||||||
return pkgPath
|
|
||||||
}
|
|
||||||
|
|
||||||
function stageWindowsDeeplinkScript(appDir) {
|
|
||||||
return stageWindowsDeeplink(appDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanWinBuildDir(arch) {
|
|
||||||
const platformDir = path.join(getBuildRoot(), `stable-win-${arch}`)
|
|
||||||
if (existsSync(platformDir)) {
|
|
||||||
rmSync(platformDir, { recursive: true, force: true })
|
|
||||||
console.log(`Removed build output ${platformDir}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWindowsNsis(appDir, arch) {
|
|
||||||
const scriptPath = path.join(rootDir, 'scripts/build-windows-nsis.ps1')
|
|
||||||
const exePath = path.join(artifactDir, artifactName(arch, 'exe'))
|
|
||||||
const buildInfoPath = path.join(rootDir, 'src/buildInfo.json')
|
|
||||||
const buildInfo = existsSync(buildInfoPath)
|
|
||||||
? JSON.parse(readFileSync(buildInfoPath, 'utf8'))
|
|
||||||
: {}
|
|
||||||
const buildNumber =
|
|
||||||
process.env.BUILD_NUMBER ||
|
|
||||||
process.env.VITE_BUILD_NUMBER ||
|
|
||||||
buildInfo.buildNumber ||
|
|
||||||
'dev'
|
|
||||||
const powershell = process.env.SystemRoot
|
|
||||||
? path.join(
|
|
||||||
process.env.SystemRoot,
|
|
||||||
'System32',
|
|
||||||
'WindowsPowerShell',
|
|
||||||
'v1.0',
|
|
||||||
'powershell.exe'
|
|
||||||
)
|
|
||||||
: 'powershell.exe'
|
|
||||||
|
|
||||||
const result = spawnSync(
|
|
||||||
powershell,
|
|
||||||
[
|
|
||||||
'-NoProfile',
|
|
||||||
'-ExecutionPolicy',
|
|
||||||
'Bypass',
|
|
||||||
'-File',
|
|
||||||
scriptPath,
|
|
||||||
'-AppDir',
|
|
||||||
appDir,
|
|
||||||
'-OutputExe',
|
|
||||||
exePath,
|
|
||||||
'-Version',
|
|
||||||
version,
|
|
||||||
'-BuildNumber',
|
|
||||||
buildNumber
|
|
||||||
],
|
|
||||||
{ stdio: 'inherit' }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`build-windows-nsis.ps1 failed with exit code ${result.status ?? 1}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const minInstallerBytes = 10 * 1024 * 1024
|
|
||||||
const installerSize = statSync(exePath).size
|
|
||||||
if (installerSize < minInstallerBytes) {
|
|
||||||
throw new Error(
|
|
||||||
`Windows installer ${path.basename(exePath)} is only ${(installerSize / 1024).toFixed(1)} KiB; expected at least ${minInstallerBytes / (1024 * 1024)} MiB`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Published ${exePath}`)
|
|
||||||
return exePath
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWindowsMsi(setupExePath, arch) {
|
|
||||||
const scriptPath = path.join(rootDir, 'scripts/build-windows-msi.ps1')
|
|
||||||
const msiPath = path.join(artifactDir, artifactName(arch, 'msi'))
|
|
||||||
const powershell = process.env.SystemRoot
|
|
||||||
? path.join(
|
|
||||||
process.env.SystemRoot,
|
|
||||||
'System32',
|
|
||||||
'WindowsPowerShell',
|
|
||||||
'v1.0',
|
|
||||||
'powershell.exe'
|
|
||||||
)
|
|
||||||
: 'powershell.exe'
|
|
||||||
|
|
||||||
const result = spawnSync(
|
|
||||||
powershell,
|
|
||||||
[
|
|
||||||
'-NoProfile',
|
|
||||||
'-ExecutionPolicy',
|
|
||||||
'Bypass',
|
|
||||||
'-File',
|
|
||||||
scriptPath,
|
|
||||||
'-SetupExe',
|
|
||||||
setupExePath,
|
|
||||||
'-OutputMsi',
|
|
||||||
msiPath,
|
|
||||||
'-Version',
|
|
||||||
version
|
|
||||||
],
|
|
||||||
{ stdio: 'inherit' }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`build-windows-msi.ps1 failed with exit code ${result.status ?? 1}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Published ${msiPath}`)
|
|
||||||
return msiPath
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buildEnv === 'dev') {
|
|
||||||
console.log('finalize-desktop-artifacts: skipping dev build')
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log(
|
|
||||||
`finalize-desktop-artifacts: os=${targetOs} arch=${buildArch} cef=${bundleCef} version=${version}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (targetOs === 'macos') {
|
|
||||||
const appBundle = findMacAppBundle(buildArch)
|
|
||||||
|
|
||||||
if (!appBundle) {
|
|
||||||
console.log(
|
|
||||||
`finalize-desktop-artifacts: no macOS ${buildArch} app bundle found, skipping`
|
|
||||||
)
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
codesignMacAppBundle(appBundle)
|
|
||||||
|
|
||||||
const existingDmg = findMacDmgSource(buildArch)
|
|
||||||
const dmgPath = existingDmg
|
|
||||||
? publishArtifact(existingDmg, buildArch, 'dmg')
|
|
||||||
: await buildMacDmg(appBundle, buildArch)
|
|
||||||
|
|
||||||
const published = [dmgPath, buildMacPkg(appBundle, buildArch)]
|
|
||||||
|
|
||||||
cleanStagingArtifacts(published.map((filePath) => path.basename(filePath)))
|
|
||||||
cleanMacBuildDir(buildArch)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetOs === 'win') {
|
|
||||||
const arch = getReleaseArch(process.env.ELECTROBUN_ARCH || 'x64')
|
|
||||||
const installerFiles = findWindowsInstallerFiles()
|
|
||||||
|
|
||||||
if (!installerFiles?.setupArchive) {
|
|
||||||
throw new Error(
|
|
||||||
'Could not find the Windows setup archive (.tar.zst) to expand'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformDir = path.dirname(installerFiles.setupArchive)
|
|
||||||
const appDir = expandWindowsAppFromArchive(
|
|
||||||
installerFiles.setupArchive,
|
|
||||||
platformDir
|
|
||||||
)
|
|
||||||
stageWindowsDeeplinkScript(appDir)
|
|
||||||
patchWindowsBinaries(appDir)
|
|
||||||
|
|
||||||
let published
|
|
||||||
try {
|
|
||||||
const setupExe = buildWindowsNsis(appDir, arch)
|
|
||||||
published = [setupExe, buildWindowsMsi(setupExe, arch)]
|
|
||||||
|
|
||||||
if (installerFiles.setupZip) {
|
|
||||||
published.push(publishArtifact(installerFiles.setupZip, arch, 'zip'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
cleanExpandedWindowsApp(platformDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanStagingArtifacts(published.map((filePath) => path.basename(filePath)))
|
|
||||||
cleanWinBuildDir(arch)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
'finalize-desktop-artifacts: no desktop packaging configured for',
|
|
||||||
targetOs ?? 'unknown target'
|
|
||||||
)
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
await main()
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
// Workaround for https://github.com/blackboardsh/electrobun/issues/485
|
|
||||||
//
|
|
||||||
// Electrobun's darwin-x64 core binaries (launcher, extractor, libasar.dylib,
|
|
||||||
// bsdiff, zig-asar in v1.18.1) are Zig-built with zero/near-zero Mach-O
|
|
||||||
// headerpad and no code signature. When codesign adds the 16-byte
|
|
||||||
// LC_CODE_SIGNATURE load command it silently overwrites the first bytes of
|
|
||||||
// __text, and the signed binary segfaults on launch (Intel Macs only;
|
|
||||||
// arm64 binaries always ship with a signature slot that is re-signed in
|
|
||||||
// place).
|
|
||||||
//
|
|
||||||
// This script frees room in the load-command area by dropping the 16-byte
|
|
||||||
// LC_SOURCE_VERSION command (LC_UUID as a fallback) from any thin x86_64
|
|
||||||
// Mach-O that has less than 16 bytes of headerpad and no existing code
|
|
||||||
// signature. It is idempotent and a no-op once upstream ships rebuilt
|
|
||||||
// binaries with headerpad_size set.
|
|
||||||
|
|
||||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const MH_MAGIC_64 = 0xfeedfacf;
|
|
||||||
const CPU_TYPE_X86_64 = 0x01000007;
|
|
||||||
const LC_SEGMENT_64 = 0x19;
|
|
||||||
const LC_UUID = 0x1b;
|
|
||||||
const LC_CODE_SIGNATURE = 0x1d;
|
|
||||||
const LC_SOURCE_VERSION = 0x2a;
|
|
||||||
const HEADER_SIZE = 32;
|
|
||||||
const CODE_SIGNATURE_CMD_SIZE = 16;
|
|
||||||
|
|
||||||
function analyzeMachO(buffer) {
|
|
||||||
if (buffer.length < HEADER_SIZE || buffer.readUInt32LE(0) !== MH_MAGIC_64) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cputype = buffer.readInt32LE(4);
|
|
||||||
const ncmds = buffer.readUInt32LE(16);
|
|
||||||
const sizeofcmds = buffer.readUInt32LE(20);
|
|
||||||
|
|
||||||
let offset = HEADER_SIZE;
|
|
||||||
let firstSectionOffset = null;
|
|
||||||
let hasCodeSignature = false;
|
|
||||||
let sourceVersionCmd = null;
|
|
||||||
let uuidCmd = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < ncmds; i += 1) {
|
|
||||||
const cmd = buffer.readUInt32LE(offset);
|
|
||||||
const cmdsize = buffer.readUInt32LE(offset + 4);
|
|
||||||
|
|
||||||
if (cmd === LC_CODE_SIGNATURE) {
|
|
||||||
hasCodeSignature = true;
|
|
||||||
} else if (cmd === LC_SOURCE_VERSION) {
|
|
||||||
sourceVersionCmd = { offset, cmdsize };
|
|
||||||
} else if (cmd === LC_UUID) {
|
|
||||||
uuidCmd = { offset, cmdsize };
|
|
||||||
} else if (cmd === LC_SEGMENT_64) {
|
|
||||||
const nsects = buffer.readUInt32LE(offset + 64);
|
|
||||||
let sectionOffset = offset + 72;
|
|
||||||
for (let s = 0; s < nsects; s += 1) {
|
|
||||||
const size = Number(buffer.readBigUInt64LE(sectionOffset + 40));
|
|
||||||
const fileOffset = buffer.readUInt32LE(sectionOffset + 48);
|
|
||||||
if (fileOffset > 0 && size > 0) {
|
|
||||||
firstSectionOffset =
|
|
||||||
firstSectionOffset === null
|
|
||||||
? fileOffset
|
|
||||||
: Math.min(firstSectionOffset, fileOffset);
|
|
||||||
}
|
|
||||||
sectionOffset += 80;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += cmdsize;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cputype,
|
|
||||||
ncmds,
|
|
||||||
sizeofcmds,
|
|
||||||
headerpad:
|
|
||||||
firstSectionOffset === null
|
|
||||||
? Infinity
|
|
||||||
: firstSectionOffset - (HEADER_SIZE + sizeofcmds),
|
|
||||||
hasCodeSignature,
|
|
||||||
removableCmd: sourceVersionCmd ?? uuidCmd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fixFile(filePath) {
|
|
||||||
const buffer = readFileSync(filePath);
|
|
||||||
const info = analyzeMachO(buffer);
|
|
||||||
const name = path.basename(filePath);
|
|
||||||
|
|
||||||
if (!info || info.cputype !== CPU_TYPE_X86_64) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.hasCodeSignature || info.headerpad >= CODE_SIGNATURE_CMD_SIZE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!info.removableCmd) {
|
|
||||||
console.warn(
|
|
||||||
`fix-macos-headerpad: ${name} has headerpad ${info.headerpad} but no removable load command; signing may corrupt it`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { offset, cmdsize } = info.removableCmd;
|
|
||||||
const loadCommandsEnd = HEADER_SIZE + info.sizeofcmds;
|
|
||||||
|
|
||||||
// Shift the remaining load commands over the removed one, then zero the
|
|
||||||
// freed tail so codesign finds clean padding.
|
|
||||||
buffer.copyWithin(offset, offset + cmdsize, loadCommandsEnd);
|
|
||||||
buffer.fill(0, loadCommandsEnd - cmdsize, loadCommandsEnd);
|
|
||||||
buffer.writeUInt32LE(info.ncmds - 1, 16);
|
|
||||||
buffer.writeUInt32LE(info.sizeofcmds - cmdsize, 20);
|
|
||||||
|
|
||||||
writeFileSync(filePath, buffer);
|
|
||||||
console.log(
|
|
||||||
`fix-macos-headerpad: ${name} freed ${cmdsize} bytes for LC_CODE_SIGNATURE (headerpad was ${info.headerpad})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fixMacosHeaderpad(directory) {
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = readdirSync(directory);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const filePath = path.join(directory, entry);
|
|
||||||
if (statSync(filePath).isFile()) {
|
|
||||||
fixFile(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
|
|
||||||
const modulePath = fileURLToPath(import.meta.url);
|
|
||||||
|
|
||||||
if (invokedPath === modulePath) {
|
|
||||||
const rootDir = path.resolve(path.dirname(modulePath), "..");
|
|
||||||
const target =
|
|
||||||
process.argv[2] ||
|
|
||||||
path.join(rootDir, "node_modules/electrobun/dist-macos-x64");
|
|
||||||
fixMacosHeaderpad(path.resolve(target));
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import path from 'node:path'
|
|
||||||
import { spawnSync } from 'node:child_process'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
[
|
|
||||||
path.join(rootDir, 'scripts/prepare-app-icons.mjs'),
|
|
||||||
'assets/logos/farmcontrolicon.png'
|
|
||||||
],
|
|
||||||
[path.join(rootDir, 'scripts/prepare-installer-icons.mjs')]
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const args of steps) {
|
|
||||||
const result = spawnSync('bun', args, {
|
|
||||||
cwd: rootDir,
|
|
||||||
stdio: 'inherit',
|
|
||||||
env: process.env
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
process.exit(result.status ?? 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('generate-app-icons: done')
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$SourceDir,
|
|
||||||
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$OutputPath
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
function ConvertTo-NsisString {
|
|
||||||
param([string]$Value)
|
|
||||||
|
|
||||||
return $Value.Replace('$', '$$').Replace('"', '$\"')
|
|
||||||
}
|
|
||||||
|
|
||||||
$sourceRoot = (Resolve-Path -LiteralPath $SourceDir).Path.TrimEnd('\')
|
|
||||||
$sourcePrefix = "$sourceRoot\"
|
|
||||||
$files = @(
|
|
||||||
Get-ChildItem -LiteralPath $sourceRoot -Recurse -File -Force |
|
|
||||||
Sort-Object FullName
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($files.Count -eq 0) {
|
|
||||||
throw "No files were found in NSIS application source directory: $sourceRoot"
|
|
||||||
}
|
|
||||||
|
|
||||||
[long]$totalBytes = ($files | Measure-Object -Property Length -Sum).Sum
|
|
||||||
$lines = [System.Collections.Generic.List[string]]::new()
|
|
||||||
$lines.Add("!define APP_COPY_TOTAL_BYTES `"$totalBytes`"")
|
|
||||||
$lines.Add("!macro copyApplicationFilesWithProgress")
|
|
||||||
|
|
||||||
$lastRelativeDirectory = $null
|
|
||||||
foreach ($file in $files) {
|
|
||||||
$relativePath = $file.FullName.Substring($sourcePrefix.Length)
|
|
||||||
$relativeDirectory = Split-Path -Parent $relativePath
|
|
||||||
$escapedRelativePath = ConvertTo-NsisString $relativePath
|
|
||||||
$escapedSourcePath = ConvertTo-NsisString $file.FullName
|
|
||||||
$escapedFileName = ConvertTo-NsisString $file.Name
|
|
||||||
|
|
||||||
if ($relativeDirectory -ne $lastRelativeDirectory) {
|
|
||||||
if ([string]::IsNullOrEmpty($relativeDirectory)) {
|
|
||||||
$lines.Add(' SetOutPath "$INSTDIR"')
|
|
||||||
} else {
|
|
||||||
$escapedDirectory = ConvertTo-NsisString $relativeDirectory
|
|
||||||
$lines.Add(" SetOutPath `"`$INSTDIR\$escapedDirectory`"")
|
|
||||||
}
|
|
||||||
$lastRelativeDirectory = $relativeDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines.Add(" File `"/oname=$escapedFileName`" `"$escapedSourcePath`"")
|
|
||||||
$lines.Add(
|
|
||||||
" !insertmacro progressCopyFile `"$($file.Length)`" `"$escapedRelativePath`""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines.Add("!macroend")
|
|
||||||
|
|
||||||
$encoding = [System.Text.UTF8Encoding]::new($false)
|
|
||||||
[System.IO.File]::WriteAllLines($OutputPath, $lines, $encoding)
|
|
||||||
|
|
||||||
Write-Host "Generated NSIS commands for $($files.Count) files ($totalBytes bytes)."
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
!macro customInstall
|
!macro customInstall
|
||||||
DetailPrint "Register farmcontrol URI Handler"
|
DetailPrint "Register farmcontrol URI Handler"
|
||||||
DeleteRegKey HKCU "Software\Classes\farmcontrol"
|
DeleteRegKey HKCR "farmcontrol"
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol" "" "URL:farmcontrol"
|
WriteRegStr HKCR "farmcontrol" "" "URL:farmcontrol"
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol" "URL Protocol" ""
|
WriteRegStr HKCR "farmcontrol" "URL Protocol" ""
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
|
WriteRegStr HKCR "farmcontrol\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol\shell" "" ""
|
WriteRegStr HKCR "farmcontrol\shell" "" ""
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" ""
|
WriteRegStr HKCR "farmcontrol\shell\Open" "" ""
|
||||||
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1"
|
WriteRegStr HKCR "farmcontrol\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1"
|
||||||
!macroend
|
!macroend
|
||||||
@ -1,350 +0,0 @@
|
|||||||
import {
|
|
||||||
cpSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync
|
|
||||||
} from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
||||||
const electrobunDir = path.join(rootDir, 'node_modules/electrobun')
|
|
||||||
const sharedSrcDir = path.join(electrobunDir, 'src/shared')
|
|
||||||
const sharedDistDir = path.join(electrobunDir, 'dist/api/shared')
|
|
||||||
|
|
||||||
if (!existsSync(sharedDistDir)) {
|
|
||||||
console.error('patch-electrobun-src: electrobun dist/api/shared not found')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(sharedSrcDir, { recursive: true })
|
|
||||||
|
|
||||||
for (const fileName of [
|
|
||||||
'cef-version.ts',
|
|
||||||
'bun-version.ts',
|
|
||||||
'electrobun-version.ts',
|
|
||||||
'naming.ts',
|
|
||||||
'rpc.ts'
|
|
||||||
]) {
|
|
||||||
const source = path.join(sharedDistDir, fileName)
|
|
||||||
const destination = path.join(sharedSrcDir, fileName)
|
|
||||||
if (existsSync(source)) {
|
|
||||||
cpSync(source, destination)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformPatch = `import { platform, arch } from "os";
|
|
||||||
|
|
||||||
export type SupportedOS = "macos" | "win" | "linux";
|
|
||||||
export type SupportedArch = "arm64" | "x64";
|
|
||||||
|
|
||||||
const platformName = platform();
|
|
||||||
const archName = arch();
|
|
||||||
|
|
||||||
export const OS: SupportedOS = (() => {
|
|
||||||
switch (platformName) {
|
|
||||||
case "win32":
|
|
||||||
return "win";
|
|
||||||
case "darwin":
|
|
||||||
return "macos";
|
|
||||||
case "linux":
|
|
||||||
return "linux";
|
|
||||||
default:
|
|
||||||
throw new Error(\`Unsupported platform: \${platformName}\`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
function normalizeForcedArch(value) {
|
|
||||||
if (value === "arm64" || value === "aarch64") {
|
|
||||||
return "arm64";
|
|
||||||
}
|
|
||||||
if (value === "x64" || value === "amd64") {
|
|
||||||
return "x64";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ARCH: SupportedArch = (() => {
|
|
||||||
const forcedArch = normalizeForcedArch(
|
|
||||||
process.env.ELECTROBUN_FORCE_ARCH || process.env.ELECTROBUN_TARGET_ARCH,
|
|
||||||
);
|
|
||||||
if (forcedArch) {
|
|
||||||
return forcedArch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (OS === "win") {
|
|
||||||
return "x64";
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (archName) {
|
|
||||||
case "arm64":
|
|
||||||
return "arm64";
|
|
||||||
case "x64":
|
|
||||||
return "x64";
|
|
||||||
default:
|
|
||||||
throw new Error(\`Unsupported architecture: \${archName}\`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
export const HOST_ARCH: SupportedArch = (() => {
|
|
||||||
if (OS === "win") {
|
|
||||||
return "x64";
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (archName) {
|
|
||||||
case "arm64":
|
|
||||||
return "arm64";
|
|
||||||
case "x64":
|
|
||||||
return "x64";
|
|
||||||
default:
|
|
||||||
throw new Error(\`Unsupported architecture: \${archName}\`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
export function getPlatformOS(): SupportedOS {
|
|
||||||
return OS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPlatformArch(): SupportedArch {
|
|
||||||
return ARCH;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
writeFileSync(path.join(sharedSrcDir, 'platform.ts'), platformPatch)
|
|
||||||
|
|
||||||
const templatesDir = path.join(electrobunDir, 'src/cli/templates')
|
|
||||||
mkdirSync(templatesDir, { recursive: true })
|
|
||||||
writeFileSync(
|
|
||||||
path.join(templatesDir, 'embedded.ts'),
|
|
||||||
`export function getTemplateNames() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTemplate(name: string) {
|
|
||||||
throw new Error(\`Template not available in patched electrobun CLI: \${name}\`);
|
|
||||||
}
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
const RCEDIT_RESOLVER_FN = `function resolveProjectRceditPkgPath(projectRoot) {
|
|
||||||
const candidates = [
|
|
||||||
join(projectRoot, "node_modules/rcedit/package.json"),
|
|
||||||
join(projectRoot, "node_modules/electrobun/node_modules/rcedit/package.json"),
|
|
||||||
];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (existsSync(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
"rcedit not found under " + projectRoot + ". Install rcedit in the project (bun add -d rcedit).",
|
|
||||||
);
|
|
||||||
}`
|
|
||||||
|
|
||||||
const cliPath = path.join(electrobunDir, 'src/cli/index.ts')
|
|
||||||
let cliSource = readFileSync(cliPath, 'utf8')
|
|
||||||
if (!cliSource.includes('HOST_ARCH')) {
|
|
||||||
cliSource = cliSource.replace(
|
|
||||||
'import { OS, ARCH } from "../shared/platform";',
|
|
||||||
'import { OS, ARCH, HOST_ARCH } from "../shared/platform";'
|
|
||||||
)
|
|
||||||
cliSource = cliSource.replace(
|
|
||||||
'const hostPaths = getPlatformPaths(OS, ARCH);',
|
|
||||||
'const hostPaths = getPlatformPaths(OS, HOST_ARCH);'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!cliSource.includes('toolPaths')) {
|
|
||||||
cliSource = cliSource.replace(
|
|
||||||
'const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);',
|
|
||||||
'const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);\n\t\tconst toolPaths = getPlatformPaths(currentTarget.os, ARCH !== HOST_ARCH ? HOST_ARCH : ARCH);'
|
|
||||||
)
|
|
||||||
cliSource = cliSource.replaceAll(
|
|
||||||
'const zstdPath = targetPaths.ZSTD;',
|
|
||||||
'const zstdPath = toolPaths.ZSTD;'
|
|
||||||
)
|
|
||||||
cliSource = cliSource.replaceAll(
|
|
||||||
'const bsdiffpath = targetPaths.BSDIFF;',
|
|
||||||
'const bsdiffpath = toolPaths.BSDIFF;'
|
|
||||||
)
|
|
||||||
cliSource = cliSource.replace(
|
|
||||||
'zigAsarCli = join(targetPaths.BSPATCH).replace("bspatch", "zig-asar");',
|
|
||||||
'zigAsarCli = join(toolPaths.BSPATCH).replace("bspatch", "zig-asar");'
|
|
||||||
)
|
|
||||||
writeFileSync(cliPath, cliSource)
|
|
||||||
cliSource = readFileSync(cliPath, 'utf8')
|
|
||||||
}
|
|
||||||
if (!cliSource.includes('hookBunBinary')) {
|
|
||||||
cliSource = cliSource.replace(
|
|
||||||
`const hostPaths = getPlatformPaths(OS, HOST_ARCH);
|
|
||||||
|
|
||||||
const result = Bun.spawnSync([hostPaths.BUN_BINARY, hookScript],`,
|
|
||||||
`const hostPaths = getPlatformPaths(OS, HOST_ARCH);
|
|
||||||
const hookBunBinary = existsSync(hostPaths.BUN_BINARY)
|
|
||||||
? hostPaths.BUN_BINARY
|
|
||||||
: process.execPath;
|
|
||||||
|
|
||||||
const result = Bun.spawnSync([hookBunBinary, hookScript],`
|
|
||||||
)
|
|
||||||
cliSource = cliSource.replace(
|
|
||||||
'console.error("Tried to run with bun at:", hostPaths.BUN_BINARY);',
|
|
||||||
'console.error("Tried to run with bun at:", hookBunBinary);'
|
|
||||||
)
|
|
||||||
writeFileSync(cliPath, cliSource)
|
|
||||||
cliSource = readFileSync(cliPath, 'utf8')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cliSource.includes('resolveProjectRceditPkgPath')) {
|
|
||||||
cliSource = cliSource.replaceAll(
|
|
||||||
'const rceditPkgPath = require.resolve("rcedit/package.json");',
|
|
||||||
'const rceditPkgPath = resolveProjectRceditPkgPath(projectRoot);'
|
|
||||||
)
|
|
||||||
cliSource = cliSource.replace(
|
|
||||||
'function getPlatformPaths(',
|
|
||||||
`${RCEDIT_RESOLVER_FN}
|
|
||||||
|
|
||||||
function getPlatformPaths(`
|
|
||||||
)
|
|
||||||
writeFileSync(cliPath, cliSource)
|
|
||||||
cliSource = readFileSync(cliPath, 'utf8')
|
|
||||||
} else {
|
|
||||||
cliSource = cliSource.replace(
|
|
||||||
/function resolveProjectRceditPkgPath\(projectRoot\) \{[\s\S]*?\n\}/m,
|
|
||||||
RCEDIT_RESOLVER_FN
|
|
||||||
)
|
|
||||||
writeFileSync(cliPath, cliSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
const electrobunCjsPath = path.join(electrobunDir, 'bin/electrobun.cjs')
|
|
||||||
let electrobunCjs = readFileSync(electrobunCjsPath, 'utf8')
|
|
||||||
|
|
||||||
if (!electrobunCjs.includes('farmcontrol-use-patched-cli')) {
|
|
||||||
electrobunCjs = electrobunCjs.replace(
|
|
||||||
`async function main() {
|
|
||||||
try {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const cliPath = await ensureCliBinary();
|
|
||||||
|
|
||||||
// Replace this process with the actual CLI
|
|
||||||
const child = spawn(cliPath, args, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: process.cwd()
|
|
||||||
});`,
|
|
||||||
`async function main() {
|
|
||||||
try {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const patchedCliPath = join(electrobunDir, 'src', 'cli', 'index.ts');
|
|
||||||
|
|
||||||
if (existsSync(patchedCliPath)) {
|
|
||||||
// farmcontrol-use-patched-cli: bundled electrobun.exe cannot resolve project rcedit
|
|
||||||
const bunBinary = process.env.BUN_INSTALL
|
|
||||||
? join(process.env.BUN_INSTALL, 'bin', 'bun' + binExt)
|
|
||||||
: 'bun';
|
|
||||||
const child = spawn(bunBinary, [patchedCliPath, ...args], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: process.env,
|
|
||||||
shell: platform === 'win',
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('exit', (code) => {
|
|
||||||
process.exit(code || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (error) => {
|
|
||||||
console.error('Failed to start electrobun patched CLI:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cliPath = await ensureCliBinary();
|
|
||||||
|
|
||||||
// Replace this process with the actual CLI
|
|
||||||
const child = spawn(cliPath, args, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: process.cwd()
|
|
||||||
});`
|
|
||||||
)
|
|
||||||
writeFileSync(electrobunCjsPath, electrobunCjs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rceditSrc = path.join(rootDir, 'node_modules/rcedit')
|
|
||||||
const rceditDest = path.join(electrobunDir, 'node_modules/rcedit')
|
|
||||||
if (existsSync(rceditSrc)) {
|
|
||||||
mkdirSync(path.join(electrobunDir, 'node_modules'), { recursive: true })
|
|
||||||
cpSync(rceditSrc, rceditDest, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchNativeWrapperPath(nativeTsPath) {
|
|
||||||
if (!existsSync(nativeTsPath)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let source = readFileSync(nativeTsPath, 'utf8')
|
|
||||||
|
|
||||||
if (source.includes('function resolveNativeWrapperPath()')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes('existsSync')) {
|
|
||||||
if (source.includes('import { dirname, join } from "path";')) {
|
|
||||||
source = source.replace(
|
|
||||||
'import { dirname, join } from "path";',
|
|
||||||
'import { existsSync } from "fs";\nimport { dirname, join } from "path";'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
source = source.replace(
|
|
||||||
'import { join } from "path";',
|
|
||||||
'import { existsSync } from "fs";\nimport { dirname, join } from "path";'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (!source.includes('dirname')) {
|
|
||||||
source = source.replace(
|
|
||||||
'import { join } from "path";',
|
|
||||||
'import { dirname, join } from "path";'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const helper = `
|
|
||||||
function resolveNativeWrapperPath() {
|
|
||||||
\tconst fileName = \`libNativeWrapper.\${suffix}\`;
|
|
||||||
\tconst candidates = [
|
|
||||||
\t\tjoin(dirname(process.execPath), fileName),
|
|
||||||
\t\tjoin(process.cwd(), fileName),
|
|
||||||
\t];
|
|
||||||
\tfor (const candidate of candidates) {
|
|
||||||
\t\tif (existsSync(candidate)) {
|
|
||||||
\t\t\treturn candidate;
|
|
||||||
\t\t}
|
|
||||||
\t}
|
|
||||||
\treturn candidates[0]!;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
source = source.replace(
|
|
||||||
'export const native = (() => {',
|
|
||||||
`${helper}\nexport const native = (() => {`
|
|
||||||
)
|
|
||||||
|
|
||||||
source = source.replace(
|
|
||||||
/const nativeWrapperPath = join\((?:dirname\(process\.execPath\)|process\.cwd\(\)), `libNativeWrapper\.\$\{suffix\}`\);/,
|
|
||||||
'const nativeWrapperPath = resolveNativeWrapperPath();'
|
|
||||||
)
|
|
||||||
|
|
||||||
writeFileSync(nativeTsPath, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const distDir of ['dist', 'dist-macos-arm64', 'dist-win-x64']) {
|
|
||||||
patchNativeWrapperPath(
|
|
||||||
path.join(electrobunDir, distDir, 'api/bun/proc/native.ts')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const markerPath = path.join(electrobunDir, '.farmcontrol-electrobun-patched')
|
|
||||||
writeFileSync(markerPath, `patched-at=${new Date().toISOString()}\n`)
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
'patch-electrobun-src: electrobun src/shared ready for cross-arch builds'
|
|
||||||
)
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { existsSync, renameSync } from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
export const WINDOWS_LAUNCHER_EXE = 'FarmControl.exe'
|
|
||||||
const SOURCE_LAUNCHER_EXE = 'launcher.exe'
|
|
||||||
|
|
||||||
export function patchWindowsBinaries(appDir) {
|
|
||||||
const binDir = path.join(appDir, 'bin')
|
|
||||||
if (!existsSync(binDir)) {
|
|
||||||
throw new Error(`patch-windows-binaries: bin directory not found at ${binDir}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePath = path.join(binDir, SOURCE_LAUNCHER_EXE)
|
|
||||||
const destPath = path.join(binDir, WINDOWS_LAUNCHER_EXE)
|
|
||||||
|
|
||||||
if (existsSync(destPath) && !existsSync(sourcePath)) {
|
|
||||||
console.log(`patch-windows-binaries: ${WINDOWS_LAUNCHER_EXE} already present`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(sourcePath)) {
|
|
||||||
console.warn(`patch-windows-binaries: skipping missing ${sourcePath}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existsSync(destPath)) {
|
|
||||||
renameSync(destPath, `${destPath}.old`)
|
|
||||||
}
|
|
||||||
|
|
||||||
renameSync(sourcePath, destPath)
|
|
||||||
console.log(
|
|
||||||
`patch-windows-binaries: renamed ${SOURCE_LAUNCHER_EXE} -> ${WINDOWS_LAUNCHER_EXE}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
||||||
const appDirArg = process.argv[2]
|
|
||||||
if (!appDirArg) {
|
|
||||||
console.error('Usage: bun scripts/patch-windows-binaries.mjs <app-dir>')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
patchWindowsBinaries(path.resolve(appDirArg))
|
|
||||||
}
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
|
|
||||||
|
|
||||||
const requiredFiles = [
|
|
||||||
"src/bun/index.js",
|
|
||||||
"electrobun.config.ts",
|
|
||||||
"package.json",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const relativePath of requiredFiles) {
|
|
||||||
const filePath = path.join(rootDir, relativePath);
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
console.error(`pre-build: required file not found: ${relativePath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buildEnv === "stable" && process.env.ELECTROBUN_OS === "macos") {
|
|
||||||
const codesignVars = [
|
|
||||||
"ELECTROBUN_DEVELOPER_ID",
|
|
||||||
"ELECTROBUN_APPLEID",
|
|
||||||
"ELECTROBUN_APPLEIDPASS",
|
|
||||||
"ELECTROBUN_TEAMID",
|
|
||||||
];
|
|
||||||
const missing = codesignVars.filter((name) => !process.env[name]);
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
console.warn(
|
|
||||||
`pre-build: stable macOS build without code signing credentials (${missing.join(", ")}); Electrobun will skip signing.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildMacosEffects = spawnSync(
|
|
||||||
"bun",
|
|
||||||
[path.join(rootDir, "scripts/build-macos-effects.mjs")],
|
|
||||||
{ cwd: rootDir, stdio: "inherit", env: process.env },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (buildMacosEffects.status !== 0) {
|
|
||||||
process.exit(buildMacosEffects.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
const { validateMacosEffectsDylib, outFile } = await import(
|
|
||||||
pathToFileURL(
|
|
||||||
path.join(rootDir, "scripts/build-macos-effects.mjs"),
|
|
||||||
).href
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validateMacosEffectsDylib({ dylibPath: outFile })) {
|
|
||||||
console.error(
|
|
||||||
"pre-build: macOS window effects dylib is missing or invalid; blur and traffic lights will not work in packaged builds.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requiredIconPaths = [
|
|
||||||
path.join(rootDir, "assets/icon.ico"),
|
|
||||||
path.join(rootDir, "assets/icon.png"),
|
|
||||||
path.join(rootDir, "assets/icon.iconset/icon_256x256.png"),
|
|
||||||
path.join(rootDir, "assets/installer.ico"),
|
|
||||||
path.join(rootDir, "assets/uninstaller.ico"),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (buildEnv === "stable") {
|
|
||||||
const prepareIcons = spawnSync(
|
|
||||||
"bun",
|
|
||||||
[path.join(rootDir, "scripts/generate-app-icons.mjs")],
|
|
||||||
{ cwd: rootDir, stdio: "inherit", env: process.env },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (prepareIcons.status !== 0) {
|
|
||||||
process.exit(prepareIcons.status ?? 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const missingIcons = requiredIconPaths.filter((filePath) => !existsSync(filePath));
|
|
||||||
if (missingIcons.length > 0) {
|
|
||||||
console.error(
|
|
||||||
`pre-build: missing generated icons:\n${missingIcons
|
|
||||||
.map((filePath) => ` - ${path.relative(rootDir, filePath)}`)
|
|
||||||
.join("\n")}\nRun \`bun run generate-app-icons\` to create them.`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeBuildInfo = spawnSync(
|
|
||||||
"bun",
|
|
||||||
[path.join(rootDir, "scripts/write-build-info.mjs")],
|
|
||||||
{ cwd: rootDir, stdio: "inherit", env: process.env },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (writeBuildInfo.status !== 0) {
|
|
||||||
process.exit(writeBuildInfo.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buildEnv === "dev") {
|
|
||||||
console.log(
|
|
||||||
"pre-build: dev environment — skipping production renderer build (use dev:renderer for Vite)",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Electrobun still copies dist/mainview into the app bundle; provide a stub so
|
|
||||||
// dev builds don't warn when Vite serves the renderer instead.
|
|
||||||
const stubDir = path.join(rootDir, "dist/mainview");
|
|
||||||
mkdirSync(stubDir, { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
path.join(stubDir, "index.html"),
|
|
||||||
"<!doctype html><html><body>Dev mode — start Vite with <code>bun run dev:renderer</code>.</body></html>",
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`pre-build: validation passed (${buildEnv})`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildRenderer = spawnSync(
|
|
||||||
"bun",
|
|
||||||
[path.join(rootDir, "scripts/build-renderer.mjs")],
|
|
||||||
{
|
|
||||||
cwd: rootDir,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
NODE_ENV: "production",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (buildRenderer.status !== 0) {
|
|
||||||
process.exit(buildRenderer.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rendererIndex = path.join(rootDir, "dist/mainview/index.html");
|
|
||||||
if (!existsSync(rendererIndex)) {
|
|
||||||
console.error(`pre-build: renderer output not found: dist/mainview/index.html`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`pre-build: validation and renderer build passed (${buildEnv})`);
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { Jimp } from "jimp";
|
|
||||||
import pngToIco from "png-to-ico";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
|
|
||||||
const MAC_ICONSET_ENTRIES = [
|
|
||||||
{ name: "icon_16x16.png", size: 16 },
|
|
||||||
{ name: "icon_16x16@2x.png", size: 32 },
|
|
||||||
{ name: "icon_32x32.png", size: 32 },
|
|
||||||
{ name: "icon_32x32@2x.png", size: 64 },
|
|
||||||
{ name: "icon_128x128.png", size: 128 },
|
|
||||||
{ name: "icon_128x128@2x.png", size: 256 },
|
|
||||||
{ name: "icon_256x256.png", size: 256 },
|
|
||||||
{ name: "icon_256x256@2x.png", size: 512 },
|
|
||||||
{ name: "icon_512x512.png", size: 512 },
|
|
||||||
{ name: "icon_512x512@2x.png", size: 1024 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const WIN_ICO_SIZES = [16, 32, 48, 256];
|
|
||||||
const LINUX_ICON_SIZE = 256;
|
|
||||||
|
|
||||||
function resolveSourcePath(arg) {
|
|
||||||
if (!arg) {
|
|
||||||
return path.join(rootDir, "assets/farmcontrolhosticon.png");
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = path.isAbsolute(arg) ? arg : path.resolve(rootDir, arg);
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resizePng(sourceImage, size) {
|
|
||||||
return sourceImage
|
|
||||||
.clone()
|
|
||||||
.resize({ w: size, h: size })
|
|
||||||
.getBuffer("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writePng(filePath, sourceImage, size) {
|
|
||||||
const png = await resizePng(sourceImage, size);
|
|
||||||
await Bun.write(filePath, png);
|
|
||||||
return png;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
|
||||||
const sourceArg = argv.find((arg) => !arg.startsWith("-"));
|
|
||||||
const outDirArgIndex = argv.indexOf("--out-dir");
|
|
||||||
const outDir =
|
|
||||||
outDirArgIndex >= 0 ? argv[outDirArgIndex + 1] : path.join(rootDir, "assets");
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourcePath: resolveSourcePath(sourceArg),
|
|
||||||
outDir: path.isAbsolute(outDir) ? outDir : path.resolve(rootDir, outDir),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const { sourcePath, outDir } = parseArgs(process.argv.slice(2));
|
|
||||||
|
|
||||||
if (!existsSync(sourcePath)) {
|
|
||||||
console.error(`prepare-app-icons: source image not found: ${sourcePath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceImage = await Jimp.read(sourcePath);
|
|
||||||
if (sourceImage.width < 256 || sourceImage.height < 256) {
|
|
||||||
console.warn(
|
|
||||||
`prepare-app-icons: source image is ${sourceImage.width}x${sourceImage.height}; Electrobun recommends at least 256x256`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
|
|
||||||
const iconsetDir = path.join(outDir, "icon.iconset");
|
|
||||||
rmSync(iconsetDir, { recursive: true, force: true });
|
|
||||||
mkdirSync(iconsetDir, { recursive: true });
|
|
||||||
|
|
||||||
for (const entry of MAC_ICONSET_ENTRIES) {
|
|
||||||
const iconPath = path.join(iconsetDir, entry.name);
|
|
||||||
await writePng(iconPath, sourceImage, entry.size);
|
|
||||||
}
|
|
||||||
|
|
||||||
const icoPngs = [];
|
|
||||||
for (const size of WIN_ICO_SIZES) {
|
|
||||||
icoPngs.push(await resizePng(sourceImage, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
const icoPath = path.join(outDir, "icon.ico");
|
|
||||||
await Bun.write(icoPath, await pngToIco(icoPngs));
|
|
||||||
|
|
||||||
const linuxIconPath = path.join(outDir, "icon.png");
|
|
||||||
await writePng(linuxIconPath, sourceImage, LINUX_ICON_SIZE);
|
|
||||||
|
|
||||||
console.log(`prepare-app-icons: wrote ${iconsetDir}`);
|
|
||||||
console.log(`prepare-app-icons: wrote ${icoPath}`);
|
|
||||||
console.log(`prepare-app-icons: wrote ${linuxIconPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(`prepare-app-icons: ${error.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { Jimp } from 'jimp'
|
|
||||||
import pngToIco from 'png-to-ico'
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
||||||
const INSTALLER_SOURCE = path.join(
|
|
||||||
rootDir,
|
|
||||||
'assets/logos/farmcontrolinstaller.png'
|
|
||||||
)
|
|
||||||
const UNINSTALLER_SOURCE = path.join(
|
|
||||||
rootDir,
|
|
||||||
'assets/logos/farmcontroluninstaller.png'
|
|
||||||
)
|
|
||||||
|
|
||||||
const MAC_ICONSET_ENTRIES = [
|
|
||||||
{ name: 'icon_16x16.png', size: 16 },
|
|
||||||
{ name: 'icon_16x16@2x.png', size: 32 },
|
|
||||||
{ name: 'icon_32x32.png', size: 32 },
|
|
||||||
{ name: 'icon_32x32@2x.png', size: 64 },
|
|
||||||
{ name: 'icon_128x128.png', size: 128 },
|
|
||||||
{ name: 'icon_128x128@2x.png', size: 256 },
|
|
||||||
{ name: 'icon_256x256.png', size: 256 },
|
|
||||||
{ name: 'icon_256x256@2x.png', size: 512 },
|
|
||||||
{ name: 'icon_512x512.png', size: 512 },
|
|
||||||
{ name: 'icon_512x512@2x.png', size: 1024 }
|
|
||||||
]
|
|
||||||
|
|
||||||
const WIN_ICO_SIZES = [16, 32, 48, 256]
|
|
||||||
|
|
||||||
async function resizePng(sourceImage, size) {
|
|
||||||
return sourceImage
|
|
||||||
.clone()
|
|
||||||
.resize({ w: size, h: size })
|
|
||||||
.getBuffer('image/png')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writePng(filePath, sourceImage, size) {
|
|
||||||
const png = await resizePng(sourceImage, size)
|
|
||||||
await Bun.write(filePath, png)
|
|
||||||
return png
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeWindowsIco(sourceImage, icoPath) {
|
|
||||||
const icoPngs = []
|
|
||||||
for (const size of WIN_ICO_SIZES) {
|
|
||||||
icoPngs.push(await resizePng(sourceImage, size))
|
|
||||||
}
|
|
||||||
|
|
||||||
await Bun.write(icoPath, await pngToIco(icoPngs))
|
|
||||||
console.log(`prepare-installer-icons: wrote ${icoPath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (!existsSync(INSTALLER_SOURCE)) {
|
|
||||||
console.error(
|
|
||||||
`prepare-installer-icons: source image not found: ${INSTALLER_SOURCE}`
|
|
||||||
)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(UNINSTALLER_SOURCE)) {
|
|
||||||
console.error(
|
|
||||||
`prepare-installer-icons: source image not found: ${UNINSTALLER_SOURCE}`
|
|
||||||
)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const installerImage = await Jimp.read(INSTALLER_SOURCE)
|
|
||||||
if (installerImage.width < 256 || installerImage.height < 256) {
|
|
||||||
console.warn(
|
|
||||||
`prepare-installer-icons: installer source is ${installerImage.width}x${installerImage.height}; recommend at least 256x256`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const uninstallerImage = await Jimp.read(UNINSTALLER_SOURCE)
|
|
||||||
if (uninstallerImage.width < 256 || uninstallerImage.height < 256) {
|
|
||||||
console.warn(
|
|
||||||
`prepare-installer-icons: uninstaller source is ${uninstallerImage.width}x${uninstallerImage.height}; recommend at least 256x256`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetsDir = path.join(rootDir, 'assets')
|
|
||||||
mkdirSync(assetsDir, { recursive: true })
|
|
||||||
|
|
||||||
const iconsetDir = path.join(assetsDir, 'installer.iconset')
|
|
||||||
rmSync(iconsetDir, { recursive: true, force: true })
|
|
||||||
mkdirSync(iconsetDir, { recursive: true })
|
|
||||||
|
|
||||||
for (const entry of MAC_ICONSET_ENTRIES) {
|
|
||||||
await writePng(
|
|
||||||
path.join(iconsetDir, entry.name),
|
|
||||||
installerImage,
|
|
||||||
entry.size
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`prepare-installer-icons: wrote ${iconsetDir}`)
|
|
||||||
await writeWindowsIco(installerImage, path.join(assetsDir, 'installer.ico'))
|
|
||||||
await writeWindowsIco(
|
|
||||||
uninstallerImage,
|
|
||||||
path.join(assetsDir, 'uninstaller.ico')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(`prepare-installer-icons: ${error.message}`)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
export function getReleaseVersion(packageJson) {
|
|
||||||
return packageJson.version;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getReleaseArch(platformArch = process.arch) {
|
|
||||||
if (platformArch === "arm64" || platformArch === "aarch64") {
|
|
||||||
return "arm64";
|
|
||||||
}
|
|
||||||
if (platformArch === "x64" || platformArch === "amd64") {
|
|
||||||
return "x64";
|
|
||||||
}
|
|
||||||
return platformArch;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isBundleCefEnabled(
|
|
||||||
value = process.env.ELECTROBUN_BUNDLE_CEF,
|
|
||||||
) {
|
|
||||||
return ["1", "true", "yes"].includes(String(value || "").toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getReleaseArtifactName(version, arch, ext, options = {}) {
|
|
||||||
const cef =
|
|
||||||
typeof options === "boolean"
|
|
||||||
? options
|
|
||||||
: Boolean(options.cef ?? isBundleCefEnabled());
|
|
||||||
const cefSuffix = cef ? "-cef" : "";
|
|
||||||
return `farmcontrol-${version}-${arch}${cefSuffix}.${ext}`;
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
import { getReleaseArch, isBundleCefEnabled } from "./release-artifact-utils.mjs";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const electrobunCli = path.join(
|
|
||||||
rootDir,
|
|
||||||
"node_modules/electrobun/src/cli/index.ts",
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "stable";
|
|
||||||
const targetArch = getReleaseArch(
|
|
||||||
process.env.ELECTROBUN_TARGET_ARCH ||
|
|
||||||
process.env.ELECTROBUN_FORCE_ARCH ||
|
|
||||||
process.arch,
|
|
||||||
);
|
|
||||||
const bundleCef = isBundleCefEnabled();
|
|
||||||
|
|
||||||
const patchResult = spawnSync("bun", [path.join(rootDir, "scripts/patch-electrobun-src.mjs")], {
|
|
||||||
cwd: rootDir,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (patchResult.status !== 0) {
|
|
||||||
process.exit(patchResult.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const macosEffectsResult = spawnSync(
|
|
||||||
"bun",
|
|
||||||
[path.join(rootDir, "scripts/build-macos-effects.mjs")],
|
|
||||||
{ cwd: rootDir, stdio: "inherit", env: process.env },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (macosEffectsResult.status !== 0) {
|
|
||||||
process.exit(macosEffectsResult.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
const { validateMacosEffectsDylib, outFile } = await import(
|
|
||||||
pathToFileURL(
|
|
||||||
path.join(rootDir, "scripts/build-macos-effects.mjs"),
|
|
||||||
).href
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validateMacosEffectsDylib({ dylibPath: outFile })) {
|
|
||||||
console.error(
|
|
||||||
"run-electrobun-build: macOS window effects dylib is missing or invalid.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureCoreEnv = {
|
|
||||||
...process.env,
|
|
||||||
ELECTROBUN_TARGET_ARCH: targetArch,
|
|
||||||
ELECTROBUN_FORCE_ARCH: targetArch,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureCoreResult = spawnSync(
|
|
||||||
"bun",
|
|
||||||
[path.join(rootDir, "scripts/ensure-electrobun-core.mjs")],
|
|
||||||
{
|
|
||||||
cwd: rootDir,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: ensureCoreEnv,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ensureCoreResult.status !== 0) {
|
|
||||||
process.exit(ensureCoreResult.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
ELECTROBUN_BUILD_ENV: buildEnv,
|
|
||||||
ELECTROBUN_OS: "macos",
|
|
||||||
ELECTROBUN_ARCH: targetArch,
|
|
||||||
ELECTROBUN_FORCE_ARCH: targetArch,
|
|
||||||
ELECTROBUN_TARGET_ARCH: targetArch,
|
|
||||||
NODE_ENV: process.env.NODE_ENV || "production",
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`run-electrobun-build: env=${buildEnv} os=macos arch=${targetArch} cef=${bundleCef} (host ${getReleaseArch(process.arch)})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = spawnSync(
|
|
||||||
"bun",
|
|
||||||
[electrobunCli, "build", `--env=${buildEnv}`],
|
|
||||||
{
|
|
||||||
cwd: rootDir,
|
|
||||||
stdio: "inherit",
|
|
||||||
env,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { cpSync, mkdirSync, rmSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const buildDir = path.join(rootDir, "build");
|
|
||||||
const viewDir = path.join(rootDir, "dist", "mainview");
|
|
||||||
|
|
||||||
rmSync(viewDir, { recursive: true, force: true });
|
|
||||||
mkdirSync(viewDir, { recursive: true });
|
|
||||||
cpSync(buildDir, viewDir, { recursive: true });
|
|
||||||
|
|
||||||
console.log(`Synced renderer build to ${viewDir}`);
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const buildNumber =
|
|
||||||
process.env.BUILD_NUMBER || process.env.VITE_BUILD_NUMBER || "dev";
|
|
||||||
|
|
||||||
const buildInfoPath = path.join(rootDir, "src/buildInfo.json");
|
|
||||||
|
|
||||||
await Bun.write(
|
|
||||||
buildInfoPath,
|
|
||||||
`${JSON.stringify({ buildNumber }, null, 2)}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`write-build-info: ${buildInfoPath} (buildNumber=${buildNumber})`);
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"buildNumber": "dev"
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { spawn } from 'node:child_process'
|
|
||||||
import { existsSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import {
|
|
||||||
buildDeeplinkPayload,
|
|
||||||
findProtocolUrl,
|
|
||||||
forwardDeeplinkToRunningInstance,
|
|
||||||
writeDeeplinkSignal
|
|
||||||
} from '../desktop/deeplink-ipc.js'
|
|
||||||
|
|
||||||
const url = findProtocolUrl(process.argv)
|
|
||||||
|
|
||||||
console.log('[deeplink] argv:', process.argv)
|
|
||||||
console.log('[deeplink] url:', url ?? null)
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = buildDeeplinkPayload(url, process.argv)
|
|
||||||
const forwarded = await forwardDeeplinkToRunningInstance(payload)
|
|
||||||
|
|
||||||
if (forwarded) {
|
|
||||||
console.log('[deeplink] forwarded to running Farm Control instance')
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[deeplink] no running instance found, launching Farm Control')
|
|
||||||
|
|
||||||
writeDeeplinkSignal(payload)
|
|
||||||
|
|
||||||
const binDir = dirname(process.execPath)
|
|
||||||
const launcherPath = ['FarmControl.exe', 'launcher.exe']
|
|
||||||
.map((name) => join(binDir, name))
|
|
||||||
.find((candidate) => existsSync(candidate))
|
|
||||||
|
|
||||||
if (!launcherPath) {
|
|
||||||
console.error('[deeplink] FarmControl.exe not found in', binDir)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = spawn(launcherPath, [], {
|
|
||||||
cwd: dirname(launcherPath),
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
windowsHide: true
|
|
||||||
})
|
|
||||||
|
|
||||||
child.unref()
|
|
||||||
process.exit(0)
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { ensureWindowsWorkingDirectory } from '../desktop/windows-app-paths.js'
|
|
||||||
import {
|
|
||||||
captureLaunchUrl,
|
|
||||||
closeSingleInstanceServer,
|
|
||||||
ensureSingleInstanceLock
|
|
||||||
} from '../desktop/single-instance.js'
|
|
||||||
|
|
||||||
ensureWindowsWorkingDirectory()
|
|
||||||
|
|
||||||
const launchUrl = captureLaunchUrl()
|
|
||||||
const gotSingleInstanceLock = await ensureSingleInstanceLock({ launchUrl })
|
|
||||||
|
|
||||||
if (!gotSingleInstanceLock) {
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { createAppRpc } = await import('../desktop/rpc.js')
|
|
||||||
const {
|
|
||||||
registerGlobalShortcuts,
|
|
||||||
unregisterGlobalShortcuts
|
|
||||||
} = await import('../desktop/spotlight.js')
|
|
||||||
const {
|
|
||||||
createMainWindow,
|
|
||||||
handleDeepLinkFromArgv,
|
|
||||||
setupDevAuthServer,
|
|
||||||
setupNavigationGestures,
|
|
||||||
setupWindowsDeepLinkHandling
|
|
||||||
} = await import('../desktop/window.js')
|
|
||||||
|
|
||||||
setupWindowsDeepLinkHandling()
|
|
||||||
|
|
||||||
const rpc = createAppRpc()
|
|
||||||
const mainWindow = await createMainWindow(rpc)
|
|
||||||
|
|
||||||
setupNavigationGestures(mainWindow)
|
|
||||||
registerGlobalShortcuts(rpc)
|
|
||||||
setupDevAuthServer()
|
|
||||||
handleDeepLinkFromArgv(launchUrl)
|
|
||||||
|
|
||||||
process.on('exit', () => {
|
|
||||||
unregisterGlobalShortcuts()
|
|
||||||
closeSingleInstanceServer()
|
|
||||||
})
|
|
||||||
@ -30,7 +30,7 @@ const DashboardLayout = ({ children }) => {
|
|||||||
<MessageProvider>
|
<MessageProvider>
|
||||||
<Layout
|
<Layout
|
||||||
style={{ height: 'var(--unit-100vh)' }}
|
style={{ height: 'var(--unit-100vh)' }}
|
||||||
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout`}
|
className={isDarkMode ? 'dark-mode' : 'light-mode'}
|
||||||
>
|
>
|
||||||
<DashboardNavigation />
|
<DashboardNavigation />
|
||||||
<Layout>
|
<Layout>
|
||||||
@ -49,7 +49,7 @@ const DashboardLayout = ({ children }) => {
|
|||||||
) : (
|
) : (
|
||||||
<ProductionSidebar /> // Default to production sidebar
|
<ProductionSidebar /> // Default to production sidebar
|
||||||
)}
|
)}
|
||||||
<Layout style={{ padding: '24px' }} className='main-content-layout'>
|
<Layout style={{ padding: '24px' }}>
|
||||||
<Content>
|
<Content>
|
||||||
<Flex vertical style={{ height: '100%' }} gap='20px'>
|
<Flex vertical style={{ height: '100%' }} gap='20px'>
|
||||||
<Flex justify='space-between'>
|
<Flex justify='space-between'>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const About = () => {
|
|||||||
const { token } = useContext(AuthContext)
|
const { token } = useContext(AuthContext)
|
||||||
const { fetchApiServerVersion, fetchWsServerVersion } =
|
const { fetchApiServerVersion, fetchWsServerVersion } =
|
||||||
useContext(ApiServerContext)
|
useContext(ApiServerContext)
|
||||||
const { isElectron, getAppEngine } = useContext(ElectronContext)
|
const { isElectron, getElectronVersion } = useContext(ElectronContext)
|
||||||
const { checkForUpdates } = useContext(AppUpdateContext)
|
const { checkForUpdates } = useContext(AppUpdateContext)
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
|
||||||
@ -74,18 +74,18 @@ const About = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isElectron) return
|
if (!isElectron) return
|
||||||
|
|
||||||
getAppEngine()
|
getElectronVersion()
|
||||||
.then((engine) => {
|
.then((version) => {
|
||||||
setAppEngine(engine === 'chromium' ? 'Chromium' : 'Native')
|
setElectronVersion(version || 'unknown')
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setAppEngine('Unknown')
|
setElectronVersion('unknown')
|
||||||
})
|
})
|
||||||
}, [getAppEngine, isElectron])
|
}, [getElectronVersion, isElectron])
|
||||||
|
|
||||||
const [apiServerVersion, setApiServerVersion] = useState(null)
|
const [apiServerVersion, setApiServerVersion] = useState(null)
|
||||||
const [wsServerVersion, setWsServerVersion] = useState(null)
|
const [wsServerVersion, setWsServerVersion] = useState(null)
|
||||||
const [appEngine, setAppEngine] = useState(null)
|
const [electronVersion, setElectronVersion] = useState(null)
|
||||||
|
|
||||||
const apiServerVersionText = apiServerVersion ? (
|
const apiServerVersionText = apiServerVersion ? (
|
||||||
<Text>
|
<Text>
|
||||||
@ -99,8 +99,8 @@ const About = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Skeleton.Input active size='small' className='text-skeleton' />
|
<Skeleton.Input active size='small' className='text-skeleton' />
|
||||||
)
|
)
|
||||||
const appEngineText = appEngine ? (
|
const electronVersionText = electronVersion ? (
|
||||||
<Text>{appEngine}</Text>
|
<Text>{`v${electronVersion}`}</Text>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton.Input active size='small' className='text-skeleton' />
|
<Skeleton.Input active size='small' className='text-skeleton' />
|
||||||
)
|
)
|
||||||
@ -153,7 +153,9 @@ const About = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
{isElectron && (
|
{isElectron && (
|
||||||
<Text type='secondary'>Engine: {appEngineText}</Text>
|
<Text type='secondary'>
|
||||||
|
Electron: {electronVersionText}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text type='secondary'>REST API: {apiServerVersionText}</Text>
|
<Text type='secondary'>REST API: {apiServerVersionText}</Text>
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button, Flex, Modal, Typography, theme, Divider } from 'antd'
|
import { Button, Flex, Modal, Progress, Typography, theme, Divider } from 'antd'
|
||||||
|
|
||||||
import CloudIcon from '../../../Icons/CloudIcon'
|
import CloudIcon from '../../../Icons/CloudIcon'
|
||||||
import HostIcon from '../../../Icons/HostIcon'
|
import HostIcon from '../../../Icons/HostIcon'
|
||||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||||
import HProgress from '../../common/HProgress'
|
|
||||||
|
|
||||||
import CheckCircleIcon from '../../../Icons/CheckCircleIcon'
|
import CheckCircleIcon from '../../../Icons/CheckCircleIcon'
|
||||||
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
|
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
|
||||||
@ -66,9 +65,16 @@ const getStageColor = (status, token) => {
|
|||||||
return token.colorTextQuaternary
|
return token.colorTextQuaternary
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_INDEX = { download: 0, install: 1, restart: 2 }
|
const getDownloadStageStatus = (phase, isError) => {
|
||||||
|
if (isError && ['preparing', 'downloading'].includes(phase)) return 'error'
|
||||||
|
if (['downloaded', 'installing'].includes(phase)) return 'complete'
|
||||||
|
if (['preparing', 'downloading'].includes(phase)) return 'active'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInstallComplete = (phase, message) => {
|
||||||
|
if (phase !== 'installing') return false
|
||||||
|
|
||||||
const isCompletionMessage = (message) => {
|
|
||||||
const normalized = String(message || '').toLowerCase()
|
const normalized = String(message || '').toLowerCase()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -78,33 +84,17 @@ const isCompletionMessage = (message) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps a progress event to the stage that should currently be active.
|
const getInstallStageStatus = (phase, isError, message) => {
|
||||||
// Returns null when the event carries no stage information (e.g. errors),
|
if (isError && ['downloaded', 'installing'].includes(phase)) return 'error'
|
||||||
// so the previously reached stage is kept.
|
if (isInstallComplete(phase, message)) return 'complete'
|
||||||
const getStageIndexFromProgress = (phase, percent, message) => {
|
if (phase === 'installing') return 'active'
|
||||||
if (phase === 'restarting') return STAGE_INDEX.restart
|
return 'pending'
|
||||||
|
|
||||||
if (phase === 'installing') {
|
|
||||||
return percent >= 100 || isCompletionMessage(message)
|
|
||||||
? STAGE_INDEX.restart
|
|
||||||
: STAGE_INDEX.install
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === 'downloaded') return STAGE_INDEX.install
|
const getRestartStageStatus = (phase, isError, message) => {
|
||||||
|
if (isError && isInstallComplete(phase, message)) return 'error'
|
||||||
if (phase === 'preparing' || phase === 'downloading') {
|
if (isInstallComplete(phase, message)) return 'active'
|
||||||
return phase === 'downloading' && percent >= 100
|
return 'pending'
|
||||||
? STAGE_INDEX.install
|
|
||||||
: STAGE_INDEX.download
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStageStatus = (stageIndex, activeIndex, isError) => {
|
|
||||||
if (stageIndex < activeIndex) return 'complete'
|
|
||||||
if (stageIndex > activeIndex) return 'pending'
|
|
||||||
return isError ? 'error' : 'active'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getProgressStatus = (stageStatus) => {
|
const getProgressStatus = (stageStatus) => {
|
||||||
@ -119,13 +109,15 @@ const UpdateStage = ({ stage, status, percent, detail }) => {
|
|||||||
const StageIcon = config.icon
|
const StageIcon = config.icon
|
||||||
const resolvedPercent =
|
const resolvedPercent =
|
||||||
typeof percent === 'number' ? Math.min(percent, 100) : undefined
|
typeof percent === 'number' ? Math.min(percent, 100) : undefined
|
||||||
const color = getStageColor(status, token)
|
const resolvedStatus =
|
||||||
const showProgress = status === 'active' && stage !== 'restart'
|
status !== 'error' && resolvedPercent === 100 ? 'complete' : status
|
||||||
|
const color = getStageColor(resolvedStatus, token)
|
||||||
|
const showProgress = resolvedStatus === 'active' && stage !== 'restart'
|
||||||
|
|
||||||
const StatusIcon =
|
const StatusIcon =
|
||||||
status === 'complete'
|
resolvedStatus === 'complete'
|
||||||
? CheckCircleIcon
|
? CheckCircleIcon
|
||||||
: status === 'error'
|
: resolvedStatus === 'error'
|
||||||
? XMarkCircleIcon
|
? XMarkCircleIcon
|
||||||
: StageIcon
|
: StageIcon
|
||||||
|
|
||||||
@ -133,20 +125,16 @@ const UpdateStage = ({ stage, status, percent, detail }) => {
|
|||||||
<Flex align='start' gap='middle' style={{ width: '100%' }}>
|
<Flex align='start' gap='middle' style={{ width: '100%' }}>
|
||||||
<StatusIcon style={{ fontSize: 22, color, flexShrink: 0 }} />
|
<StatusIcon style={{ fontSize: 22, color, flexShrink: 0 }} />
|
||||||
<Flex align='start' gap='24px' style={{ flex: 1, minWidth: 0 }}>
|
<Flex align='start' gap='24px' style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text style={{ flexShrink: 0 }}>{config.labels[status]}</Text>
|
<Text style={{ flexShrink: 0 }}>{config.labels[resolvedStatus]}</Text>
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
<Flex vertical gap={2} style={{ flex: 1, minWidth: 0 }}>
|
<Flex vertical gap={2} style={{ flex: 1 }}>
|
||||||
<HProgress
|
<Progress
|
||||||
percent={resolvedPercent}
|
percent={resolvedPercent}
|
||||||
status={getProgressStatus(status)}
|
status={getProgressStatus(resolvedStatus)}
|
||||||
showInfo={typeof resolvedPercent === 'number'}
|
showInfo={typeof resolvedPercent === 'number'}
|
||||||
style={{ flex: 1, margin: 0 }}
|
style={{ flex: 1, margin: 0 }}
|
||||||
/>
|
/>
|
||||||
{detail && (
|
{detail && <Text type='secondary'>{detail}</Text>}
|
||||||
<Text type='secondary' ellipsis style={{ minWidth: 0 }}>
|
|
||||||
{detail}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
@ -175,46 +163,21 @@ const AppUpdateProgress = ({ progress, update, onClose }) => {
|
|||||||
|
|
||||||
const [errorModalOpen, setErrorModalOpen] = useState(true)
|
const [errorModalOpen, setErrorModalOpen] = useState(true)
|
||||||
|
|
||||||
// Track the furthest stage reached so out-of-order or skipped progress
|
const downloadStatus = getDownloadStageStatus(phase, isError)
|
||||||
// events can never move the steps backwards.
|
const installStatus = getInstallStageStatus(phase, isError, message)
|
||||||
const [activeStageIndex, setActiveStageIndex] = useState(STAGE_INDEX.download)
|
const restartStatus = getRestartStageStatus(phase, isError, message)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stageIndex = getStageIndexFromProgress(phase, percent, message)
|
|
||||||
if (stageIndex !== null) {
|
|
||||||
setActiveStageIndex((previous) => Math.max(previous, stageIndex))
|
|
||||||
}
|
|
||||||
}, [phase, percent, message])
|
|
||||||
|
|
||||||
const downloadStatus = getStageStatus(
|
|
||||||
STAGE_INDEX.download,
|
|
||||||
activeStageIndex,
|
|
||||||
isError
|
|
||||||
)
|
|
||||||
const installStatus = getStageStatus(
|
|
||||||
STAGE_INDEX.install,
|
|
||||||
activeStageIndex,
|
|
||||||
isError
|
|
||||||
)
|
|
||||||
const restartStatus = getStageStatus(
|
|
||||||
STAGE_INDEX.restart,
|
|
||||||
activeStageIndex,
|
|
||||||
isError
|
|
||||||
)
|
|
||||||
|
|
||||||
const downloadPercent =
|
const downloadPercent =
|
||||||
downloadStatus === 'active' && phase === 'downloading' ? percent : 0
|
downloadStatus === 'active' ? (phase === 'preparing' ? 0 : percent) : null
|
||||||
|
|
||||||
const installPercent =
|
const installPercent = installStatus === 'active' ? percent : null
|
||||||
installStatus === 'active' && phase === 'installing' ? percent : null
|
|
||||||
|
|
||||||
const downloadDetail =
|
const downloadDetail =
|
||||||
downloadStatus === 'active' && downloaded && total
|
downloadStatus === 'active' && downloaded && total
|
||||||
? `${downloaded} of ${total}`
|
? `${downloaded} of ${total}`
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const installDetail =
|
const installDetail = installStatus === 'active' ? message : null
|
||||||
installStatus === 'active' && phase === 'installing' ? message : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap='middle'>
|
<Flex vertical gap='middle'>
|
||||||
|
|||||||
@ -88,16 +88,6 @@ const NewAppUpdate = ({ update, onCancel, onUpdate }) => {
|
|||||||
<Text style={{ margin: 0 }} type='secondary'>
|
<Text style={{ margin: 0 }} type='secondary'>
|
||||||
Branch: <Text>{update?.branch || 'Unknown'}</Text>
|
Branch: <Text>{update?.branch || 'Unknown'}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ margin: 0 }} type='secondary'>
|
|
||||||
Engine:{' '}
|
|
||||||
<Text>
|
|
||||||
{update?.engine === 'chromium'
|
|
||||||
? 'Chromium'
|
|
||||||
: update?.engine === 'native'
|
|
||||||
? 'Native'
|
|
||||||
: 'Unknown'}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
<Dropdown menu={actionsMenu}>
|
<Dropdown menu={actionsMenu}>
|
||||||
<Button size='small' type='text'>
|
<Button size='small' type='text'>
|
||||||
|
|||||||
@ -5,10 +5,6 @@ import { useThemeContext } from '../context/ThemeContext'
|
|||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
import {
|
|
||||||
normalizeAppUpdateEngine,
|
|
||||||
useAppUpdateContext
|
|
||||||
} from '../context/AppUpdateContext'
|
|
||||||
import { useMessageContext } from '../context/MessageContext'
|
import { useMessageContext } from '../context/MessageContext'
|
||||||
import useCollapseState from '../hooks/useCollapseState'
|
import useCollapseState from '../hooks/useCollapseState'
|
||||||
import InfoCollapse from '../common/InfoCollapse'
|
import InfoCollapse from '../common/InfoCollapse'
|
||||||
@ -18,22 +14,13 @@ import EditButtons from '../common/EditButtons'
|
|||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
const { Option } = Select
|
const { Option } = Select
|
||||||
const DEFAULT_UPDATE_BRANCH = 'main'
|
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||||
const DEFAULT_UPDATE_ENGINE = 'native'
|
|
||||||
|
|
||||||
const engineLabel = (engine) => {
|
|
||||||
const normalized = normalizeAppUpdateEngine(engine)
|
|
||||||
if (normalized === 'chromium') return 'Chromium'
|
|
||||||
if (normalized === 'native') return 'Native'
|
|
||||||
return 'Not configured'
|
|
||||||
}
|
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
|
const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
|
||||||
useThemeContext()
|
useThemeContext()
|
||||||
const { fetchAppUpdateBranches } = useContext(ApiServerContext)
|
const { fetchAppUpdateBranches } = useContext(ApiServerContext)
|
||||||
const { isElectron, getAppSettings, setAppSettings, getAppEngine } =
|
const { isElectron, getAppSettings, setAppSettings } =
|
||||||
useContext(ElectronContext)
|
useContext(ElectronContext)
|
||||||
const { recheckForUpdates } = useAppUpdateContext()
|
|
||||||
const { userProfile, setUserProfile } = useContext(AuthContext)
|
const { userProfile, setUserProfile } = useContext(AuthContext)
|
||||||
const { showSuccess, showError } = useMessageContext()
|
const { showSuccess, showError } = useMessageContext()
|
||||||
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
|
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
|
||||||
@ -47,31 +34,20 @@ const Settings = () => {
|
|||||||
const [draftSettings, setDraftSettings] = useState({})
|
const [draftSettings, setDraftSettings] = useState({})
|
||||||
const [branches, setBranches] = useState([])
|
const [branches, setBranches] = useState([])
|
||||||
const [branchLoading, setBranchLoading] = useState(false)
|
const [branchLoading, setBranchLoading] = useState(false)
|
||||||
const [runningEngine, setRunningEngine] = useState(DEFAULT_UPDATE_ENGINE)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
setSettingsLoading(true)
|
setSettingsLoading(true)
|
||||||
const [storedSettings, detectedEngine] = await Promise.all([
|
const storedSettings = isElectron
|
||||||
isElectron ? getAppSettings() : Promise.resolve(userProfile?.settings || {}),
|
? await getAppSettings()
|
||||||
isElectron ? getAppEngine() : Promise.resolve(DEFAULT_UPDATE_ENGINE)
|
: userProfile?.settings || {}
|
||||||
])
|
setAppSettingsState(storedSettings || {})
|
||||||
const nextEngine =
|
setDraftSettings(storedSettings || {})
|
||||||
normalizeAppUpdateEngine(detectedEngine) || DEFAULT_UPDATE_ENGINE
|
|
||||||
setRunningEngine(nextEngine)
|
|
||||||
|
|
||||||
const nextSettings = { ...(storedSettings || {}) }
|
|
||||||
if (isElectron && !normalizeAppUpdateEngine(nextSettings.appUpdateEngine)) {
|
|
||||||
nextSettings.appUpdateEngine = nextEngine
|
|
||||||
}
|
|
||||||
|
|
||||||
setAppSettingsState(nextSettings)
|
|
||||||
setDraftSettings(nextSettings)
|
|
||||||
setSettingsLoading(false)
|
setSettingsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSettings()
|
loadSettings()
|
||||||
}, [getAppEngine, getAppSettings, isElectron, userProfile?.settings])
|
}, [getAppSettings, isElectron, userProfile?.settings])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsLoading || isEditing) return
|
if (settingsLoading || isEditing) return
|
||||||
@ -142,17 +118,12 @@ const Settings = () => {
|
|||||||
(branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
|
(branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
|
||||||
branches[0] ||
|
branches[0] ||
|
||||||
'Not configured'
|
'Not configured'
|
||||||
const currentEngine =
|
|
||||||
normalizeAppUpdateEngine(appSettings.appUpdateEngine) ||
|
|
||||||
normalizeAppUpdateEngine(runningEngine) ||
|
|
||||||
DEFAULT_UPDATE_ENGINE
|
|
||||||
|
|
||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
setDraftSettings({
|
setDraftSettings({
|
||||||
...appSettings,
|
...appSettings,
|
||||||
appUpdateBranch:
|
appUpdateBranch:
|
||||||
currentBranch === 'Not configured' ? undefined : currentBranch,
|
currentBranch === 'Not configured' ? undefined : currentBranch,
|
||||||
appUpdateEngine: currentEngine,
|
|
||||||
theme: currentThemeValue,
|
theme: currentThemeValue,
|
||||||
density: currentDensityValue
|
density: currentDensityValue
|
||||||
})
|
})
|
||||||
@ -168,18 +139,12 @@ const Settings = () => {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextEngine =
|
|
||||||
normalizeAppUpdateEngine(draftSettings.appUpdateEngine) ||
|
|
||||||
currentEngine
|
|
||||||
const nextSettings = {
|
const nextSettings = {
|
||||||
...appSettings,
|
...appSettings,
|
||||||
theme: draftSettings.theme,
|
theme: draftSettings.theme,
|
||||||
density: draftSettings.density,
|
density: draftSettings.density,
|
||||||
...(isElectron
|
...(isElectron
|
||||||
? {
|
? { appUpdateBranch: draftSettings.appUpdateBranch }
|
||||||
appUpdateBranch: draftSettings.appUpdateBranch,
|
|
||||||
appUpdateEngine: nextEngine
|
|
||||||
}
|
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
const saved = isElectron
|
const saved = isElectron
|
||||||
@ -208,16 +173,6 @@ const Settings = () => {
|
|||||||
setDraftSettings(nextSettings)
|
setDraftSettings(nextSettings)
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
showSuccess('Settings saved.')
|
showSuccess('Settings saved.')
|
||||||
|
|
||||||
const appUpdateSettingsChanged =
|
|
||||||
isElectron &&
|
|
||||||
(nextSettings.appUpdateBranch !== appSettings.appUpdateBranch ||
|
|
||||||
nextSettings.appUpdateEngine !==
|
|
||||||
normalizeAppUpdateEngine(appSettings.appUpdateEngine))
|
|
||||||
|
|
||||||
if (appUpdateSettingsChanged) {
|
|
||||||
void recheckForUpdates()
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@ -241,9 +196,7 @@ const Settings = () => {
|
|||||||
startEditing={startEditing}
|
startEditing={startEditing}
|
||||||
formValid={
|
formValid={
|
||||||
Boolean(draftSettings.theme && draftSettings.density) &&
|
Boolean(draftSettings.theme && draftSettings.density) &&
|
||||||
(!isElectron ||
|
(!isElectron || Boolean(draftSettings.appUpdateBranch))
|
||||||
(Boolean(draftSettings.appUpdateBranch) &&
|
|
||||||
Boolean(normalizeAppUpdateEngine(draftSettings.appUpdateEngine))))
|
|
||||||
}
|
}
|
||||||
disabled={settingsLoading || (!isElectron && !userProfile)}
|
disabled={settingsLoading || (!isElectron && !userProfile)}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
@ -344,29 +297,6 @@ const Settings = () => {
|
|||||||
<Text>{currentBranch}</Text>
|
<Text>{currentBranch}</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Engine'>
|
|
||||||
{isEditing ? (
|
|
||||||
<Select
|
|
||||||
value={
|
|
||||||
normalizeAppUpdateEngine(draftSettings.appUpdateEngine) ||
|
|
||||||
currentEngine
|
|
||||||
}
|
|
||||||
onChange={(value) =>
|
|
||||||
setDraftSettings((previous) => ({
|
|
||||||
...previous,
|
|
||||||
appUpdateEngine: value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
placeholder='Select an engine'
|
|
||||||
>
|
|
||||||
<Option value='native'>Native</Option>
|
|
||||||
<Option value='chromium'>Chromium</Option>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<Text>{engineLabel(currentEngine)}</Text>
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</InfoCollapse>
|
</InfoCollapse>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import SettingsIcon from '../../Icons/SettingsIcon'
|
|||||||
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import DashboardWindowButtons from './DashboardWindowButtons'
|
import DashboardWindowButtons from './DashboardWindowButtons'
|
||||||
import WindowAppMenu from './WindowAppMenu'
|
|
||||||
import WebAppSwitcher from './WebAppSwitcher'
|
import WebAppSwitcher from './WebAppSwitcher'
|
||||||
import {
|
import {
|
||||||
getSidebarDefaultPath,
|
getSidebarDefaultPath,
|
||||||
@ -53,8 +52,7 @@ const DashboardNavigation = () => {
|
|||||||
const { userProfile } = useContext(AuthContext)
|
const { userProfile } = useContext(AuthContext)
|
||||||
const { showSpotlight } = useContext(SpotlightContext)
|
const { showSpotlight } = useContext(SpotlightContext)
|
||||||
const { connecting, connected } = useContext(ApiServerContext)
|
const { connecting, connected } = useContext(ApiServerContext)
|
||||||
const { authenticated } = useContext(AuthContext)
|
const { toggleNotificationCenter, unreadCount, notificationCenterVisible } =
|
||||||
const { toggleNotificationCenter, unreadCount } =
|
|
||||||
useContext(NotificationContext)
|
useContext(NotificationContext)
|
||||||
const [apiServerState, setApiServerState] = useState('disconnected')
|
const [apiServerState, setApiServerState] = useState('disconnected')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -66,44 +64,34 @@ const DashboardNavigation = () => {
|
|||||||
icon: <ProductionIcon />
|
icon: <ProductionIcon />
|
||||||
})
|
})
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
const {
|
const { platform, isElectron, isFullScreen, setSidebarViewMenu } =
|
||||||
platform,
|
useContext(ElectronContext)
|
||||||
isElectron,
|
|
||||||
setSidebarViewMenu,
|
|
||||||
isFullScreen,
|
|
||||||
isMaximized
|
|
||||||
} = useContext(ElectronContext)
|
|
||||||
const { availableUpdate, checkForUpdates } = useAppUpdateContext()
|
const { availableUpdate, checkForUpdates } = useAppUpdateContext()
|
||||||
const mainMenuItems = useMemo(
|
const mainMenuItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: 'production',
|
key: 'production',
|
||||||
label: 'Production',
|
label: 'Production',
|
||||||
className: 'electrobun-webkit-app-region-no-drag',
|
|
||||||
icon: <ProductionIcon />
|
icon: <ProductionIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'inventory',
|
key: 'inventory',
|
||||||
label: 'Inventory',
|
label: 'Inventory',
|
||||||
className: 'electrobun-webkit-app-region-no-drag',
|
|
||||||
icon: <InventoryIcon />
|
icon: <InventoryIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sales',
|
key: 'sales',
|
||||||
label: 'Sales',
|
label: 'Sales',
|
||||||
className: 'electrobun-webkit-app-region-no-drag',
|
|
||||||
icon: <SalesIcon />
|
icon: <SalesIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'finance',
|
key: 'finance',
|
||||||
label: 'Finance',
|
label: 'Finance',
|
||||||
className: 'electrobun-webkit-app-region-no-drag',
|
|
||||||
icon: <FinanceIcon />
|
icon: <FinanceIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'management',
|
key: 'management',
|
||||||
label: 'Management',
|
label: 'Management',
|
||||||
className: 'electrobun-webkit-app-region-no-drag',
|
|
||||||
icon: <SettingsIcon />
|
icon: <SettingsIcon />
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -147,26 +135,28 @@ const DashboardNavigation = () => {
|
|||||||
setSidebarViewMenu(sections)
|
setSidebarViewMenu(sections)
|
||||||
}, [isElectron, setSidebarViewMenu])
|
}, [isElectron, setSidebarViewMenu])
|
||||||
|
|
||||||
|
const showAppLogo =
|
||||||
|
(isElectron && platform == 'darwin' && isFullScreen == true) ||
|
||||||
|
(isElectron && platform != 'darwin')
|
||||||
const isMacOSApp = isElectron && platform == 'darwin'
|
const isMacOSApp = isElectron && platform == 'darwin'
|
||||||
const isOtherApp = isElectron && platform != 'darwin'
|
const isOtherApp = isElectron && platform != 'darwin'
|
||||||
|
|
||||||
const showDesktopLogo = !isElectron && !isMobile
|
const showDesktopLogo = !isElectron && !isMobile
|
||||||
const showMobileLogo = !isElectron && isMobile
|
const showMobileLogo = !isElectron && isMobile
|
||||||
|
|
||||||
const showControls = !isElectron || authenticated
|
|
||||||
|
|
||||||
const navigationContents = (
|
const navigationContents = (
|
||||||
<Flex style={{ width: '100%' }} align='center'>
|
<Flex style={{ width: '100%' }} align='center'>
|
||||||
{isMacOSApp ? <DashboardWindowButtons /> : null}
|
{isMacOSApp ? <DashboardWindowButtons /> : null}
|
||||||
{isOtherApp ? (
|
{showAppLogo == true && (
|
||||||
<>
|
<FarmControlLogoSmall
|
||||||
<WindowAppMenu />{' '}
|
style={{
|
||||||
<Divider
|
fontSize: '46px',
|
||||||
type='vertical'
|
height: '16px',
|
||||||
style={{ height: '14px', margin: '3px 3px 0 1.5px' }}
|
marginLeft: '13px',
|
||||||
|
marginRight: '0px'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
) : null}
|
|
||||||
{showDesktopLogo == true ? (
|
{showDesktopLogo == true ? (
|
||||||
<FarmControlLogo
|
<FarmControlLogo
|
||||||
style={{
|
style={{
|
||||||
@ -209,8 +199,6 @@ 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}
|
||||||
@ -231,30 +219,12 @@ 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
|
||||||
@ -289,7 +259,6 @@ const DashboardNavigation = () => {
|
|||||||
</KeyboardShortcut>
|
</KeyboardShortcut>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
|
||||||
{import.meta.env.MODE === 'development' && (
|
{import.meta.env.MODE === 'development' && (
|
||||||
<Space>
|
<Space>
|
||||||
{apiServerState === 'connected' ? (
|
{apiServerState === 'connected' ? (
|
||||||
@ -331,7 +300,7 @@ const DashboardNavigation = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
{showControls && userProfile ? (
|
{userProfile ? (
|
||||||
<Space>
|
<Space>
|
||||||
<Popover
|
<Popover
|
||||||
content={userPopoverContent}
|
content={userPopoverContent}
|
||||||
@ -347,7 +316,7 @@ const DashboardNavigation = () => {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</Space>
|
</Space>
|
||||||
) : null}
|
) : null}
|
||||||
{showControls && isElectron && availableUpdate ? (
|
{isElectron && availableUpdate ? (
|
||||||
<Tag
|
<Tag
|
||||||
icon={<CloudIcon />}
|
icon={<CloudIcon />}
|
||||||
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
|
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
|
||||||
@ -358,7 +327,6 @@ const DashboardNavigation = () => {
|
|||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
|
||||||
{isOtherApp ? <DashboardWindowButtons /> : null}
|
{isOtherApp ? <DashboardWindowButtons /> : null}
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
@ -367,7 +335,7 @@ const DashboardNavigation = () => {
|
|||||||
<>
|
<>
|
||||||
{isElectron ? (
|
{isElectron ? (
|
||||||
<Flex
|
<Flex
|
||||||
className={`ant-menu-horizontal electron-navigation-wrapper ant-menu-light ${isFullScreen || (isMaximized && isOtherApp) ? 'electrobun-webkit-app-region-no-drag' : 'electrobun-webkit-app-region-drag'}`}
|
className={`ant-menu-horizontal ant-menu-light${!notificationCenterVisible ? ' electron-navigation-wrapper' : ''}`}
|
||||||
style={{ lineHeight: '40px', padding: '0 2px 0 2px' }}
|
style={{ lineHeight: '40px', padding: '0 2px 0 2px' }}
|
||||||
>
|
>
|
||||||
{navigationContents}
|
{navigationContents}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { useContext } from 'react'
|
import { useContext } from 'react'
|
||||||
import { Flex, Button, Divider } from 'antd'
|
import { Flex, Button } from 'antd'
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||||
import MinusIcon from '../../Icons/MinusIcon'
|
import MinusIcon from '../../Icons/MinusIcon'
|
||||||
import ContractIcon from '../../Icons/ContractIcon'
|
import ContractIcon from '../../Icons/ContractIcon'
|
||||||
import ExpandIcon from '../../Icons/ExpandIcon'
|
import ExpandIcon from '../../Icons/ExpandIcon'
|
||||||
import MacOSTrafficLights from './MacOSTrafficLights'
|
|
||||||
|
|
||||||
const DashboardWindowButtons = () => {
|
const DashboardWindowButtons = () => {
|
||||||
const { isMaximized, handleWindowControl, platform, isFullScreen } =
|
const { isMaximized, handleWindowControl, platform, isFullScreen } =
|
||||||
@ -18,21 +17,14 @@ const DashboardWindowButtons = () => {
|
|||||||
onClick={() => handleWindowControl('close')}
|
onClick={() => handleWindowControl('close')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
const minimizeButton = (
|
const maximizeButton = (
|
||||||
<Button
|
<Button
|
||||||
icon={<MinusIcon />}
|
icon={<MinusIcon />}
|
||||||
type={'text'}
|
type={'text'}
|
||||||
onClick={() => handleWindowControl('minimize')}
|
onClick={() => handleWindowControl('minimize')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
const fullscreenButton = (
|
const minimizeButton = (
|
||||||
<Button
|
|
||||||
icon={isFullScreen ? <ContractIcon /> : <ExpandIcon />}
|
|
||||||
type={'text'}
|
|
||||||
onClick={() => handleWindowControl('fullscreen')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const maximizeButton = (
|
|
||||||
<Button
|
<Button
|
||||||
icon={isMaximized ? <ContractIcon /> : <ExpandIcon />}
|
icon={isMaximized ? <ContractIcon /> : <ExpandIcon />}
|
||||||
type={'text'}
|
type={'text'}
|
||||||
@ -40,41 +32,20 @@ const DashboardWindowButtons = () => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const customWindowControls = (
|
|
||||||
<div
|
|
||||||
className='electrobun-webkit-app-region-no-drag'
|
|
||||||
style={{ width: '95px', marginRight: '2.5px' }}
|
|
||||||
>
|
|
||||||
<Flex style={{ position: 'relative', zIndex: 9999 }}>
|
|
||||||
{platform == 'darwin' ? (
|
|
||||||
<>
|
|
||||||
{closeButton}
|
|
||||||
{minimizeButton}
|
|
||||||
{fullscreenButton}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{minimizeButton}
|
|
||||||
{maximizeButton}
|
|
||||||
{closeButton}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align='center'>
|
<Flex align='center'>
|
||||||
{platform == 'darwin' ? (
|
{platform == 'darwin' ? (
|
||||||
<>
|
isFullScreen == false ? (
|
||||||
<MacOSTrafficLights />
|
<div style={{ width: '80px' }} />
|
||||||
<Divider
|
) : null
|
||||||
type='vertical'
|
|
||||||
style={{ height: '14px', margin: '3px 6px 0 0' }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
customWindowControls
|
<div style={{ width: '95px', marginRight: '2.5px' }}>
|
||||||
|
<Flex style={{ position: 'relative', zIndex: 9999 }}>
|
||||||
|
{maximizeButton}
|
||||||
|
{minimizeButton}
|
||||||
|
{closeButton}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,287 +0,0 @@
|
|||||||
import { forwardRef, useMemo } from 'react'
|
|
||||||
import PropTypes from 'prop-types'
|
|
||||||
import { theme } from 'antd'
|
|
||||||
import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled'
|
|
||||||
import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'
|
|
||||||
|
|
||||||
const validProgress = (progress) => {
|
|
||||||
if (!progress || progress < 0) return 0
|
|
||||||
if (progress > 100) return 100
|
|
||||||
return progress
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSuccessPercent = ({ success, successPercent }) => {
|
|
||||||
if (success && 'progress' in success) return success.progress
|
|
||||||
if (success && 'percent' in success) return success.percent
|
|
||||||
return successPercent
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortGradient = (gradients) => {
|
|
||||||
const entries = Object.keys(gradients)
|
|
||||||
.map((key) => ({
|
|
||||||
key: Number.parseFloat(key.replace(/%/g, '')),
|
|
||||||
value: gradients[key]
|
|
||||||
}))
|
|
||||||
.filter(({ key }) => !Number.isNaN(key))
|
|
||||||
.sort((a, b) => a.key - b.key)
|
|
||||||
|
|
||||||
return entries.map(({ key, value }) => `${value} ${key}%`).join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveStrokeBackground = (strokeColor, direction = 'to right') => {
|
|
||||||
if (!strokeColor) return undefined
|
|
||||||
|
|
||||||
if (typeof strokeColor === 'string') {
|
|
||||||
return strokeColor
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(strokeColor)) {
|
|
||||||
return strokeColor[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { from, to, direction: gradientDirection, ...rest } = strokeColor
|
|
||||||
|
|
||||||
if (Object.keys(rest).length > 0) {
|
|
||||||
return `linear-gradient(${gradientDirection || direction}, ${sortGradient(rest)})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `linear-gradient(${gradientDirection || direction}, ${from}, ${to})`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getBarHeight = ({ size, strokeWidth }) => {
|
|
||||||
if (typeof strokeWidth === 'number') return strokeWidth
|
|
||||||
|
|
||||||
if (size === 'small') return 6
|
|
||||||
if (typeof size === 'number') return size
|
|
||||||
if (Array.isArray(size)) return size[1] ?? 8
|
|
||||||
if (typeof size === 'object' && size?.height != null) return size.height
|
|
||||||
|
|
||||||
return 8
|
|
||||||
}
|
|
||||||
|
|
||||||
const getOuterWidth = ({ size, strokeWidth }) => {
|
|
||||||
if (typeof size === 'number') return size
|
|
||||||
if (Array.isArray(size)) return size[0] ?? -1
|
|
||||||
if (typeof size === 'object' && size?.width != null) return size.width
|
|
||||||
if (strokeWidth) return -1
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
const HProgress = forwardRef(function HProgress(
|
|
||||||
{
|
|
||||||
className,
|
|
||||||
rootClassName,
|
|
||||||
style,
|
|
||||||
percent = 0,
|
|
||||||
success,
|
|
||||||
successPercent,
|
|
||||||
status,
|
|
||||||
showInfo = true,
|
|
||||||
strokeColor,
|
|
||||||
trailColor,
|
|
||||||
strokeLinecap = 'round',
|
|
||||||
strokeWidth,
|
|
||||||
size = 'default',
|
|
||||||
format,
|
|
||||||
percentPosition = {}
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const { token } = theme.useToken()
|
|
||||||
|
|
||||||
const { align: infoAlign = 'end', type: infoPosition = 'outer' } =
|
|
||||||
percentPosition
|
|
||||||
|
|
||||||
const resolvedPercent = validProgress(percent)
|
|
||||||
const resolvedSuccessPercent = getSuccessPercent({ success, successPercent })
|
|
||||||
const successValue =
|
|
||||||
resolvedSuccessPercent == null
|
|
||||||
? undefined
|
|
||||||
: validProgress(resolvedSuccessPercent)
|
|
||||||
|
|
||||||
const percentNumber = Number.parseInt(
|
|
||||||
String(successValue ?? resolvedPercent),
|
|
||||||
10
|
|
||||||
)
|
|
||||||
|
|
||||||
const progressStatus = useMemo(() => {
|
|
||||||
if (status === 'exception' || status === 'success' || status === 'active') {
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
if (percentNumber >= 100) return 'success'
|
|
||||||
return status || 'normal'
|
|
||||||
}, [status, percentNumber])
|
|
||||||
|
|
||||||
const barHeight = getBarHeight({ size, strokeWidth })
|
|
||||||
const outerWidth = getOuterWidth({ size, strokeWidth })
|
|
||||||
const borderRadius =
|
|
||||||
strokeLinecap === 'square' || strokeLinecap === 'butt' ? 0 : barHeight
|
|
||||||
|
|
||||||
const trailBackground = trailColor || token.colorFillSecondary
|
|
||||||
|
|
||||||
const defaultFillColor =
|
|
||||||
progressStatus === 'exception'
|
|
||||||
? token.colorError
|
|
||||||
: progressStatus === 'success'
|
|
||||||
? token.colorSuccess
|
|
||||||
: token.colorPrimary
|
|
||||||
|
|
||||||
const fillBackground =
|
|
||||||
resolveStrokeBackground(strokeColor) || defaultFillColor
|
|
||||||
|
|
||||||
const successBackground =
|
|
||||||
success?.strokeColor || token.colorSuccess
|
|
||||||
|
|
||||||
const textFormatter = format || ((value) => `${value}%`)
|
|
||||||
const progressTextValue = textFormatter(
|
|
||||||
resolvedPercent,
|
|
||||||
successValue ?? undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
const progressInfo = !showInfo ? null : (
|
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
'h-progress-text',
|
|
||||||
infoPosition === 'outer' && infoAlign === 'start'
|
|
||||||
? 'h-progress-text-start'
|
|
||||||
: null
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
title={typeof progressTextValue === 'string' ? progressTextValue : undefined}
|
|
||||||
style={{ color: token.colorText }}
|
|
||||||
>
|
|
||||||
{progressStatus === 'exception' ? (
|
|
||||||
<CloseCircleFilled style={{ color: token.colorError }} />
|
|
||||||
) : progressStatus === 'success' ? (
|
|
||||||
<CheckCircleFilled style={{ color: token.colorSuccess }} />
|
|
||||||
) : (
|
|
||||||
progressTextValue
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
const innerInfo =
|
|
||||||
infoPosition === 'inner' && showInfo ? (
|
|
||||||
<span className='h-progress-text-inner'>{progressTextValue}</span>
|
|
||||||
) : null
|
|
||||||
|
|
||||||
const lineInner = (
|
|
||||||
<div
|
|
||||||
className='h-progress-inner'
|
|
||||||
style={{
|
|
||||||
height: barHeight,
|
|
||||||
backgroundColor: trailBackground,
|
|
||||||
borderRadius
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className='h-progress-bg'
|
|
||||||
style={{
|
|
||||||
width: `${resolvedPercent}%`,
|
|
||||||
height: barHeight,
|
|
||||||
borderRadius,
|
|
||||||
background: fillBackground,
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{innerInfo}
|
|
||||||
</div>
|
|
||||||
{progressStatus === 'active' && (
|
|
||||||
<div
|
|
||||||
className='h-progress-bg-mask'
|
|
||||||
style={{ width: `${resolvedPercent}%`, borderRadius }}
|
|
||||||
>
|
|
||||||
<div className='h-progress-bg-wave' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{successValue != null && (
|
|
||||||
<div
|
|
||||||
className='h-progress-success-bg'
|
|
||||||
style={{
|
|
||||||
width: `${successValue}%`,
|
|
||||||
height: barHeight,
|
|
||||||
borderRadius,
|
|
||||||
background: successBackground
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const isOuterStart = infoPosition === 'outer' && infoAlign === 'start'
|
|
||||||
const isOuterEnd = infoPosition === 'outer' && infoAlign === 'end'
|
|
||||||
const isLayoutBottom =
|
|
||||||
infoPosition === 'outer' && infoAlign === 'center'
|
|
||||||
|
|
||||||
const content = isLayoutBottom ? (
|
|
||||||
<div className='h-progress-layout-bottom'>
|
|
||||||
{lineInner}
|
|
||||||
{progressInfo}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className='h-progress-outer'
|
|
||||||
style={{ width: outerWidth < 0 ? '100%' : outerWidth }}
|
|
||||||
>
|
|
||||||
{isOuterStart && progressInfo}
|
|
||||||
{lineInner}
|
|
||||||
{isOuterEnd && progressInfo}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={['h-progress', className, rootClassName]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
style={style}
|
|
||||||
role='progressbar'
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={100}
|
|
||||||
aria-valuenow={resolvedPercent}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
HProgress.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
rootClassName: PropTypes.string,
|
|
||||||
style: PropTypes.object,
|
|
||||||
percent: PropTypes.number,
|
|
||||||
success: PropTypes.shape({
|
|
||||||
percent: PropTypes.number,
|
|
||||||
progress: PropTypes.number,
|
|
||||||
strokeColor: PropTypes.string
|
|
||||||
}),
|
|
||||||
successPercent: PropTypes.number,
|
|
||||||
status: PropTypes.oneOf(['normal', 'exception', 'active', 'success']),
|
|
||||||
showInfo: PropTypes.bool,
|
|
||||||
strokeColor: PropTypes.oneOfType([
|
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.arrayOf(PropTypes.string),
|
|
||||||
PropTypes.object
|
|
||||||
]),
|
|
||||||
trailColor: PropTypes.string,
|
|
||||||
strokeLinecap: PropTypes.oneOf(['butt', 'square', 'round']),
|
|
||||||
strokeWidth: PropTypes.number,
|
|
||||||
size: PropTypes.oneOfType([
|
|
||||||
PropTypes.oneOf(['default', 'small']),
|
|
||||||
PropTypes.number,
|
|
||||||
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
|
|
||||||
PropTypes.shape({
|
|
||||||
width: PropTypes.number,
|
|
||||||
height: PropTypes.number
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
format: PropTypes.func,
|
|
||||||
percentPosition: PropTypes.shape({
|
|
||||||
align: PropTypes.oneOf(['start', 'center', 'end']),
|
|
||||||
type: PropTypes.oneOf(['inner', 'outer'])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HProgress
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
import { useContext, useEffect, useState } from 'react'
|
|
||||||
import PropTypes from 'prop-types'
|
|
||||||
import { Flex } from 'antd'
|
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
|
||||||
|
|
||||||
import CloseColored from '../../../../assets/trafficlights/closecolored.svg?react'
|
|
||||||
import CloseHoverColored from '../../../../assets/trafficlights/closehovercolored.svg?react'
|
|
||||||
import CloseDownColored from '../../../../assets/trafficlights/closedowncolored.svg?react'
|
|
||||||
import MinimizeColored from '../../../../assets/trafficlights/minimizecolored.svg?react'
|
|
||||||
import MinimizeHoverColored from '../../../../assets/trafficlights/minimizehovercolored.svg?react'
|
|
||||||
import MinimizeDownColored from '../../../../assets/trafficlights/minimizedowncolored.svg?react'
|
|
||||||
import FullscreenColored from '../../../../assets/trafficlights/fullscreencolored.svg?react'
|
|
||||||
import FullscreenHoverColored from '../../../../assets/trafficlights/fullscreenhovercolored.svg?react'
|
|
||||||
import FullscreenDownColored from '../../../../assets/trafficlights/fullscreendowncolored.svg?react'
|
|
||||||
import ExitFullscreenHoverColored from '../../../../assets/trafficlights/exitfullscreenhovercolored.svg?react'
|
|
||||||
import ExitFullscreenDownColored from '../../../../assets/trafficlights/exitfullscreendowncolored.svg?react'
|
|
||||||
import NoFocus from '../../../../assets/trafficlights/nofocus.svg?react'
|
|
||||||
|
|
||||||
const TRAFFIC_LIGHT_SIZE = 12
|
|
||||||
|
|
||||||
const TRAFFIC_LIGHT_BUTTON_STYLE = {
|
|
||||||
width: TRAFFIC_LIGHT_SIZE,
|
|
||||||
height: TRAFFIC_LIGHT_SIZE,
|
|
||||||
padding: 0,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
cursor: 'default',
|
|
||||||
lineHeight: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRAFFIC_LIGHT_ICON_STYLE = {
|
|
||||||
width: TRAFFIC_LIGHT_SIZE,
|
|
||||||
height: TRAFFIC_LIGHT_SIZE,
|
|
||||||
display: 'block'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTrafficLightIcon({ isEnabled, isFocused, isActive, isHovered, icons }) {
|
|
||||||
if (!isEnabled || !isFocused) {
|
|
||||||
return icons.noFocus
|
|
||||||
}
|
|
||||||
if (isActive) {
|
|
||||||
return icons.down
|
|
||||||
}
|
|
||||||
if (isHovered) {
|
|
||||||
return icons.hover
|
|
||||||
}
|
|
||||||
return icons.colored
|
|
||||||
}
|
|
||||||
|
|
||||||
const TrafficLightButton = ({
|
|
||||||
action,
|
|
||||||
icons,
|
|
||||||
isGroupHovered,
|
|
||||||
activeAction,
|
|
||||||
isEnabled,
|
|
||||||
isWindowFocused,
|
|
||||||
onActive,
|
|
||||||
onAction
|
|
||||||
}) => {
|
|
||||||
const Icon = getTrafficLightIcon({
|
|
||||||
isEnabled,
|
|
||||||
isFocused: isWindowFocused,
|
|
||||||
isActive: activeAction === action,
|
|
||||||
isHovered: isGroupHovered,
|
|
||||||
icons
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleMouseDown = (event) => {
|
|
||||||
if (!isEnabled) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
onActive(action)
|
|
||||||
onAction(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
aria-label={action}
|
|
||||||
aria-disabled={!isEnabled}
|
|
||||||
disabled={!isEnabled}
|
|
||||||
className='electrobun-webkit-app-region-no-drag'
|
|
||||||
style={TRAFFIC_LIGHT_BUTTON_STYLE}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseUp={isEnabled ? () => onActive(null) : undefined}
|
|
||||||
>
|
|
||||||
<Icon style={TRAFFIC_LIGHT_ICON_STYLE} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TrafficLightButton.propTypes = {
|
|
||||||
action: PropTypes.string.isRequired,
|
|
||||||
icons: PropTypes.shape({
|
|
||||||
colored: PropTypes.elementType.isRequired,
|
|
||||||
hover: PropTypes.elementType.isRequired,
|
|
||||||
down: PropTypes.elementType.isRequired,
|
|
||||||
noFocus: PropTypes.elementType.isRequired
|
|
||||||
}).isRequired,
|
|
||||||
isGroupHovered: PropTypes.bool.isRequired,
|
|
||||||
activeAction: PropTypes.string,
|
|
||||||
isEnabled: PropTypes.bool.isRequired,
|
|
||||||
isWindowFocused: PropTypes.bool.isRequired,
|
|
||||||
onActive: PropTypes.func.isRequired,
|
|
||||||
onAction: PropTypes.func.isRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
const MacOSTrafficLights = () => {
|
|
||||||
const { handleWindowControl, isFullScreen } = useContext(ElectronContext)
|
|
||||||
const [isGroupHovered, setIsGroupHovered] = useState(false)
|
|
||||||
const [activeAction, setActiveAction] = useState(null)
|
|
||||||
const [isWindowFocused, setIsWindowFocused] = useState(
|
|
||||||
() => document.hasFocus?.() ?? true
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleFocus = () => setIsWindowFocused(true)
|
|
||||||
const handleBlur = () => setIsWindowFocused(false)
|
|
||||||
|
|
||||||
window.addEventListener('focus', handleFocus)
|
|
||||||
window.addEventListener('blur', handleBlur)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('focus', handleFocus)
|
|
||||||
window.removeEventListener('blur', handleBlur)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fullscreenIcons = isFullScreen
|
|
||||||
? {
|
|
||||||
colored: FullscreenColored,
|
|
||||||
hover: ExitFullscreenHoverColored,
|
|
||||||
down: ExitFullscreenDownColored,
|
|
||||||
noFocus: NoFocus
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
colored: FullscreenColored,
|
|
||||||
hover: FullscreenHoverColored,
|
|
||||||
down: FullscreenDownColored,
|
|
||||||
noFocus: NoFocus
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAction = (action) => {
|
|
||||||
handleWindowControl(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
className='electrobun-webkit-app-region-no-drag'
|
|
||||||
style={{
|
|
||||||
width: '56.6px',
|
|
||||||
marginLeft: '12.5px',
|
|
||||||
marginRight: '12.5px',
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 9999
|
|
||||||
}}
|
|
||||||
gap={8}
|
|
||||||
onMouseEnter={() => setIsGroupHovered(true)}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setIsGroupHovered(false)
|
|
||||||
setActiveAction(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrafficLightButton
|
|
||||||
action='close'
|
|
||||||
icons={{
|
|
||||||
colored: CloseColored,
|
|
||||||
hover: CloseHoverColored,
|
|
||||||
down: CloseDownColored,
|
|
||||||
noFocus: NoFocus
|
|
||||||
}}
|
|
||||||
isGroupHovered={isGroupHovered}
|
|
||||||
activeAction={activeAction}
|
|
||||||
isEnabled
|
|
||||||
isWindowFocused={isWindowFocused}
|
|
||||||
onActive={setActiveAction}
|
|
||||||
onAction={handleAction}
|
|
||||||
/>
|
|
||||||
<TrafficLightButton
|
|
||||||
action='minimize'
|
|
||||||
icons={{
|
|
||||||
colored: MinimizeColored,
|
|
||||||
hover: MinimizeHoverColored,
|
|
||||||
down: MinimizeDownColored,
|
|
||||||
noFocus: NoFocus
|
|
||||||
}}
|
|
||||||
isGroupHovered={isGroupHovered}
|
|
||||||
activeAction={activeAction}
|
|
||||||
isEnabled={!isFullScreen}
|
|
||||||
isWindowFocused={isWindowFocused}
|
|
||||||
onActive={setActiveAction}
|
|
||||||
onAction={handleAction}
|
|
||||||
/>
|
|
||||||
<TrafficLightButton
|
|
||||||
action='fullscreen'
|
|
||||||
icons={fullscreenIcons}
|
|
||||||
isGroupHovered={isGroupHovered}
|
|
||||||
activeAction={activeAction}
|
|
||||||
isEnabled
|
|
||||||
isWindowFocused={isWindowFocused}
|
|
||||||
onActive={setActiveAction}
|
|
||||||
onAction={handleAction}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MacOSTrafficLights
|
|
||||||
@ -47,10 +47,7 @@ import { useActions } from '../context/ActionsContext'
|
|||||||
import ActionsIcon from '../../Icons/ActionsIcon'
|
import ActionsIcon from '../../Icons/ActionsIcon'
|
||||||
import FilterIcon from '../../Icons/FilterIcon'
|
import FilterIcon from '../../Icons/FilterIcon'
|
||||||
import ScrollBox from './ScrollBox'
|
import ScrollBox from './ScrollBox'
|
||||||
import {
|
import { useTableStatePersistence } from '../context/TableStateContext'
|
||||||
getActiveFilterValues,
|
|
||||||
useTableStatePersistence
|
|
||||||
} from '../context/TableStateContext'
|
|
||||||
|
|
||||||
const logger = loglevel.getLogger('DasboardTable')
|
const logger = loglevel.getLogger('DasboardTable')
|
||||||
logger.setLevel(config.logLevel)
|
logger.setLevel(config.logLevel)
|
||||||
@ -157,7 +154,8 @@ const ObjectTable = forwardRef(
|
|||||||
getPersistedSorter,
|
getPersistedSorter,
|
||||||
persistFilter,
|
persistFilter,
|
||||||
persistTableState,
|
persistTableState,
|
||||||
registerPageFilter
|
registerPageFilter,
|
||||||
|
getActiveFilterValues
|
||||||
} = useTableStatePersistence({
|
} = useTableStatePersistence({
|
||||||
scope: type,
|
scope: type,
|
||||||
pagePath: location.pathname,
|
pagePath: location.pathname,
|
||||||
|
|||||||
@ -6,10 +6,7 @@ import ArrowRightIcon from '../../Icons/ArrowRightIcon.jsx'
|
|||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
import {
|
import { useTableStatePersistence } from '../context/TableStateContext'
|
||||||
getActiveFilterValues,
|
|
||||||
useTableStatePersistence
|
|
||||||
} from '../context/TableStateContext'
|
|
||||||
import KeyboardShortcut from './KeyboardShortcut.jsx'
|
import KeyboardShortcut from './KeyboardShortcut.jsx'
|
||||||
|
|
||||||
const ObjectTableNavigationButtons = ({
|
const ObjectTableNavigationButtons = ({
|
||||||
@ -24,7 +21,8 @@ const ObjectTableNavigationButtons = ({
|
|||||||
const [neighbors, setNeighbors] = useState({ next: null, previous: null })
|
const [neighbors, setNeighbors] = useState({ next: null, previous: null })
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const { getPersistedFilter, getPersistedSorter } = useTableStatePersistence({
|
const { getPersistedFilter, getPersistedSorter, getActiveFilterValues } =
|
||||||
|
useTableStatePersistence({
|
||||||
scope: objectType,
|
scope: objectType,
|
||||||
useFilterInSession: true,
|
useFilterInSession: true,
|
||||||
useSortInSession: true
|
useSortInSession: true
|
||||||
@ -89,25 +87,8 @@ const ObjectTableNavigationButtons = ({
|
|||||||
[searchParams, setSearchParams, objectType]
|
[searchParams, setSearchParams, objectType]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePrevious = useCallback(() => {
|
const handlePrevious = () => navigateToNeighbor(neighbors.previous)
|
||||||
navigateToNeighbor(neighbors.previous)
|
const handleNext = () => navigateToNeighbor(neighbors.next)
|
||||||
}, [navigateToNeighbor, neighbors.previous])
|
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
|
||||||
navigateToNeighbor(neighbors.next)
|
|
||||||
}, [navigateToNeighbor, neighbors.next])
|
|
||||||
|
|
||||||
const handlePreviousShortcut = useCallback(() => {
|
|
||||||
if (!disabled && !loading && neighbors.previous?._id) {
|
|
||||||
handlePrevious()
|
|
||||||
}
|
|
||||||
}, [disabled, loading, neighbors.previous?._id, handlePrevious])
|
|
||||||
|
|
||||||
const handleNextShortcut = useCallback(() => {
|
|
||||||
if (!disabled && !loading && neighbors.next?._id) {
|
|
||||||
handleNext()
|
|
||||||
}
|
|
||||||
}, [disabled, loading, neighbors.next?._id, handleNext])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex gap='small' align='center'>
|
<Flex gap='small' align='center'>
|
||||||
@ -118,7 +99,11 @@ const ObjectTableNavigationButtons = ({
|
|||||||
<KeyboardShortcut
|
<KeyboardShortcut
|
||||||
shortcut='alt+arrowleft'
|
shortcut='alt+arrowleft'
|
||||||
hint='ALT ←'
|
hint='ALT ←'
|
||||||
onTrigger={handlePreviousShortcut}
|
onTrigger={useCallback(() => {
|
||||||
|
if (!disabled && !loading && neighbors.previous?._id) {
|
||||||
|
handlePrevious()
|
||||||
|
}
|
||||||
|
}, [disabled, loading, neighbors.previous?._id])}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowLeftIcon />}
|
icon={<ArrowLeftIcon />}
|
||||||
@ -129,7 +114,11 @@ const ObjectTableNavigationButtons = ({
|
|||||||
<KeyboardShortcut
|
<KeyboardShortcut
|
||||||
shortcut='alt+arrowright'
|
shortcut='alt+arrowright'
|
||||||
hint='ALT →'
|
hint='ALT →'
|
||||||
onTrigger={handleNextShortcut}
|
onTrigger={useCallback(() => {
|
||||||
|
if (!disabled && !loading && neighbors.next?._id) {
|
||||||
|
handleNext()
|
||||||
|
}
|
||||||
|
}, [disabled, loading, neighbors.next?._id])}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowRightIcon />}
|
icon={<ArrowRightIcon />}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
// PrinterSelect.js
|
// PrinterSelect.js
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { Flex, Space, Modal, Button, Typography } from 'antd'
|
import { Progress, Flex, Space, Modal, Button, Typography } from 'antd'
|
||||||
import StateTag from './StateTag'
|
import StateTag from './StateTag'
|
||||||
import HProgress from './HProgress'
|
|
||||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
@ -44,7 +43,7 @@ const StateDisplay = ({
|
|||||||
currentState?.progress &&
|
currentState?.progress &&
|
||||||
progressValue !== 100 &&
|
progressValue !== 100 &&
|
||||||
currentState?.progress > 0 ? (
|
currentState?.progress > 0 ? (
|
||||||
<HProgress
|
<Progress
|
||||||
percent={progressValue}
|
percent={progressValue}
|
||||||
status={
|
status={
|
||||||
activeProgressTypes.includes(currentState.type) ? 'active' : ''
|
activeProgressTypes.includes(currentState.type) ? 'active' : ''
|
||||||
|
|||||||
@ -1,259 +0,0 @@
|
|||||||
import { useContext, useMemo } from 'react'
|
|
||||||
import PropTypes from 'prop-types'
|
|
||||||
import { Button, Dropdown, Flex } from 'antd'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
|
||||||
import { useAppUpdateContext } from '../context/AppUpdateContext'
|
|
||||||
import { getSidebarMenuSections } from '../../../database/Sidebars'
|
|
||||||
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
|
|
||||||
|
|
||||||
const runEditCommand = (command) => {
|
|
||||||
try {
|
|
||||||
document.execCommand(command)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
`[WindowAppMenu] Failed to run edit command: ${command}`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapSidebarItemsToMenuItems = (items = [], navigate) =>
|
|
||||||
items
|
|
||||||
.map((item, index) => {
|
|
||||||
if (item?.type === 'divider') {
|
|
||||||
return { type: 'divider', key: `divider-${index}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item?.children?.length) {
|
|
||||||
return {
|
|
||||||
key: item.key || `group-${item.label}-${index}`,
|
|
||||||
label: item.label,
|
|
||||||
children: mapSidebarItemsToMenuItems(item.children, navigate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item?.path) {
|
|
||||||
return {
|
|
||||||
key: item.path,
|
|
||||||
label: item.label,
|
|
||||||
onClick: () => navigate(item.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: item.key || `disabled-${item.label}-${index}`,
|
|
||||||
label: item.label,
|
|
||||||
disabled: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
const MenuButton = ({ label, items, logo }) => (
|
|
||||||
<Dropdown menu={{ items }} trigger={['click']} placement='bottomLeft'>
|
|
||||||
<Button
|
|
||||||
type='text'
|
|
||||||
size='small'
|
|
||||||
className='electrobun-webkit-app-region-no-drag'
|
|
||||||
style={{
|
|
||||||
height: '28px',
|
|
||||||
paddingInline: logo ? '6px' : '8px',
|
|
||||||
marginRight: logo ? '1px' : '0',
|
|
||||||
fontWeight: 500
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
)
|
|
||||||
|
|
||||||
MenuButton.propTypes = {
|
|
||||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
|
|
||||||
items: PropTypes.array.isRequired,
|
|
||||||
logo: PropTypes.bool
|
|
||||||
}
|
|
||||||
|
|
||||||
const WindowAppMenu = () => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { handleWindowControl } = useContext(ElectronContext)
|
|
||||||
const { checkForUpdates } = useAppUpdateContext()
|
|
||||||
const includeDev = import.meta.env.DEV
|
|
||||||
|
|
||||||
const viewSections = useMemo(
|
|
||||||
() => getSidebarMenuSections({ includeDev }),
|
|
||||||
[includeDev]
|
|
||||||
)
|
|
||||||
|
|
||||||
const appMenuItems = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'about',
|
|
||||||
label: 'About Farm Control',
|
|
||||||
onClick: () => navigate('/dashboard/management/about')
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
key: 'check-for-updates',
|
|
||||||
label: 'Check For Updates...',
|
|
||||||
onClick: () => checkForUpdates()
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
key: 'quit',
|
|
||||||
label: 'Quit Farm Control',
|
|
||||||
onClick: () => handleWindowControl('quit')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[checkForUpdates, handleWindowControl, navigate]
|
|
||||||
)
|
|
||||||
|
|
||||||
const fileMenuItems = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'close',
|
|
||||||
label: 'Close Window',
|
|
||||||
onClick: () => handleWindowControl('close')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[handleWindowControl]
|
|
||||||
)
|
|
||||||
|
|
||||||
const editMenuItems = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'undo',
|
|
||||||
label: 'Undo',
|
|
||||||
onClick: () => runEditCommand('undo')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'redo',
|
|
||||||
label: 'Redo',
|
|
||||||
onClick: () => runEditCommand('redo')
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
key: 'cut',
|
|
||||||
label: 'Cut',
|
|
||||||
onClick: () => runEditCommand('cut')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'copy',
|
|
||||||
label: 'Copy',
|
|
||||||
onClick: () => runEditCommand('copy')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'paste',
|
|
||||||
label: 'Paste',
|
|
||||||
onClick: () => runEditCommand('paste')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'pasteAndMatchStyle',
|
|
||||||
label: 'Paste and Match Style',
|
|
||||||
onClick: () => runEditCommand('paste')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'delete',
|
|
||||||
label: 'Delete',
|
|
||||||
onClick: () => runEditCommand('delete')
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
key: 'selectAll',
|
|
||||||
label: 'Select All',
|
|
||||||
onClick: () => runEditCommand('selectAll')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const viewMenuItems = useMemo(() => {
|
|
||||||
const sectionItems =
|
|
||||||
viewSections.length > 0
|
|
||||||
? viewSections.map((section) => ({
|
|
||||||
key: `view-section-${section.key}`,
|
|
||||||
label: section.label,
|
|
||||||
children: mapSidebarItemsToMenuItems(section.items || [], navigate)
|
|
||||||
}))
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
key: 'no-sidebar-items',
|
|
||||||
label: 'No sidebar items available',
|
|
||||||
disabled: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
if (!includeDev) {
|
|
||||||
return sectionItems
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
...sectionItems,
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
key: 'toggle-devtools',
|
|
||||||
label: 'Toggle Developer Tools',
|
|
||||||
onClick: () => handleWindowControl('toggle-devtools')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}, [handleWindowControl, includeDev, navigate, viewSections])
|
|
||||||
|
|
||||||
const windowMenuItems = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'minimize',
|
|
||||||
label: 'Minimize',
|
|
||||||
onClick: () => handleWindowControl('minimize')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'zoom',
|
|
||||||
label: 'Zoom',
|
|
||||||
onClick: () => handleWindowControl('maximize')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[handleWindowControl]
|
|
||||||
)
|
|
||||||
|
|
||||||
const menus = useMemo(
|
|
||||||
() => [
|
|
||||||
{ key: 'app', label: 'Farm Control', items: appMenuItems },
|
|
||||||
{ key: 'file', label: 'File', items: fileMenuItems },
|
|
||||||
{ key: 'edit', label: 'Edit', items: editMenuItems },
|
|
||||||
{ key: 'view', label: 'View', items: viewMenuItems },
|
|
||||||
{ key: 'window', label: 'Window', items: windowMenuItems }
|
|
||||||
],
|
|
||||||
[appMenuItems, editMenuItems, fileMenuItems, viewMenuItems, windowMenuItems]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
align='center'
|
|
||||||
className='electrobun-webkit-app-region-no-drag'
|
|
||||||
style={{ marginRight: '8px', flexShrink: 0, marginLeft: '4px' }}
|
|
||||||
>
|
|
||||||
{menus.map((menu) => {
|
|
||||||
if (menu.key === 'app') {
|
|
||||||
return (
|
|
||||||
<MenuButton
|
|
||||||
key={menu.key}
|
|
||||||
logo={true}
|
|
||||||
label={
|
|
||||||
<FarmControlLogoSmall
|
|
||||||
style={{
|
|
||||||
fontSize: '46px',
|
|
||||||
height: '16px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
items={menu.items}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<MenuButton key={menu.key} label={menu.label} items={menu.items} />
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WindowAppMenu
|
|
||||||
@ -16,28 +16,14 @@ import { ElectronContext } from './ElectronContext'
|
|||||||
import NewAppUpdate from '../Management/AppUpdates/NewAppUpdate'
|
import NewAppUpdate from '../Management/AppUpdates/NewAppUpdate'
|
||||||
import AppUpdateProgress from '../Management/AppUpdates/AppUpdateProgress'
|
import AppUpdateProgress from '../Management/AppUpdates/AppUpdateProgress'
|
||||||
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
|
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
|
||||||
import ExclamationOctogonIcon from '../../Icons/ExclamationOctagonIcon'
|
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
|
const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
|
||||||
const DEFAULT_MODEL_WIDTH = 710
|
|
||||||
const DEFAULT_UPDATE_BRANCH = 'main'
|
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||||
const DEFAULT_UPDATE_ENGINE = 'native'
|
|
||||||
const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER
|
const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER
|
||||||
const APP_UPDATE_DISMISSED_KEY = 'appUpdateDismissed'
|
const APP_UPDATE_DISMISSED_KEY = 'appUpdateDismissed'
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const normalizeAppUpdateEngine = (engine) => {
|
|
||||||
const value = String(engine || '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
|
|
||||||
if (value === 'chromium' || value === 'cef') return 'chromium'
|
|
||||||
if (value === 'native') return 'native'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDismissedUpdate = () => {
|
const getDismissedUpdate = () => {
|
||||||
try {
|
try {
|
||||||
const stored = sessionStorage.getItem(APP_UPDATE_DISMISSED_KEY)
|
const stored = sessionStorage.getItem(APP_UPDATE_DISMISSED_KEY)
|
||||||
@ -56,30 +42,10 @@ const isUpdateDismissed = (update) => {
|
|||||||
return (
|
return (
|
||||||
dismissed.version === update.version &&
|
dismissed.version === update.version &&
|
||||||
dismissed.buildNumber === update.buildNumber &&
|
dismissed.buildNumber === update.buildNumber &&
|
||||||
dismissed.branch === update.branch &&
|
dismissed.branch === update.branch
|
||||||
normalizeAppUpdateEngine(dismissed.engine) ===
|
|
||||||
normalizeAppUpdateEngine(update.engine)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatFullAppVersion = (version, buildNumber) => {
|
|
||||||
const normalizedVersion = String(version || '')
|
|
||||||
.trim()
|
|
||||||
.replace(/^v/i, '')
|
|
||||||
|
|
||||||
if (!normalizedVersion) return null
|
|
||||||
|
|
||||||
const build = String(buildNumber || '').trim()
|
|
||||||
const buildSuffix =
|
|
||||||
!build || build === 'dev'
|
|
||||||
? 'dev'
|
|
||||||
: build.startsWith('b')
|
|
||||||
? build
|
|
||||||
: `b${build}`
|
|
||||||
|
|
||||||
return `v${normalizedVersion}-${buildSuffix}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveDismissedUpdate = (update) => {
|
const saveDismissedUpdate = (update) => {
|
||||||
if (!update) return
|
if (!update) return
|
||||||
|
|
||||||
@ -88,8 +54,7 @@ const saveDismissedUpdate = (update) => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: update.version,
|
version: update.version,
|
||||||
buildNumber: update.buildNumber,
|
buildNumber: update.buildNumber,
|
||||||
branch: update.branch,
|
branch: update.branch
|
||||||
engine: normalizeAppUpdateEngine(update.engine) || DEFAULT_UPDATE_ENGINE
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -144,41 +109,23 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
|
const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
|
||||||
useContext(ApiServerContext)
|
useContext(ApiServerContext)
|
||||||
const { token } = useContext(AuthContext)
|
const { token } = useContext(AuthContext)
|
||||||
const {
|
const { isElectron, getAppSettings, startAppUpdate, onAppUpdateProgress } =
|
||||||
isElectron,
|
useContext(ElectronContext)
|
||||||
getAppSettings,
|
|
||||||
setAppSettings,
|
|
||||||
getAppEngine,
|
|
||||||
startAppUpdate,
|
|
||||||
checkAppUpdateResult,
|
|
||||||
checkDuplicateInstallations,
|
|
||||||
removeDuplicateInstallations,
|
|
||||||
onDuplicateInstallationsRemoved,
|
|
||||||
onAppUpdateProgress,
|
|
||||||
onCheckForUpdatesRequest
|
|
||||||
} = useContext(ElectronContext)
|
|
||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
const [noUpdateOpen, setNoUpdateOpen] = useState(false)
|
const [noUpdateOpen, setNoUpdateOpen] = useState(false)
|
||||||
const [availableUpdate, setAvailableUpdate] = useState(null)
|
const [availableUpdate, setAvailableUpdate] = useState(null)
|
||||||
const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
|
const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
|
||||||
const [installingUpdate, setInstallingUpdate] = useState(null)
|
const [installingUpdate, setInstallingUpdate] = useState(null)
|
||||||
const [updateProgress, setUpdateProgress] = useState(null)
|
const [updateProgress, setUpdateProgress] = useState(null)
|
||||||
const [completedUpdate, setCompletedUpdate] = useState(null)
|
|
||||||
const [duplicateInstall, setDuplicateInstall] = useState(null)
|
|
||||||
const [duplicatePromptOpen, setDuplicatePromptOpen] = useState(false)
|
|
||||||
const [removingDuplicate, setRemovingDuplicate] = useState(false)
|
|
||||||
const [duplicateRemovalResult, setDuplicateRemovalResult] = useState(null)
|
|
||||||
const runningCheckRef = useRef(null)
|
const runningCheckRef = useRef(null)
|
||||||
const updateCheckDependenciesRef = useRef({})
|
const updateCheckDependenciesRef = useRef({})
|
||||||
|
|
||||||
const [modelWidth, setModelWidth] = useState(DEFAULT_MODEL_WIDTH)
|
const [modelWidth, setModelWidth] = useState(650)
|
||||||
|
|
||||||
updateCheckDependenciesRef.current = {
|
updateCheckDependenciesRef.current = {
|
||||||
fetchAppUpdateBranches,
|
fetchAppUpdateBranches,
|
||||||
fetchAppUpdateCurrent,
|
fetchAppUpdateCurrent,
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
setAppSettings,
|
|
||||||
getAppEngine,
|
|
||||||
isElectron,
|
isElectron,
|
||||||
token
|
token
|
||||||
}
|
}
|
||||||
@ -188,8 +135,6 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
fetchAppUpdateBranches,
|
fetchAppUpdateBranches,
|
||||||
fetchAppUpdateCurrent,
|
fetchAppUpdateCurrent,
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
setAppSettings,
|
|
||||||
getAppEngine,
|
|
||||||
isElectron,
|
isElectron,
|
||||||
token
|
token
|
||||||
} = updateCheckDependenciesRef.current
|
} = updateCheckDependenciesRef.current
|
||||||
@ -198,10 +143,9 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
if (runningCheckRef.current) return runningCheckRef.current
|
if (runningCheckRef.current) return runningCheckRef.current
|
||||||
|
|
||||||
const checkPromise = (async () => {
|
const checkPromise = (async () => {
|
||||||
const [branches, appSettings, runningEngine] = await Promise.all([
|
const [branches, appSettings] = await Promise.all([
|
||||||
fetchAppUpdateBranches(),
|
fetchAppUpdateBranches(),
|
||||||
getAppSettings(),
|
getAppSettings()
|
||||||
getAppEngine()
|
|
||||||
])
|
])
|
||||||
const configuredBranch = appSettings?.appUpdateBranch
|
const configuredBranch = appSettings?.appUpdateBranch
|
||||||
const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH)
|
const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH)
|
||||||
@ -213,52 +157,11 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
|
|
||||||
if (!selectedBranch) return null
|
if (!selectedBranch) return null
|
||||||
|
|
||||||
const selectedEngine =
|
|
||||||
normalizeAppUpdateEngine(appSettings?.appUpdateEngine) ||
|
|
||||||
normalizeAppUpdateEngine(runningEngine) ||
|
|
||||||
DEFAULT_UPDATE_ENGINE
|
|
||||||
const currentRunningEngine =
|
|
||||||
normalizeAppUpdateEngine(runningEngine) || DEFAULT_UPDATE_ENGINE
|
|
||||||
|
|
||||||
const settingsUpdates = {}
|
|
||||||
if (!appSettings?.appUpdateRunningBranch) {
|
|
||||||
settingsUpdates.appUpdateRunningBranch = selectedBranch
|
|
||||||
}
|
|
||||||
if (!normalizeAppUpdateEngine(appSettings?.appUpdateEngine)) {
|
|
||||||
settingsUpdates.appUpdateEngine = selectedEngine
|
|
||||||
}
|
|
||||||
if (Object.keys(settingsUpdates).length > 0) {
|
|
||||||
await setAppSettings({
|
|
||||||
...appSettings,
|
|
||||||
...settingsUpdates
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const runningBranch =
|
|
||||||
appSettings?.appUpdateRunningBranch ||
|
|
||||||
settingsUpdates.appUpdateRunningBranch ||
|
|
||||||
selectedBranch
|
|
||||||
|
|
||||||
const update = await fetchAppUpdateCurrent(selectedBranch)
|
const update = await fetchAppUpdateCurrent(selectedBranch)
|
||||||
if (!update) return null
|
|
||||||
|
|
||||||
const newerVersionAvailable = isAppUpdateAvailable(
|
return isAppUpdateAvailable(update, appVersion, CURRENT_BUILD_NUMBER)
|
||||||
update,
|
? update
|
||||||
appVersion,
|
: null
|
||||||
CURRENT_BUILD_NUMBER
|
|
||||||
)
|
|
||||||
const engineMismatch = selectedEngine !== currentRunningEngine
|
|
||||||
const branchMismatch = selectedBranch !== runningBranch
|
|
||||||
|
|
||||||
if (!newerVersionAvailable && !engineMismatch && !branchMismatch) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...update,
|
|
||||||
branch: update.branch || selectedBranch,
|
|
||||||
engine: selectedEngine
|
|
||||||
}
|
|
||||||
})()
|
})()
|
||||||
|
|
||||||
runningCheckRef.current = checkPromise
|
runningCheckRef.current = checkPromise
|
||||||
@ -278,7 +181,6 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
setNoUpdateOpen(false)
|
setNoUpdateOpen(false)
|
||||||
setAvailableUpdate(update)
|
setAvailableUpdate(update)
|
||||||
if (forcePrompt || !isUpdateDismissed(update)) {
|
if (forcePrompt || !isUpdateDismissed(update)) {
|
||||||
setModelWidth(DEFAULT_MODEL_WIDTH)
|
|
||||||
setUpdatePromptOpen(true)
|
setUpdatePromptOpen(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -303,11 +205,6 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [isElectron, showUpdateIfAvailable])
|
}, [isElectron, showUpdateIfAvailable])
|
||||||
|
|
||||||
const recheckForUpdates = useCallback(async () => {
|
|
||||||
if (!isElectron) return null
|
|
||||||
return showUpdateIfAvailable({ forcePrompt: true })
|
|
||||||
}, [isElectron, showUpdateIfAvailable])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isElectron) return undefined
|
if (!isElectron) return undefined
|
||||||
|
|
||||||
@ -322,54 +219,6 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [isElectron, showUpdateIfAvailable])
|
}, [isElectron, showUpdateIfAvailable])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isElectron) return undefined
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
const runStartupChecks = async () => {
|
|
||||||
let result = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
result = await checkAppUpdateResult?.()
|
|
||||||
if (!cancelled && result?.updated) {
|
|
||||||
setCompletedUpdate(result)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[AppUpdateContext] Startup update check failed:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const installations =
|
|
||||||
result?.duplicates || (await checkDuplicateInstallations?.())
|
|
||||||
if (!cancelled && installations?.duplicatePath) {
|
|
||||||
setDuplicateInstall(installations)
|
|
||||||
setDuplicatePromptOpen(true)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
'[AppUpdateContext] Duplicate installation check failed:',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void runStartupChecks()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [isElectron, checkAppUpdateResult, checkDuplicateInstallations])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isElectron || !onDuplicateInstallationsRemoved) return undefined
|
|
||||||
|
|
||||||
return onDuplicateInstallationsRemoved((result) => {
|
|
||||||
setRemovingDuplicate(false)
|
|
||||||
setDuplicateRemovalResult(result || { ok: false })
|
|
||||||
})
|
|
||||||
}, [isElectron, onDuplicateInstallationsRemoved])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isElectron || !onAppUpdateProgress) return undefined
|
if (!isElectron || !onAppUpdateProgress) return undefined
|
||||||
|
|
||||||
@ -378,14 +227,6 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
})
|
})
|
||||||
}, [isElectron, onAppUpdateProgress])
|
}, [isElectron, onAppUpdateProgress])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isElectron || !onCheckForUpdatesRequest) return undefined
|
|
||||||
|
|
||||||
return onCheckForUpdatesRequest(() => {
|
|
||||||
void checkForUpdates()
|
|
||||||
})
|
|
||||||
}, [isElectron, onCheckForUpdatesRequest, checkForUpdates])
|
|
||||||
|
|
||||||
const dismissUpdatePrompt = () => {
|
const dismissUpdatePrompt = () => {
|
||||||
if (availableUpdate) {
|
if (availableUpdate) {
|
||||||
saveDismissedUpdate(availableUpdate)
|
saveDismissedUpdate(availableUpdate)
|
||||||
@ -403,7 +244,7 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
setNoUpdateOpen(false)
|
setNoUpdateOpen(false)
|
||||||
setUpdatePromptOpen(false)
|
setUpdatePromptOpen(false)
|
||||||
setInstallingUpdate(update)
|
setInstallingUpdate(update)
|
||||||
setModelWidth(580)
|
setModelWidth(550)
|
||||||
setUpdateProgress({
|
setUpdateProgress({
|
||||||
phase: 'preparing',
|
phase: 'preparing',
|
||||||
percent: 0,
|
percent: 0,
|
||||||
@ -411,20 +252,10 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appSettings = await getAppSettings()
|
const result = await startAppUpdate(update)
|
||||||
const engine =
|
|
||||||
normalizeAppUpdateEngine(update?.engine) ||
|
|
||||||
normalizeAppUpdateEngine(appSettings?.appUpdateEngine) ||
|
|
||||||
DEFAULT_UPDATE_ENGINE
|
|
||||||
const result = await startAppUpdate({
|
|
||||||
...update,
|
|
||||||
engine
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error(
|
throw new Error('App updates are only available in the desktop app.')
|
||||||
'Failed to start the app update. Please restart the app and try again.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUpdateProgress({
|
setUpdateProgress({
|
||||||
@ -435,33 +266,12 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveDuplicate = async () => {
|
|
||||||
setRemovingDuplicate(true)
|
|
||||||
setDuplicateRemovalResult(null)
|
|
||||||
|
|
||||||
const started = await removeDuplicateInstallations?.()
|
|
||||||
if (!started) {
|
|
||||||
setRemovingDuplicate(false)
|
|
||||||
setDuplicateRemovalResult({
|
|
||||||
ok: false,
|
|
||||||
error: 'Failed to start removing the duplicate installation.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeDuplicatePrompt = () => {
|
|
||||||
setDuplicatePromptOpen(false)
|
|
||||||
setDuplicateRemovalResult(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateModalOpen = Boolean(updatePromptOpen || installingUpdate)
|
const updateModalOpen = Boolean(updatePromptOpen || installingUpdate)
|
||||||
const updateModalBusy =
|
const updateModalBusy =
|
||||||
Boolean(installingUpdate) && updateProgress?.phase !== 'error'
|
Boolean(installingUpdate) && updateProgress?.phase !== 'error'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppUpdateContext.Provider
|
<AppUpdateContext.Provider value={{ availableUpdate, checkForUpdates }}>
|
||||||
value={{ availableUpdate, checkForUpdates, recheckForUpdates }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
<Modal
|
<Modal
|
||||||
open={checking}
|
open={checking}
|
||||||
@ -479,18 +289,12 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title='Software Update'
|
||||||
<Flex align='center' gap='middle'>
|
|
||||||
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
|
|
||||||
Software Update
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
open={noUpdateOpen}
|
open={noUpdateOpen}
|
||||||
okText='OK'
|
okText='OK'
|
||||||
style={{ maxWidth: 430 }}
|
style={{ maxWidth: 430 }}
|
||||||
centered
|
centered
|
||||||
maskClosable
|
maskClosable
|
||||||
closable={false}
|
|
||||||
onOk={() => setNoUpdateOpen(false)}
|
onOk={() => setNoUpdateOpen(false)}
|
||||||
onCancel={() => setNoUpdateOpen(false)}
|
onCancel={() => setNoUpdateOpen(false)}
|
||||||
footer={[
|
footer={[
|
||||||
@ -540,103 +344,6 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<Flex align='center' gap='middle'>
|
|
||||||
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
|
|
||||||
Update Installed
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
open={Boolean(completedUpdate)}
|
|
||||||
style={{ maxWidth: 430 }}
|
|
||||||
centered
|
|
||||||
closable={false}
|
|
||||||
onCancel={() => setCompletedUpdate(null)}
|
|
||||||
footer={[
|
|
||||||
<Button
|
|
||||||
key='ok'
|
|
||||||
type='primary'
|
|
||||||
onClick={() => setCompletedUpdate(null)}
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</Button>
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text>
|
|
||||||
{completedUpdate?.message ||
|
|
||||||
`Farm Control was successfully updated to version ${
|
|
||||||
completedUpdate?.current?.version ||
|
|
||||||
formatFullAppVersion(appVersion, CURRENT_BUILD_NUMBER) ||
|
|
||||||
`v${appVersion}`
|
|
||||||
}.`}
|
|
||||||
</Text>
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<Flex align='center' gap='middle'>
|
|
||||||
<ExclamationOctogonIcon />
|
|
||||||
Duplicate Installation Found
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
open={Boolean(duplicatePromptOpen && duplicateInstall)}
|
|
||||||
width={!removingDuplicate && !duplicateRemovalResult ? 560 : 430}
|
|
||||||
centered
|
|
||||||
closable={false}
|
|
||||||
maskClosable={false}
|
|
||||||
onCancel={removingDuplicate ? undefined : closeDuplicatePrompt}
|
|
||||||
footer={
|
|
||||||
removingDuplicate
|
|
||||||
? null
|
|
||||||
: duplicateRemovalResult
|
|
||||||
? [
|
|
||||||
<Button
|
|
||||||
key='close'
|
|
||||||
type='primary'
|
|
||||||
onClick={closeDuplicatePrompt}
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</Button>
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
<Button key='no' onClick={closeDuplicatePrompt}>
|
|
||||||
No
|
|
||||||
</Button>,
|
|
||||||
<Button
|
|
||||||
key='yes'
|
|
||||||
type='primary'
|
|
||||||
onClick={handleRemoveDuplicate}
|
|
||||||
>
|
|
||||||
Yes
|
|
||||||
</Button>
|
|
||||||
]
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{removingDuplicate ? (
|
|
||||||
<Space size='middle'>
|
|
||||||
<LoadingOutlined />
|
|
||||||
<Text>
|
|
||||||
Removing the duplicate installation... You may be asked for an
|
|
||||||
administrator password.
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
) : duplicateRemovalResult ? (
|
|
||||||
<Text>
|
|
||||||
{duplicateRemovalResult.ok
|
|
||||||
? 'The duplicate installation was removed.'
|
|
||||||
: duplicateRemovalResult.error ||
|
|
||||||
'Failed to remove the duplicate installation.'}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Space direction='vertical' size='small'>
|
|
||||||
<Text>
|
|
||||||
Farm Control is now installed in your user applications folder,
|
|
||||||
but an older copy is still installed at:{' '}
|
|
||||||
<Text code>{duplicateInstall?.duplicatePath}</Text>.{' '}
|
|
||||||
<Text>Do you want to remove the duplicate installation?</Text>
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</AppUpdateContext.Provider>
|
</AppUpdateContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,33 @@
|
|||||||
import { createContext, useCallback, useEffect, useRef, useState } from 'react'
|
import { createContext, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import desktopBridge, {
|
|
||||||
isElectrobunBridgeReady,
|
|
||||||
isElectrobunDesktop
|
|
||||||
} from '../../../electrobun-bridge.js'
|
|
||||||
|
|
||||||
|
// Only available in Electron renderer
|
||||||
|
const electron = window.require ? window.require('electron') : null
|
||||||
|
const ipcRenderer = electron ? electron.ipcRenderer : null
|
||||||
|
|
||||||
|
// Utility to check if running in Electron
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export function isElectron() {
|
export function isElectron() {
|
||||||
return isElectrobunBridgeReady() || isElectrobunDesktop()
|
// Renderer process
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.process &&
|
||||||
|
window.process.type === 'renderer'
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// User agent
|
||||||
|
if (
|
||||||
|
typeof navigator === 'object' &&
|
||||||
|
typeof navigator.userAgent === 'string' &&
|
||||||
|
navigator.userAgent.indexOf('Electron') >= 0
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const ElectronContext = createContext()
|
const ElectronContext = createContext()
|
||||||
@ -51,76 +70,70 @@ const ElectronProvider = ({ children }) => {
|
|||||||
[navigate]
|
[navigate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const applyWindowState = useCallback((state) => {
|
// Function to open external URL via Electron
|
||||||
|
const openExternalUrl = (url) => {
|
||||||
|
if (electronAvailable && ipcRenderer) {
|
||||||
|
ipcRenderer.invoke('open-external-url', url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to open internal URL via Electron
|
||||||
|
const openInternalUrl = (url) => {
|
||||||
|
if (electronAvailable && ipcRenderer) {
|
||||||
|
ipcRenderer.invoke('open-internal-url', url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ipcRenderer) return
|
||||||
|
|
||||||
|
// Get initial platform
|
||||||
|
ipcRenderer.invoke('os-info').then((info) => {
|
||||||
|
if (info && info.platform) setPlatform(info.platform)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get initial window state
|
||||||
|
ipcRenderer.invoke('window-state').then((state) => {
|
||||||
if (state && typeof state.isMaximized === 'boolean') {
|
if (state && typeof state.isMaximized === 'boolean') {
|
||||||
setIsMaximized(state.isMaximized)
|
setIsMaximized(state.isMaximized)
|
||||||
}
|
}
|
||||||
if (state && typeof state.isFullScreen === 'boolean') {
|
if (state && typeof state.isFullScreen === 'boolean') {
|
||||||
setIsFullScreen(state.isFullScreen)
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const openInternalUrl = (url) => {
|
|
||||||
if (!electronAvailable) return false
|
|
||||||
void desktopBridge.openInternalUrl(url).catch((error) => {
|
|
||||||
console.warn('[ElectronContext] Failed to open internal url:', error)
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!electronAvailable) return
|
|
||||||
|
|
||||||
document.body.classList.add('electron-body')
|
|
||||||
return () => {
|
|
||||||
document.body.classList.remove('electron-body')
|
|
||||||
}
|
|
||||||
}, [electronAvailable])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!electronAvailable) return
|
|
||||||
|
|
||||||
desktopBridge.getOsInfo().then((info) => {
|
|
||||||
if (info?.platform) {
|
|
||||||
setPlatform(info.platform)
|
|
||||||
if (info.platform === 'darwin') {
|
|
||||||
document.documentElement.classList.add('macos-vibrancy')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
desktopBridge.getWindowState().then(applyWindowState)
|
// Listen for window state changes
|
||||||
|
const windowStateHandler = (event, state) => {
|
||||||
const unsubWindowState = desktopBridge.onMessage(
|
if (state && typeof state.isMaximized === 'boolean') {
|
||||||
'windowState',
|
setIsMaximized(state.isMaximized)
|
||||||
applyWindowState
|
|
||||||
)
|
|
||||||
const unsubNavigate = desktopBridge.onMessage('navigate', (url) => {
|
|
||||||
if (url.toLowerCase() == '/favicon.ico') {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
console.log('[ElectronContext] Navigating to:', url)
|
if (state && typeof state.isFullScreen === 'boolean') {
|
||||||
|
setIsFullScreen(state.isFullScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ipcRenderer.on('window-state', windowStateHandler)
|
||||||
|
|
||||||
|
// Listen for navigate
|
||||||
|
const navigateHandler = (event, url) => {
|
||||||
navigate(url)
|
navigate(url)
|
||||||
})
|
}
|
||||||
const unsubNavigationGesture = desktopBridge.onMessage(
|
ipcRenderer.on('navigate', navigateHandler)
|
||||||
'navigationGesture',
|
|
||||||
navigateHistory
|
const navigationGestureHandler = (_event, direction) => {
|
||||||
)
|
navigateHistory(direction)
|
||||||
|
}
|
||||||
|
ipcRenderer.on('navigation-gesture', navigationGestureHandler)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubWindowState()
|
ipcRenderer.removeListener('navigate', navigateHandler)
|
||||||
unsubNavigate()
|
ipcRenderer.removeListener('navigation-gesture', navigationGestureHandler)
|
||||||
unsubNavigationGesture()
|
ipcRenderer.removeListener('window-state', windowStateHandler)
|
||||||
}
|
}
|
||||||
}, [applyWindowState, electronAvailable, navigate, navigateHistory])
|
}, [navigate, navigateHistory])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!electronAvailable || platform !== 'darwin') return
|
if (!electronAvailable || platform !== 'darwin') return
|
||||||
@ -129,8 +142,7 @@ const ElectronProvider = ({ children }) => {
|
|||||||
let resetTimer
|
let resetTimer
|
||||||
|
|
||||||
const handleWheel = (event) => {
|
const handleWheel = (event) => {
|
||||||
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)
|
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
|
||||||
return
|
|
||||||
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return
|
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return
|
||||||
if (Math.abs(event.deltaX) < 2) return
|
if (Math.abs(event.deltaX) < 2) return
|
||||||
if (isInHorizontalScrollContainer(event.target)) return
|
if (isInHorizontalScrollContainer(event.target)) return
|
||||||
@ -153,97 +165,69 @@ const ElectronProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [electronAvailable, navigateHistory, platform])
|
}, [electronAvailable, navigateHistory, platform])
|
||||||
|
|
||||||
|
// Window control handler
|
||||||
const handleWindowControl = (action) => {
|
const handleWindowControl = (action) => {
|
||||||
if (!electronAvailable) return
|
if (electronAvailable && ipcRenderer) {
|
||||||
void desktopBridge.windowControl(action)
|
ipcRenderer.send('window-control', action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAuthSession = async () => {
|
const getAuthSession = async () => {
|
||||||
if (!electronAvailable) return null
|
if (!electronAvailable || !ipcRenderer) return null
|
||||||
return await desktopBridge.getAuthSession()
|
return await ipcRenderer.invoke('auth-session-get')
|
||||||
}
|
}
|
||||||
|
|
||||||
const setAuthSession = async (session) => {
|
const setAuthSession = async (session) => {
|
||||||
if (!electronAvailable) return false
|
if (!electronAvailable || !ipcRenderer) return false
|
||||||
const result = await desktopBridge.setAuthSession(session)
|
return await ipcRenderer.invoke('auth-session-set', session)
|
||||||
return result?.ok ?? false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearAuthSession = async () => {
|
const clearAuthSession = async () => {
|
||||||
if (!electronAvailable) return false
|
if (!electronAvailable || !ipcRenderer) return false
|
||||||
const result = await desktopBridge.clearAuthSession()
|
return await ipcRenderer.invoke('auth-session-clear')
|
||||||
return result?.ok ?? false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAppSettings = useCallback(async () => {
|
const getAppSettings = useCallback(async () => {
|
||||||
if (!electronAvailable) return {}
|
if (!electronAvailable || !ipcRenderer) return {}
|
||||||
return await desktopBridge.getAppSettings()
|
return await ipcRenderer.invoke('app-settings-get')
|
||||||
}, [electronAvailable])
|
}, [electronAvailable])
|
||||||
|
|
||||||
const setAppSettings = useCallback(
|
const setAppSettings = useCallback(
|
||||||
async (settings) => {
|
async (settings) => {
|
||||||
if (!electronAvailable) return false
|
if (!electronAvailable || !ipcRenderer) return false
|
||||||
const result = await desktopBridge.setAppSettings(settings)
|
return await ipcRenderer.invoke('app-settings-set', settings)
|
||||||
return result?.ok ?? false
|
|
||||||
},
|
},
|
||||||
[electronAvailable]
|
[electronAvailable]
|
||||||
)
|
)
|
||||||
|
|
||||||
const startAppUpdate = useCallback(
|
const startAppUpdate = useCallback(
|
||||||
async (update) => {
|
async (update) => {
|
||||||
if (!electronAvailable) return false
|
if (!electronAvailable || !ipcRenderer) return false
|
||||||
const result = await desktopBridge.startAppUpdate(update)
|
return await ipcRenderer.invoke('app-update-start', update)
|
||||||
return result?.ok ?? false
|
|
||||||
},
|
|
||||||
[electronAvailable]
|
|
||||||
)
|
|
||||||
|
|
||||||
const checkAppUpdateResult = useCallback(async () => {
|
|
||||||
if (!electronAvailable) return null
|
|
||||||
return await desktopBridge.checkAppUpdateResult()
|
|
||||||
}, [electronAvailable])
|
|
||||||
|
|
||||||
const checkDuplicateInstallations = useCallback(async () => {
|
|
||||||
if (!electronAvailable) return null
|
|
||||||
return await desktopBridge.checkDuplicateInstallations()
|
|
||||||
}, [electronAvailable])
|
|
||||||
|
|
||||||
const removeDuplicateInstallations = useCallback(async () => {
|
|
||||||
if (!electronAvailable) return false
|
|
||||||
const result = await desktopBridge.removeDuplicateInstallations()
|
|
||||||
return result?.ok ?? false
|
|
||||||
}, [electronAvailable])
|
|
||||||
|
|
||||||
const onDuplicateInstallationsRemoved = useCallback(
|
|
||||||
(handler) => {
|
|
||||||
if (!electronAvailable || typeof handler !== 'function') {
|
|
||||||
return () => {}
|
|
||||||
}
|
|
||||||
return desktopBridge.onMessage('duplicateInstallationsRemoved', handler)
|
|
||||||
},
|
},
|
||||||
[electronAvailable]
|
[electronAvailable]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAppUpdateProgress = useCallback(
|
const onAppUpdateProgress = useCallback(
|
||||||
(handler) => {
|
(handler) => {
|
||||||
if (!electronAvailable || typeof handler !== 'function') {
|
if (!electronAvailable || !ipcRenderer || typeof handler !== 'function') {
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
return desktopBridge.onMessage('appUpdateProgress', handler)
|
|
||||||
},
|
const progressHandler = (event, progress) => {
|
||||||
[electronAvailable]
|
handler(progress)
|
||||||
)
|
}
|
||||||
|
|
||||||
const onCheckForUpdatesRequest = useCallback(
|
ipcRenderer.on('app-update-progress', progressHandler)
|
||||||
(handler) => {
|
|
||||||
if (!electronAvailable || typeof handler !== 'function') {
|
return () => {
|
||||||
return () => {}
|
ipcRenderer.removeListener('app-update-progress', progressHandler)
|
||||||
}
|
}
|
||||||
return desktopBridge.onMessage('checkForUpdates', handler)
|
|
||||||
},
|
},
|
||||||
[electronAvailable]
|
[electronAvailable]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Backwards-compatible helpers
|
||||||
const getToken = async () => {
|
const getToken = async () => {
|
||||||
const session = await getAuthSession()
|
const session = await getAuthSession()
|
||||||
return session?.token || null
|
return session?.token || null
|
||||||
@ -255,10 +239,9 @@ const ElectronProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resizeSpotlightWindow = async (height) => {
|
const resizeSpotlightWindow = async (height) => {
|
||||||
if (!electronAvailable) return false
|
if (!electronAvailable || !ipcRenderer) return false
|
||||||
try {
|
try {
|
||||||
const result = await desktopBridge.resizeSpotlightWindow(height)
|
return await ipcRenderer.invoke('spotlight-window-resize', height)
|
||||||
return result?.ok ?? false
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[ElectronContext] Failed to resize spotlight window:',
|
'[ElectronContext] Failed to resize spotlight window:',
|
||||||
@ -270,22 +253,15 @@ const ElectronProvider = ({ children }) => {
|
|||||||
|
|
||||||
const setSidebarViewMenu = useCallback(
|
const setSidebarViewMenu = useCallback(
|
||||||
async (sections) => {
|
async (sections) => {
|
||||||
if (!electronAvailable) return false
|
if (!electronAvailable || !ipcRenderer) return false
|
||||||
const result = await desktopBridge.setSidebarViewMenu(sections)
|
return await ipcRenderer.invoke('set-sidebar-view-menu', sections)
|
||||||
return result?.ok ?? false
|
|
||||||
},
|
},
|
||||||
[electronAvailable]
|
[electronAvailable]
|
||||||
)
|
)
|
||||||
|
|
||||||
const getElectronVersion = useCallback(async () => {
|
const getElectronVersion = useCallback(async () => {
|
||||||
if (!electronAvailable) return null
|
if (!electronAvailable || !ipcRenderer) return null
|
||||||
return await desktopBridge.getAppVersion()
|
return await ipcRenderer.invoke('electron-version')
|
||||||
}, [electronAvailable])
|
|
||||||
|
|
||||||
const getAppEngine = useCallback(async () => {
|
|
||||||
if (!electronAvailable) return 'native'
|
|
||||||
const engine = await desktopBridge.getAppEngine()
|
|
||||||
return engine === 'chromium' ? 'chromium' : 'native'
|
|
||||||
}, [electronAvailable])
|
}, [electronAvailable])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -304,18 +280,12 @@ const ElectronProvider = ({ children }) => {
|
|||||||
getAppSettings,
|
getAppSettings,
|
||||||
setAppSettings,
|
setAppSettings,
|
||||||
startAppUpdate,
|
startAppUpdate,
|
||||||
checkAppUpdateResult,
|
|
||||||
checkDuplicateInstallations,
|
|
||||||
removeDuplicateInstallations,
|
|
||||||
onDuplicateInstallationsRemoved,
|
|
||||||
onAppUpdateProgress,
|
onAppUpdateProgress,
|
||||||
onCheckForUpdatesRequest,
|
|
||||||
getToken,
|
getToken,
|
||||||
setToken,
|
setToken,
|
||||||
resizeSpotlightWindow,
|
resizeSpotlightWindow,
|
||||||
setSidebarViewMenu,
|
setSidebarViewMenu,
|
||||||
getElectronVersion,
|
getElectronVersion
|
||||||
getAppEngine
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
import { createContext, useContext } from 'react'
|
import { createContext, useContext } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { message } from 'antd'
|
import { message } from 'antd'
|
||||||
import { isElectrobunDesktop } from '../../../electrobun-bridge'
|
|
||||||
|
|
||||||
const MessageContext = createContext()
|
const MessageContext = createContext()
|
||||||
|
|
||||||
// antd's default message top is 8px; push messages down an extra 40px in the
|
|
||||||
// desktop app so they clear the custom title bar / drag region.
|
|
||||||
const MESSAGE_TOP = isElectrobunDesktop() ? 48 : undefined
|
|
||||||
|
|
||||||
export const MessageProvider = ({ children }) => {
|
export const MessageProvider = ({ children }) => {
|
||||||
const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP })
|
const [msgApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
const showMessage = (type, content, options = {}) => {
|
const showMessage = (type, content, options = {}) => {
|
||||||
return msgApi.open({
|
return msgApi.open({
|
||||||
|
|||||||