Compare commits

...

112 Commits

Author SHA1 Message Date
7d79e0d206 Enhance MenuButton component in WindowAppMenu
Some checks are pending
farmcontrol/farmcontrol-ui/pipeline/head Build started...
- Added a new `logo` prop to the `MenuButton` component to conditionally adjust padding and margin based on the presence of a logo.
- Updated the `MenuButton` prop types to include the new `logo` boolean, improving flexibility in rendering menu items with logos.
2026-08-09 11:06:02 +01:00
557c334aa0 Refine Windows installer process for improved shutdown handling and error reporting
Some checks are pending
farmcontrol/farmcontrol-ui/pipeline/head Build started...
- Reduced the wait time for graceful shutdown of the Farm Control application from two minutes to two seconds, enhancing responsiveness during updates.
- Implemented additional checks to confirm the termination of the Farm Control process, providing clearer error messages if the process fails to stop.
- Adjusted the loop for verifying process termination to improve efficiency and reduce unnecessary delays.
2026-08-09 11:01:25 +01:00
28295f5912 Enhance Windows installer with parent process ID handling for improved update reliability
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new variable to capture the parent process ID, allowing the installer to wait for the exact updater process instead of relying on executable names.
- Updated the `restartFarmControlAfterUpdate` function to handle the new parent PID, ensuring a more reliable shutdown of the Farm Control application during updates.
- Modified the `launchWindowsInstaller` function to pass the current process ID as an argument, enhancing the installer’s ability to manage application restarts effectively.
2026-08-09 10:37:02 +01:00
2bbecb0d6a Implement file enumeration and progress tracking in Windows installer
- Added a new PowerShell script to generate NSIS file lists, enabling the installer to track file copy progress.
- Updated the NSIS scripts to include macros for logging total bytes and individual file copy progress during installation.
- Enhanced the `winappupdate.js` to parse and display copy progress information, improving user feedback during updates.
2026-08-09 10:32:56 +01:00
67511e8b8c Enhance Windows installer update process with improved timing and error handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Increased sleep durations in the `restartFarmControlAfterUpdate` function to ensure smoother application restarts during updates.
- Refactored the `startWindowsInstallerProgressWatch` function to reduce polling interval for better responsiveness.
- Cleaned up code formatting for improved readability and consistency across the `appupdate.js` and `winappupdate.js` files.
2026-08-09 10:12:06 +01:00
6bf8648e1f Refine Windows installer update process to handle in-app updates more effectively
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Adjusted the success signal emission in the `restartFarmControlAfterUpdate` function to ensure the application quits and restarts correctly during in-app updates.
- Added a conditional check to control the progress success indication based on the update state, improving the user experience during installations.
2026-08-09 00:32:52 +01:00
de2c93d82f Introduce delay in Windows installer to improve update reliability
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added a 3-second sleep before clearing leftover installation directories, allowing for smoother transitions during the update process and reducing potential conflicts with file locks.
2026-08-09 00:16:10 +01:00
a0ea239aa6 Enhance main window controls and introduce application menu
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated `mainWindow.js` to include a 'quit' action in the IPC handlers, allowing the application to close gracefully.
- Added a new `WindowAppMenu` component to manage application menu items, including options for 'About', 'Check For Updates', and 'Quit'.
- Refactored `DashboardNavigation` to integrate the new `WindowAppMenu`, improving the user interface for non-macOS platforms.
- Adjusted window control handling in `window.js` to support the new menu actions, ensuring consistent behavior across the application.
2026-08-09 00:09:42 +01:00
d7a7e798cb Enhance Windows installer update process with improved file handling and retry mechanisms
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added a temporary path change to avoid renaming issues during the update process, ensuring smoother transitions between old and new installations.
- Implemented a retry mechanism for renaming the old installation directory, increasing reliability in scenarios where files are locked.
- Enhanced logging to provide clearer feedback during the installation and renaming steps, improving user experience.
2026-08-08 23:41:44 +01:00
2216a28ce3 Refactor Windows installer update process to improve file handling and cleanup
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated the `swapUpdateStaging` macro to streamline the update process by swapping the old and new versions of Farm Control more efficiently.
- Introduced a cleanup step to remove leftover `.old` directories from previous updates, enhancing the installer’s reliability.
- Improved logging for better visibility during the update and removal processes, ensuring users are informed of actions taken.
2026-08-08 23:16:03 +01:00
29bf25cc20 Refactor Windows installer update process for improved file handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced new macros `tryRenameProbe` and `waitForInstallDirWritable` to enhance the update process by ensuring files are accessible before replacement.
- Updated the `swapUpdateStaging` macro to include checks for file locks and implement a retry mechanism for directory removal, improving reliability during updates.
- Enhanced detail logging for better visibility during the update process.
2026-08-08 22:35:37 +01:00
ddaeb3cfff Restrict application menu functionality to macOS
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated `applyApplicationMenu` and `setupApplicationMenuEvents` functions to return early if the platform is not macOS, ensuring that menu features are only available on macOS.
- Adjusted the `createMainWindow` function to conditionally call menu setup functions based on the operating system, improving cross-platform compatibility.
2026-08-08 22:19:39 +01:00
d2d64ef45d Refactor Windows installer for side-by-side updates and improved directory handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated the NSIS installer to support side-by-side updates, allowing the running application to remain active while a new version is staged in a separate directory.
- Modified the installation directory handling to ensure the correct paths are used for shortcuts and registry entries.
- Enhanced the update process to include a retry mechanism for folder swaps, improving reliability during updates.
- Adjusted comments and documentation for clarity on the update process and its implications.
2026-08-08 22:06:53 +01:00
8d689617f5 Add macOS volume icon for DMG packaging
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new volume icon file `volicon.icns` for macOS DMG packaging, enhancing the visual presentation of the application during installation.
2026-08-08 20:58:05 +01:00
21a88e00d0 Add macOS headerpad fix for x64 binaries
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Introduced `fix-macos-headerpad.mjs` to address issues with Mach-O headerpad in x64 binaries, preventing corruption during code signing.
- Updated `ensure-electrobun-core.mjs` to call the new headerpad fix function, ensuring compatibility with macOS environments.
- This change resolves issue #485 by freeing load-command slots before signing binaries, enhancing stability on Intel Macs.
2026-08-08 20:57:37 +01:00
c1e0921c3e Update MSI wrapper to use dynamic log path and ensure cleanup on uninstall
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Modified the `ExeCommand` in `msi-wrapped.wxs` to log installation updates to the correct per-user directory.
- Added removal actions for update files and directories during uninstallation to maintain a clean environment.
2026-08-08 20:36:57 +01:00
9051b94767 Bump version
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
2026-08-08 20:24:33 +01:00
4ee72fdbac Add MSI wrapper support for Windows installer
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Updated `package.json` to include `msiWrapped` configuration for MSI installer generation.
- Introduced `msi-wrapped.wxs` file to define the MSI wrapper around the NSIS setup, enabling silent updates.
- Created `build-windows-msi.ps1` script to automate the MSI building process, integrating with the existing build workflow.
- Modified `finalize-desktop-artifacts.mjs` to call the new MSI build function, ensuring both NSIS and MSI installers are generated during the build process.
2026-08-08 20:21:50 +01:00
e32eda100a Enhance app update checks with improved error handling and duplicate installation detection
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Updated `AppUpdateProvider` to handle errors during startup checks for app updates and duplicate installations, improving robustness.
- Refactored logic to ensure duplicate installation checks are integrated into the update process, providing clearer feedback to users.
- Adjusted modal visibility conditions for duplicate installation prompts to streamline user experience.
2026-08-08 20:08:55 +01:00
c5d8d9cf70 Add duplicate installation checks and update handling in Electron context
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-08-08 20:04:55 +01:00
e46e12f15d Add check for updates functionality in Electron context and menu
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Implemented `onCheckForUpdatesRequest` in `ElectronContext` to handle update requests from the renderer.
- Updated `AppUpdateProvider` to utilize the new check for updates functionality.
- Added "Check For Updates..." option in the desktop menu to trigger the update check.
- Enhanced event handling in the main window to support the new update check action, improving user experience for application updates.
2026-08-08 19:46:07 +01:00
3534a797fc Refactor About component to use getAppEngine for Electron context
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Replaced getElectronVersion with getAppEngine in the About component to retrieve the app engine type.
- Updated state management to reflect the app engine instead of the Electron version.
- Adjusted UI elements to display the app engine type, enhancing clarity for users regarding the application environment.
2026-08-08 19:40:30 +01:00
a5cad3b746 Integrate Electron context into NotificationProvider for responsive styling
- Added `ElectronContext` to the `NotificationProvider` to access the `isElectron` state.
- Updated the notification center's styling to conditionally apply a top margin based on whether the app is running in an Electron environment, enhancing the UI for better responsiveness.
2026-08-08 19:38:00 +01:00
998084160f Add app update engine handling and UI enhancements
- Introduced `getAppEngine` method in the Electron context to retrieve the current app update engine.
- Updated the Settings component to manage and display the app update engine, allowing users to select between 'Native' and 'Chromium'.
- Enhanced the app update logic to persist the selected engine and ensure proper handling during updates.
- Improved UI in the NewAppUpdate component to show the engine type associated with updates.
- Refactored app update settings management to include engine normalization and validation.
2026-08-08 19:37:51 +01:00
6b9e4bff63 Implement URL filtering in Electron navigation handlers
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added checks in the Electron context to prevent navigation to '/favicon.ico', improving the handling of unwanted requests.
- Enhanced logging for navigation events to provide better visibility into the navigation process.
2026-08-08 19:05:35 +01:00
643dc46402 Update pnpm-lock.yaml to specify exact versions and add new dependency
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed the version specification for `electrobun` from caret (^) to exact version for consistency.
- Added `rcedit` as a new dependency with its version specified as 4.0.1.
- Updated `@types/node` dependency to version 25.2.0 for compatibility improvements.
2026-08-07 21:58:03 +01:00
2e08c454a7 Update dependencies and improve Electrobun configuration
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Changed the version specification for `electrobun` in `package.json` and `bun.lock` from caret (^) to exact version to ensure consistent builds.
- Enhanced `electrobun.config.ts` by adding path handling for reading `package.json`, improving file resolution and compatibility with module imports.
2026-08-07 21:56:52 +01:00
e81bc235a8 Remove unused installer icon handling in finalize-desktop-artifacts.mjs
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Deleted the logic for checking and applying the installer icon for macOS packages, streamlining the build process.
- This change simplifies the artifact finalization workflow by eliminating unnecessary steps related to the installer icon.
2026-08-04 23:39:57 +01:00
f52ca059a1 Enhance CEF bundling support and update build scripts
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced `bundleCEF` configuration to control CEF bundling in the build process.
- Updated `Jenkinsfile` to support separate builds for CEF and native applications on Windows and macOS.
- Modified `build-macos.mjs` and `finalize-desktop-artifacts.mjs` to incorporate CEF bundling logic and adjust artifact naming accordingly.
- Enhanced logging to indicate CEF bundling status during builds, improving transparency in the build process.
2026-08-04 22:49:50 +01:00
482cc5dc86 Refactor DashboardNavigation to streamline notification context usage
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Removed unused `notificationCenterVisible` from the `DashboardNavigation` component, simplifying the context usage and improving code clarity.
2026-08-04 02:54:11 +01:00
be51fecba6 Update class logic in DashboardNavigation for electron navigation wrapper
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Adjusted the class name logic in the `DashboardNavigation` component to conditionally apply the 'electrobun-webkit-app-region-drag' or 'electrobun-webkit-app-region-no-drag' class based on the fullscreen state, enhancing the user interface for electron applications.
2026-08-04 02:40:53 +01:00
19ce749e80 Fix variable naming inconsistency in DashboardNavigation component
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated `isFullscreen` to `isFullScreen` for consistency with context naming conventions.
- Adjusted class logic in the navigation wrapper to reflect the updated variable name, ensuring proper styling application.
2026-08-04 02:29:26 +01:00
184a6621d9 Refactor DashboardNavigation and enhance CSS styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated `DashboardNavigation` to conditionally render controls based on authentication and platform.
- Improved user experience by displaying a "Farm Control" message when controls are hidden.
- Enhanced CSS for the electron navigation wrapper to include z-index and user-select properties for better UI handling.
2026-08-04 02:14:57 +01:00
d28bed7136 Enhance window management and navigation handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added `isFullscreen` context to `DashboardNavigation` for improved UI responsiveness.
- Updated navigation class logic to conditionally apply styles based on fullscreen state.
- Refined window control logic in `window.js` to handle maximize/unmaximize actions more reliably, including asynchronous state synchronization on Windows.
- Introduced adjustments for window frame dimensions in `windows-work-area.js` to account for Win32 DWM overhangs, ensuring accurate maximized state detection and clamping.
2026-08-04 01:57:51 +01:00
eb1ee1e82b Update Windows installer to use FarmControl.exe and enhance process handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Replaced references to launcher.exe with FarmControl.exe in the installer script, ensuring consistency in application execution.
- Added taskkill command to stop running FarmControl processes during installation.
- Updated shortcut creation to point to FarmControl.exe for both desktop and start menu.
- Enhanced cleanup process to remove old executable backups and ensure proper application state after updates.
- Refactored updater logic to check for the presence of FarmControl.exe, improving application launch reliability.
2026-08-04 01:29:31 +01:00
893804a17c Add uninstaller icons and update packaging scripts
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Introduced `uninstaller.ico` and `farmcontroluninstaller.png` assets for the uninstaller.
- Updated `farmcontrol.nsi` to define and use the uninstaller icon.
- Modified build scripts to copy and handle the uninstaller icon during the packaging process.
- Enhanced pre-build script to include the uninstaller icon in the required assets check.
2026-08-04 01:18:44 +01:00
3c4dc329d0 Remove MSI installer support and related scripts
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Deleted the `msiWrapped` configuration from `package.json` and removed the associated `msi-wrapped.wxs` file to streamline the packaging process.
- Eliminated the `build-windows-msi.ps1` script, which was previously used for generating MSI installers, as part of the transition to a simpler installation method.
- Updated the `finalize-desktop-artifacts.mjs` script to remove references to the MSI build process, ensuring a cleaner artifact finalization workflow.
2026-08-04 01:15:29 +01:00
cfd0849cba Add app icon generation script and update related processes
- Introduced a new script `generate-app-icons.mjs` to automate the generation of application and installer icons.
- Updated error messages in the Windows MSI build script and the finalize artifacts script to reference the new icon generation command.
- Refactored the pre-build process to call the new icon generation script, ensuring required icons are created before building.
2026-08-04 01:11:18 +01:00
ee62045416 Add support for automatic restart after successful installation on Windows
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Implemented a new function to restart Farm Control after a successful installation, triggered by the `/RESTARTFC` option.
- Enhanced the installer script to wait for the application to close before relaunching, improving user experience during updates.
- Updated the app update process to conditionally schedule a restart based on the platform, ensuring compatibility with macOS.
2026-08-04 00:45:52 +01:00
6f2f7ed5c2 Enhance app update process with improved restart handling and Windows launcher resolution
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added a delay in the app update process to ensure the UI reflects completion before quitting.
- Updated the Windows launcher resolution logic to prefer the installed launcher, improving post-update behavior.
- Refactored the restart script to use `.cmd` extension and optimized process handling for better reliability.
2026-08-04 00:24:38 +01:00
4fdf3a233d Enhance Windows installer with improved logging and installation progress tracking
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added support for silent installation mode.
- Implemented detailed progress logging during installation phases, including status updates and percentage completion.
- Refactored the handling of previous installations to ensure smoother updates and in-app upgrade processes.
- Updated error handling for installation failures, providing clearer feedback to users.
2026-08-03 23:54:23 +01:00
2383d14a80 Refactor Windows installer shortcut creation process
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Removed the `fixShortcutWorkingDir` macro to simplify the shortcut creation logic.
- Updated `createDesktopShortcut` and `createStartMenuShortcut` macros to directly set the output path for shortcuts, enhancing clarity and maintainability.
2026-08-03 22:54:06 +01:00
debf6802ce Update Windows installer to support .msi format and enhance installation process
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed the installer extension from `.exe` to `.msi` for better compatibility with Windows Installer.
- Refactored validation functions to ensure proper handling of MSI packages.
- Improved logging and progress tracking during installation, providing clearer feedback on the installation status.
- Updated the installer script to manage previous installations more effectively, allowing for smoother upgrades.
2026-08-03 22:44:46 +01:00
79ee30fd25 Refactor window state handling in window.js and adjust frame dimensions in windows-work-area.js
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Removed redundant window state synchronization calls in `applyWindowsStartupWindowState` to streamline the startup process.
- Updated the `clampWindowToWorkArea` function to adjust the window frame dimensions, ensuring proper fitting within the work area.
2026-08-03 18:21:52 +01:00
98ee99ae69 Update Windows installer to use .exe extension instead of .msi for compatibility with new executable format. Refactor validation function to check for Windows executables, enhancing installer integrity checks. 2026-08-03 18:03:22 +01:00
d418e4b809 Remove rcedit entry from pnpm-lock.yaml to reflect the recent dependency cleanup, streamlining project dependencies.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-08-03 03:25:17 +01:00
caabc62509 Remove rcedit dependency from package.json and bun.lock, streamlining project dependencies. Additionally, eliminate the patchWindowsBinaries function call in the finalize-desktop-artifacts.mjs script, simplifying the artifact finalization process. 2026-08-03 03:24:59 +01:00
c056a2b021 Enhance patch-windows-binaries script with retry logic and improved error handling
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Introduced a retry mechanism in the `patchExecutable` function to handle transient errors when patching executables, allowing up to 6 attempts with exponential backoff.
- Updated the patching process to create a temporary copy of the executable before applying changes, reducing the risk of file locks by antivirus software.
- Improved logging to provide clearer feedback on patching attempts and failures.
2026-08-03 03:01:47 +01:00
4a1badb5aa Refactor Windows path handling in windows-app-paths.js
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Removed the use of `chdirSync` in favor of `process.chdir` for changing the working directory, improving compatibility with the Node.js environment.
- Streamlined the code for ensuring the correct working directory is set before launching the application on Windows.
2026-08-03 02:37:32 +01:00
3d710e2d2e Remove grant-users-access.nsh script from Windows installer packaging
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Deleted the `grant-users-access.nsh` script as user access permissions are now managed directly within the installer, streamlining the installation process for standard users.
2026-08-03 01:17:35 +01:00
9bd581ee03 Refactor Windows installer and scripts to enhance user access and installation behavior
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Updated `package.json` to change installation scope from `perMachine` to `perUser`, allowing installations for standard users without admin rights.
- Modified `farmcontrol.nsi` to adjust installation directory and registry keys to use `HKCU` instead of `HKLM`, improving accessibility for non-admin users.
- Refined `installer.nsh` and related scripts to ensure proper handling of the farmcontrol URI handler and shortcuts, aligning with the new user access model.
- Removed references to the `grant-users-access.nsh` script, as user access permissions are now managed directly within the installer.
2026-08-03 01:17:00 +01:00
7d48ab6e85 Update Windows installer and build scripts to transition from deeplink.exe to deeplink.js
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Modified the Windows installer script to change the command for the farmcontrol URI handler from `deeplink.exe` to `bun.exe` executing `deeplink.js`, improving the handling of deep links.
- Updated the build script to output `deeplink.js` instead of `deeplink.exe`, aligning with the new execution method.
- Adjusted the PowerShell script to check for the presence of `deeplink.js` instead of `deeplink.exe`, ensuring the correct file is validated during the staging process.
- Refined the patching script to remove references to `deeplink.exe`, streamlining the target executables for patching.
2026-08-03 01:15:02 +01:00
2e7d255b20 Refactor patch-windows-binaries script to improve error handling and target specific executables
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Replaced the dynamic executable discovery with a predefined set of target executables (`launcher.exe`, `deeplink.exe`) for clarity.
- Added checks to skip missing executables and improved error handling during the patching process, enhancing robustness and user feedback.
2026-08-03 01:14:06 +01:00
634113ec5c Refactor import statement in patch-windows-binaries script
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Changed the import of `rcedit` from named import to default import for consistency and clarity in the `patch-windows-binaries.mjs` script.
2026-08-03 01:07:05 +01:00
f641ea390d Implement user access permissions and update deeplink handling in Windows installer
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Added a new macro in `grant-users-access.nsh` to grant standard users read and execute access to the installation directory, enhancing accessibility for non-admin users.
- Updated `installer.nsh` to include the new macro during installation, ensuring proper permissions are set.
- Changed the output file from `deeplink.js` to `deeplink.exe` in the build process, improving the executable handling for the application.
- Refactored the installer script to ensure the correct command is used for the farmcontrol URI handler, enhancing functionality.
- Introduced a new script, `patch-windows-binaries.mjs`, to set the requested execution level for Windows binaries, improving security and compliance.
2026-08-03 00:59:07 +01:00
7d1dc22327 Remove unused dependencies from package files
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Deleted `standard`, `svgo`, and `svgo-loader` from `package.json`, `bun.lock`, and `pnpm-lock.yaml` as they are no longer needed.
- This cleanup reduces the overall package size and improves project maintainability.
2026-08-03 00:53:31 +01:00
9c86737691 Add grant users access script to Windows installer process
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Included the `grant-users-access.nsh` script in the Windows installer build process to ensure standard users receive the necessary permissions during installation.
- This addition enhances the installer by addressing permission issues for non-admin users, improving overall accessibility.
2026-08-03 00:50:08 +01:00
add206457f Add user access permissions for Windows installer</message>
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
<message>
- Introduced a new macro in `grant-users-access.nsh` to grant standard users read and execute access to the installation directory, addressing permission issues that affect non-admin users.
- Updated `installer.nsh` and `scripts/installer.nsh` to include the new macro during the installation process, ensuring proper access rights are set for the install tree.
2026-08-03 00:45:53 +01:00
cc3846397a Update branding text in Windows installer script to ensure correct version display
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added a new branding text line in `farmcontrol.nsi` to reflect the version and build number during installation.
- This change improves the visibility of versioning information for users, aligning with previous updates to enhance installer branding.
2026-08-03 00:29:44 +01:00
27e5ef13bc Refactor Windows app path handling to remove unnecessary directory change
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Removed the `chdirSync` call in `ensureWindowsWorkingDirectory` function, replacing it with a check using `existsSync` to streamline the directory validation process.
- This change enhances the reliability of the function by avoiding unnecessary directory changes that could lead to errors.
2026-08-03 00:26:17 +01:00
e1051499f9 Add installer icons for Windows and macOS
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new installer icon in ICO format and a complete iconset for macOS, enhancing the visual branding of the installer.
- Updated Windows installer scripts to reference the new icon files, ensuring they are included in the installation package.
- Implemented a script to prepare the installer icons from a source image, streamlining the icon generation process for different platforms.
2026-08-02 23:35:37 +01:00
ee35e12959 Add farmcontrolinstaller logo and enhance Windows path handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new logo file for the farmcontrol installer.
- Updated the `patch-electrobun-src.mjs` script to improve the resolution of the native wrapper path, ensuring compatibility across different execution contexts.
- Refactored the `index.js` file to utilize a new function for setting the working directory on Windows, enhancing application behavior.
- Added a new module in `windows-app-paths.js` to manage Windows-specific directory resolution and ensure the correct working directory is set before launching the application.
2026-08-02 23:32:27 +01:00
4cf4f5e27f Refactor updater script for Windows application restart handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Simplified the script for restarting the application by removing unnecessary process checks and sleep logic.
- Introduced a new variable for the application directory to ensure the correct working directory is set before launching the application.
- Updated the method of executing the restart script to use `spawn` instead of `schtasks`, improving reliability and clarity in the restart process.
2026-08-02 23:12:28 +01:00
c48b8d72da Update branding text in Windows installer script
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Reintroduced the branding text definition in `farmcontrol.nsi` to ensure the installer reflects the correct version and build number.
- This change enhances the visibility of versioning information during the installation process.
2026-08-02 23:09:46 +01:00
5c1ea30e5d Enhance Windows installer and application path handling
- Added a macro to fix the working directory for shortcuts in the Windows installer, ensuring correct execution context.
- Updated shortcut creation logic in `installer.nsh` to utilize the new macro for both desktop and start menu shortcuts.
- Implemented a function to patch the native wrapper path in the Electrobun source, improving compatibility with different execution contexts.
- Adjusted the `deeplink.js` and `index.js` files to set the current working directory appropriately, enhancing application behavior on Windows.
- Introduced a new module for managing window work area constraints, improving window management and user experience on Windows platforms.
2026-08-02 23:09:39 +01:00
17b5068644 Refactor Windows installer scripts to remove desktop and start menu shortcut sections
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Removed the sections for creating desktop and start menu shortcuts from `farmcontrol.nsi`, streamlining the installer process.
- Updated `installer.nsh` to ensure shortcuts are created during installation and removed during uninstallation, enhancing user experience and consistency.
2026-08-02 22:50:30 +01:00
2335a64c03 Refactor Windows path handling for application data directories
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated multiple files to consistently use `os.homedir()` for constructing the local application data path, improving compatibility and clarity across the codebase.
- This change enhances the reliability of directory retrieval for Windows environments.
2026-08-02 22:35:24 +01:00
1fdbb01ceb Refactor Windows instance directory retrieval in deeplink handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated the `getInstanceDir` function to use `os.homedir()` as a fallback for the local app data path, ensuring compatibility across different Windows environments.
- Improved code clarity by consolidating the logic for determining the local application data directory.
2026-08-02 22:25:14 +01:00
5f061f92eb Enhance deeplink and single instance management for Windows
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added functions to manage lock files and check if the primary instance is running, improving instance handling.
- Implemented retry logic for forwarding deeplinks via Windows pipes, enhancing reliability in communication between instances.
- Refactored existing functions to streamline the deeplink forwarding process and improve code clarity.
2026-08-02 22:02:17 +01:00
35a50fa97d Update Windows installer scripts to include build number and branding text
- Modified `farmcontrol.nsi` to define a default build number and updated branding text to reflect versioning.
- Enhanced `build-windows-nsis.ps1` to accept a build number parameter, defaulting to "dev".
- Updated `finalize-desktop-artifacts.mjs` to retrieve the build number from various sources, ensuring accurate versioning in the installer.
2026-08-02 21:53:38 +01:00
282d0c6630 Add isElectrobunBridgeReady function and update error handling in AppUpdateContext
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced isElectrobunBridgeReady function to check if the Electrobun bridge is initialized and ready for RPC requests.
- Updated error message in AppUpdateContext to provide clearer guidance when app updates fail, enhancing user experience.
- Modified ElectronContext to utilize the new isElectrobunBridgeReady function for improved bridge readiness checks.
- Adjusted RPC handling in the desktop module to ensure non-blocking app update progress reporting.
2026-08-02 21:40:30 +01:00
a196d0f26d Update Windows installer and deeplink handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed the default icon reference in the Windows installer from `deeplink.exe` to `launcher.exe`.
- Updated the command for opening the deeplink to use `bun.exe` with the new `deeplink.js` script.
- Refactored the deeplink build script to rename the function from `buildWindowsDeeplinkExe` to `stageWindowsDeeplink` for clarity.
- Adjusted the PowerShell script to check for the presence of `deeplink.js` instead of `deeplink.exe`, ensuring correct staging of the application.
- Modified the finalization script to reflect the new staging function for the deeplink, enhancing the build process.
2026-08-02 21:34:43 +01:00
09d90632f9 Add macOS code signing functionality to finalize desktop artifacts
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Imported `codesignMacAppBundle` function to handle code signing for macOS application bundles.
- This addition prepares the script for enhanced macOS artifact finalization, ensuring compliance with platform requirements.
2026-08-02 21:28:21 +01:00
1cc21fd9e3 Enhance Windows startup window management
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Introduced a delay for maximizing the window on startup to improve user experience on Windows.
- Refactored window state application logic to ensure proper handling of window maximization and layout synchronization.
- Updated platform checks to use a dedicated constant for Windows, improving code clarity and maintainability.
2026-08-02 21:27:22 +01:00
5450473f74 Implement deeplink functionality and update installer for Windows
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Introduced a new script to build the Windows deeplink executable, enhancing deep link handling.
- Updated the installer script to replace references from `launcher.exe` to `deeplink.exe` for better integration with the new deeplink functionality.
- Added a new `deeplink.js` file to manage deeplink processing and communication with running instances.
- Refactored single instance management to utilize the new deeplink capabilities, improving instance handling and navigation.
- Removed deprecated Windows launch argument handling to streamline the codebase.
2026-08-02 21:24:05 +01:00
37593d73ce Implement logging for Windows launch arguments in single instance management
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added `logWindowsLaunchArgs` function to log launch arguments and sources for better debugging on Windows.
- Integrated logging into `ensureSingleInstanceLock` and instance handling to provide insights during instance management.
- Enhanced existing logging to include context-specific messages for improved clarity in the Windows environment.
2026-08-02 21:03:12 +01:00
93df523b7c Enhance single instance and deep link functionality for Windows
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a Windows pipe server to facilitate communication between instances, improving single instance management.
- Updated `ensureSingleInstanceLock` to handle payload forwarding and signal writing more effectively.
- Refactored deep link handling to utilize the new protocol detection logic, enhancing URL parsing and navigation.
- Improved error handling and connection management for the Windows-specific implementation.
2026-08-02 20:51:03 +01:00
3ed7945b7c Enhance single instance and deep link handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced `captureLaunchUrl` to retrieve launch URLs for Windows, improving deep link processing.
- Updated `ensureSingleInstanceLock` to accept a launch URL, enhancing instance management.
- Refactored deep link handling functions to utilize the captured launch URL, ensuring better navigation and focus management.
- Improved signal processing by consolidating instance signal handling functions for better clarity and efficiency.
2026-08-02 20:38:06 +01:00
76ec8389c2 Add logging for navigation events in ElectronContext
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a console log statement to track navigation events within the ElectronContext component.
- This enhancement aids in debugging and monitoring navigation actions triggered by the desktop bridge.
2026-08-02 20:20:14 +01:00
9ef02304fe Add restart stage to app update process
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new restart stage in the app update progress component, allowing for better handling of application restarts.
- Updated the status management to include restart states, enhancing user feedback during the update process.
- Modified installation completion messages to indicate that a restart will occur after installation.
- Adjusted prop types to accommodate the new restart stage in the UpdateStage component.
2026-08-02 20:04:30 +01:00
30f0ac1559 Refactor app update process and restart handling
- Removed the restart stage from the app update progress component, simplifying the update stages to only include download and install.
- Updated the app update logic to schedule a restart after installation instead of handling it directly in the installer functions.
- Enhanced the messaging for installation completion by removing references to automatic restarts in the progress messages.
- Introduced a new updater-runner module to manage application restarts across platforms, improving the update workflow.
2026-08-02 20:03:54 +01:00
4caa7a4bd1 Refactor single instance and deep link handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Removed deprecated single instance handlers and streamlined deep link processing.
- Updated `ensureSingleInstanceLock` to initiate signal watching for better instance management.
- Enhanced deep link handling by introducing `setupWindowsDeepLinkHandling` for improved navigation and focus management.
- Refactored URL parsing in `findProtocolUrl` for better reliability in deep link resolution.
2026-08-02 19:45:54 +01:00
37113b1b41 Implement enhanced window management for Windows platform
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced syncWindowsWebviewLayout function to ensure webview layout updates with window size changes.
- Added applyWindowsStartupWindowState function to manage window state and layout synchronization during startup on Windows.
- Updated createMainWindow to call applyWindowsStartupWindowState for improved initial window handling on Windows.
2026-08-02 19:40:18 +01:00
fb81cfedd2 Enhance single instance management and deep link processing
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Refactored single instance handling to improve lock management and signal processing.
- Introduced functions for acquiring and releasing primary locks, along with handling instance signals.
- Updated deep link processing to utilize a signal file for better communication between instances.
- Enhanced Windows-specific launch argument retrieval to improve deep link handling on that platform.
2026-08-02 19:37:09 +01:00
813b43595b Update DashboardNavigation to include a vertical divider between logos
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added a vertical Divider component between the FarmControlLogoSmall and the next logo for improved visual separation.
- Adjusted the right margin of the FarmControlLogoSmall to enhance layout consistency.
2026-08-02 19:31:53 +01:00
6dad9c820c Enhance single instance management and deep link processing
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Refactored single instance handling to support multiple local hosts, improving reliability in message forwarding.
- Introduced functions for managing pending deep links, including staging, consuming, and clearing deeplink files.
- Updated `getSingleInstanceUrl` to accept dynamic host parameters and improved error handling in instance locking.
- Added Windows-specific launch argument retrieval to enhance deep link handling on that platform.
2026-08-02 19:16:44 +01:00
3b28aa4308 Add application menu structure for macOS and Windows
- Implemented functions to build application menus for macOS, including 'About', 'Edit', 'File', and 'Window' menus.
- Enhanced the application menu template to include these new menu structures, improving user navigation and functionality.
- Ensured compatibility with macOS by conditionally adding the app menu based on the platform.
2026-08-02 19:04:14 +01:00
40ccacc9a8 Refactor single instance management and deep link handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Updated single instance server functionality to improve message dispatching and handling of deep links.
- Introduced `setSingleInstanceHandlers` to manage event callbacks for deep link and focus events.
- Enhanced `findProtocolUrl` to support better URL parsing and handling of incoming messages.
- Refactored `startPrimaryInstanceServer` to utilize fetch for message forwarding, improving reliability.
2026-08-02 18:43:33 +01:00
1c8fc51db2 Update window management for Windows platform
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed fullscreen mode to maximize window on Windows during startup in both mainWindow.js and window.js.
- Improved consistency in window behavior across different operating systems.
2026-08-02 18:34:43 +01:00
611cd4aad5 Implement single instance management and enhance deep link handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added single instance server functionality to prevent multiple app instances.
- Introduced `ensureSingleInstanceLock` and `closeSingleInstanceServer` methods for managing instance locks.
- Updated main application entry point to handle deep links and focus events.
- Enhanced window management by showing the main window on focus and handling deep links appropriately.
2026-08-02 16:25:09 +01:00
1b77428a8a Fix button order in DashboardWindowButtons component for consistent window management display 2026-08-02 16:24:54 +01:00
d0345fd7f3 Enhance window management for macOS and Windows
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added functionality to maximize the window on macOS and set it to fullscreen on Windows during startup.
- Introduced applyStartupWindowState function to manage initial window state based on the operating system.
- Updated createMainWindow to call applyStartupWindowState for improved user experience.
2026-08-02 16:20:59 +01:00
8e3ce20bb6 Add macOS traffic light controls and enhance window management
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new MacOSTrafficLights component to manage window controls (close, minimize, fullscreen) with custom SVG icons.
- Updated DashboardWindowButtons to integrate the new traffic light controls for macOS.
- Enhanced window control functionality in mainWindow.js to toggle fullscreen mode.
- Added CSS styles for traffic light indicators to improve visual representation.
- Refactored DashboardNavigation to streamline the handling of fullscreen state.
2026-08-02 15:57:54 +01:00
c334781689 Refactor Windows MSI installer sequence for improved action flow
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Adjusted the order of custom actions in the MSI wrapped configuration to streamline the installation process.
- Enhanced conditions for uninstalling previous versions and running the installer, improving clarity and maintainability of the script.
2026-08-02 14:16:58 +01:00
7150639908 Remove media queries for color scheme in preload.css to simplify styling. This change eliminates the background color definitions for dark and light modes, streamlining the CSS for the loader component.
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
2026-08-02 14:15:04 +01:00
655e1803ff Update macOS vibrancy styles for improved background opacity and add navigation wrapper styles
- Adjusted background opacity for light and dark modes in the sidebar to enhance visual consistency.
- Introduced new styles for the electron navigation wrapper in both light and dark modes to improve UI clarity.
- Refined overall CSS for macOS vibrancy to ensure better integration with the application layout.
2026-08-02 13:53:56 +01:00
884f66cc4c Refactor Windows MSI installer sequence for improved uninstall handling
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Adjusted custom actions in the MSI wrapped configuration to enhance the uninstall sequence for previous installations.
- Updated conditions for running the installer and managing previous uninstall commands and directories.
- Improved clarity and maintainability of the installer script.
2026-08-02 13:10:46 +01:00
a8339aa9f8 Enhance macOS window effects validation and build process
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Introduced functions to validate the macOS dynamic library, ensuring it meets size and architecture requirements.
- Updated build scripts to resolve target architecture dynamically and validate the generated dylib.
- Enhanced the macOS window effects implementation to include traffic light positioning and improved error handling.
- Refactored the dylib path resolution logic to support multiple potential locations for the library.
2026-08-02 13:05:28 +01:00
8d9455eaa9 Refine macOS vibrancy styles and enhance layout structure
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Updated CSS for macOS vibrancy, improving background opacity for light and dark modes in main content and sidebar.
- Added styles for selected menu items in light mode to enhance UI consistency.
- Modified DashboardLayout to include a specific class for the main content layout, ensuring better styling application.
2026-08-02 12:50:29 +01:00
805361516e Enhance macOS window effects and improve Electrobun integration
- Adjusted CSS for macOS vibrancy, refining background opacity for light and dark modes.
- Introduced new styles for selected menu items in dark mode to enhance UI consistency.
- Added traffic light positioning and window chrome observer in native macOS window effects for better window management.
- Refactored Electron context to utilize the Electrobun bridge for improved API interactions and error handling.
- Streamlined the initialization process for the Electrobun bridge in the main application entry point.
2026-08-02 12:18:30 +01:00
f6978f44b2 Enhance Electrobun integration and pre-build process
- Updated pre-build script to create a stub for the dev environment, preventing warnings during Vite development.
- Refactored electrobun-bridge.js to improve initialization logic and ensure proper setup of the Electrobun RPC.
- Modified ElectronContext to dynamically retrieve the desktop API, enhancing compatibility and maintainability.
- Integrated Electrobun bridge initialization in the main application entry point for improved startup behavior.
2026-08-02 03:19:36 +01:00
f59000778e Refactor Windows MSI installer configuration for improved uninstall handling
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Updated custom actions in the MSI wrapped configuration to use a unified property for command line execution.
- Simplified the install sequence by adjusting conditions for running the installer and uninstalling previous versions.
- Enhanced clarity and maintainability of the installer script.
2026-08-02 02:53:46 +01:00
b1ca3818e3 Enhance DashboardNavigation component with drag-and-drop support
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Added 'electrobun-webkit-app-region-no-drag' class to navigation items for improved drag-and-drop functionality in Electron.
- Updated layout structure to ensure proper flex behavior in the navigation component.
2026-08-02 02:52:28 +01:00
274852b895 Add macOS vibrancy support and related build scripts
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Introduced native macOS window effects with a new dynamic library for enhanced UI.
- Updated build scripts to compile the macOS effects library and integrate it into the application.
- Modified Electron context to apply macOS-specific styles and effects based on the platform.
- Enhanced CSS for macOS vibrancy support in the application layout.
- Updated configuration files to include new build steps and dependencies for macOS.
2026-08-02 02:44:24 +01:00
f90ef10b78 Refactor Windows installer scripts and enhance Electron navigation
- Added macros for uninstalling previous Farm Control installations in the NSIS installer.
- Updated the MSI wrapped configuration to include custom actions for handling previous installations.
- Improved the Electron navigation by modifying CSS classes for better drag-and-drop functionality.
- Enhanced build scripts to include the WixUtilExtension for improved MSI packaging.
2026-08-02 01:49:37 +01:00
37fe489b07 Refactor macappupdate.js to streamline imports and improve code clarity
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Replaced the use of createRequire with a direct import for the '@vscode/sudo-prompt' module.
- Removed unnecessary whitespace for cleaner code formatting.
2026-08-02 00:41:15 +01:00
613cb7712d Refactor finalize-desktop-artifacts script for improved readability and consistency
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Standardized import statements and formatting for better code clarity.
- Simplified logic in functions for determining build environment and artifact paths.
- Enhanced error handling in artifact publishing and cleaning functions.
- Streamlined file walking and searching functions to improve maintainability.
2026-08-02 00:38:34 +01:00
47c97a1771 Update Vite configuration to use dynamic import for SVGO plugin
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Replaced static import of svgo with dynamic require to enhance compatibility with ES modules.
- Improved overall build configuration by ensuring proper loading of the SVGO plugin.
2026-08-02 00:30:55 +01:00
3209880cc5 Add new dependencies and update package versions in pnpm-lock.yaml
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Added electrobun, jimp, png-to-ico, and rcedit with specified versions.
- Updated package resolutions for various jimp plugins and utilities to version 1.6.1.
- Included new tokenizer and quickjs-emscripten packages with their respective versions.
- Ensured compatibility with Node.js version 18 for new packages.
2026-08-02 00:29:49 +01:00
888c0fe38a Enhance build configuration and scripts for multi-platform support
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Updated electrobun.config.ts to include environment-based settings for macOS builds.
- Added new build scripts for Windows and macOS, including support for architecture-specific builds.
- Introduced scripts for cleaning build artifacts, managing dependencies, and ensuring core binaries are present.
- Enhanced Jenkinsfile to integrate new build processes and improve artifact management.
- Added new assets for application icons and installer configurations.
- Implemented NSIS and MSI packaging scripts for Windows installer creation.
- Updated package.json with new scripts for development and build processes, including post-installation tasks.
2026-08-02 00:28:33 +01:00
f1826548ea Add initial Electrobun configuration and build scripts for macOS
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit
- Created electrobun.config.ts to define application settings and build parameters.
- Added build-macos.mjs script to handle macOS builds, including architecture targeting.
- Introduced finalize-desktop-artifacts.mjs for managing post-build processes and artifact publishing.
- Implemented pre-build.mjs for validating required files before the build process.
- Added utility scripts for managing release artifacts and syncing views.
- Established desktop-specific functionalities in the src/desktop directory, including app update handling and menu management.
- Updated .gitignore to include dist directory and test results for cleaner repository management.
- Introduced bun.lock for dependency management with Bun.
2026-08-01 20:18:02 +01:00
0b98d8041e Update Jenkinsfile to reduce build log retention from 20 to 10 builds for improved resource management 2026-08-01 20:17:12 +01:00
114 changed files with 13740 additions and 529 deletions

