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.
This commit is contained in:
Tom Butcher 2026-08-02 02:44:24 +01:00
parent f90ef10b78
commit 274852b895
12 changed files with 640 additions and 5 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

@ -108,6 +108,43 @@
user-select: none;
}
/* 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 {
line-height: 40px;
}

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

@ -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

@ -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

@ -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({