Compare commits

...

2 Commits

Author SHA1 Message Date
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
19 changed files with 946 additions and 212 deletions

5
.gitignore vendored
View File

@ -31,4 +31,7 @@ stats.html
test-results.xml
dist/*
dist/*
# Native macOS vibrancy (built by scripts/build-macos-effects.mjs)
src/bun/libMacWindowEffects.dylib

View File

@ -104,14 +104,45 @@
line-height: 0.7;
}
.electron-navigation-wrapper {
-webkit-app-region: drag;
.electron-navigation-wrapper.electrobun-webkit-app-region-drag {
user-select: none;
}
.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-sider {
background: rgba(255, 255, 255, 0.75) !important;
}
html.macos-vibrancy .dark-mode .ant-layout-sider {
background: rgba(10, 10, 10, 0.65) !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;
}
.electron-navigation {

View File

@ -42,6 +42,7 @@ export default {
},
copy: {
"dist/mainview": "views/mainview",
"src/bun/libMacWindowEffects.dylib": "bun/libMacWindowEffects.dylib",
},
watch: ["scripts", "src"],
watchIgnore: ["dist/**", "build/**", "app_dist/**"],

View File

@ -0,0 +1,402 @@
#import <Cocoa/Cocoa.h>
static NSString *const kElectrobunVibrancyViewIdentifier =
@"ElectrobunVibrancyView";
static NSString *const kElectrobunNativeDragViewIdentifier =
@"ElectrobunNativeDragView";
static NSString *const kElectrobunWindowBorderViewIdentifier =
@"ElectrobunWindowBorderView";
static CGFloat gWindowCornerRadius = 15.0;
@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
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 void applyWindowCornerRadius(NSWindow *window, CGFloat radius) {
gWindowCornerRadius = MAX(0.0, radius);
NSView *contentView = [window contentView];
if (contentView == nil) {
return;
}
contentView.wantsLayer = YES;
contentView.layer.cornerRadius = gWindowCornerRadius;
contentView.layer.masksToBounds = YES;
NSVisualEffectView *effectView = findVibrancyView(contentView);
if (effectView != nil) {
effectView.wantsLayer = YES;
effectView.layer.cornerRadius = gWindowCornerRadius;
effectView.layer.masksToBounds = YES;
}
ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView);
if (borderView != nil) {
borderView.cornerRadius = gWindowCornerRadius;
[borderView setNeedsDisplay:YES];
}
}
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.cornerRadius = gWindowCornerRadius;
[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];
}
[borderView setNeedsDisplay:YES];
}
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:NSVisualEffectMaterialUnderWindowBackground];
} 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);
[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;
}
NSButton *closeButton =
[window standardWindowButton:NSWindowCloseButton];
NSButton *minimizeButton =
[window standardWindowButton:NSWindowMiniaturizeButton];
NSButton *zoomButton = [window standardWindowButton:NSWindowZoomButton];
if (closeButton == nil || minimizeButton == nil || zoomButton == nil) {
return;
}
NSView *buttonContainer = [closeButton superview];
if (buttonContainer == nil) {
return;
}
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 setFrameOrigin:NSMakePoint(currentX, targetY)];
currentX += spacing;
}
[buttonContainer setNeedsLayout:YES];
[buttonContainer layoutSubtreeIfNeeded];
[window invalidateShadow];
success = YES;
});
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

@ -94,7 +94,8 @@
"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",
"postinstall": "bun scripts/patch-electrobun-src.mjs",
"postinstall": "bun scripts/patch-electrobun-src.mjs && bun scripts/build-macos-effects.mjs",
"build:macos-effects": "bun scripts/build-macos-effects.mjs",
"clean": "bun scripts/clean-build.mjs"
},
"eslintConfig": {

View File

@ -43,6 +43,7 @@ Function .onInit
${If} ${Silent}
SetAutoClose true
${EndIf}
!insertmacro uninstallPreviousFarmControl
FunctionEnd
Section "Farm Control" SecMain

View File

@ -1,3 +1,33 @@
!macro quitFarmControl
DetailPrint "Stopping running Farm Control processes..."
ExecWait 'taskkill /F /IM launcher.exe /T' $R0
!macroend
!macro uninstallPreviousFarmControl
ClearErrors
ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString"
StrCmp $R0 "" check_install_dir run_uninstall
check_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 "$PROGRAMFILES64\Farm Control\Uninstall.exe"
IfFileExists $R0 run_uninstall done_uninstall
run_uninstall:
IfFileExists $R0 0 done_uninstall
DetailPrint "Removing previous Farm Control installation..."
!insertmacro quitFarmControl
ExecWait '$R0 /S' $R1
DetailPrint "Previous installation removed (exit code: $R1)"
done_uninstall:
!macroend
!macro createDesktopShortcut
CreateShortCut "$DESKTOP\Farm Control.lnk" "$INSTDIR\bin\launcher.exe"
!macroend

View File

@ -23,8 +23,42 @@
<Property Id="DISABLEADVTSHORTCUTS" Value="1" />
<Property Id="MSIINSTALLPERUSER" Value="1" />
<Property Id="PREVIOUS_UNINSTALL_CMD">
<RegistrySearch
Id="PreviousUninstallCmdSearch"
Root="HKLM"
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control"
Name="UninstallString"
Type="raw" />
</Property>
<Property Id="PREVIOUS_INSTALL_DIR">
<RegistrySearch
Id="PreviousInstallDirSearch"
Root="HKLM"
Key="Software\Tom Butcher\Farm Control"
Name="InstallDir"
Type="directory" />
</Property>
<Binary Id="WrappedExe" SourceFile="__SETUP_EXE__" />
<CustomAction
Id="KillFarmControl"
BinaryKey="WixCA"
DllEntry="WixQuietExec"
Execute="deferred"
Impersonate="no"
Return="ignore" />
<CustomAction
Id="UninstallPrevious"
BinaryKey="WixCA"
DllEntry="WixQuietExec"
Execute="deferred"
Impersonate="no"
Return="ignore" />
<CustomAction
Id="RunInstaller"
BinaryKey="WrappedExe"
@ -45,8 +79,33 @@
<ComponentRef Id="EmptyComponent" />
</Feature>
<CustomAction
Id="SetKillFarmControl"
Property="QtKillFarmControl"
Value="taskkill.exe /F /IM launcher.exe /T"
Execute="immediate" />
<CustomAction
Id="SetUninstallPreviousFromRegistry"
Property="QtUninstallPrevious"
Value="&quot;[PREVIOUS_UNINSTALL_CMD]&quot; /S"
Execute="immediate" />
<CustomAction
Id="SetUninstallPreviousFromInstallDir"
Property="QtUninstallPrevious"
Value="&quot;[PREVIOUS_INSTALL_DIR]Uninstall.exe&quot; /S"
Execute="immediate" />
<InstallExecuteSequence>
<Custom Action="RunInstaller" After="ProcessComponents">NOT Installed</Custom>
<Custom Action="SetKillFarmControl" Before="KillFarmControl">NOT Installed</Custom>
<Custom Action="KillFarmControl" After="SetKillFarmControl">NOT Installed</Custom>
<Custom Action="SetUninstallPreviousFromRegistry" After="KillFarmControl">NOT Installed AND PREVIOUS_UNINSTALL_CMD</Custom>
<Custom Action="SetUninstallPreviousFromInstallDir" After="KillFarmControl">NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND PREVIOUS_INSTALL_DIR</Custom>
<Custom Action="UninstallPrevious" After="SetUninstallPreviousFromRegistry">NOT Installed AND (PREVIOUS_UNINSTALL_CMD OR PREVIOUS_INSTALL_DIR)</Custom>
<Custom Action="UninstallPrevious" After="SetUninstallPreviousFromInstallDir">NOT Installed AND (PREVIOUS_UNINSTALL_CMD OR PREVIOUS_INSTALL_DIR)</Custom>
<Custom Action="RunInstaller" After="UninstallPrevious">NOT Installed AND (PREVIOUS_UNINSTALL_CMD OR PREVIOUS_INSTALL_DIR)</Custom>
<Custom Action="RunInstaller" After="KillFarmControl">NOT Installed AND NOT PREVIOUS_UNINSTALL_CMD AND NOT PREVIOUS_INSTALL_DIR</Custom>
</InstallExecuteSequence>
</Product>
</Wix>

View File

@ -0,0 +1,48 @@
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");
const outFile = path.join(rootDir, "src/bun/libMacWindowEffects.dylib");
function createPlaceholder() {
mkdirSync(path.dirname(outFile), { recursive: true });
writeFileSync(outFile, "");
console.log(`build-macos-effects: created placeholder dylib at ${outFile}`);
}
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 result = spawnSync(
"xcrun",
[
"clang++",
"-dynamiclib",
"-fobjc-arc",
"-framework",
"Cocoa",
srcFile,
"-o",
outFile,
],
{ cwd: rootDir, stdio: "inherit" },
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
const { size } = statSync(outFile);
console.log(`build-macos-effects: built ${outFile} (${size} bytes)`);

View File

@ -103,12 +103,12 @@ $wixObj = Join-Path $workDir "project.wixobj"
Push-Location $workDir
try {
& $wix.Candle "-arch" "x64" $projectWxs
& $wix.Candle "-arch" "x64" "-ext" "WixUtilExtension" $projectWxs
if ($LASTEXITCODE -ne 0) {
throw "candle.exe failed with exit code $LASTEXITCODE"
}
& $wix.Light "-out" $outputMsiPath "-spdb" "-sw1076" $wixObj
& $wix.Light "-out" $outputMsiPath "-spdb" "-sw1076" "-ext" "WixUtilExtension" $wixObj
if ($LASTEXITCODE -ne 0) {
throw "light.exe failed with exit code $LASTEXITCODE"
}

View File

@ -3,34 +3,34 @@ import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
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");
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);
console.error('patch-electrobun-src: electrobun dist/api/shared not found')
process.exit(1)
}
mkdirSync(sharedSrcDir, { recursive: true });
mkdirSync(sharedSrcDir, { recursive: true })
for (const fileName of [
"cef-version.ts",
"bun-version.ts",
"electrobun-version.ts",
"naming.ts",
"rpc.ts",
'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);
const source = path.join(sharedDistDir, fileName)
const destination = path.join(sharedSrcDir, fileName)
if (existsSync(source)) {
cpSync(source, destination);
cpSync(source, destination)
}
}
@ -109,14 +109,14 @@ export function getPlatformOS(): SupportedOS {
export function getPlatformArch(): SupportedArch {
return ARCH;
}
`;
`
writeFileSync(path.join(sharedSrcDir, "platform.ts"), platformPatch);
writeFileSync(path.join(sharedSrcDir, 'platform.ts'), platformPatch)
const templatesDir = path.join(electrobunDir, "src/cli/templates");
mkdirSync(templatesDir, { recursive: true });
const templatesDir = path.join(electrobunDir, 'src/cli/templates')
mkdirSync(templatesDir, { recursive: true })
writeFileSync(
path.join(templatesDir, "embedded.ts"),
path.join(templatesDir, 'embedded.ts'),
`export function getTemplateNames() {
return [];
}
@ -124,8 +124,8 @@ writeFileSync(
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 = [
@ -140,35 +140,41 @@ const RCEDIT_RESOLVER_FN = `function resolveProjectRceditPkgPath(projectRoot) {
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")) {
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";',
);
'import { OS, ARCH, HOST_ARCH } from "../shared/platform";'
)
cliSource = cliSource.replace(
"const hostPaths = getPlatformPaths(OS, ARCH);",
"const hostPaths = getPlatformPaths(OS, HOST_ARCH);",
);
'const hostPaths = getPlatformPaths(OS, ARCH);',
'const hostPaths = getPlatformPaths(OS, HOST_ARCH);'
)
}
if (!cliSource.includes("toolPaths")) {
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;");
'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");
'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")) {
if (!cliSource.includes('hookBunBinary')) {
cliSource = cliSource.replace(
`const hostPaths = getPlatformPaths(OS, HOST_ARCH);
@ -178,41 +184,41 @@ if (!cliSource.includes("hookBunBinary")) {
? hostPaths.BUN_BINARY
: process.execPath;
const result = Bun.spawnSync([hookBunBinary, hookScript],`,
);
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");
'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")) {
if (!cliSource.includes('resolveProjectRceditPkgPath')) {
cliSource = cliSource.replaceAll(
"const rceditPkgPath = require.resolve(\"rcedit/package.json\");",
"const rceditPkgPath = resolveProjectRceditPkgPath(projectRoot);",
);
'const rceditPkgPath = require.resolve("rcedit/package.json");',
'const rceditPkgPath = resolveProjectRceditPkgPath(projectRoot);'
)
cliSource = cliSource.replace(
"function getPlatformPaths(",
'function getPlatformPaths(',
`${RCEDIT_RESOLVER_FN}
function getPlatformPaths(`,
);
writeFileSync(cliPath, cliSource);
cliSource = readFileSync(cliPath, "utf8");
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);
RCEDIT_RESOLVER_FN
)
writeFileSync(cliPath, cliSource)
}
const electrobunCjsPath = path.join(electrobunDir, "bin/electrobun.cjs");
let electrobunCjs = readFileSync(electrobunCjsPath, "utf8");
const electrobunCjsPath = path.join(electrobunDir, 'bin/electrobun.cjs')
let electrobunCjs = readFileSync(electrobunCjsPath, 'utf8')
if (!electrobunCjs.includes("farmcontrol-use-patched-cli")) {
if (!electrobunCjs.includes('farmcontrol-use-patched-cli')) {
electrobunCjs = electrobunCjs.replace(
`async function main() {
try {
@ -259,19 +265,21 @@ if (!electrobunCjs.includes("farmcontrol-use-patched-cli")) {
const child = spawn(cliPath, args, {
stdio: 'inherit',
cwd: process.cwd()
});`,
);
writeFileSync(electrobunCjsPath, electrobunCjs);
});`
)
writeFileSync(electrobunCjsPath, electrobunCjs)
}
const rceditSrc = path.join(rootDir, "node_modules/rcedit");
const rceditDest = path.join(electrobunDir, "node_modules/rcedit");
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 });
mkdirSync(path.join(electrobunDir, 'node_modules'), { recursive: true })
cpSync(rceditSrc, rceditDest, { recursive: true, force: true })
}
const markerPath = path.join(electrobunDir, ".farmcontrol-electrobun-patched");
writeFileSync(markerPath, `patched-at=${new Date().toISOString()}\n`);
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");
console.log(
'patch-electrobun-src: electrobun src/shared ready for cross-arch builds'
)

View File

@ -36,6 +36,16 @@ if (buildEnv === "stable" && process.env.ELECTROBUN_OS === "macos") {
}
}
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);
}
const prepareIcons = spawnSync(
"bun",
[

View File

@ -26,6 +26,16 @@ 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);
}
const ensureCoreEnv = {
...process.env,
ELECTROBUN_TARGET_ARCH: targetArch,

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>

View File

@ -199,134 +199,138 @@ 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()}
>
<div className='electrobun-webkit-app-region-no-drag'>
<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={<SearchIcon />}
type='text'
style={{ marginTop: '4px' }}
onClick={() => showSpotlight()}
icon={<MenuIcon />}
style={{ marginBottom: '4px' }}
/>
</KeyboardShortcut>
<Badge
count={unreadCount}
size='small'
offset={[-5, 8]}
style={{ padding: 0, fontWeight: 600 }}
>
}
/>
</div>
{isMobile && <div style={{ flexGrow: 1 }} />}
<div className='electrobun-webkit-app-region-no-drag'>
<Flex
gap={'small'}
align='center'
style={{ marginTop: '-2px', marginRight: '6px' }}
>
<Space style={{ paddingTop: '2px', marginRight: '8px' }}>
<WebAppSwitcher />
<KeyboardShortcut
shortcut='alt+n'
hint='ALT N'
onTrigger={() => toggleNotificationCenter()}
shortcut='alt+q'
hint='ALT Q'
onTrigger={() => showSpotlight()}
>
<Button
icon={<BellIcon />}
icon={<SearchIcon />}
type='text'
style={{ marginTop: '2px' }}
onClick={() => toggleNotificationCenter()}
style={{ marginTop: '4px' }}
onClick={() => showSpotlight()}
/>
</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}
<Badge
count={unreadCount}
size='small'
offset={[-5, 8]}
style={{ padding: 0, fontWeight: 600 }}
>
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
{!isMobile && (userProfile?.name || userProfile.username)}
</Tag>
</Popover>
<KeyboardShortcut
shortcut='alt+n'
hint='ALT N'
onTrigger={() => toggleNotificationCenter()}
>
<Button
icon={<BellIcon />}
type='text'
style={{ marginTop: '2px' }}
onClick={() => toggleNotificationCenter()}
/>
</KeyboardShortcut>
</Badge>
</Space>
) : null}
{isElectron && availableUpdate ? (
<Tag
icon={<CloudIcon />}
style={{ cursor: 'pointer', margin: '2px 0 0 0' }}
color='cyan'
onClick={() => checkForUpdates()}
>
Update Available
</Tag>
) : null}
</Flex>
{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()}
>
Update Available
</Tag>
) : null}
</Flex>
</div>
{isOtherApp ? <DashboardWindowButtons /> : null}
</Flex>
)
@ -335,7 +339,7 @@ const DashboardNavigation = () => {
<>
{isElectron ? (
<Flex
className={`ant-menu-horizontal ant-menu-light${!notificationCenterVisible ? ' electron-navigation-wrapper' : ''}`}
className={`ant-menu-horizontal ant-menu-light${!notificationCenterVisible ? ' electron-navigation-wrapper electrobun-webkit-app-region-drag' : ''}`}
style={{ lineHeight: '40px', padding: '0 2px 0 2px' }}
>
{navigationContents}

View File

@ -39,7 +39,10 @@ const DashboardWindowButtons = () => {
<div style={{ width: '80px' }} />
) : null
) : (
<div style={{ width: '95px', marginRight: '2.5px' }}>
<div
className='electrobun-webkit-app-region-no-drag'
style={{ width: '95px', marginRight: '2.5px' }}
>
<Flex style={{ position: 'relative', zIndex: 9999 }}>
{maximizeButton}
{minimizeButton}

View File

@ -118,7 +118,12 @@ const ElectronProvider = ({ children }) => {
if (useElectrobun) {
desktopAPI.getOsInfo().then((info) => {
if (info?.platform) setPlatform(info.platform)
if (info?.platform) {
setPlatform(info.platform)
if (info.platform === 'darwin') {
document.documentElement.classList.add('macos-vibrancy')
}
}
})
desktopAPI.getWindowState().then(applyWindowState)
@ -142,7 +147,12 @@ const ElectronProvider = ({ children }) => {
if (!ipcRenderer) return
ipcRenderer.invoke('os-info').then((info) => {
if (info?.platform) setPlatform(info.platform)
if (info?.platform) {
setPlatform(info.platform)
if (info.platform === 'darwin') {
document.documentElement.classList.add('macos-vibrancy')
}
}
})
ipcRenderer.invoke('window-state').then(applyWindowState)

View File

@ -0,0 +1,105 @@
import { dlopen, FFIType } from 'bun:ffi'
import { existsSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const MAC_TRAFFIC_LIGHTS_X = 14
const MAC_TRAFFIC_LIGHTS_Y = 12
export const MAC_WINDOW_CORNER_RADIUS = 15
function resolveDylibPath() {
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
const candidates = [
path.join(moduleDir, '../bun/libMacWindowEffects.dylib'),
path.join(moduleDir, 'libMacWindowEffects.dylib')
]
for (const candidate of candidates) {
if (!existsSync(candidate)) {
continue
}
try {
if (statSync(candidate).size > 0) {
return candidate
}
} catch {
// Ignore unreadable paths and keep searching.
}
}
return null
}
export function applyMacOSWindowEffects(mainWindow) {
if (process.platform !== 'darwin' || !mainWindow?.ptr) {
return
}
const dylibPath = resolveDylibPath()
if (!dylibPath) {
console.warn(
'macOS vibrancy: native effects library not found; using transparent window only.'
)
return
}
try {
const 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
}
})
const vibrancyEnabled = lib.symbols.enableWindowVibrancy(
mainWindow.ptr,
MAC_WINDOW_CORNER_RADIUS
)
const shadowEnabled = lib.symbols.ensureWindowShadow(mainWindow.ptr)
const alignButtons = () =>
lib.symbols.setWindowTrafficLightsPosition(
mainWindow.ptr,
MAC_TRAFFIC_LIGHTS_X,
MAC_TRAFFIC_LIGHTS_Y
)
const refreshWindowChrome = () => {
lib.symbols.setWindowCornerRadius(
mainWindow.ptr,
MAC_WINDOW_CORNER_RADIUS
)
alignButtons()
}
const buttonsAlignedNow = alignButtons()
setTimeout(() => {
refreshWindowChrome()
}, 120)
mainWindow.on?.('resize', () => {
refreshWindowChrome()
})
console.log(
`macOS vibrancy applied (vibrancy=${vibrancyEnabled}, shadow=${shadowEnabled}, trafficLights=${buttonsAlignedNow}, cornerRadius=${MAC_WINDOW_CORNER_RADIUS})`
)
} catch (error) {
console.warn(
'macOS vibrancy: failed to apply native window effects:',
error
)
}
}

View File

@ -1,7 +1,10 @@
import Electrobun, { BrowserWindow, Updater, Utils } from "electrobun/bun";
import { applyApplicationMenu, setupApplicationMenuEvents } from "./menu.js";
import { applyMacOSWindowEffects } from "./macos-window-effects.js";
import { sendToRenderer, setMessageSender } from "./notify.js";
const isMacOS = process.platform === "darwin";
const DEV_SERVER_PORT = 5780;
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
const PROTOCOL_PREFIX = "farmcontrol://";
@ -127,6 +130,7 @@ export async function createMainWindow(rpc) {
rpc,
titleBarStyle: "hiddenInset",
trafficLightOffset: { x: 14, y: 12 },
...(isMacOS ? { transparent: true } : {}),
frame: {
width: 1200,
height: 800,
@ -135,6 +139,10 @@ export async function createMainWindow(rpc) {
},
});
if (isMacOS) {
applyMacOSWindowEffects(mainWindow);
}
setupMainWindowMessaging(mainWindow);
applyApplicationMenu();
setupApplicationMenuEvents({