5
.gitignore vendored
View File

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

164
Jenkinsfile vendored
View File

@ -1,7 +1,57 @@
properties([
buildDiscarder(logRotator(numToKeepStr: '20'))
buildDiscarder(logRotator(numToKeepStr: '10'))
])
def bunBinDir() {
if (isUnix()) {
return "${env.HOME}/.bun/bin"
}
return "${env.USERPROFILE}/.bun/bin"
}
def withBun(Closure body) {
def sep = isUnix() ? ':' : ';'
withEnv(["PATH=${bunBinDir()}${sep}${env.PATH}"]) {
body()
}
}
def checkBun() {
if (isUnix()) {
sh 'bun -v'
} else {
bat 'bun -v'
}
}
def writeBuildMetadata() {
if (isUnix()) {
sh '''
bun --eval "await Bun.write('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n')"
'''
} else {
bat '''
bun --eval "await Bun.write('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n')"
'''
}
}
def runBuild(buildCommand) {
def releaseBaseUrl =
"https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
withEnv([
"VITE_BUILD_NUMBER=${env.BUILD_NUMBER ?: 'dev'}",
'NODE_ENV=production',
"ELECTROBUN_RELEASE_BASE_URL=${releaseBaseUrl}",
]) {
if (isUnix()) {
sh buildCommand
} else {
bat buildCommand
}
}
}
def deploy() {
node('ubuntu') {
try {
@ -30,7 +80,6 @@ def deploy() {
stage('Deploy (Ubuntu)') {
nodejs(nodeJSInstallationName: 'Node23') {
// Deploy to Cloudflare Pages using wrangler
withCredentials([string(credentialsId: 'cloudflare-api-token', variable: 'CLOUDFLARE_API_TOKEN')]) {
sh 'pnpm wrangler pages deploy build --branch main'
}
@ -42,66 +91,87 @@ def deploy() {
}
}
def buildOnLabel(label, buildCommand) {
def prepareMacBuildWorkspace() {
if (isUnix()) {
sh '''
df -h .
for vol in /Volumes/Farm\\ Control*; do
if [ -d "$vol" ]; then
hdiutil detach "$vol" -force || true
fi
done
rm -rf build/stable-macos-*/.dmg-staging build/stable-macos-*/.finalize-dmg-staging 2>/dev/null || true
'''
}
}
def buildOnLabel(label, buildCommand, bundleCef = false) {
def cefLabel = bundleCef ? 'cef' : 'native'
return {
node(label) {
stage("Checkout (${label})") {
checkout scm
}
try {
stage("Checkout (${label}/${cefLabel})") {
checkout scm
}
stage("Setup Node.js (${label})") {
nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh 'node -v'
sh 'pnpm -v'
} else {
bat 'node -v'
bat 'pnpm -v'
withBun {
stage("Check Bun (${label}/${cefLabel})") {
checkBun()
}
if (label.startsWith('macos')) {
stage("Prepare macOS workspace (${label}/${cefLabel})") {
prepareMacBuildWorkspace()
}
}
stage("Install Dependencies (${label}/${cefLabel})") {
if (isUnix()) {
sh 'bun install --frozen-lockfile'
} else {
bat 'bun install --frozen-lockfile'
}
}
stage("Write Build Metadata (${label}/${cefLabel})") {
writeBuildMetadata()
}
stage("Build (${label}/${cefLabel})") {
withEnv(["ELECTROBUN_BUNDLE_CEF=${bundleCef ? 'true' : 'false'}"]) {
runBuild(buildCommand)
}
}
}
}
stage("Install Dependencies (${label})") {
nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh 'pnpm install --frozen-lockfile --production=false'
} else {
bat 'pnpm install --frozen-lockfile --production=false'
}
stage("Archive Artifacts (${label}/${cefLabel})") {
archiveArtifacts artifacts: 'app_dist/farmcontrol-*', fingerprint: true
}
}
stage("Build (${label})") {
nodejs(nodeJSInstallationName: 'Node23') {
if (isUnix()) {
sh "VITE_BUILD_NUMBER=${env.BUILD_NUMBER} NODE_ENV=production ${buildCommand}"
} else {
bat "set VITE_BUILD_NUMBER=${env.BUILD_NUMBER} && set NODE_ENV=production && ${buildCommand}"
}
}
}
stage("Archive Artifacts (${label})") {
archiveArtifacts artifacts: 'app_dist/**/farmcontrol-*.dmg, app_dist/**/farmcontrol-*.exe, app_dist/**/farmcontrol-*.pkg, app_dist/**/farmcontrol-*.msi', fingerprint: true
} finally {
cleanWs()
}
}
}
}
def buildMacOnLabel(label, targetArch, bundleCef = false) {
return buildOnLabel(label, "ELECTROBUN_TARGET_ARCH=${targetArch} bun run build:app:mac", bundleCef)
}
def setBuildNameFromPackageVersion() {
node('ubuntu') {
stage('Set Build Name') {
checkout scm
def version
nodejs(nodeJSInstallationName: 'Node23') {
version = sh(
script: "node -p \"require('./package.json').version\"",
withBun {
checkBun()
def version = sh(
script: "bun --eval \"console.log(JSON.parse(await Bun.file('package.json').text()).version)\"",
returnStdout: true
).trim()
def buildName = "v${version}-b${env.BUILD_NUMBER}"
currentBuild.displayName = buildName
echo "Build name set to: ${buildName}"
}
def buildName = "v${version}-b${env.BUILD_NUMBER}"
currentBuild.displayName = buildName
echo "Build name set to: ${buildName}"
}
}
}
@ -110,8 +180,12 @@ try {
setBuildNameFromPackageVersion()
parallel(
'Windows Build': buildOnLabel('windows', 'pnpm build:electron'),
'MacOS Build': buildOnLabel('macos', 'pnpm build:electron'),
'Windows Build': buildOnLabel('windows', 'bun run build:app', false),
'Windows CEF Build': buildOnLabel('windows', 'bun run build:app', true),
'MacOS x64 Build': buildMacOnLabel('macos', 'x64', false),
'MacOS x64 CEF Build': buildMacOnLabel('macos', 'x64', true),
'MacOS arm64 Build': buildMacOnLabel('macos', 'arm64', false),
'MacOS arm64 CEF Build': buildMacOnLabel('macos', 'arm64', true),
'Ubuntu Deploy': { deploy() }
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
assets/installer.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

View File

@ -105,23 +105,130 @@
}
.electron-navigation-wrapper {
-webkit-app-region: drag;
user-select: none;
--webkit-user-select: none;
z-index: 10000;
}
.electron-navigation-wrapper li,
.electron-navigation-wrapper button,
.electron-navigation-wrapper .ant-tag {
-webkit-app-region: no-drag;
/* Native macOS vibrancy — transparent web content over NSVisualEffectView */
html.macos-vibrancy {
--macos-window-corner-radius: 15px; /* keep in sync with MAC_WINDOW_CORNER_RADIUS */
}
html.macos-vibrancy,
html.macos-vibrancy body,
html.macos-vibrancy #root {
background: transparent !important;
}
html.macos-vibrancy .ant-layout.ant-layout-has-sider,
html.macos-vibrancy .main-layout {
background: transparent !important;
}
html.macos-vibrancy .light-mode .ant-layout.main-content-layout {
background: rgba(255, 255, 255, 0.88) !important;
}
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout {
background: rgba(0, 0, 0, 0.88) !important;
}
html.macos-vibrancy .light-mode .ant-layout-sider {
background: rgba(244, 244, 244, 0.82) !important;
}
html.macos-vibrancy .dark-mode .ant-layout-sider {
background: rgba(10, 10, 10, 0.82) !important;
}
html.macos-vibrancy .light-mode .ant-layout-content {
background: transparent !important;
}
html.macos-vibrancy .dark-mode .ant-layout-content {
background: transparent !important;
}
html.macos-vibrancy .ant-layout-sider-light,
html.macos-vibrancy .electron-sider .ant-menu-light {
background: transparent !important;
}
html.macos-vibrancy
.dark-mode
.electron-sider
.ant-menu-light
.ant-menu-item-selected {
background-color: color-mix(in srgb, var(--color-primary) 30%, #00000010);
color: color-mix(in srgb, var(--color-primary) 75%, #ffffff);
}
html.macos-vibrancy
.light-mode
.electron-sider
.ant-menu-light
.ant-menu-item-selected {
background-color: color-mix(in srgb, var(--color-primary) 80%, #ffffff3d);
color: #ffffff;
}
html.macos-vibrancy .dark-mode .electron-navigation-wrapper {
background: rgba(10, 10, 10, 0.9) !important;
}
html.macos-vibrancy .light-mode .electron-navigation-wrapper {
background: rgba(255, 255, 255, 0.9) !important;
}
html.macos-vibrancy .electron-navigation {
background: rgba(0, 0, 0, 0) !important;
}
html.macos-vibrancy .light-mode .ant-layout.main-content-layout .ant-card {
background: rgba(255, 255, 255, 0.5) !important;
}
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout .ant-card {
background: rgba(31, 31, 31, 0.5) !important;
}
.redTrafficLight {
width: 12px;
height: 12px;
background-color: #fe5f57;
border-radius: 50%;
}
.yellowTrafficLight {
width: 12px;
height: 12px;
background-color: #febc2e;
border-radius: 50%;
}
.greenTrafficLight {
width: 12px;
height: 12px;
background-color: #28c840;
border-radius: 50%;
}
.electron-navigation {
line-height: 40px;
}
.electron-sidebar.ant-menu-inline .ant-menu-item {
padding-left: 20px !important;
}
.electron-navigation .ant-menu-overflow-item-rest {
padding-inline: 10px;
}
.electron-navigation.ant-menu-horizontal .ant-menu-item {
padding-inline: 12px;
}
.electron-sidebar .ant-menu-item,
.electron-sidebar .ant-menu-submenu-title {
height: 32.5px !important;

View File

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

After

Width:  |  Height:  |  Size: 804 B

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 804 B

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 805 B

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 839 B

BIN
assets/uninstaller.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

3419
bun.lock Normal file

File diff suppressed because it is too large Load Diff

88
electrobun.config.ts Normal file
View File

@ -0,0 +1,88 @@
import type { ElectrobunConfig } from "electrobun";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(
readFileSync(path.join(rootDir, "package.json"), "utf8"),
);
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
const isStable = buildEnv === "stable";
const canCodesign = Boolean(process.env.ELECTROBUN_DEVELOPER_ID);
const bundleCEF = ["1", "true", "yes"].includes(
String(process.env.ELECTROBUN_BUNDLE_CEF || "").toLowerCase(),
);
const defaultRenderer = bundleCEF ? "cef" : "native";
const targetOs =
process.env.ELECTROBUN_OS ||
(process.platform === "darwin"
? "macos"
: process.platform === "win32"
? "win"
: "linux");
export default {
app: {
name: "Farm Control",
identifier: "com.tombutcher.farmcontrol",
version: packageJson.version,
description: "3D Printer ERP and Control Software.",
urlSchemes: ["farmcontrol"],
},
runtime: {
exitOnLastWindowClosed: true,
},
build: {
buildFolder: "build",
artifactFolder: "app_dist",
// macOS WebKit loads views:// assets reliably from a flat app folder;
// ASAR-packed views can fail to load module scripts on first launch.
useAsar: isStable && targetOs !== "macos",
asarUnpack: [
"*.node",
"*.dll",
"*.dylib",
"*.so",
"views/**",
],
bun: {
entrypoint: "src/bun/index.js",
},
copy: {
"dist/mainview": "views/mainview",
"src/bun/libMacWindowEffects.dylib": "bun/libMacWindowEffects.dylib",
},
watch: ["scripts", "src"],
watchIgnore: ["dist/**", "build/**", "app_dist/**"],
mac: {
bundleCEF,
defaultRenderer,
icons: "assets/icon.iconset",
createDmg: false,
codesign: isStable && canCodesign,
notarize: isStable && canCodesign,
},
linux: {
bundleCEF: false,
defaultRenderer: "native",
icon: "assets/icon.png",
},
win: {
bundleCEF,
defaultRenderer,
icon: "assets/icon.iconset/icon_256x256.png",
},
},
scripts: {
preBuild: "scripts/pre-build.mjs",
postWrap: "scripts/expand-macos-bundle.mjs",
postPackage: "scripts/finalize-desktop-artifacts.mjs",
},
release: {
baseUrl:
process.env.ELECTROBUN_RELEASE_BASE_URL ||
process.env.RELEASE_BASE_URL ||
"",
},
} satisfies ElectrobunConfig;

View File

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

View File

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

Binary file not shown.

View File

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

View File

@ -0,0 +1,402 @@
!include "FileFunc.nsh"
!insertmacro GetParameters
!insertmacro GetOptions
Var ProgressLogFile
Var IsInAppUpdate
Var RestartAfterInstall
Var FinalInstDir
Var UpdateParentPid
; Generate an include at compile time that defines APP_COPY_TOTAL_BYTES and
; copyApplicationFilesWithProgress. The generated macro contains one File
; command and one byte-progress log entry for every file in the source tree.
!macro compileProgressFileCopy sourceDir generator
!tempfile GENERATED_FILE_COPY_INCLUDE
!system 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${generator}" -SourceDir "${sourceDir}" -OutputPath "${GENERATED_FILE_COPY_INCLUDE}"' = 0
!include "${GENERATED_FILE_COPY_INCLUDE}"
!delfile "${GENERATED_FILE_COPY_INCLUDE}"
!undef GENERATED_FILE_COPY_INCLUDE
!macroend
!macro initProgressLog
StrCpy $ProgressLogFile ""
StrCpy $IsInAppUpdate "0"
StrCpy $RestartAfterInstall "0"
StrCpy $FinalInstDir ""
StrCpy $UpdateParentPid "0"
${GetParameters} $R9
ClearErrors
${GetOptions} $R9 "/UPDATE" $R8
${IfNot} ${Errors}
StrCpy $IsInAppUpdate "1"
${EndIf}
ClearErrors
${GetOptions} $R9 "/RESTARTFC" $R8
${IfNot} ${Errors}
StrCpy $RestartAfterInstall "1"
${EndIf}
ClearErrors
${GetOptions} $R9 "/PARENTPID=" $R8
${IfNot} ${Errors}
; Convert to an integer before it is used in Win32 calls or taskkill.
IntOp $UpdateParentPid $R8 + 0
${EndIf}
; Prefer env var so paths with spaces are reliable; /LOG= remains supported.
ReadEnvStr $ProgressLogFile "FARMCONTROL_INSTALL_LOG"
${If} $ProgressLogFile == ""
ClearErrors
${GetOptions} $R9 "/LOG=" $ProgressLogFile
${If} ${Errors}
StrCpy $ProgressLogFile ""
${EndIf}
${EndIf}
${If} $ProgressLogFile != ""
; Strip surrounding quotes from /LOG="C:\path with spaces\log.log"
StrCpy $R8 $ProgressLogFile 1
${If} $R8 == '"'
StrCpy $ProgressLogFile $ProgressLogFile "" 1
StrLen $R8 $ProgressLogFile
IntOp $R8 $R8 - 1
${If} $R8 > 0
StrCpy $ProgressLogFile $ProgressLogFile $R8
${EndIf}
${EndIf}
; Truncate any previous log so progress starts clean.
Push $0
FileOpen $0 "$ProgressLogFile" w
${If} $0 != ""
FileClose $0
${EndIf}
Pop $0
${EndIf}
!macroend
; Open/append/close each line so the updater can read the log concurrently
; (avoids an exclusive lock for the whole install; LogEx SHARE_READ is an alternative).
!macro progressLog line
DetailPrint "${line}"
${If} $ProgressLogFile != ""
Push $0
FileOpen $0 "$ProgressLogFile" a
${If} $0 != ""
FileSeek $0 0 END
FileWrite $0 "${line}$\r$\n"
FileClose $0
${EndIf}
Pop $0
${EndIf}
!macroend
!macro progressPercent percent
!insertmacro progressLog "installer:%${percent}"
!macroend
!macro progressPhase phase
!insertmacro progressLog "installer:PHASE:${phase}"
!macroend
!macro progressStatus status
!insertmacro progressLog "installer:STATUS:${status}"
!macroend
!macro progressCopyTotal bytes
!insertmacro progressLog "installer:COPY_TOTAL:${bytes}"
!macroend
!macro progressCopyFile bytes relativePath
!insertmacro progressLog "installer:COPY_FILE:${bytes}:${relativePath}"
!macroend
!macro progressSuccess
!insertmacro progressPercent 100
!insertmacro progressStatus "Installation complete. Restarting Farm Control..."
!insertmacro progressLog "installer: The install was successful."
!macroend
!macro progressFailure message
!insertmacro progressLog "installer:STATUS:${message}"
!insertmacro progressLog "installer: The install failed."
!macroend
!macro quitFarmControl
!insertmacro progressPhase "Stopping Farm Control"
!insertmacro progressStatus "Stopping running Farm Control processes..."
DetailPrint "Stopping running Farm Control processes..."
ExecWait 'taskkill /F /IM FarmControl.exe /T' $R0
ExecWait 'taskkill /F /IM launcher.exe /T' $R0
!macroend
; Side-by-side update: install into Farm Control.new while the running app
; keeps using Farm Control, then swap folders after the process exits.
!macro prepareSideBySideUpdate
!insertmacro progressPhase "Preparing update"
!insertmacro progressStatus "Preparing staging folder for update..."
StrCpy $FinalInstDir $INSTDIR
StrCpy $INSTDIR "$FinalInstDir.new"
${If} ${FileExists} "$INSTDIR"
DetailPrint "Removing leftover staging folder..."
RMDir /r "$INSTDIR"
${EndIf}
!macroend
; Swap order: Farm Control -> .old, Farm Control.new -> Farm Control, delete .old.
!macro swapUpdateStaging
!insertmacro progressPhase "Applying update"
!insertmacro progressStatus "Replacing Farm Control with the new version..."
DetailPrint "Replacing Farm Control with staged update..."
; The install section ran SetOutPath into the install tree; a directory that
; is any process's working directory cannot be renamed, so move out first.
SetOutPath "$TEMP"
Sleep 3000
; Clear a leftover .old from a previous interrupted update.
${If} ${FileExists} "$FinalInstDir.old"
DetailPrint "Removing leftover Farm Control.old..."
RMDir /r "$FinalInstDir.old"
${EndIf}
${If} ${FileExists} "$FinalInstDir"
!insertmacro progressStatus "Waiting for Farm Control to release install files..."
DetailPrint "Waiting to rename install directory to Farm Control.old..."
; Retry every 500ms; success is verified on disk rather than via the
; error flag, which Rename does not set reliably.
StrCpy $R7 0
swap_rename_old_retry:
Rename "$FinalInstDir" "$FinalInstDir.old"
${IfNot} ${FileExists} "$FinalInstDir"
Goto swap_rename_old_done
${EndIf}
IntOp $R7 $R7 + 1
${If} $R7 < 120
Sleep 500
Goto swap_rename_old_retry
${EndIf}
!insertmacro progressFailure "Could not move the previous Farm Control installation aside."
Abort
swap_rename_old_done:
DetailPrint "Install directory renamed to Farm Control.old"
${EndIf}
!insertmacro progressStatus "Activating new Farm Control installation..."
DetailPrint "Renaming Farm Control.new to Farm Control..."
StrCpy $R7 0
swap_rename_new_retry:
Rename "$INSTDIR" "$FinalInstDir"
${If} ${FileExists} "$FinalInstDir"
Goto swap_rename_new_done
${EndIf}
IntOp $R7 $R7 + 1
${If} $R7 < 20
Sleep 500
Goto swap_rename_new_retry
${EndIf}
; Best-effort rollback so the previous install is usable again.
${If} ${FileExists} "$FinalInstDir.old"
Rename "$FinalInstDir.old" "$FinalInstDir"
${EndIf}
!insertmacro progressFailure "Could not activate the new Farm Control installation."
Abort
swap_rename_new_done:
StrCpy $INSTDIR $FinalInstDir
!insertmacro progressLog "installer:STATUS:Update files activated"
${If} ${FileExists} "$FinalInstDir.old"
!insertmacro progressStatus "Removing previous Farm Control installation..."
DetailPrint "Removing Farm Control.old..."
StrCpy $R7 0
swap_delete_old_retry:
RMDir /r "$FinalInstDir.old"
${If} ${FileExists} "$FinalInstDir.old"
IntOp $R7 $R7 + 1
${If} $R7 < 20
Sleep 500
Goto swap_delete_old_retry
${EndIf}
; New version is already active; leftover .old is non-fatal.
DetailPrint "Warning: could not fully remove Farm Control.old"
!insertmacro progressLog "installer:STATUS:Warning: could not fully remove previous installation"
${EndIf}
${EndIf}
!macroend
!macro uninstallPreviousFarmControl
ClearErrors
ReadRegStr $R0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString"
StrCmp $R0 "" check_install_dir run_uninstall
check_install_dir:
ReadRegStr $R0 HKCU "Software\Tom Butcher\Farm Control" "InstallDir"
StrCmp $R0 "" check_legacy_uninstall
StrCpy $R0 "$R0\Uninstall.exe"
Goto run_uninstall
check_legacy_uninstall:
ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString"
StrCmp $R0 "" check_legacy_install_dir run_uninstall
check_legacy_install_dir:
ReadRegStr $R0 HKLM "Software\Tom Butcher\Farm Control" "InstallDir"
StrCmp $R0 "" check_default_dir
StrCpy $R0 "$R0\Uninstall.exe"
Goto run_uninstall
check_default_dir:
StrCpy $R0 "$LOCALAPPDATA\Programs\Farm Control\Uninstall.exe"
IfFileExists $R0 run_uninstall check_legacy_default_dir
check_legacy_default_dir:
StrCpy $R0 "$PROGRAMFILES64\Farm Control\Uninstall.exe"
IfFileExists $R0 run_uninstall done_uninstall
run_uninstall:
IfFileExists $R0 0 done_uninstall
!insertmacro progressPhase "Removing previous version"
!insertmacro progressStatus "Removing previous Farm Control installation..."
DetailPrint "Removing previous Farm Control installation..."
!insertmacro quitFarmControl
ExecWait '"$R0" /S' $R1
DetailPrint "Previous installation removed (exit code: $R1)"
!insertmacro progressLog "installer:STATUS:Previous installation removed"
done_uninstall:
!macroend
!macro createDesktopShortcut
SetShellVarContext current
SetOutPath "$FinalInstDir\bin"
CreateShortCut "$DESKTOP\Farm Control.lnk" "$FinalInstDir\bin\FarmControl.exe" "" "$FinalInstDir\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control"
!macroend
!macro createStartMenuShortcut
SetShellVarContext current
CreateDirectory "$SMPROGRAMS\Farm Control"
SetOutPath "$FinalInstDir\bin"
CreateShortCut "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$FinalInstDir\bin\FarmControl.exe" "" "$FinalInstDir\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control"
!macroend
!macro removeDesktopShortcut
SetShellVarContext current
Delete "$DESKTOP\Farm Control.lnk"
!macroend
!macro removeStartMenuShortcut
SetShellVarContext current
Delete "$SMPROGRAMS\Farm Control\Farm Control.lnk"
RMDir "$SMPROGRAMS\Farm Control"
!macroend
!macro customInstall
!insertmacro progressPhase "Configuring Farm Control"
!insertmacro progressStatus "Registering farmcontrol URI handler..."
DetailPrint "Register farmcontrol URI Handler"
DeleteRegKey HKCU "Software\Classes\farmcontrol"
WriteRegStr HKCU "Software\Classes\farmcontrol" "" "URL:farmcontrol"
WriteRegStr HKCU "Software\Classes\farmcontrol" "URL Protocol" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\DefaultIcon" "" "$FinalInstDir\bin\FarmControl.exe"
WriteRegStr HKCU "Software\Classes\farmcontrol\shell" "" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" '"$FinalInstDir\bin\bun.exe" "$FinalInstDir\bin\deeplink.js" "%1"'
!insertmacro progressStatus "Creating shortcuts..."
DetailPrint "Creating shortcuts"
!insertmacro createDesktopShortcut
!insertmacro createStartMenuShortcut
!macroend
!macro customUnInstall
!insertmacro removeDesktopShortcut
!insertmacro removeStartMenuShortcut
DeleteRegKey HKCU "Software\Classes\farmcontrol"
!macroend
; Wait for the running app to exit, swap staged update into place, then relaunch.
; Only used when /RESTARTFC is passed.
Function restartFarmControlAfterUpdate
${If} $RestartAfterInstall != "1"
Return
${EndIf}
; All files are copied and we are about to rename the install directory.
; Emit the success line (matched by isWindowsInstallSuccessful in
; winappupdate.js) so the app knows to quit and restart; this installer
; stays alive to swap the folders and relaunch Farm Control.
!insertmacro progressSuccess
!insertmacro progressStatus "Waiting for Farm Control to close..."
${If} $UpdateParentPid > 0
; Wait for the exact updater process rather than an executable name. CEF
; builds can keep bun/renderer processes alive after the launcher exits.
System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $UpdateParentPid) p .r0'
${If} $0 != 0
; Allow two seconds for a graceful exit, then terminate the process tree.
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 its process tree..."
ExecWait 'taskkill /F /T /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:
; Give CEF descendants a moment to release DLLs before renaming the tree.
Sleep 3000
${If} $IsInAppUpdate == "1"
!insertmacro swapUpdateStaging
${EndIf}
!insertmacro progressStatus "Starting Farm Control..."
SetOutPath "$FinalInstDir\bin"
Exec "$FinalInstDir\bin\FarmControl.exe"
FunctionEnd

View File

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

903
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

111
scripts/build-macos.mjs Normal file
View File

@ -0,0 +1,111 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { getReleaseArch, isBundleCefEnabled } from "./release-artifact-utils.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const hostArch = getReleaseArch(process.arch);
const targetArchs = process.env.ELECTROBUN_TARGET_ARCH
? [getReleaseArch(process.env.ELECTROBUN_TARGET_ARCH)]
: ["arm64", "x64"];
const bundleCef = isBundleCefEnabled();
function canRunUnderArch(archFlag) {
const result = spawnSync("arch", [archFlag, "true"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return result.status === 0;
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: rootDir,
stdio: "inherit",
env: process.env,
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function buildElectrobunEnv(targetArch) {
return {
...process.env,
ELECTROBUN_BUILD_ENV: "stable",
ELECTROBUN_OS: "macos",
ELECTROBUN_ARCH: targetArch,
ELECTROBUN_FORCE_ARCH: targetArch,
ELECTROBUN_TARGET_ARCH: targetArch,
NODE_ENV: "production",
};
}
function getInvocation(targetArch) {
const env = buildElectrobunEnv(targetArch);
const runScript = path.join(rootDir, "scripts/run-electrobun-build.mjs");
if (targetArch === hostArch) {
return { command: "bun", args: [runScript], env };
}
if (targetArch === "x64" && hostArch === "arm64" && canRunUnderArch("-x86_64")) {
return {
command: "arch",
args: ["-x86_64", "bun", runScript],
env,
};
}
if (targetArch === "arm64" && hostArch === "x64") {
return { command: "bun", args: [runScript], env };
}
return null;
}
const orderedTargets = [...targetArchs].sort((a, b) => {
if (a === hostArch) return -1;
if (b === hostArch) return 1;
return 0;
});
console.log(`Host architecture: ${hostArch}`);
console.log(`CEF bundling: ${bundleCef ? "enabled" : "disabled"}`);
console.log(`Build order: ${orderedTargets.join(", ")}`);
let builtCount = 0;
for (const targetArch of orderedTargets) {
const invocation = getInvocation(targetArch);
if (!invocation) {
console.error(
`Cannot build macOS ${targetArch} from host ${hostArch}.`,
);
continue;
}
console.log(`\n=== Building macOS ${targetArch} (host ${hostArch}, cef=${bundleCef}) ===`);
console.log(
`ELECTROBUN_BUILD_ENV=stable ELECTROBUN_OS=macos ELECTROBUN_ARCH=${targetArch} ELECTROBUN_BUNDLE_CEF=${bundleCef}\n`,
);
run(invocation.command, invocation.args, { env: invocation.env });
builtCount += 1;
}
if (builtCount === 0) {
console.error("No macOS architectures were built.");
process.exit(1);
}
if (builtCount < targetArchs.length) {
console.error(
`Built ${builtCount}/${targetArchs.length} macOS architectures.`,
);
process.exit(1);
}
console.log(`\nBuilt macOS architectures: ${orderedTargets.join(", ")}`);

View File

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

View File

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

View File

@ -0,0 +1,130 @@
param(
[Parameter(Mandatory = $true)]
[string]$SetupExe,
[Parameter(Mandatory = $true)]
[string]$OutputMsi,
[Parameter(Mandatory = $true)]
[string]$Version,
[string]$UpgradeCode = "735812DB-E33B-57A0-8FBC-5FC3155925AA"
)
$ErrorActionPreference = "Stop"
function Get-MsiVersion {
param([string]$InputVersion)
$parts = $InputVersion.Split(".")
while ($parts.Count -lt 4) {
$parts += "0"
}
return ($parts[0..3] -join ".")
}
function Find-WixToolset {
$searchRoots = @(
$env:WIX,
$env:WIX_TOOLSET_PATH,
"${env:ProgramFiles(x86)}\WiX Toolset v3.14\bin",
"${env:ProgramFiles}\WiX Toolset v3.14\bin",
"${env:ProgramFiles(x86)}\WiX Toolset v3.11\bin",
"${env:ProgramFiles}\WiX Toolset v3.11\bin"
)
foreach ($root in $searchRoots) {
if (-not $root) {
continue
}
$candle = Join-Path $root "candle.exe"
$light = Join-Path $root "light.exe"
if ((Test-Path $candle) -and (Test-Path $light)) {
return @{
Candle = $candle
Light = $light
}
}
}
$candleCommand = Get-Command candle.exe -ErrorAction SilentlyContinue
$lightCommand = Get-Command light.exe -ErrorAction SilentlyContinue
if ($candleCommand -and $lightCommand) {
return @{
Candle = $candleCommand.Source
Light = $lightCommand.Source
}
}
throw @"
WiX Toolset not found. Install WiX Toolset 3.11+ on the Windows build agent and ensure candle.exe and light.exe are on PATH.
Example: choco install wixtoolset --version=3.14.0.4118
"@
}
function Escape-WixSourcePath {
param([string]$Path)
return $Path.Replace("\", "\\")
}
$rootDir = Split-Path -Parent $PSScriptRoot
$templatePath = Join-Path $rootDir "packaging/windows/msi-wrapped.wxs"
$workDir = Join-Path $env:TEMP "farmcontrol-wix"
$wix = Find-WixToolset
if (Test-Path $workDir) {
Remove-Item $workDir -Recurse -Force
}
New-Item -ItemType Directory -Path $workDir | Out-Null
$setupExePath = (Resolve-Path -LiteralPath $SetupExe).Path
$outputMsiPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputMsi)
$outputDir = Split-Path $outputMsiPath -Parent
if ($outputDir -and -not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
$installerIconPath = Join-Path $rootDir "assets\installer.ico"
if (-not (Test-Path $installerIconPath)) {
throw "Installer icon not found at $installerIconPath. Run 'bun run generate-app-icons' first."
}
$installerIconWorkPath = Join-Path $workDir "installer.ico"
Copy-Item -LiteralPath $installerIconPath -Destination $installerIconWorkPath -Force
$msiVersion = Get-MsiVersion $Version
$wxsContent = Get-Content -LiteralPath $templatePath -Raw
$wxsContent = $wxsContent.Replace("__UPGRADE_CODE__", $UpgradeCode)
$wxsContent = $wxsContent.Replace("__VERSION__", $msiVersion)
$wxsContent = $wxsContent.Replace("__SETUP_EXE__", (Escape-WixSourcePath $setupExePath))
$wxsContent = $wxsContent.Replace("__INSTALLER_ICON__", (Escape-WixSourcePath $installerIconWorkPath))
$projectWxs = Join-Path $workDir "project.wxs"
Set-Content -LiteralPath $projectWxs -Value $wxsContent -Encoding UTF8
$wixObj = Join-Path $workDir "project.wixobj"
Push-Location $workDir
try {
& $wix.Candle "-arch" "x64" $projectWxs
if ($LASTEXITCODE -ne 0) {
throw "candle.exe failed with exit code $LASTEXITCODE"
}
& $wix.Light "-out" $outputMsiPath "-spdb" "-sw1076" $wixObj
if ($LASTEXITCODE -ne 0) {
throw "light.exe failed with exit code $LASTEXITCODE"
}
} finally {
Pop-Location
Remove-Item $workDir -Recurse -Force
}
if (-not (Test-Path $outputMsiPath)) {
throw "MSI installer was not created at $outputMsiPath"
}
Write-Host "Created MSI installer at $outputMsiPath"

View File

@ -0,0 +1,149 @@
param(
[Parameter(Mandatory = $true)]
[string]$AppDir,
[Parameter(Mandatory = $true)]
[string]$OutputExe,
[Parameter(Mandatory = $true)]
[string]$Version,
[string]$BuildNumber = "dev",
[long]$MinInstallerBytes = 10485760
)
$ErrorActionPreference = "Stop"
function Find-Makensis() {
$command = Get-Command makensis -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
$searchRoots = @(
"${env:ProgramFiles(x86)}\NSIS\makensis.exe",
"${env:ProgramFiles}\NSIS\makensis.exe"
)
foreach ($candidate in $searchRoots) {
if (Test-Path $candidate) {
return $candidate
}
}
throw "Could not find makensis. Install NSIS 3.x on the Windows build agent."
}
function Get-DirectoryBytes {
param([string]$Path)
$total = 0
foreach ($item in Get-ChildItem -LiteralPath $Path -Recurse -File -Force) {
$total += $item.Length
}
return $total
}
$rootDir = Split-Path -Parent $PSScriptRoot
$nsiPath = Join-Path $rootDir "packaging/windows/farmcontrol.nsi"
$installerInclude = Join-Path $rootDir "packaging/windows/installer.nsh"
$fileListGenerator = Join-Path $rootDir "scripts/generate-nsis-file-list.ps1"
$workDir = Join-Path $env:TEMP "farmcontrol-nsis"
$localInstallerName = "farmcontrol-installer.exe"
if (Test-Path $workDir) {
Remove-Item $workDir -Recurse -Force
}
New-Item -ItemType Directory -Path $workDir | Out-Null
$appDirPath = (Resolve-Path -LiteralPath $AppDir).Path
$stagingAppDir = Join-Path $workDir "app"
Write-Host "Staging application files for NSIS from $appDirPath"
Copy-Item -LiteralPath $appDirPath -Destination $stagingAppDir -Recurse -Force
$stagedAppBytes = Get-DirectoryBytes $stagingAppDir
Write-Host "Staged application size: $([math]::Round($stagedAppBytes / 1MB, 2)) MB"
if ($stagedAppBytes -lt 5242880) {
throw "Staged application is only $([math]::Round($stagedAppBytes / 1KB, 1)) KiB. Expected a full Electrobun app bundle before building the installer."
}
$requiredExe = Join-Path $stagingAppDir "bin\FarmControl.exe"
$requiredDeeplinkScript = Join-Path $stagingAppDir "bin\deeplink.js"
if (-not (Test-Path $requiredExe)) {
throw "Staged application is missing bin\FarmControl.exe"
}
if (-not (Test-Path $requiredDeeplinkScript)) {
throw "Staged application is missing bin\deeplink.js"
}
Copy-Item -LiteralPath $nsiPath -Destination (Join-Path $workDir "farmcontrol.nsi")
Copy-Item -LiteralPath $installerInclude -Destination (Join-Path $workDir "installer.nsh")
Copy-Item -LiteralPath $fileListGenerator -Destination (Join-Path $workDir "generate-nsis-file-list.ps1")
$iconPath = Join-Path $rootDir "assets\installer.ico"
if (Test-Path $iconPath) {
Copy-Item -LiteralPath $iconPath -Destination (Join-Path $workDir "icon.ico") -Force
Write-Host "Using installer icon from $iconPath"
}
$uninstallerIconPath = Join-Path $rootDir "assets\uninstaller.ico"
if (Test-Path $uninstallerIconPath) {
Copy-Item -LiteralPath $uninstallerIconPath -Destination (Join-Path $workDir "uninstaller.ico") -Force
Write-Host "Using uninstaller icon from $uninstallerIconPath"
}
$makensis = Find-Makensis
$outputExePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputExe)
$outputDir = Split-Path $outputExePath -Parent
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
$makensisArgs = @(
"/V3"
"/NOCD"
"/DOUTFILE=$localInstallerName"
"/DVERSION=$Version"
"/DBUILD_NUMBER=$BuildNumber"
"/DAPP_SOURCE_DIR=app"
"/DAPP_FILE_LIST_GENERATOR=generate-nsis-file-list.ps1"
)
if (Test-Path (Join-Path $workDir "icon.ico")) {
$makensisArgs += "/DINSTALLER_ICON=icon.ico"
}
if (Test-Path (Join-Path $workDir "uninstaller.ico")) {
$makensisArgs += "/DUNINSTALLER_ICON=uninstaller.ico"
}
$makensisArgs += (Join-Path $workDir "farmcontrol.nsi")
Push-Location $workDir
try {
& $makensis @makensisArgs
if ($LASTEXITCODE -ne 0) {
throw "makensis failed with exit code $LASTEXITCODE"
}
} finally {
Pop-Location
}
$localInstallerPath = Join-Path $workDir $localInstallerName
if (-not (Test-Path $localInstallerPath)) {
throw "NSIS installer was not created at $localInstallerPath"
}
$installerBytes = (Get-Item -LiteralPath $localInstallerPath).Length
Write-Host "NSIS installer size: $([math]::Round($installerBytes / 1MB, 2)) MB"
if ($installerBytes -lt $MinInstallerBytes) {
throw "NSIS installer is only $([math]::Round($installerBytes / 1KB, 1)) KiB at $localInstallerPath. The application files were not packaged. Check NSIS logs above."
}
Move-Item -LiteralPath $localInstallerPath -Destination $outputExePath -Force
Write-Host "Created NSIS installer at $outputExePath"

11
scripts/clean-build.mjs Normal file
View File

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

View File

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

View File

@ -0,0 +1,131 @@
import {
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
unlinkSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getReleaseArch } from "./release-artifact-utils.mjs";
import { fixMacosHeaderpad } from "./fix-macos-headerpad.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const electrobunDir = path.join(rootDir, "node_modules/electrobun");
const electrobunVersion = JSON.parse(
await Bun.file(path.join(electrobunDir, "package.json")).text(),
).version;
function getPlatformPaths(targetOS, targetArch) {
const binExt = targetOS === "win" ? ".exe" : "";
const platformDistDir = path.join(
electrobunDir,
`dist-${targetOS}-${targetArch}`,
);
return {
platformDistDir,
bunBinary: path.join(platformDistDir, "bun") + binExt,
bsdiff: path.join(platformDistDir, "bsdiff") + binExt,
bspatch: path.join(platformDistDir, "bspatch") + binExt,
launcher: path.join(platformDistDir, "launcher") + binExt,
nativeWrapperMacos: path.join(platformDistDir, "libNativeWrapper.dylib"),
};
}
async function downloadFile(url, destination) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
}
mkdirSync(path.dirname(destination), { recursive: true });
const fileStream = createWriteStream(destination);
const reader = response.body?.getReader();
if (!reader) {
throw new Error(`No response body for ${url}`);
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
fileStream.write(Buffer.from(value));
}
await new Promise((resolve, reject) => {
fileStream.end((error) => (error ? reject(error) : resolve()));
});
}
function extractTarGz(tarPath, destination) {
mkdirSync(destination, { recursive: true });
const result = spawnSync("tar", ["-xzf", tarPath, "-C", destination], {
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`tar extraction failed for ${tarPath}`);
}
}
async function ensureCoreDependencies(targetOS, targetArch) {
const paths = getPlatformPaths(targetOS, targetArch);
const required = [
paths.bunBinary,
paths.bsdiff,
paths.bspatch,
paths.launcher,
paths.nativeWrapperMacos,
];
if (required.every((filePath) => existsSync(filePath))) {
console.log(`ensure-electrobun-core: ${targetOS}-${targetArch} already present`);
return;
}
const platformName =
targetOS === "macos" ? "darwin" : targetOS === "win" ? "win" : "linux";
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${electrobunVersion}/electrobun-core-${platformName}-${targetArch}.tar.gz`;
const tempFile = path.join(
electrobunDir,
`core-${targetOS}-${targetArch}-temp.tar.gz`,
);
console.log(`ensure-electrobun-core: downloading ${targetOS}-${targetArch}`);
console.log(url);
await downloadFile(url, tempFile);
extractTarGz(tempFile, paths.platformDistDir);
if (existsSync(tempFile)) {
unlinkSync(tempFile);
}
const missing = required.filter((filePath) => !existsSync(filePath));
if (missing.length > 0) {
const extracted = existsSync(paths.platformDistDir)
? readdirSync(paths.platformDistDir)
: [];
throw new Error(
`Missing ${targetOS}-${targetArch} binaries after extract: ${missing.join(", ")}; extracted=${extracted.join(", ")}`,
);
}
console.log(`ensure-electrobun-core: ${targetOS}-${targetArch} ready`);
}
const targetOS = "macos";
const hostArch = getReleaseArch(process.arch);
const targetArch = getReleaseArch(
process.env.ELECTROBUN_TARGET_ARCH ||
process.env.ELECTROBUN_FORCE_ARCH ||
process.arch,
);
const archesToEnsure = new Set([hostArch, targetArch]);
for (const arch of archesToEnsure) {
await ensureCoreDependencies(targetOS, arch);
// electrobun#485: x64 core binaries lack Mach-O headerpad and get corrupted
// by codesign; free a load-command slot before anything copies or signs them.
fixMacosHeaderpad(getPlatformPaths(targetOS, arch).platformDistDir);
}

View File

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

View File

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

View File

@ -0,0 +1,681 @@
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync
} from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import {
getReleaseArch,
getReleaseArtifactName,
getReleaseVersion,
isBundleCefEnabled
} from './release-artifact-utils.mjs'
import {
cleanExpandedWindowsApp,
expandWindowsAppFromArchive
} from './expand-windows-installer.mjs'
import { stageWindowsDeeplink } from './build-windows-deeplink.mjs'
import { patchWindowsBinaries } from './patch-windows-binaries.mjs'
import { codesignMacAppBundle } from './codesign-macos-app.mjs'
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const packageJson = JSON.parse(
readFileSync(path.join(rootDir, 'package.json'), 'utf8')
)
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || 'stable'
const targetOs =
process.env.ELECTROBUN_OS ||
(process.platform === 'darwin'
? 'macos'
: process.platform === 'win32'
? 'win'
: process.platform === 'linux'
? 'linux'
: null)
const buildArch = getReleaseArch(process.env.ELECTROBUN_ARCH || process.arch)
const version =
process.env.ELECTROBUN_APP_VERSION || getReleaseVersion(packageJson)
const bundleCef = isBundleCefEnabled()
const artifactDir =
process.env.ELECTROBUN_ARTIFACT_DIR || path.join(rootDir, 'app_dist')
const identifier =
process.env.ELECTROBUN_APP_IDENTIFIER || 'com.tombutcher.farmcontrol'
const artifactPrefix = `farmcontrol-${version}-`
function artifactName(arch, ext) {
return getReleaseArtifactName(version, arch, ext, { cef: bundleCef })
}
function getBuildRoot() {
const electrobunBuildDir = process.env.ELECTROBUN_BUILD_DIR
if (!electrobunBuildDir) {
return path.join(rootDir, 'build')
}
const baseName = path.basename(electrobunBuildDir)
if (baseName.startsWith('stable-')) {
return path.dirname(electrobunBuildDir)
}
return electrobunBuildDir
}
function walkFiles(dir) {
const files = []
if (!existsSync(dir)) {
return files
}
for (const entry of readdirSync(dir)) {
const fullPath = path.join(dir, entry)
const stats = statSync(fullPath)
if (stats.isDirectory()) {
files.push(...walkFiles(fullPath))
} else {
files.push(fullPath)
}
}
return files
}
function findByExtension(root, extension) {
return walkFiles(root).find((filePath) =>
filePath.toLowerCase().endsWith(extension.toLowerCase())
)
}
function findMacAppBundle(arch) {
const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`)
if (!existsSync(platformDir)) {
return null
}
for (const child of readdirSync(platformDir)) {
if (child.endsWith('.app')) {
return path.join(platformDir, child)
}
}
return null
}
function findMacDmgSource(arch) {
const prefixedArtifact = walkFiles(artifactDir).find(
(filePath) =>
filePath.includes(`stable-macos-${arch}`) &&
filePath.toLowerCase().endsWith('.dmg')
)
if (prefixedArtifact) {
return prefixedArtifact
}
const buildDmg = findByExtension(
path.join(getBuildRoot(), `stable-macos-${arch}`),
'.dmg'
)
if (buildDmg) {
return buildDmg
}
return findByExtension(artifactDir, '.dmg')
}
function findWindowsInstallerFiles() {
const buildRoot = getBuildRoot()
if (!existsSync(buildRoot)) {
return null
}
for (const entry of readdirSync(buildRoot)) {
if (!entry.startsWith('stable-win-')) {
continue
}
const platformDir = path.join(buildRoot, entry)
const files = walkFiles(platformDir)
const setupExe = files.find((filePath) => /-setup\.exe$/i.test(filePath))
const setupArchive = files.find((filePath) =>
/-setup\.tar\.zst$/i.test(filePath)
)
const setupMetadata = files.find((filePath) =>
/-setup\.metadata\.json$/i.test(filePath)
)
const setupZip = files.find((filePath) => /-setup\.zip$/i.test(filePath))
if (setupExe && setupArchive && setupMetadata) {
return { setupExe, setupArchive, setupMetadata, setupZip }
}
}
return null
}
function publishArtifact(sourcePath, arch, ext) {
if (!sourcePath || !existsSync(sourcePath)) {
throw new Error(
`Missing source artifact for ${arch}.${ext}: ${sourcePath ?? 'not found'}`
)
}
mkdirSync(artifactDir, { recursive: true })
const destination = path.join(artifactDir, artifactName(arch, ext))
cpSync(sourcePath, destination)
console.log(`Published ${destination}`)
return destination
}
function cleanStagingArtifacts(keepNames) {
if (!existsSync(artifactDir)) {
return
}
for (const entry of readdirSync(artifactDir)) {
if (keepNames.includes(entry) || entry.startsWith(artifactPrefix)) {
continue
}
if (
entry.includes('stable-macos-') ||
entry.includes('stable-win-') ||
entry.endsWith('-update.json') ||
entry.endsWith('.tar.gz')
) {
rmSync(path.join(artifactDir, entry), { recursive: true, force: true })
}
}
}
const MAC_DMG_ASSETS_DIR = path.join(rootDir, 'packaging/macos/dmg')
const MAC_INSTALLER_ICONSET_PATH = path.join(rootDir, 'assets/installer.iconset')
const MAC_DMG_BACKGROUND_PATH = path.join(rootDir, 'assets/dmg/background.png')
const MAC_DMG_BACKGROUND_RETINA_PATH = path.join(
rootDir,
'assets/dmg/background@2x.png'
)
const MAC_DMG_APP_NAME = 'Farm Control.app'
const MAC_DMG_WINDOW_WIDTH = 540
const MAC_DMG_WINDOW_HEIGHT = 380
function findCommand(command) {
const which = spawnSync('which', [command], { encoding: 'utf8' })
if (which.status === 0 && which.stdout.trim()) {
return which.stdout.trim()
}
return null
}
function buildInstallerIcns(destinationPath) {
if (!existsSync(MAC_INSTALLER_ICONSET_PATH)) {
throw new Error(
`Installer iconset not found at ${MAC_INSTALLER_ICONSET_PATH}. Run \`bun run generate-app-icons\` first.`
)
}
mkdirSync(path.dirname(destinationPath), { recursive: true })
rmSync(destinationPath, { force: true })
const iconutil = spawnSync(
'iconutil',
['-c', 'icns', '-o', destinationPath, MAC_INSTALLER_ICONSET_PATH],
{ stdio: 'inherit' }
)
if (iconutil.status !== 0) {
throw new Error(
`iconutil failed to create installer icns with exit code ${iconutil.status ?? 1}`
)
}
return destinationPath
}
function applyMacInstallerFileIcon(targetPath, icnsPath) {
if (!existsSync(icnsPath)) {
console.warn(
`finalize-desktop-artifacts: installer icns not found at ${icnsPath}, skipping custom icon`
)
return false
}
const fileicon = findCommand('fileicon')
if (fileicon) {
const result = spawnSync(fileicon, ['set', targetPath, icnsPath], {
stdio: 'inherit'
})
if (result.status === 0) {
return true
}
}
const escapedTarget = targetPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
const escapedIcon = icnsPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
const result = spawnSync(
'osascript',
[
'-e',
`tell application "Finder" to set icon of (POSIX file "${escapedTarget}") to (read (POSIX file "${escapedIcon}") as picture)`
],
{ stdio: 'inherit' }
)
if (result.status === 0) {
return true
}
console.warn(
'finalize-desktop-artifacts: could not apply installer icon; install fileicon or run on macOS with Finder access'
)
return false
}
function ensureMacDmgAssets() {
mkdirSync(MAC_DMG_ASSETS_DIR, { recursive: true })
const voliconPath = path.join(MAC_DMG_ASSETS_DIR, 'volicon.icns')
buildInstallerIcns(voliconPath)
if (!existsSync(MAC_DMG_BACKGROUND_PATH)) {
throw new Error(`DMG background not found at ${MAC_DMG_BACKGROUND_PATH}`)
}
if (!existsSync(MAC_DMG_BACKGROUND_RETINA_PATH)) {
console.warn(
`finalize-desktop-artifacts: retina DMG background not found at ${MAC_DMG_BACKGROUND_RETINA_PATH}`
)
}
return {
volicon: voliconPath,
background: MAC_DMG_BACKGROUND_PATH
}
}
function findCreateDmgCommand() {
const which = spawnSync('which', ['create-dmg'], { encoding: 'utf8' })
if (which.status === 0 && which.stdout.trim()) {
return which.stdout.trim()
}
for (const candidate of [
'/opt/homebrew/bin/create-dmg',
'/usr/local/bin/create-dmg'
]) {
if (existsSync(candidate)) {
return candidate
}
}
return null
}
function buildMacDmgWithCreateDmg(
createDmg,
dmgPath,
sourceFolder,
appBundleName,
dmgAssets
) {
rmSync(dmgPath, { force: true })
const args = [
'--volname',
'Farm Control',
'--window-pos',
'200',
'120',
'--window-size',
String(MAC_DMG_WINDOW_WIDTH),
String(MAC_DMG_WINDOW_HEIGHT),
'--icon-size',
'100',
'--icon',
appBundleName,
'130',
'212',
'--hide-extension',
appBundleName,
'--app-drop-link',
'410',
'212',
'--format',
'UDZO'
]
if (dmgAssets.volicon) {
args.push('--volicon', dmgAssets.volicon)
}
if (dmgAssets.background) {
args.push('--background', dmgAssets.background)
}
args.push(dmgPath, sourceFolder)
const result = spawnSync(createDmg, args, { stdio: 'inherit' })
if (result.status !== 0) {
throw new Error(`create-dmg failed with exit code ${result.status ?? 1}`)
}
if (!existsSync(dmgPath)) {
throw new Error(`create-dmg did not produce ${dmgPath}`)
}
return dmgPath
}
function copyMacAppBundle(sourcePath, destinationPath) {
rmSync(destinationPath, { recursive: true, force: true })
mkdirSync(path.dirname(destinationPath), { recursive: true })
const result = spawnSync('ditto', [sourcePath, destinationPath], {
stdio: 'inherit'
})
if (result.status !== 0) {
throw new Error(`ditto failed with exit code ${result.status ?? 1}`)
}
}
function canUseDirectDmgSourceFolder(appBundlePath) {
const platformDir = path.dirname(appBundlePath)
const appName = path.basename(appBundlePath)
const entries = readdirSync(platformDir).filter(
(entry) => !entry.startsWith('.') && entry !== '.finalize-dmg-staging'
)
return entries.length === 1 && entries[0] === appName
}
async function buildMacDmg(appBundlePath, arch) {
const createDmg = findCreateDmgCommand()
if (!createDmg) {
throw new Error(
'create-dmg not found. Install with: brew install create-dmg'
)
}
const appBundleName = path.basename(appBundlePath)
if (appBundleName !== MAC_DMG_APP_NAME) {
console.warn(
`finalize-desktop-artifacts: expected ${MAC_DMG_APP_NAME}, found ${appBundleName}`
)
}
const dmgPath = path.join(artifactDir, artifactName(arch, 'dmg'))
mkdirSync(artifactDir, { recursive: true })
const stagingDir = path.join(
path.dirname(appBundlePath),
'.finalize-dmg-staging'
)
const useDirectSource = canUseDirectDmgSourceFolder(appBundlePath)
const sourceFolder = useDirectSource
? path.dirname(appBundlePath)
: stagingDir
if (!useDirectSource) {
rmSync(stagingDir, { recursive: true, force: true })
mkdirSync(stagingDir, { recursive: true })
const stagedAppPath = path.join(stagingDir, path.basename(appBundlePath))
copyMacAppBundle(appBundlePath, stagedAppPath)
codesignMacAppBundle(stagedAppPath)
}
let builtDmgPath
const dmgAssets = ensureMacDmgAssets()
try {
builtDmgPath = buildMacDmgWithCreateDmg(
createDmg,
dmgPath,
sourceFolder,
appBundleName,
dmgAssets
)
applyMacInstallerFileIcon(builtDmgPath, dmgAssets.volicon)
} finally {
if (!useDirectSource) {
rmSync(stagingDir, { recursive: true, force: true })
}
}
console.log(`Published ${builtDmgPath}`)
return builtDmgPath
}
function cleanMacBuildDir(arch) {
const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`)
if (existsSync(platformDir)) {
rmSync(platformDir, { recursive: true, force: true })
console.log(`Removed build output ${platformDir}`)
}
}
function buildMacPkg(appBundlePath, arch) {
const pkgPath = path.join(artifactDir, artifactName(arch, 'pkg'))
const result = spawnSync(
'pkgbuild',
[
'--component',
appBundlePath,
'--install-location',
'/Applications',
'--identifier',
identifier,
'--version',
version,
pkgPath
],
{ stdio: 'inherit' }
)
if (result.status !== 0) {
throw new Error(`pkgbuild failed with exit code ${result.status ?? 1}`)
}
console.log(`Published ${pkgPath}`)
return pkgPath
}
function stageWindowsDeeplinkScript(appDir) {
return stageWindowsDeeplink(appDir)
}
function cleanWinBuildDir(arch) {
const platformDir = path.join(getBuildRoot(), `stable-win-${arch}`)
if (existsSync(platformDir)) {
rmSync(platformDir, { recursive: true, force: true })
console.log(`Removed build output ${platformDir}`)
}
}
function buildWindowsNsis(appDir, arch) {
const scriptPath = path.join(rootDir, 'scripts/build-windows-nsis.ps1')
const exePath = path.join(artifactDir, artifactName(arch, 'exe'))
const buildInfoPath = path.join(rootDir, 'src/buildInfo.json')
const buildInfo = existsSync(buildInfoPath)
? JSON.parse(readFileSync(buildInfoPath, 'utf8'))
: {}
const buildNumber =
process.env.BUILD_NUMBER ||
process.env.VITE_BUILD_NUMBER ||
buildInfo.buildNumber ||
'dev'
const powershell = process.env.SystemRoot
? path.join(
process.env.SystemRoot,
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe'
)
: 'powershell.exe'
const result = spawnSync(
powershell,
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
scriptPath,
'-AppDir',
appDir,
'-OutputExe',
exePath,
'-Version',
version,
'-BuildNumber',
buildNumber
],
{ stdio: 'inherit' }
)
if (result.status !== 0) {
throw new Error(
`build-windows-nsis.ps1 failed with exit code ${result.status ?? 1}`
)
}
const minInstallerBytes = 10 * 1024 * 1024
const installerSize = statSync(exePath).size
if (installerSize < minInstallerBytes) {
throw new Error(
`Windows installer ${path.basename(exePath)} is only ${(installerSize / 1024).toFixed(1)} KiB; expected at least ${minInstallerBytes / (1024 * 1024)} MiB`
)
}
console.log(`Published ${exePath}`)
return exePath
}
function buildWindowsMsi(setupExePath, arch) {
const scriptPath = path.join(rootDir, 'scripts/build-windows-msi.ps1')
const msiPath = path.join(artifactDir, artifactName(arch, 'msi'))
const powershell = process.env.SystemRoot
? path.join(
process.env.SystemRoot,
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe'
)
: 'powershell.exe'
const result = spawnSync(
powershell,
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
scriptPath,
'-SetupExe',
setupExePath,
'-OutputMsi',
msiPath,
'-Version',
version
],
{ stdio: 'inherit' }
)
if (result.status !== 0) {
throw new Error(
`build-windows-msi.ps1 failed with exit code ${result.status ?? 1}`
)
}
console.log(`Published ${msiPath}`)
return msiPath
}
if (buildEnv === 'dev') {
console.log('finalize-desktop-artifacts: skipping dev build')
process.exit(0)
}
async function main() {
console.log(
`finalize-desktop-artifacts: os=${targetOs} arch=${buildArch} cef=${bundleCef} version=${version}`
)
if (targetOs === 'macos') {
const appBundle = findMacAppBundle(buildArch)
if (!appBundle) {
console.log(
`finalize-desktop-artifacts: no macOS ${buildArch} app bundle found, skipping`
)
process.exit(0)
}
codesignMacAppBundle(appBundle)
const existingDmg = findMacDmgSource(buildArch)
const dmgPath = existingDmg
? publishArtifact(existingDmg, buildArch, 'dmg')
: await buildMacDmg(appBundle, buildArch)
const published = [dmgPath, buildMacPkg(appBundle, buildArch)]
cleanStagingArtifacts(published.map((filePath) => path.basename(filePath)))
cleanMacBuildDir(buildArch)
return
}
if (targetOs === 'win') {
const arch = getReleaseArch(process.env.ELECTROBUN_ARCH || 'x64')
const installerFiles = findWindowsInstallerFiles()
if (!installerFiles?.setupArchive) {
throw new Error(
'Could not find the Windows setup archive (.tar.zst) to expand'
)
}
const platformDir = path.dirname(installerFiles.setupArchive)
const appDir = expandWindowsAppFromArchive(
installerFiles.setupArchive,
platformDir
)
stageWindowsDeeplinkScript(appDir)
patchWindowsBinaries(appDir)
let published
try {
const setupExe = buildWindowsNsis(appDir, arch)
published = [setupExe, buildWindowsMsi(setupExe, arch)]
if (installerFiles.setupZip) {
published.push(publishArtifact(installerFiles.setupZip, arch, 'zip'))
}
} finally {
cleanExpandedWindowsApp(platformDir)
}
cleanStagingArtifacts(published.map((filePath) => path.basename(filePath)))
cleanWinBuildDir(arch)
return
}
console.log(
'finalize-desktop-artifacts: no desktop packaging configured for',
targetOs ?? 'unknown target'
)
process.exit(0)
}
await main()

View File

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

View File

@ -0,0 +1,27 @@
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const steps = [
[
path.join(rootDir, 'scripts/prepare-app-icons.mjs'),
'assets/logos/farmcontrolicon.png'
],
[path.join(rootDir, 'scripts/prepare-installer-icons.mjs')]
]
for (const args of steps) {
const result = spawnSync('bun', args, {
cwd: rootDir,
stdio: 'inherit',
env: process.env
})
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
console.log('generate-app-icons: done')

View File

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

View File

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

View File

@ -0,0 +1,350 @@
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync
} from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const electrobunDir = path.join(rootDir, 'node_modules/electrobun')
const sharedSrcDir = path.join(electrobunDir, 'src/shared')
const sharedDistDir = path.join(electrobunDir, 'dist/api/shared')
if (!existsSync(sharedDistDir)) {
console.error('patch-electrobun-src: electrobun dist/api/shared not found')
process.exit(1)
}
mkdirSync(sharedSrcDir, { recursive: true })
for (const fileName of [
'cef-version.ts',
'bun-version.ts',
'electrobun-version.ts',
'naming.ts',
'rpc.ts'
]) {
const source = path.join(sharedDistDir, fileName)
const destination = path.join(sharedSrcDir, fileName)
if (existsSync(source)) {
cpSync(source, destination)
}
}
const platformPatch = `import { platform, arch } from "os";
export type SupportedOS = "macos" | "win" | "linux";
export type SupportedArch = "arm64" | "x64";
const platformName = platform();
const archName = arch();
export const OS: SupportedOS = (() => {
switch (platformName) {
case "win32":
return "win";
case "darwin":
return "macos";
case "linux":
return "linux";
default:
throw new Error(\`Unsupported platform: \${platformName}\`);
}
})();
function normalizeForcedArch(value) {
if (value === "arm64" || value === "aarch64") {
return "arm64";
}
if (value === "x64" || value === "amd64") {
return "x64";
}
return null;
}
export const ARCH: SupportedArch = (() => {
const forcedArch = normalizeForcedArch(
process.env.ELECTROBUN_FORCE_ARCH || process.env.ELECTROBUN_TARGET_ARCH,
);
if (forcedArch) {
return forcedArch;
}
if (OS === "win") {
return "x64";
}
switch (archName) {
case "arm64":
return "arm64";
case "x64":
return "x64";
default:
throw new Error(\`Unsupported architecture: \${archName}\`);
}
})();
export const HOST_ARCH: SupportedArch = (() => {
if (OS === "win") {
return "x64";
}
switch (archName) {
case "arm64":
return "arm64";
case "x64":
return "x64";
default:
throw new Error(\`Unsupported architecture: \${archName}\`);
}
})();
export function getPlatformOS(): SupportedOS {
return OS;
}
export function getPlatformArch(): SupportedArch {
return ARCH;
}
`
writeFileSync(path.join(sharedSrcDir, 'platform.ts'), platformPatch)
const templatesDir = path.join(electrobunDir, 'src/cli/templates')
mkdirSync(templatesDir, { recursive: true })
writeFileSync(
path.join(templatesDir, 'embedded.ts'),
`export function getTemplateNames() {
return [];
}
export function getTemplate(name: string) {
throw new Error(\`Template not available in patched electrobun CLI: \${name}\`);
}
`
)
const RCEDIT_RESOLVER_FN = `function resolveProjectRceditPkgPath(projectRoot) {
const candidates = [
join(projectRoot, "node_modules/rcedit/package.json"),
join(projectRoot, "node_modules/electrobun/node_modules/rcedit/package.json"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
throw new Error(
"rcedit not found under " + projectRoot + ". Install rcedit in the project (bun add -d rcedit).",
);
}`
const cliPath = path.join(electrobunDir, 'src/cli/index.ts')
let cliSource = readFileSync(cliPath, 'utf8')
if (!cliSource.includes('HOST_ARCH')) {
cliSource = cliSource.replace(
'import { OS, ARCH } from "../shared/platform";',
'import { OS, ARCH, HOST_ARCH } from "../shared/platform";'
)
cliSource = cliSource.replace(
'const hostPaths = getPlatformPaths(OS, ARCH);',
'const hostPaths = getPlatformPaths(OS, HOST_ARCH);'
)
}
if (!cliSource.includes('toolPaths')) {
cliSource = cliSource.replace(
'const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);',
'const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);\n\t\tconst toolPaths = getPlatformPaths(currentTarget.os, ARCH !== HOST_ARCH ? HOST_ARCH : ARCH);'
)
cliSource = cliSource.replaceAll(
'const zstdPath = targetPaths.ZSTD;',
'const zstdPath = toolPaths.ZSTD;'
)
cliSource = cliSource.replaceAll(
'const bsdiffpath = targetPaths.BSDIFF;',
'const bsdiffpath = toolPaths.BSDIFF;'
)
cliSource = cliSource.replace(
'zigAsarCli = join(targetPaths.BSPATCH).replace("bspatch", "zig-asar");',
'zigAsarCli = join(toolPaths.BSPATCH).replace("bspatch", "zig-asar");'
)
writeFileSync(cliPath, cliSource)
cliSource = readFileSync(cliPath, 'utf8')
}
if (!cliSource.includes('hookBunBinary')) {
cliSource = cliSource.replace(
`const hostPaths = getPlatformPaths(OS, HOST_ARCH);
const result = Bun.spawnSync([hostPaths.BUN_BINARY, hookScript],`,
`const hostPaths = getPlatformPaths(OS, HOST_ARCH);
const hookBunBinary = existsSync(hostPaths.BUN_BINARY)
? hostPaths.BUN_BINARY
: process.execPath;
const result = Bun.spawnSync([hookBunBinary, hookScript],`
)
cliSource = cliSource.replace(
'console.error("Tried to run with bun at:", hostPaths.BUN_BINARY);',
'console.error("Tried to run with bun at:", hookBunBinary);'
)
writeFileSync(cliPath, cliSource)
cliSource = readFileSync(cliPath, 'utf8')
}
if (!cliSource.includes('resolveProjectRceditPkgPath')) {
cliSource = cliSource.replaceAll(
'const rceditPkgPath = require.resolve("rcedit/package.json");',
'const rceditPkgPath = resolveProjectRceditPkgPath(projectRoot);'
)
cliSource = cliSource.replace(
'function getPlatformPaths(',
`${RCEDIT_RESOLVER_FN}
function getPlatformPaths(`
)
writeFileSync(cliPath, cliSource)
cliSource = readFileSync(cliPath, 'utf8')
} else {
cliSource = cliSource.replace(
/function resolveProjectRceditPkgPath\(projectRoot\) \{[\s\S]*?\n\}/m,
RCEDIT_RESOLVER_FN
)
writeFileSync(cliPath, cliSource)
}
const electrobunCjsPath = path.join(electrobunDir, 'bin/electrobun.cjs')
let electrobunCjs = readFileSync(electrobunCjsPath, 'utf8')
if (!electrobunCjs.includes('farmcontrol-use-patched-cli')) {
electrobunCjs = electrobunCjs.replace(
`async function main() {
try {
const args = process.argv.slice(2);
const cliPath = await ensureCliBinary();
// Replace this process with the actual CLI
const child = spawn(cliPath, args, {
stdio: 'inherit',
cwd: process.cwd()
});`,
`async function main() {
try {
const args = process.argv.slice(2);
const patchedCliPath = join(electrobunDir, 'src', 'cli', 'index.ts');
if (existsSync(patchedCliPath)) {
// farmcontrol-use-patched-cli: bundled electrobun.exe cannot resolve project rcedit
const bunBinary = process.env.BUN_INSTALL
? join(process.env.BUN_INSTALL, 'bin', 'bun' + binExt)
: 'bun';
const child = spawn(bunBinary, [patchedCliPath, ...args], {
stdio: 'inherit',
cwd: process.cwd(),
env: process.env,
shell: platform === 'win',
});
child.on('exit', (code) => {
process.exit(code || 0);
});
child.on('error', (error) => {
console.error('Failed to start electrobun patched CLI:', error.message);
process.exit(1);
});
return;
}
const cliPath = await ensureCliBinary();
// Replace this process with the actual CLI
const child = spawn(cliPath, args, {
stdio: 'inherit',
cwd: process.cwd()
});`
)
writeFileSync(electrobunCjsPath, electrobunCjs)
}
const rceditSrc = path.join(rootDir, 'node_modules/rcedit')
const rceditDest = path.join(electrobunDir, 'node_modules/rcedit')
if (existsSync(rceditSrc)) {
mkdirSync(path.join(electrobunDir, 'node_modules'), { recursive: true })
cpSync(rceditSrc, rceditDest, { recursive: true, force: true })
}
function patchNativeWrapperPath(nativeTsPath) {
if (!existsSync(nativeTsPath)) {
return
}
let source = readFileSync(nativeTsPath, 'utf8')
if (source.includes('function resolveNativeWrapperPath()')) {
return
}
if (!source.includes('existsSync')) {
if (source.includes('import { dirname, join } from "path";')) {
source = source.replace(
'import { dirname, join } from "path";',
'import { existsSync } from "fs";\nimport { dirname, join } from "path";'
)
} else {
source = source.replace(
'import { join } from "path";',
'import { existsSync } from "fs";\nimport { dirname, join } from "path";'
)
}
} else if (!source.includes('dirname')) {
source = source.replace(
'import { join } from "path";',
'import { dirname, join } from "path";'
)
}
const helper = `
function resolveNativeWrapperPath() {
\tconst fileName = \`libNativeWrapper.\${suffix}\`;
\tconst candidates = [
\t\tjoin(dirname(process.execPath), fileName),
\t\tjoin(process.cwd(), fileName),
\t];
\tfor (const candidate of candidates) {
\t\tif (existsSync(candidate)) {
\t\t\treturn candidate;
\t\t}
\t}
\treturn candidates[0]!;
}
`
source = source.replace(
'export const native = (() => {',
`${helper}\nexport const native = (() => {`
)
source = source.replace(
/const nativeWrapperPath = join\((?:dirname\(process\.execPath\)|process\.cwd\(\)), `libNativeWrapper\.\$\{suffix\}`\);/,
'const nativeWrapperPath = resolveNativeWrapperPath();'
)
writeFileSync(nativeTsPath, source)
}
for (const distDir of ['dist', 'dist-macos-arm64', 'dist-win-x64']) {
patchNativeWrapperPath(
path.join(electrobunDir, distDir, 'api/bun/proc/native.ts')
)
}
const markerPath = path.join(electrobunDir, '.farmcontrol-electrobun-patched')
writeFileSync(markerPath, `patched-at=${new Date().toISOString()}\n`)
console.log(
'patch-electrobun-src: electrobun src/shared ready for cross-arch builds'
)

View File

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

145
scripts/pre-build.mjs Normal file
View File

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

View File

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

View File

@ -0,0 +1,110 @@
import { existsSync, mkdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { Jimp } from 'jimp'
import pngToIco from 'png-to-ico'
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const INSTALLER_SOURCE = path.join(
rootDir,
'assets/logos/farmcontrolinstaller.png'
)
const UNINSTALLER_SOURCE = path.join(
rootDir,
'assets/logos/farmcontroluninstaller.png'
)
const MAC_ICONSET_ENTRIES = [
{ name: 'icon_16x16.png', size: 16 },
{ name: 'icon_16x16@2x.png', size: 32 },
{ name: 'icon_32x32.png', size: 32 },
{ name: 'icon_32x32@2x.png', size: 64 },
{ name: 'icon_128x128.png', size: 128 },
{ name: 'icon_128x128@2x.png', size: 256 },
{ name: 'icon_256x256.png', size: 256 },
{ name: 'icon_256x256@2x.png', size: 512 },
{ name: 'icon_512x512.png', size: 512 },
{ name: 'icon_512x512@2x.png', size: 1024 }
]
const WIN_ICO_SIZES = [16, 32, 48, 256]
async function resizePng(sourceImage, size) {
return sourceImage
.clone()
.resize({ w: size, h: size })
.getBuffer('image/png')
}
async function writePng(filePath, sourceImage, size) {
const png = await resizePng(sourceImage, size)
await Bun.write(filePath, png)
return png
}
async function writeWindowsIco(sourceImage, icoPath) {
const icoPngs = []
for (const size of WIN_ICO_SIZES) {
icoPngs.push(await resizePng(sourceImage, size))
}
await Bun.write(icoPath, await pngToIco(icoPngs))
console.log(`prepare-installer-icons: wrote ${icoPath}`)
}
async function main() {
if (!existsSync(INSTALLER_SOURCE)) {
console.error(
`prepare-installer-icons: source image not found: ${INSTALLER_SOURCE}`
)
process.exit(1)
}
if (!existsSync(UNINSTALLER_SOURCE)) {
console.error(
`prepare-installer-icons: source image not found: ${UNINSTALLER_SOURCE}`
)
process.exit(1)
}
const installerImage = await Jimp.read(INSTALLER_SOURCE)
if (installerImage.width < 256 || installerImage.height < 256) {
console.warn(
`prepare-installer-icons: installer source is ${installerImage.width}x${installerImage.height}; recommend at least 256x256`
)
}
const uninstallerImage = await Jimp.read(UNINSTALLER_SOURCE)
if (uninstallerImage.width < 256 || uninstallerImage.height < 256) {
console.warn(
`prepare-installer-icons: uninstaller source is ${uninstallerImage.width}x${uninstallerImage.height}; recommend at least 256x256`
)
}
const assetsDir = path.join(rootDir, 'assets')
mkdirSync(assetsDir, { recursive: true })
const iconsetDir = path.join(assetsDir, 'installer.iconset')
rmSync(iconsetDir, { recursive: true, force: true })
mkdirSync(iconsetDir, { recursive: true })
for (const entry of MAC_ICONSET_ENTRIES) {
await writePng(
path.join(iconsetDir, entry.name),
installerImage,
entry.size
)
}
console.log(`prepare-installer-icons: wrote ${iconsetDir}`)
await writeWindowsIco(installerImage, path.join(assetsDir, 'installer.ico'))
await writeWindowsIco(
uninstallerImage,
path.join(assetsDir, 'uninstaller.ico')
)
}
main().catch((error) => {
console.error(`prepare-installer-icons: ${error.message}`)
process.exit(1)
})

View File

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

View File

@ -0,0 +1,101 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { getReleaseArch, isBundleCefEnabled } from "./release-artifact-utils.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const electrobunCli = path.join(
rootDir,
"node_modules/electrobun/src/cli/index.ts",
);
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "stable";
const targetArch = getReleaseArch(
process.env.ELECTROBUN_TARGET_ARCH ||
process.env.ELECTROBUN_FORCE_ARCH ||
process.arch,
);
const bundleCef = isBundleCefEnabled();
const patchResult = spawnSync("bun", [path.join(rootDir, "scripts/patch-electrobun-src.mjs")], {
cwd: rootDir,
stdio: "inherit",
env: process.env,
});
if (patchResult.status !== 0) {
process.exit(patchResult.status ?? 1);
}
const macosEffectsResult = spawnSync(
"bun",
[path.join(rootDir, "scripts/build-macos-effects.mjs")],
{ cwd: rootDir, stdio: "inherit", env: process.env },
);
if (macosEffectsResult.status !== 0) {
process.exit(macosEffectsResult.status ?? 1);
}
if (process.platform === "darwin") {
const { validateMacosEffectsDylib, outFile } = await import(
pathToFileURL(
path.join(rootDir, "scripts/build-macos-effects.mjs"),
).href
);
if (!validateMacosEffectsDylib({ dylibPath: outFile })) {
console.error(
"run-electrobun-build: macOS window effects dylib is missing or invalid.",
);
process.exit(1);
}
}
const ensureCoreEnv = {
...process.env,
ELECTROBUN_TARGET_ARCH: targetArch,
ELECTROBUN_FORCE_ARCH: targetArch,
};
const ensureCoreResult = spawnSync(
"bun",
[path.join(rootDir, "scripts/ensure-electrobun-core.mjs")],
{
cwd: rootDir,
stdio: "inherit",
env: ensureCoreEnv,
},
);
if (ensureCoreResult.status !== 0) {
process.exit(ensureCoreResult.status ?? 1);
}
const env = {
...process.env,
ELECTROBUN_BUILD_ENV: buildEnv,
ELECTROBUN_OS: "macos",
ELECTROBUN_ARCH: targetArch,
ELECTROBUN_FORCE_ARCH: targetArch,
ELECTROBUN_TARGET_ARCH: targetArch,
NODE_ENV: process.env.NODE_ENV || "production",
};
console.log(
`run-electrobun-build: env=${buildEnv} os=macos arch=${targetArch} cef=${bundleCef} (host ${getReleaseArch(process.arch)})`,
);
const result = spawnSync(
"bun",
[electrobunCli, "build", `--env=${buildEnv}`],
{
cwd: rootDir,
stdio: "inherit",
env,
},
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}

View File

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

View File

@ -0,0 +1,15 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const buildNumber =
process.env.BUILD_NUMBER || process.env.VITE_BUILD_NUMBER || "dev";
const buildInfoPath = path.join(rootDir, "src/buildInfo.json");
await Bun.write(
buildInfoPath,
`${JSON.stringify({ buildNumber }, null, 2)}\n`,
);
console.log(`write-build-info: ${buildInfoPath} (buildNumber=${buildNumber})`);

3
src/buildInfo.json Normal file
View File

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

50
src/bun/deeplink.js Normal file
View File

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

43
src/bun/index.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import { ApiServerContext } from './ApiServerContext'
import NotificationCenter from '../common/NotificationCenter'
import Notification from '../common/Notification'
import { useMediaQuery } from 'react-responsive'
import { ElectronContext } from './ElectronContext'
const NotificationContext = createContext()
@ -35,6 +36,8 @@ const NotificationProvider = ({ children }) => {
const [notifications, setNotifications] = useState([])
const [notificationsLoading, setNotificationsLoading] = useState(false)
const { isElectron } = useContext(ElectronContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const fetchNotifications = useCallback(async () => {
@ -180,6 +183,7 @@ const NotificationProvider = ({ children }) => {
title='Notifications'
placement='right'
width={isMobile ? '100%' : 460}
style={{ marginTop: isElectron ? '40px' : '0px' }}
onClose={() => setNotificationCenterVisible(false)}
open={notificationCenterVisible}
>

397
src/desktop/appupdate.js Normal file
View File

@ -0,0 +1,397 @@
import { createWriteStream, promises as fs } from 'node:fs'
import http from 'node:http'
import https from 'node:https'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { Utils } from 'electrobun/bun'
import { launchMacInstaller } from './macappupdate.js'
import { launchWindowsInstaller } from './winappupdate.js'
import { checkForDuplicateInstallations } from './check-duplicate-installations.js'
import { scheduleAppRestart } from './updater-runner.js'
import { getAppSettings, setAppSettings } from './store.js'
const SUPPORTED_TARGETS = {
darwin: {
extension: '.pkg',
osMatchers: ['darwin', 'mac', 'macos', 'osx']
},
win32: {
extension: '.exe',
osMatchers: ['win32', 'win', 'windows']
}
}
const DEFAULT_UPDATE_ENGINE = 'native'
let runningUpdate = null
const getArtifactName = (artifact) =>
String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
const normalizeArch = (arch) => {
if (arch === 'x64' || arch === 'amd64') return 'x64'
if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
return arch
}
const normalizeEngine = (engine) => {
const value = String(engine || '')
.trim()
.toLowerCase()
if (value === 'chromium' || value === 'cef') return 'chromium'
if (value === 'native') return 'native'
return null
}
const artifactIsChromium = (artifact) => {
const explicit = String(
artifact?.engine || artifact?.renderer || ''
).toLowerCase()
if (explicit === 'cef' || explicit === 'chromium') return true
if (explicit === 'native') return false
const name = getArtifactName(artifact).toLowerCase()
return /[-_.]cef(?:[-_.]|$)/.test(name)
}
const artifactMatchesEngine = (artifact, engine) => {
const wantsChromium = normalizeEngine(engine) === 'chromium'
return artifactIsChromium(artifact) === wantsChromium
}
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
const name = getArtifactName(artifact).toLowerCase()
const normalizedArch = normalizeArch(arch)
const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
const artifactPlatform = String(
artifact?.platform || artifact?.os || artifact?.target || ''
).toLowerCase()
if (!name.endsWith(target.extension)) return false
if (!artifact?.url) return false
const matchesArch =
artifactArch === normalizedArch ||
name.includes(`-${normalizedArch}`) ||
name.includes(`_${normalizedArch}`) ||
name.includes(`.${normalizedArch}.`) ||
name.includes(normalizedArch)
const matchesOs =
!artifactPlatform ||
target.osMatchers.includes(artifactPlatform) ||
target.osMatchers.some((matcher) => name.includes(matcher)) ||
(platform === 'darwin' && name.includes('mac')) ||
(platform === 'win32' && name.includes('win'))
return matchesArch && matchesOs
}
const selectUpdateArtifact = (
update,
platform = process.platform,
arch = process.arch
) => {
const target = SUPPORTED_TARGETS[platform]
if (!target) {
throw new Error(`App updates are not supported on ${platform}.`)
}
const engine = normalizeEngine(update?.engine) || DEFAULT_UPDATE_ENGINE
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
const matchingArtifact = artifacts.find(
(artifact) =>
artifactMatchesPlatform(artifact, target, platform, arch) &&
artifactMatchesEngine(artifact, engine)
)
if (!matchingArtifact) {
const engineLabel = engine === 'chromium' ? 'Chromium (cef)' : 'Native'
throw new Error(
`No ${target.extension} ${engineLabel} update artifact found for ${platform}/${arch}.`
)
}
return matchingArtifact
}
const getInstallErrorMessage = (error, output = '') => {
const combined = `${output}\n${error?.message || ''}`.trim()
if (
/cancel/i.test(combined) ||
/did not grant permission/i.test(combined) ||
/user canceled/i.test(combined)
) {
return 'Update installation was cancelled.'
}
if (/incorrect/i.test(combined)) {
return 'The administrator password was incorrect.'
}
if (
/1625/.test(combined) ||
/forbidden by system policy/i.test(combined) ||
/Non-assigned apps are disabled/i.test(combined)
) {
return 'Update installation was blocked by system policy.'
}
return combined || 'Failed to install update.'
}
const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => {
if (redirectCount > 5) {
reject(new Error('Too many redirects while downloading update.'))
return
}
const parsedUrl = new URL(url)
const client = parsedUrl.protocol === 'https:' ? https : http
const request = client.get(parsedUrl, (response) => {
const location = response.headers.location
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
response.resume()
resolve(
getDownloadUrl(
new URL(location, parsedUrl).toString(),
redirectCount + 1
)
)
return
}
resolve({ response, url: parsedUrl.toString() })
})
request.on('error', reject)
})
const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
const { response } = await getDownloadUrl(artifact.url)
if (response.statusCode < 200 || response.statusCode >= 300) {
response.resume()
throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
}
const totalBytes =
Number.parseInt(response.headers['content-length'], 10) || 0
let downloadedBytes = 0
await new Promise((resolve, reject) => {
const output = createWriteStream(destinationPath)
response.on('data', (chunk) => {
downloadedBytes += chunk.length
const percent = totalBytes
? Math.round((downloadedBytes / totalBytes) * 100)
: null
sendProgress({
phase: 'downloading',
percent,
downloadedBytes,
totalBytes,
message: totalBytes
? `Downloading update (${percent}%)`
: 'Downloading update'
})
})
response.on('error', reject)
output.on('error', reject)
output.on('finish', resolve)
response.pipe(output)
})
}
const getRunningEngine = (mainWindow) =>
mainWindow?.renderer === 'cef' ? 'chromium' : 'native'
const getRunningAppState = (mainWindow, settings) => ({
version: process.env.ELECTROBUN_VERSION || null,
branch: settings?.appUpdateRunningBranch || null,
engine: getRunningEngine(mainWindow)
})
// Snapshot the running version/branch/engine so the next launch can tell
// whether an update actually completed.
const persistCurrentAppState = async (mainWindow) => {
try {
const settings = await getAppSettings()
await setAppSettings({
...settings,
current: getRunningAppState(mainWindow, settings)
})
} catch (error) {
console.warn('[app-update] Failed to persist current app state.', error)
}
}
// Ignore missing values: a field only counts as changed when it was recorded
// both before and after the update.
const stateValueChanged = (previous, next) =>
Boolean(previous) && Boolean(next) && previous !== next
let completedUpdateResult = null
export const checkForCompletedUpdate = async (mainWindow) => {
// Cache per process so repeated renderer calls (e.g. remounts) get the same
// answer instead of a false negative after `current` has been rewritten.
if (completedUpdateResult) return completedUpdateResult
try {
const settings = await getAppSettings()
const previous =
settings?.current && typeof settings.current === 'object'
? settings.current
: null
const current = getRunningAppState(mainWindow, settings)
await setAppSettings({ ...settings, current })
const updated =
Boolean(previous) &&
(stateValueChanged(previous.version, current.version) ||
stateValueChanged(previous.branch, current.branch) ||
stateValueChanged(
normalizeEngine(previous.engine),
normalizeEngine(current.engine)
))
const duplicates = checkForDuplicateInstallations()
completedUpdateResult = { updated, previous, current, duplicates }
} catch (error) {
console.warn('[app-update] Failed to check for a completed update.', error)
completedUpdateResult = {
updated: false,
previous: null,
current: null,
duplicates: checkForDuplicateInstallations()
}
}
return completedUpdateResult
}
const persistInstalledUpdateSettings = async (update) => {
try {
const settings = await getAppSettings()
const engine =
normalizeEngine(update?.engine) ||
normalizeEngine(settings?.appUpdateEngine) ||
DEFAULT_UPDATE_ENGINE
await setAppSettings({
...settings,
appUpdateEngine: engine,
...(update?.branch ? { appUpdateRunningBranch: update.branch } : {})
})
} catch (error) {
console.warn(
'[app-update] Failed to persist installed update settings.',
error
)
}
}
const launchInstallerAndRestart = async (
mainWindow,
installerPath,
sendProgress,
update
) => {
const installerHelpers = { sendProgress, getInstallErrorMessage }
if (process.platform === 'darwin') {
await launchMacInstaller(
mainWindow,
installerPath,
sendProgress,
installerHelpers
)
} else if (process.platform === 'win32') {
await launchWindowsInstaller(
mainWindow,
installerPath,
sendProgress,
installerHelpers
)
} else {
throw new Error(`App updates are not supported on ${process.platform}.`)
}
await persistInstalledUpdateSettings(update)
if (process.platform === 'darwin') {
scheduleAppRestart()
}
// Give the UI a moment to show completion before the app exits.
await new Promise((resolve) => setTimeout(resolve, 500))
Utils.quit()
}
const runAppUpdate = async (mainWindow, update, sendProgress) => {
await persistCurrentAppState(mainWindow)
const artifact = selectUpdateArtifact(update)
const tempDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), 'farmcontrol-update-')
)
const artifactName = path.basename(getArtifactName(artifact))
const installerPath = path.join(tempDirectory, artifactName)
sendProgress({
phase: 'preparing',
percent: 0,
artifact,
message: 'Preparing update download'
})
await downloadArtifact(artifact, installerPath, sendProgress)
sendProgress({
phase: 'downloaded',
percent: 100,
downloadedBytes: null,
totalBytes: null,
artifact,
message: 'Update downloaded'
})
await launchInstallerAndRestart(
mainWindow,
installerPath,
sendProgress,
update
)
}
export function startAppUpdate(mainWindow, update, sendProgress) {
if (runningUpdate) return runningUpdate
runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
.then(() => ({ ok: true }))
.catch((error) => {
sendProgress({
phase: 'error',
percent: null,
message: error?.message || 'Failed to update app.'
})
throw error
})
.finally(() => {
runningUpdate = null
})
return runningUpdate
}

View File

@ -0,0 +1,294 @@
import { execSync, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { unlink, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import sudo from '@vscode/sudo-prompt'
import { findMacAppBundle } from './updater-runner.js'
const MAC_APP_NAME = 'Farm Control.app'
const MAC_SYSTEM_APP_PATH = path.join('/Applications', MAC_APP_NAME)
const MAC_PKG_IDENTIFIER = 'com.tombutcher.farmcontrol'
const WINDOWS_APP_DIR_NAME = 'Farm Control'
const WINDOWS_LAUNCHER_EXE = 'FarmControl.exe'
const WINDOWS_LEGACY_LAUNCHER_EXE = 'launcher.exe'
const WINDOWS_REGISTRY_APP_KEY = 'Software\\Tom Butcher\\Farm Control'
const WINDOWS_REGISTRY_UNINSTALL_KEY =
'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Farm Control'
const UNSUPPORTED_RESULT = {
supported: false,
runningInstall: 'unknown',
runningPath: null,
duplicatePath: null,
}
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
const isPathInside = (childPath, parentPath) => {
if (!childPath || !parentPath) return false
const relative = path.relative(parentPath, childPath)
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
)
}
const samePath = (left, right) => {
if (!left || !right) return false
if (process.platform === 'win32') {
return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase()
}
return path.resolve(left) === path.resolve(right)
}
const checkMacInstallations = () => {
const runningBundle = findMacAppBundle(process.execPath)
const userAppPath = path.join(os.homedir(), 'Applications', MAC_APP_NAME)
let runningInstall = 'unknown'
if (runningBundle) {
if (isPathInside(runningBundle, path.join(os.homedir(), 'Applications'))) {
runningInstall = 'user'
} else if (isPathInside(runningBundle, '/Applications')) {
runningInstall = 'system'
}
}
const systemCopyExists =
existsSync(MAC_SYSTEM_APP_PATH) &&
!samePath(runningBundle, MAC_SYSTEM_APP_PATH)
return {
supported: true,
runningInstall,
runningPath: runningBundle,
userPath: userAppPath,
duplicatePath:
runningInstall === 'user' && systemCopyExists
? MAC_SYSTEM_APP_PATH
: null,
}
}
const readWindowsInstallDir = (hive) => {
try {
const output = execSync(
`reg query "${hive}\\${WINDOWS_REGISTRY_APP_KEY}" /v InstallDir`,
{ encoding: 'utf8', windowsHide: true },
)
const match = output.match(/InstallDir\s+REG_\w+\s+(.+)/i)
return match?.[1]?.trim() || null
} catch {
return null
}
}
const windowsDirLooksLikeInstall = (dir) =>
Boolean(dir) &&
(existsSync(path.join(dir, 'bin', WINDOWS_LAUNCHER_EXE)) ||
existsSync(path.join(dir, 'bin', WINDOWS_LEGACY_LAUNCHER_EXE)) ||
existsSync(path.join(dir, WINDOWS_LAUNCHER_EXE)) ||
existsSync(path.join(dir, 'Uninstall.exe')) ||
existsSync(path.join(dir, `Uninstall ${WINDOWS_APP_DIR_NAME}.exe`)))
const getWindowsUserInstallDir = () =>
readWindowsInstallDir('HKCU') ||
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
WINDOWS_APP_DIR_NAME,
)
const getWindowsSystemInstallCandidates = () => {
const candidates = [
readWindowsInstallDir('HKLM'),
process.env.ProgramFiles &&
path.join(process.env.ProgramFiles, WINDOWS_APP_DIR_NAME),
process.env['ProgramFiles(x86)'] &&
path.join(process.env['ProgramFiles(x86)'], WINDOWS_APP_DIR_NAME),
process.env.ProgramW6432 &&
path.join(process.env.ProgramW6432, WINDOWS_APP_DIR_NAME),
].filter(Boolean)
const unique = []
for (const candidate of candidates) {
if (!unique.some((existing) => samePath(existing, candidate))) {
unique.push(candidate)
}
}
return unique
}
const checkWindowsInstallations = () => {
const runningDir = path.dirname(process.execPath)
const userInstallDir = getWindowsUserInstallDir()
const systemCandidates = getWindowsSystemInstallCandidates()
const localAppData =
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
let runningInstall = 'unknown'
if (
isPathInside(runningDir, userInstallDir) ||
isPathInside(runningDir, localAppData)
) {
runningInstall = 'user'
} else if (
systemCandidates.some((candidate) => isPathInside(runningDir, candidate))
) {
runningInstall = 'system'
}
const duplicatePath =
runningInstall === 'user'
? systemCandidates.find(
(candidate) =>
windowsDirLooksLikeInstall(candidate) &&
!isPathInside(runningDir, candidate),
) || null
: null
return {
supported: true,
runningInstall,
runningPath: runningDir,
userPath: userInstallDir,
duplicatePath,
}
}
export const checkForDuplicateInstallations = () => {
try {
if (process.platform === 'darwin') return checkMacInstallations()
if (process.platform === 'win32') return checkWindowsInstallations()
} catch (error) {
console.warn(
'[duplicate-install] Failed to check for duplicate installations.',
error,
)
}
return UNSUPPORTED_RESULT
}
const removeMacDuplicate = (duplicatePath) =>
new Promise((resolve, reject) => {
// Removing from /Applications typically needs admin rights; also forget
// the pkg receipt so future installer runs start clean.
const script = [
`/bin/rm -rf ${quoteShellArg(duplicatePath)}`,
`/usr/sbin/pkgutil --forget ${quoteShellArg(MAC_PKG_IDENTIFIER)} || true`,
].join(' && ')
sudo.exec(script, { name: 'farmcontrol' }, (error, stdout, stderr) => {
if (error) {
const combined = `${stderr || ''}\n${error?.message || ''}`
const message =
/cancel/i.test(combined) || /did not grant permission/i.test(combined)
? 'Removal was cancelled.'
: 'Failed to remove the duplicate installation.'
reject(new Error(message))
return
}
resolve()
})
})
const psQuote = (value) => `'${String(value).replaceAll("'", "''")}'`
// Intentionally does NOT run the old install's Uninstall.exe: it taskkills
// FarmControl.exe, which would terminate the running app. Instead the old
// files, HKLM registry keys and machine-level shortcuts are removed directly.
const buildWindowsRemovalScript = (duplicatePath) =>
[
`$ErrorActionPreference = 'SilentlyContinue'`,
`$installDir = ${psQuote(duplicatePath)}`,
`if (Test-Path -LiteralPath $installDir) {`,
` Remove-Item -LiteralPath $installDir -Recurse -Force`,
`}`,
`& reg.exe delete ${psQuote(`HKLM\\${WINDOWS_REGISTRY_APP_KEY}`)} /f 2>$null | Out-Null`,
`& reg.exe delete ${psQuote(`HKLM\\${WINDOWS_REGISTRY_UNINSTALL_KEY}`)} /f 2>$null | Out-Null`,
`$commonStartMenu = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\${WINDOWS_APP_DIR_NAME}'`,
`if (Test-Path -LiteralPath $commonStartMenu) {`,
` Remove-Item -LiteralPath $commonStartMenu -Recurse -Force`,
`}`,
`$publicDesktopShortcut = 'C:\\Users\\Public\\Desktop\\${WINDOWS_APP_DIR_NAME}.lnk'`,
`if (Test-Path -LiteralPath $publicDesktopShortcut) {`,
` Remove-Item -LiteralPath $publicDesktopShortcut -Force`,
`}`,
`if (Test-Path -LiteralPath $installDir) { exit 1 }`,
`exit 0`,
].join('\r\n')
const removeWindowsDuplicate = async (duplicatePath) => {
const scriptPath = path.join(
os.tmpdir(),
`farmcontrol-remove-duplicate-${Date.now()}.ps1`,
)
await writeFile(scriptPath, buildWindowsRemovalScript(duplicatePath), 'utf8')
// Pre-quote the -File argument: PowerShell 5.1's Start-Process does not
// quote ArgumentList entries containing spaces.
const elevateCommand = [
`$p = Start-Process -FilePath 'powershell.exe'`,
`-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','"${scriptPath.replaceAll("'", "''")}"')`,
`-Verb RunAs -Wait -PassThru; exit $p.ExitCode`,
].join(' ')
try {
await new Promise((resolve, reject) => {
const child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', elevateCommand],
{ stdio: 'ignore', windowsHide: true },
)
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) {
resolve()
return
}
// Start-Process throws when the UAC prompt is declined, which exits
// the outer PowerShell with a non-zero code and no script output.
reject(
new Error(
code === 1
? 'Failed to remove the duplicate installation.'
: 'Removal was cancelled.',
),
)
})
})
} finally {
await unlink(scriptPath).catch(() => {})
}
}
export const removeDuplicateInstallations = async () => {
// Re-detect instead of trusting a path from the renderer so this can never
// be used to delete an arbitrary directory.
const check = checkForDuplicateInstallations()
if (!check.duplicatePath) {
return { ok: false, error: 'No duplicate installation was found.' }
}
try {
if (process.platform === 'darwin') {
await removeMacDuplicate(check.duplicatePath)
} else if (process.platform === 'win32') {
await removeWindowsDuplicate(check.duplicatePath)
} else {
return { ok: false, error: 'Not supported on this platform.' }
}
} catch (error) {
return {
ok: false,
error: error?.message || 'Failed to remove the duplicate installation.',
}
}
return { ok: true, removedPath: check.duplicatePath }
}

220
src/desktop/deeplink-ipc.js Normal file
View File

@ -0,0 +1,220 @@
import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import { join } from 'node:path'
export const PROTOCOL_PREFIX = 'farmcontrol://'
export const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance'
export const SIGNAL_FILE = 'instance-signal.json'
export const LOCK_FILE = 'primary.lock'
const FORWARD_TIMEOUT_MS = 750
const PIPE_RETRY_ATTEMPTS = 3
const PIPE_RETRY_DELAY_MS = 100
export function getInstanceDir() {
if (process.platform === 'win32') {
return join(
os.homedir(),
'AppData',
'Local',
'com.tombutcher.farmcontrol',
'instance'
)
}
return join(process.env.TMPDIR || '/tmp', 'com.tombutcher.farmcontrol', 'instance')
}
export function getSignalPath() {
return join(getInstanceDir(), SIGNAL_FILE)
}
export function getLockPath() {
return join(getInstanceDir(), LOCK_FILE)
}
export function isProcessAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
export function isPrimaryInstanceRunning() {
const lockPath = getLockPath()
if (!existsSync(lockPath)) {
return false
}
try {
const existingPid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10)
return isProcessAlive(existingPid)
} catch {
return false
}
}
export function findProtocolUrl(args) {
const directMatch = args.find(
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
)
if (directMatch) {
return directMatch
}
const sources = args.filter((arg) => typeof arg === 'string')
for (const arg of sources) {
const trimmed = arg.trim().replace(/^['"]+|['"]+$/g, '')
const match = trimmed.match(/farmcontrol:\/\/\S+/i)
if (match) {
const rawUrl = match[0].replace(/['"]+$/g, '')
try {
return decodeURI(rawUrl)
} catch {
return rawUrl
}
}
}
const combinedMatch = sources.join(' ').match(/farmcontrol:\/\/\S+/i)
if (combinedMatch) {
const rawUrl = combinedMatch[0].replace(/['"]+$/g, '')
try {
return decodeURI(rawUrl)
} catch {
return rawUrl
}
}
return undefined
}
export function buildDeeplinkPayload(url, argv = process.argv) {
const commandLine = [...argv]
return url
? { type: 'deeplink', url, argv: commandLine }
: { type: 'focus', argv: commandLine }
}
function readSignalQueue({ clear = true } = {}) {
const signalPath = getSignalPath()
if (!existsSync(signalPath)) {
return []
}
try {
const raw = readFileSync(signalPath, 'utf8')
const parsed = JSON.parse(raw)
if (clear) {
unlinkSync(signalPath)
}
if (Array.isArray(parsed)) {
return parsed
}
return parsed ? [parsed] : []
} catch {
if (clear) {
try {
unlinkSync(signalPath)
} catch {
// Ignore cleanup failures.
}
}
return []
}
}
export function writeDeeplinkSignal(payload) {
const signalPath = getSignalPath()
mkdirSync(getInstanceDir(), { recursive: true })
const queue = readSignalQueue({ clear: false })
queue.push({ ...payload, timestamp: Date.now() })
const tempPath = `${signalPath}.${process.pid}.${Date.now()}.tmp`
writeFileSync(tempPath, JSON.stringify(queue), 'utf8')
writeFileSync(signalPath, readFileSync(tempPath))
unlinkSync(tempPath)
}
function tryForwardViaPipe(payload) {
return new Promise((resolve) => {
let settled = false
const finish = (forwarded) => {
if (settled) return
settled = true
resolve(forwarded)
}
const client = net.connect({ path: WINDOWS_PIPE_NAME })
const message = JSON.stringify(payload)
client.on('connect', () => {
client.write(message)
client.end()
finish(true)
})
client.on('error', () => {
finish(false)
})
client.setTimeout(FORWARD_TIMEOUT_MS, () => {
client.destroy()
finish(false)
})
})
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
async function tryForwardViaPipeWithRetries(payload) {
for (let attempt = 0; attempt < PIPE_RETRY_ATTEMPTS; attempt += 1) {
if (await tryForwardViaPipe(payload)) {
return true
}
if (attempt < PIPE_RETRY_ATTEMPTS - 1) {
await delay(PIPE_RETRY_DELAY_MS)
}
}
return false
}
export async function forwardDeeplinkToRunningInstance(payload) {
if (process.platform === 'win32') {
if (await tryForwardViaPipeWithRetries(payload)) {
return true
}
}
if (isPrimaryInstanceRunning()) {
writeDeeplinkSignal(payload)
return true
}
return false
}

167
src/desktop/macappupdate.js Normal file
View File

@ -0,0 +1,167 @@
import { promises as fs } from 'fs'
import path from 'path'
import sudo from '@vscode/sudo-prompt'
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
const parseMacInstallerProgress = (output) => {
const lines = String(output || '').split('\n')
let percent = null
let message = 'Installing update...'
for (const line of lines) {
if (line.startsWith('installer:PHASE:')) {
message = line.slice('installer:PHASE:'.length).trim() || message
} else if (line.startsWith('installer:STATUS:')) {
const status = line.slice('installer:STATUS:'.length).trim()
if (status) message = status
} else if (line.startsWith('installer:%')) {
const value = Number.parseFloat(line.slice('installer:%'.length))
if (Number.isFinite(value)) {
percent = Math.min(100, Math.round(value <= 1 ? value * 100 : value))
}
} else if (
line.startsWith('installer: ') &&
!line.startsWith('installer:PHASE:') &&
!line.startsWith('installer:STATUS:') &&
!line.startsWith('installer:%')
) {
const text = line.slice('installer: '.length).trim()
if (text) message = text
}
}
return { percent, message }
}
const isMacInstallSuccessful = (output) =>
/installer: The (install|upgrade) was successful\./i.test(output)
const isMacInstallFailed = (output) =>
/installer: The install failed/i.test(output)
const buildMacInstallScript = (installerPath, logPath) =>
`sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg(
installerPath
)} -target / -verboseR 2>&1 | /usr/bin/tee ${quoteShellArg(logPath)}`
const startMacInstallerProgressWatch = (logPath, sendProgress) => {
let installerOutput = ''
let offset = 0
let lastPercent = null
let lastMessage = null
const poll = async () => {
try {
const stat = await fs.stat(logPath)
if (stat.size <= offset) return
const handle = await fs.open(logPath, 'r')
try {
const buffer = Buffer.alloc(stat.size - offset)
await handle.read(buffer, 0, buffer.length, offset)
offset = stat.size
installerOutput += buffer.toString('utf8')
const { percent, message } = parseMacInstallerProgress(installerOutput)
const resolvedMessage = message || 'Installing update...'
if (percent !== lastPercent || resolvedMessage !== lastMessage) {
lastPercent = percent
lastMessage = resolvedMessage
sendProgress( {
phase: 'installing',
percent,
message: resolvedMessage
})
}
} finally {
await handle.close()
}
} catch (error) {
if (error?.code !== 'ENOENT') {
console.error('[app-update] installer log poll error:', error)
}
}
}
const intervalId = setInterval(() => {
poll().catch((error) => {
console.error('[app-update] installer log poll error:', error)
})
}, 300)
return async () => {
clearInterval(intervalId)
await poll()
return installerOutput
}
}
export const launchMacInstaller = (
mainWindow,
installerPath,
webContents,
{ sendProgress, getInstallErrorMessage }
) => {
const logPath = path.join(path.dirname(installerPath), 'install.log')
const installScript = buildMacInstallScript(installerPath, logPath)
const promptName = 'farmcontrol'
sendProgress( {
phase: 'installing',
percent: 0,
message: 'Enter your Mac password when prompted.'
})
if (mainWindow && !mainWindow.isDestroyed?.()) {
mainWindow.focus?.();
mainWindow.show?.();
}
const stopProgressWatch = startMacInstallerProgressWatch(logPath, sendProgress)
return new Promise((resolve, reject) => {
sudo.exec(installScript, { name: promptName }, async (error, stdout, stderr) => {
const watchedOutput = await stopProgressWatch()
const output = `${stdout || ''}${stderr || ''}` || watchedOutput
await fs.unlink(logPath).catch(() => {})
if (stderr) console.error('[app-update] installer stderr:', stderr)
if (error) {
console.error('[app-update] installer error:', error)
const message = getInstallErrorMessage(error, output)
sendProgress( {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
if (isMacInstallFailed(output) || !isMacInstallSuccessful(output)) {
const message = getInstallErrorMessage(null, output)
sendProgress( {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const { percent, message } = parseMacInstallerProgress(output)
sendProgress( {
phase: 'installing',
percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...'
})
resolve()
})
})
}

View File

@ -0,0 +1,148 @@
import { dlopen, FFIType } from 'bun:ffi'
import { existsSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export const MAC_WINDOW_CORNER_RADIUS = 15
export const MAC_TRAFFIC_LIGHT_OFFSET = { x: 14, y: 12 }
const DYLIB_NAME = 'libMacWindowEffects.dylib'
const MIN_DYLIB_BYTES = 10_000
function resolveDylibPath() {
const candidates = new Set()
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
candidates.add(path.join(moduleDir, DYLIB_NAME))
candidates.add(path.join(moduleDir, '../bun', DYLIB_NAME))
candidates.add(path.join(moduleDir, '../../bun', DYLIB_NAME))
candidates.add(path.join(process.cwd(), 'src/bun', DYLIB_NAME))
for (const execPath of [process.execPath, process.argv0].filter(Boolean)) {
const execDir = path.dirname(execPath)
candidates.add(
path.join(execDir, '../Resources/app/bun', DYLIB_NAME)
)
candidates.add(path.join(execDir, 'Resources/app/bun', DYLIB_NAME))
candidates.add(path.join(execDir, DYLIB_NAME))
}
for (const candidate of candidates) {
const resolved = path.resolve(candidate)
if (!existsSync(resolved)) {
continue
}
try {
if (statSync(resolved).size >= MIN_DYLIB_BYTES) {
return resolved
}
} catch {
// Ignore unreadable paths and keep searching.
}
}
return null
}
function loadMacWindowEffectsLibrary() {
const dylibPath = resolveDylibPath()
if (!dylibPath) {
console.warn(
'macOS vibrancy: native effects library not found; using transparent window only.'
)
return null
}
try {
return {
path: dylibPath,
lib: dlopen(dylibPath, {
enableWindowVibrancy: {
args: [FFIType.ptr, FFIType.f64],
returns: FFIType.bool
},
ensureWindowShadow: {
args: [FFIType.ptr],
returns: FFIType.bool
},
setWindowCornerRadius: {
args: [FFIType.ptr, FFIType.f64],
returns: FFIType.bool
},
setWindowTrafficLightsPosition: {
args: [FFIType.ptr, FFIType.f64, FFIType.f64],
returns: FFIType.bool
},
setNativeWindowDragRegion: {
args: [FFIType.ptr, FFIType.f64, FFIType.f64],
returns: FFIType.bool
}
})
}
} catch (error) {
console.warn(
`macOS vibrancy: failed to load native effects library (${dylibPath}):`,
error
)
return null
}
}
function applyTrafficLightsPosition(lib, mainWindow, attempt = 0) {
const applied = lib.symbols.setWindowTrafficLightsPosition(
mainWindow.ptr,
MAC_TRAFFIC_LIGHT_OFFSET.x,
MAC_TRAFFIC_LIGHT_OFFSET.y
)
if (applied || attempt >= 10) {
return applied
}
setTimeout(() => applyTrafficLightsPosition(lib, mainWindow, attempt + 1), 50)
return false
}
export function applyMacOSWindowEffects(mainWindow) {
if (process.platform !== 'darwin' || !mainWindow?.ptr) {
return
}
const loaded = loadMacWindowEffectsLibrary()
if (!loaded) {
return
}
const { path: dylibPath, lib } = loaded
try {
const vibrancyEnabled = lib.symbols.enableWindowVibrancy(
mainWindow.ptr,
MAC_WINDOW_CORNER_RADIUS
)
const shadowEnabled = lib.symbols.ensureWindowShadow(mainWindow.ptr)
const trafficLightsScheduled = applyTrafficLightsPosition(lib, mainWindow)
mainWindow.on?.('resize', () => {
lib.symbols.setWindowCornerRadius(
mainWindow.ptr,
MAC_WINDOW_CORNER_RADIUS
)
lib.symbols.setWindowTrafficLightsPosition(
mainWindow.ptr,
MAC_TRAFFIC_LIGHT_OFFSET.x,
MAC_TRAFFIC_LIGHT_OFFSET.y
)
})
console.log(
`macOS vibrancy applied (dylib=${dylibPath}, vibrancy=${vibrancyEnabled}, shadow=${shadowEnabled}, trafficLights=${trafficLightsScheduled}, cornerRadius=${MAC_WINDOW_CORNER_RADIUS})`
)
} catch (error) {
console.warn(
'macOS vibrancy: failed to apply native window effects:',
error
)
}
}

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