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.
This commit is contained in:
Tom Butcher 2026-08-02 12:18:30 +01:00
parent f6978f44b2
commit 805361516e
10 changed files with 548 additions and 423 deletions

View File

@ -125,11 +125,11 @@ html.macos-vibrancy .main-layout {
}
html.macos-vibrancy .light-mode .ant-layout-sider {
background: rgba(255, 255, 255, 0.75) !important;
background: rgba(255, 255, 255, 0.7) !important;
}
html.macos-vibrancy .dark-mode .ant-layout-sider {
background: rgba(10, 10, 10, 0.65) !important;
background: rgba(5, 5, 5, 0.7) !important;
}
html.macos-vibrancy .light-mode .ant-layout-content {
@ -145,6 +145,15 @@ 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);
}
.electron-navigation {
line-height: 40px;
}
@ -153,6 +162,10 @@ html.macos-vibrancy .electron-sider .ant-menu-light {
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

@ -1,4 +1,5 @@
#import <Cocoa/Cocoa.h>
#import <objc/runtime.h>
static NSString *const kElectrobunVibrancyViewIdentifier =
@"ElectrobunVibrancyView";
@ -8,6 +9,10 @@ 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;
@ -94,6 +99,58 @@ static CGFloat gWindowCornerRadius = 15.0;
}
@end
@interface ElectrobunWindowChromeObserver : NSObject
@property(nonatomic, weak) NSWindow *window;
@end
static bool applyTrafficLightsPosition(NSWindow *window, CGFloat x,
CGFloat yFromTop);
static void refreshWindowChrome(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];
}
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]] &&
@ -130,8 +187,13 @@ static ElectrobunWindowBorderView *findWindowBorderView(NSView *contentView) {
return nil;
}
static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) {
gWindowCornerRadius = MAX(0.0, radius);
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) {
@ -139,21 +201,29 @@ static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) {
}
contentView.wantsLayer = YES;
contentView.layer.cornerRadius = gWindowCornerRadius;
contentView.layer.cornerRadius = radius;
contentView.layer.masksToBounds = YES;
NSVisualEffectView *effectView = findVibrancyView(contentView);
if (effectView != nil) {
effectView.wantsLayer = YES;
effectView.layer.cornerRadius = gWindowCornerRadius;
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) {
@ -171,7 +241,6 @@ static void ensureWindowBorder(NSWindow *window) {
setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)];
}
borderView.cornerRadius = gWindowCornerRadius;
[borderView setFrame:[contentView bounds]];
if ([borderView superview] == nil) {
@ -185,7 +254,76 @@ static void ensureWindowBorder(NSWindow *window) {
relativeTo:nil];
}
[borderView setNeedsDisplay:YES];
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) {
@ -223,7 +361,7 @@ extern "C" bool enableWindowVibrancy(void *windowPtr, double cornerRadius) {
}
if (@available(macOS 10.14, *)) {
[effectView setMaterial:NSVisualEffectMaterialUnderWindowBackground];
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
} else {
[effectView setMaterial:NSVisualEffectMaterialSidebar];
}
@ -243,6 +381,8 @@ extern "C" bool enableWindowVibrancy(void *windowPtr, double cornerRadius) {
ensureWindowBorder(window);
applyWindowCornerRadius(window, gWindowCornerRadius);
ensureTrafficLightsObserver(window);
scheduleTrafficLightsPosition(window, 0);
[window invalidateShadow];
success = YES;
@ -307,45 +447,13 @@ extern "C" bool setWindowTrafficLightsPosition(void *windowPtr, double x,
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];
gTrafficLightsX = (CGFloat)x;
gTrafficLightsY = (CGFloat)yFromTop;
ensureTrafficLightsObserver(window);
success = applyTrafficLightsPosition(window, gTrafficLightsX, gTrafficLightsY);
if (success) {
[window invalidateShadow];
success = YES;
}
});
return success;

View File

@ -1,20 +1,23 @@
import { createAppRpc } from "../desktop/rpc.js";
import { registerGlobalShortcuts, unregisterGlobalShortcuts } from "../desktop/spotlight.js";
import { createAppRpc } from '../desktop/rpc.js'
import {
registerGlobalShortcuts,
unregisterGlobalShortcuts
} from '../desktop/spotlight.js'
import {
createMainWindow,
handleDeepLinkFromArgv,
setupDevAuthServer,
setupNavigationGestures,
} from "../desktop/window.js";
setupNavigationGestures
} from '../desktop/window.js'
const rpc = createAppRpc();
const mainWindow = await createMainWindow(rpc);
const rpc = createAppRpc()
const mainWindow = await createMainWindow(rpc)
setupNavigationGestures(mainWindow);
registerGlobalShortcuts(rpc);
setupDevAuthServer();
handleDeepLinkFromArgv();
setupNavigationGestures(mainWindow)
registerGlobalShortcuts(rpc)
setupDevAuthServer()
handleDeepLinkFromArgv()
process.on("exit", () => {
unregisterGlobalShortcuts();
});
process.on('exit', () => {
unregisterGlobalShortcuts()
})

View File

@ -1,5 +1,5 @@
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'
@ -36,7 +36,13 @@ const DashboardWindowButtons = () => {
<Flex align='center'>
{platform == 'darwin' ? (
isFullScreen == false ? (
<div style={{ width: '80px' }} />
<>
<div style={{ width: '82px' }} />
<Divider
type='vertical'
style={{ height: '14px', margin: '3px 6px 0 0' }}
/>
</>
) : null
) : (
<div

View File

@ -1,27 +1,16 @@
import { createContext, useCallback, useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import { useNavigate } from 'react-router-dom'
import desktopBridge, {
isElectrobunDesktop
} from '../../../electrobun-bridge.js'
const electron = window.require ? window.require('electron') : null
const ipcRenderer = electron ? electron.ipcRenderer : null
function getDesktopAPI() {
return window.electronAPI
}
// eslint-disable-next-line react-refresh/only-export-components
export function isElectron() {
const desktopAPI = getDesktopAPI()
if (desktopAPI?.isDesktop) {
return true
}
if (
typeof window !== 'undefined' &&
window.__electrobunWebviewId &&
window.__electrobunRpcSocketPort
) {
if (isElectrobunDesktop()) {
return true
}
@ -66,12 +55,7 @@ const ElectronProvider = ({ children }) => {
const [isMaximized, setIsMaximized] = useState(false)
const [isFullScreen, setIsFullScreen] = useState(false)
const [electronAvailable] = useState(isElectron())
const useElectrobun = Boolean(
getDesktopAPI()?.isDesktop ||
(typeof window !== 'undefined' &&
window.__electrobunWebviewId &&
window.__electrobunRpcSocketPort)
)
const useElectrobun = isElectrobunDesktop()
const navigate = useNavigate()
const lastNavigationAtRef = useRef(0)
@ -101,7 +85,9 @@ const ElectronProvider = ({ children }) => {
const openExternalUrl = (url) => {
if (useElectrobun) {
getDesktopAPI().openExternalUrl(url)
void desktopBridge.openExternalUrl(url).catch((error) => {
console.warn('[ElectronContext] Failed to open external url:', error)
})
return true
}
if (electronAvailable && ipcRenderer) {
@ -113,7 +99,9 @@ const ElectronProvider = ({ children }) => {
const openInternalUrl = (url) => {
if (useElectrobun) {
getDesktopAPI().openInternalUrl(url)
void desktopBridge.openInternalUrl(url).catch((error) => {
console.warn('[ElectronContext] Failed to open internal url:', error)
})
return true
}
if (electronAvailable && ipcRenderer) {
@ -127,7 +115,7 @@ const ElectronProvider = ({ children }) => {
if (!electronAvailable) return
if (useElectrobun) {
getDesktopAPI().getOsInfo().then((info) => {
desktopBridge.getOsInfo().then((info) => {
if (info?.platform) {
setPlatform(info.platform)
if (info.platform === 'darwin') {
@ -136,16 +124,16 @@ const ElectronProvider = ({ children }) => {
}
})
getDesktopAPI().getWindowState().then(applyWindowState)
desktopBridge.getWindowState().then(applyWindowState)
const unsubWindowState = getDesktopAPI().onMessage(
const unsubWindowState = desktopBridge.onMessage(
'windowState',
applyWindowState
)
const unsubNavigate = getDesktopAPI().onMessage('navigate', (url) => {
const unsubNavigate = desktopBridge.onMessage('navigate', (url) => {
navigate(url)
})
const unsubNavigationGesture = getDesktopAPI().onMessage(
const unsubNavigationGesture = desktopBridge.onMessage(
'navigationGesture',
navigateHistory
)
@ -205,7 +193,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
@ -230,7 +219,7 @@ const ElectronProvider = ({ children }) => {
const handleWindowControl = (action) => {
if (useElectrobun) {
getDesktopAPI().windowControl(action)
void desktopBridge.windowControl(action)
return
}
if (electronAvailable && ipcRenderer) {
@ -240,7 +229,7 @@ const ElectronProvider = ({ children }) => {
const getAuthSession = async () => {
if (!electronAvailable) return null
if (useElectrobun) return await getDesktopAPI().getAuthSession()
if (useElectrobun) return await desktopBridge.getAuthSession()
if (!ipcRenderer) return null
return await ipcRenderer.invoke('auth-session-get')
}
@ -248,7 +237,7 @@ const ElectronProvider = ({ children }) => {
const setAuthSession = async (session) => {
if (!electronAvailable) return false
if (useElectrobun) {
const result = await getDesktopAPI().setAuthSession(session)
const result = await desktopBridge.setAuthSession(session)
return result?.ok ?? false
}
if (!ipcRenderer) return false
@ -258,7 +247,7 @@ const ElectronProvider = ({ children }) => {
const clearAuthSession = async () => {
if (!electronAvailable) return false
if (useElectrobun) {
const result = await getDesktopAPI().clearAuthSession()
const result = await desktopBridge.clearAuthSession()
return result?.ok ?? false
}
if (!ipcRenderer) return false
@ -267,7 +256,7 @@ const ElectronProvider = ({ children }) => {
const getAppSettings = useCallback(async () => {
if (!electronAvailable) return {}
if (useElectrobun) return await getDesktopAPI().getAppSettings()
if (useElectrobun) return await desktopBridge.getAppSettings()
if (!ipcRenderer) return {}
return await ipcRenderer.invoke('app-settings-get')
}, [electronAvailable, useElectrobun])
@ -276,7 +265,7 @@ const ElectronProvider = ({ children }) => {
async (settings) => {
if (!electronAvailable) return false
if (useElectrobun) {
const result = await getDesktopAPI().setAppSettings(settings)
const result = await desktopBridge.setAppSettings(settings)
return result?.ok ?? false
}
if (!ipcRenderer) return false
@ -289,7 +278,7 @@ const ElectronProvider = ({ children }) => {
async (update) => {
if (!electronAvailable) return false
if (useElectrobun) {
const result = await getDesktopAPI().startAppUpdate(update)
const result = await desktopBridge.startAppUpdate(update)
return result?.ok ?? false
}
if (!ipcRenderer) return false
@ -305,7 +294,7 @@ const ElectronProvider = ({ children }) => {
}
if (useElectrobun) {
return getDesktopAPI().onMessage('appUpdateProgress', handler)
return desktopBridge.onMessage('appUpdateProgress', handler)
}
if (!ipcRenderer) return () => {}
@ -337,7 +326,7 @@ const ElectronProvider = ({ children }) => {
if (!electronAvailable) return false
try {
if (useElectrobun) {
const result = await getDesktopAPI().resizeSpotlightWindow(height)
const result = await desktopBridge.resizeSpotlightWindow(height)
return result?.ok ?? false
}
if (!ipcRenderer) return false
@ -355,7 +344,7 @@ const ElectronProvider = ({ children }) => {
async (sections) => {
if (!electronAvailable) return false
if (useElectrobun) {
const result = await getDesktopAPI().setSidebarViewMenu(sections)
const result = await desktopBridge.setSidebarViewMenu(sections)
return result?.ok ?? false
}
if (!ipcRenderer) return false
@ -366,7 +355,7 @@ const ElectronProvider = ({ children }) => {
const getElectronVersion = useCallback(async () => {
if (!electronAvailable) return null
if (useElectrobun) return await getDesktopAPI().getAppVersion()
if (useElectrobun) return await desktopBridge.getAppVersion()
if (!ipcRenderer) return null
return await ipcRenderer.invoke('electron-version')
}, [electronAvailable, useElectrobun])

View File

@ -3,8 +3,6 @@ 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() {
@ -57,10 +55,6 @@ export function applyMacOSWindowEffects(mainWindow) {
setWindowCornerRadius: {
args: [FFIType.ptr, FFIType.f64],
returns: FFIType.bool
},
setWindowTrafficLightsPosition: {
args: [FFIType.ptr, FFIType.f64, FFIType.f64],
returns: FFIType.bool
}
})
@ -69,32 +63,16 @@ export function applyMacOSWindowEffects(mainWindow) {
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 = () => {
mainWindow.on?.('resize', () => {
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})`
`macOS vibrancy applied (vibrancy=${vibrancyEnabled}, shadow=${shadowEnabled}, cornerRadius=${MAC_WINDOW_CORNER_RADIUS})`
)
} catch (error) {
console.warn(

View File

@ -1,22 +1,22 @@
import { BrowserView } from "electrobun/bun";
import { startAppUpdate } from "./appupdate.js";
import { setSidebarViewMenu } from "./menu.js";
import { sendToRenderer } from "./notify.js";
import { resizeSpotlightWindow } from "./spotlight.js";
import { BrowserView } from 'electrobun/bun'
import { startAppUpdate } from './appupdate.js'
import { setSidebarViewMenu } from './menu.js'
import { sendToRenderer } from './notify.js'
import { resizeSpotlightWindow } from './spotlight.js'
import {
clearAuthSession,
getAppSettings,
getAuthSession,
setAppSettings,
setAuthSession,
} from "./store.js";
setAuthSession
} from './store.js'
import {
getMainWindow,
getWindowState,
handleWindowControl,
openExternalUrl,
openInternalUrl,
} from "./window.js";
openInternalUrl
} from './window.js'
export function createAppRpc() {
return BrowserView.defineRPC({
@ -24,52 +24,53 @@ export function createAppRpc() {
handlers: {
requests: {
getOsInfo: async () => ({
platform: process.platform,
platform: process.platform
}),
getWindowState: async () => getWindowState(),
windowControl: async ({ action }) => {
handleWindowControl(action);
return { ok: true };
handleWindowControl(action)
return { ok: true }
},
openExternalUrl: async ({ url }) => {
openExternalUrl(url);
return { ok: true };
openExternalUrl(url)
console.log('openExternalUrl', url)
return { ok: true }
},
openInternalUrl: async ({ url }) => ({
ok: openInternalUrl(url),
ok: openInternalUrl(url)
}),
getAuthSession: async () => getAuthSession(),
setAuthSession: async ({ session }) => ({
ok: await setAuthSession(session),
ok: await setAuthSession(session)
}),
clearAuthSession: async () => ({
ok: await clearAuthSession(),
ok: await clearAuthSession()
}),
getAppSettings: async () => getAppSettings(),
setAppSettings: async ({ settings }) => ({
ok: await setAppSettings(settings),
ok: await setAppSettings(settings)
}),
startAppUpdate: async ({ update }) => {
const mainWindow = getMainWindow();
const mainWindow = getMainWindow()
const sendProgress = (payload) => {
sendToRenderer("appUpdateProgress", {
sendToRenderer('appUpdateProgress', {
timestamp: new Date().toISOString(),
...payload,
});
};
...payload
})
}
await startAppUpdate(mainWindow, update, sendProgress);
return { ok: true };
await startAppUpdate(mainWindow, update, sendProgress)
return { ok: true }
},
resizeSpotlightWindow: async ({ height }) => ({
ok: resizeSpotlightWindow(height),
ok: resizeSpotlightWindow(height)
}),
setSidebarViewMenu: async ({ sections }) => ({
ok: setSidebarViewMenu(sections),
ok: setSidebarViewMenu(sections)
}),
getAppVersion: async () => process.env.ELECTROBUN_VERSION || "desktop",
getAppVersion: async () => process.env.ELECTROBUN_VERSION || 'desktop'
},
messages: {},
},
});
messages: {}
}
})
}

View File

@ -1,33 +1,33 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { BrowserWindow, GlobalShortcut } from "electrobun/bun";
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { BrowserWindow, GlobalShortcut } from 'electrobun/bun'
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const DEV_SERVER_PORT = 5780;
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
const SPOTLIGHT_ROUTE_PATH = "/dashboard/electron/spotlightcontent";
const DEV_SERVER_PORT = 5780
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
const SPOTLIGHT_ROUTE_PATH = '/dashboard/electron/spotlightcontent'
let spotlightWindow = null;
let spotlightWindow = null
function getSpotlightRouteUrl() {
if (process.env.NODE_ENV === "development") {
return `${DEV_SERVER_URL}${SPOTLIGHT_ROUTE_PATH}`;
if (process.env.NODE_ENV === 'development') {
return `${DEV_SERVER_URL}${SPOTLIGHT_ROUTE_PATH}`
}
return `views://mainview/index.html#${SPOTLIGHT_ROUTE_PATH}`;
return `views://mainview/index.html#${SPOTLIGHT_ROUTE_PATH}`
}
export function openSpotlightContentWindow(rpc) {
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.show?.();
spotlightWindow.focus?.();
return spotlightWindow;
spotlightWindow.show?.()
spotlightWindow.focus?.()
return spotlightWindow
}
spotlightWindow = new BrowserWindow({
title: "Farm Control Spotlight",
title: 'Farm Control Spotlight',
url: getSpotlightRouteUrl(),
rpc,
transparent: true,
@ -35,69 +35,72 @@ export function openSpotlightContentWindow(rpc) {
width: 700,
height: 40,
x: 100,
y: 100,
},
});
spotlightWindow.on?.("close", (event) => {
event?.preventDefault?.();
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.();
y: 100
}
});
})
spotlightWindow.on?.("blur", () => {
spotlightWindow.on?.('close', (event) => {
event?.preventDefault?.()
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.();
spotlightWindow.hide?.()
}
});
})
return spotlightWindow;
spotlightWindow.on?.('blur', () => {
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.()
}
})
return spotlightWindow
}
export function getSpotlightWindow() {
return spotlightWindow;
return spotlightWindow
}
export function registerGlobalShortcuts(rpc) {
try {
const registered = GlobalShortcut.register("Alt+Shift+Q", () => {
openSpotlightContentWindow(rpc);
});
const registered = GlobalShortcut.register('Alt+Shift+Q', () => {
openSpotlightContentWindow(rpc)
})
if (!registered) {
console.warn("[globalShortcut] Failed to register Alt+Shift+Q");
console.warn('[globalShortcut] Failed to register Alt+Shift+Q')
}
} catch (error) {
console.warn(
"[globalShortcut] Error registering Alt+Shift+Q",
error?.message || error,
);
'[globalShortcut] Error registering Alt+Shift+Q',
error?.message || error
)
}
}
export function unregisterGlobalShortcuts() {
try {
GlobalShortcut.unregisterAll();
GlobalShortcut.unregisterAll()
} catch (error) {
console.warn(
"[globalShortcut] Error unregistering shortcuts",
error?.message || error,
);
'[globalShortcut] Error unregistering shortcuts',
error?.message || error
)
}
}
export function resizeSpotlightWindow(height) {
if (!spotlightWindow || spotlightWindow.isDestroyed?.()) return false;
if (!spotlightWindow || spotlightWindow.isDestroyed?.()) return false
try {
const frame = spotlightWindow.getFrame?.() || spotlightWindow.getBounds?.();
const width = frame?.width || 700;
spotlightWindow.setSize?.(width, height);
spotlightWindow.center?.();
return true;
const frame = spotlightWindow.getFrame?.() || spotlightWindow.getBounds?.()
const width = frame?.width || 700
spotlightWindow.setSize?.(width, height)
spotlightWindow.center?.()
return true
} catch (error) {
console.warn("[spotlight] Failed to resize window.", error?.message || error);
return false;
console.warn(
'[spotlight] Failed to resize window.',
error?.message || error
)
return false
}
}

View File

@ -1,258 +1,264 @@
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";
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 isMacOS = process.platform === 'darwin'
const DEV_SERVER_PORT = 5780;
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
const PROTOCOL_PREFIX = "farmcontrol://";
const DEV_SERVER_PORT = 5780
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
const PROTOCOL_PREFIX = 'farmcontrol://'
let mainWindow = null;
let webviewDomReady = false;
const pendingNavigations = [];
let mainWindow = null
let webviewDomReady = false
const pendingNavigations = []
export function getMainWindow() {
return mainWindow;
return mainWindow
}
export async function getMainViewUrl() {
const channel = await Updater.localInfo.channel();
if (channel === "dev" || process.env.NODE_ENV === "development") {
const channel = await Updater.localInfo.channel()
if (channel === 'dev' || process.env.NODE_ENV === 'development') {
try {
await fetch(DEV_SERVER_URL, { method: "HEAD" });
console.log(`Using Vite dev server at ${DEV_SERVER_URL}`);
return DEV_SERVER_URL;
await fetch(DEV_SERVER_URL, { method: 'HEAD' })
console.log(`Using Vite dev server at ${DEV_SERVER_URL}`)
return DEV_SERVER_URL
} catch {
console.warn(
"Vite dev server not running. Start it with `bun run dev:renderer`.",
);
'Vite dev server not running. Start it with `bun run dev:renderer`.'
)
}
}
return "views://mainview/index.html";
return 'views://mainview/index.html'
}
function deliverNavigation(redirectPath) {
sendToRenderer("navigate", redirectPath);
mainWindow?.show?.();
mainWindow?.activate?.();
sendToRenderer('navigate', redirectPath)
mainWindow?.show?.()
mainWindow?.activate?.()
}
function flushPendingNavigations() {
if (!mainWindow || !webviewDomReady) {
return;
return
}
while (pendingNavigations.length > 0) {
const redirectPath = pendingNavigations.shift();
setTimeout(() => deliverNavigation(redirectPath), 100);
const redirectPath = pendingNavigations.shift()
setTimeout(() => deliverNavigation(redirectPath), 100)
}
}
function sendNavigateToRenderer(redirectPath) {
if (!redirectPath || typeof redirectPath !== "string") {
return;
if (!redirectPath || typeof redirectPath !== 'string') {
return
}
if (!mainWindow || !webviewDomReady) {
pendingNavigations.push(redirectPath);
return;
pendingNavigations.push(redirectPath)
return
}
setTimeout(() => deliverNavigation(redirectPath), 100);
setTimeout(() => deliverNavigation(redirectPath), 100)
}
export function handleDeepLink(url) {
if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return;
const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, "") || "/";
sendNavigateToRenderer(redirectPath);
if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return
const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, '') || '/'
sendNavigateToRenderer(redirectPath)
}
function findProtocolUrl(args) {
return args.find(
(arg) => typeof arg === "string" && arg.startsWith(PROTOCOL_PREFIX),
);
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
)
}
export function handleDeepLinkFromArgv() {
if (process.platform === "darwin") return;
const url = findProtocolUrl(process.argv);
if (url) handleDeepLink(url);
if (process.platform === 'darwin') return
const url = findProtocolUrl(process.argv)
if (url) handleDeepLink(url)
}
function broadcastWindowState() {
sendToRenderer('windowState', getWindowState())
}
function setupWindowEvents(window) {
window.on?.("maximize", () => {
sendToRenderer("windowState", { isMaximized: true });
});
window.on?.("unmaximize", () => {
sendToRenderer("windowState", { isMaximized: false });
});
window.on?.("enter-full-screen", () => {
sendToRenderer("windowState", { isFullScreen: true });
});
window.on?.("leave-full-screen", () => {
sendToRenderer("windowState", { isFullScreen: false });
});
// Electrobun emits resize/focus, not Electron's maximize/fullscreen events.
window.on?.('resize', broadcastWindowState)
window.on?.('focus', broadcastWindowState)
}
export function setupMainWindowMessaging(window = mainWindow) {
if (!window) {
return;
return
}
setMessageSender((channel, data) => {
try {
const send = window.webview?.rpc?.send;
if (!send) {
console.warn(
`No RPC sender available for channel: ${channel}. Is the window ready?`,
);
return false;
const webview = window.webview
const channelLiteral = JSON.stringify(channel)
const payloadLiteral = JSON.stringify(data ?? null)
// Prefer direct JS dispatch — WebSocket RPC pushes can be deferred by
// WKWebView until the next interaction during native window transitions.
if (webview?.executeJavascript) {
webview.executeJavascript(
`window.__farmcontrolDispatchRpcMessage?.(${channelLiteral}, ${payloadLiteral})`
)
return true
}
send[channel](data);
return true;
} catch (error) {
console.warn(`Failed to send RPC message on channel: ${channel}`, error);
return false;
const send = webview?.rpc?.send
if (!send) {
console.warn(
`No RPC sender available for channel: ${channel}. Is the window ready?`
)
return false
}
});
send[channel](data)
return true
} catch (error) {
console.warn(`Failed to send RPC message on channel: ${channel}`, error)
return false
}
})
}
export async function createMainWindow(rpc) {
const url = await getMainViewUrl();
const url = await getMainViewUrl()
mainWindow = new BrowserWindow({
title: "Farm Control",
title: 'Farm Control',
url,
rpc,
titleBarStyle: "hiddenInset",
trafficLightOffset: { x: 14, y: 12 },
titleBarStyle: 'hiddenInset',
...(isMacOS ? { transparent: true } : {}),
frame: {
width: 1200,
height: 800,
x: 100,
y: 100,
},
});
y: 100
}
})
if (isMacOS) {
applyMacOSWindowEffects(mainWindow);
applyMacOSWindowEffects(mainWindow)
}
setupMainWindowMessaging(mainWindow);
applyApplicationMenu();
setupMainWindowMessaging(mainWindow)
applyApplicationMenu()
setupApplicationMenuEvents({
onNavigate: sendNavigateToRenderer,
onToggleDevTools: () => {
mainWindow?.webview?.toggleDevTools?.();
},
});
setupWindowEvents(mainWindow);
Electrobun.events.on("open-url", (event) => {
const url = event?.data?.url;
if (url) {
handleDeepLink(url);
mainWindow?.webview?.toggleDevTools?.()
}
});
})
setupWindowEvents(mainWindow)
Electrobun.events.on('open-url', (event) => {
const url = event?.data?.url
if (url) {
handleDeepLink(url)
}
})
return new Promise((resolve) => {
mainWindow.webview.on("dom-ready", () => {
webviewDomReady = true;
flushPendingNavigations();
resolve(mainWindow);
});
});
mainWindow.webview.on('dom-ready', () => {
webviewDomReady = true
flushPendingNavigations()
resolve(mainWindow)
})
})
}
export async function setupDevAuthServer() {
const env = (process.env.NODE_ENV || "development").trim();
if (env !== "development") return;
const env = (process.env.NODE_ENV || 'development').trim()
if (env !== 'development') return
const express = (await import("express")).default;
const app = express();
const port = 3500;
const express = (await import('express')).default
const app = express()
const port = 3500
app.use((req, res) => {
const redirectPath = req.originalUrl;
res.send(
`Open Farmcontrol to continue... (Redirect path: ${redirectPath})`,
);
sendNavigateToRenderer(redirectPath);
});
const redirectPath = req.originalUrl
res.send(`Open Farmcontrol to continue... (Redirect path: ${redirectPath})`)
sendNavigateToRenderer(redirectPath)
})
app.listen(port, () => {});
app.listen(port, () => {})
}
export function openInternalUrl(url) {
sendNavigateToRenderer(url);
return true;
sendNavigateToRenderer(url)
return true
}
export function getWindowState() {
if (!mainWindow) {
return { isFullScreen: false, isMaximized: false };
return { isFullScreen: false, isMaximized: false }
}
return {
isFullScreen: mainWindow.isFullScreen?.() ?? false,
isMaximized: mainWindow.isMaximized?.() ?? false,
};
isMaximized: mainWindow.isMaximized?.() ?? false
}
}
export function handleWindowControl(action) {
if (!mainWindow) return;
if (!mainWindow) return
switch (action) {
case "minimize":
mainWindow.minimize?.();
break;
case "maximize":
case 'minimize':
mainWindow.minimize?.()
break
case 'maximize':
if (mainWindow.isMaximized?.()) {
mainWindow.unmaximize?.();
mainWindow.unmaximize?.()
} else {
mainWindow.maximize?.();
mainWindow.maximize?.()
}
break;
case "close":
mainWindow.close?.();
break;
break
case 'close':
mainWindow.close?.()
break
default:
break;
break
}
}
export function sendNavigationGesture(direction) {
sendToRenderer("navigationGesture", direction);
sendToRenderer('navigationGesture', direction)
}
export function setupNavigationGestures(window) {
if (!window) return;
if (!window) return
if (process.platform === "darwin") {
window.on?.("swipe", (_event, direction) => {
if (direction === "left") {
sendNavigationGesture("back");
} else if (direction === "right") {
sendNavigationGesture("forward");
if (process.platform === 'darwin') {
window.on?.('swipe', (_event, direction) => {
if (direction === 'left') {
sendNavigationGesture('back')
} else if (direction === 'right') {
sendNavigationGesture('forward')
}
});
})
}
window.on?.("app-command", (_event, command) => {
if (command === "browser-backward") {
sendNavigationGesture("back");
} else if (command === "browser-forward") {
sendNavigationGesture("forward");
window.on?.('app-command', (_event, command) => {
if (command === 'browser-backward') {
sendNavigationGesture('back')
} else if (command === 'browser-forward') {
sendNavigationGesture('forward')
}
});
})
}
export function openExternalUrl(url) {
Utils.openExternal(url);
console.log('openExternalUrl', url)
Utils.openExternal(url)
}

View File

@ -1,173 +1,191 @@
const listeners = new Map();
const pendingMessages = new Map();
let rpc = null;
let initPromise = null;
let initialized = false;
const listeners = new Map()
const pendingMessages = new Map()
let rpc = null
let initPromise = null
let initialized = false
export function isElectrobunDesktop() {
return Boolean(
typeof window !== "undefined" &&
typeof window !== 'undefined' &&
window.__electrobunWebviewId &&
window.__electrobunRpcSocketPort,
);
window.__electrobunRpcSocketPort
)
}
function dispatchMessage(channel, data) {
const channelListeners = listeners.get(channel);
const channelListeners = listeners.get(channel)
if (!channelListeners?.size) {
if (!pendingMessages.has(channel)) {
pendingMessages.set(channel, []);
pendingMessages.set(channel, [])
}
pendingMessages.get(channel).push(data);
return;
pendingMessages.get(channel).push(data)
return
}
for (const callback of channelListeners) {
callback(data);
callback(data)
}
}
// Bun delivers push messages via executeJavascript for immediate handling.
// WebSocket RPC messages can be deferred by WKWebView during native window
// transitions (e.g. fullscreen) until the next user interaction.
if (typeof window !== 'undefined') {
window.__farmcontrolDispatchRpcMessage = dispatchMessage
}
async function setupRpc() {
// electrobun/view captures webview globals at import time — import only after
// Electrobun preload has set them (Vite dev can evaluate modules earlier).
const { default: Electrobun, Electroview } = await import("electrobun/view");
const { default: Electrobun, Electroview } = await import('electrobun/view')
rpc = Electroview.defineRPC({
maxRequestTime: 30000,
handlers: {
requests: {},
messages: {
"*": (channel, data) => {
dispatchMessage(channel, data);
},
},
},
});
'*': (channel, data) => {
dispatchMessage(channel, data)
}
}
}
})
new Electrobun.Electroview({ rpc });
new Electrobun.Electroview({ rpc })
}
function shouldWaitForElectrobun() {
if (isElectrobunDesktop()) {
return true;
return true
}
if (typeof window === "undefined") {
return false;
if (typeof window === 'undefined') {
return false
}
if (window.__electrobunWebviewId) {
return true;
return true
}
const { protocol, hostname, port } = window.location;
const { protocol, hostname, port } = window.location
if (protocol === "views:") {
return true;
if (protocol === 'views:') {
return true
}
// Vite dev server used by electrobun dev:app
if (hostname === "localhost" && port === "5780") {
return true;
if (hostname === 'localhost' && port === '5780') {
return true
}
return Boolean(
window.__electrobun ||
window.__electrobunEventBridge ||
window.__electrobunInternalBridge,
);
window.__electrobunInternalBridge
)
}
export async function initElectrobunBridge() {
if (initialized) {
return;
return
}
if (!initPromise) {
initPromise = (async () => {
if (!shouldWaitForElectrobun()) {
return;
return
}
const deadline = Date.now() + 3000;
const deadline = Date.now() + 3000
while (Date.now() < deadline) {
if (isElectrobunDesktop()) {
await setupRpc();
window.electronAPI = electronAPI;
initialized = true;
return;
await setupRpc()
window.electronAPI = electronAPI
initialized = true
return
}
await new Promise((resolve) => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50))
}
console.warn(
"Electrobun bridge: webview globals not found; desktop RPC unavailable.",
);
})();
'Electrobun bridge: webview globals not found; desktop RPC unavailable.'
)
})()
}
await initPromise;
await initPromise
}
function onMessage(channel, callback) {
if (!listeners.has(channel)) {
listeners.set(channel, new Set());
}
listeners.get(channel).add(callback);
void initElectrobunBridge()
const queued = pendingMessages.get(channel);
if (!listeners.has(channel)) {
listeners.set(channel, new Set())
}
listeners.get(channel).add(callback)
const queued = pendingMessages.get(channel)
if (queued?.length) {
pendingMessages.delete(channel);
pendingMessages.delete(channel)
for (const payload of queued) {
callback(payload);
callback(payload)
}
}
return () => {
listeners.get(channel)?.delete(callback);
};
listeners.get(channel)?.delete(callback)
}
}
function removeAllListeners(channel) {
listeners.delete(channel);
pendingMessages.delete(channel);
listeners.delete(channel)
pendingMessages.delete(channel)
}
async function invokeRequest(method, params = {}) {
await initElectrobunBridge();
await initElectrobunBridge()
if (!rpc?.request?.[method]) {
console.warn(`Unhandled RPC request: ${method}`);
return null;
if (!rpc?.request) {
console.warn(`Electrobun RPC unavailable for request: ${method}`)
return null
}
return await rpc.request[method](params);
try {
return await rpc.request[method](params)
} catch (error) {
console.warn(`Electrobun RPC request failed: ${method}`, error)
return null
}
}
const electronAPI = {
get isDesktop() {
return isElectrobunDesktop();
return isElectrobunDesktop()
},
onMessage,
removeAllListeners,
getOsInfo: () => invokeRequest("getOsInfo"),
getWindowState: () => invokeRequest("getWindowState"),
windowControl: (action) => invokeRequest("windowControl", { action }),
openExternalUrl: (url) => invokeRequest("openExternalUrl", { url }),
openInternalUrl: (url) => invokeRequest("openInternalUrl", { url }),
getAuthSession: () => invokeRequest("getAuthSession"),
setAuthSession: (session) => invokeRequest("setAuthSession", { session }),
clearAuthSession: () => invokeRequest("clearAuthSession"),
getAppSettings: () => invokeRequest("getAppSettings"),
setAppSettings: (settings) => invokeRequest("setAppSettings", { settings }),
startAppUpdate: (update) => invokeRequest("startAppUpdate", { update }),
getOsInfo: () => invokeRequest('getOsInfo'),
getWindowState: () => invokeRequest('getWindowState'),
windowControl: (action) => invokeRequest('windowControl', { action }),
openExternalUrl: (url) => invokeRequest('openExternalUrl', { url }),
openInternalUrl: (url) => invokeRequest('openInternalUrl', { url }),
getAuthSession: () => invokeRequest('getAuthSession'),
setAuthSession: (session) => invokeRequest('setAuthSession', { session }),
clearAuthSession: () => invokeRequest('clearAuthSession'),
getAppSettings: () => invokeRequest('getAppSettings'),
setAppSettings: (settings) => invokeRequest('setAppSettings', { settings }),
startAppUpdate: (update) => invokeRequest('startAppUpdate', { update }),
resizeSpotlightWindow: (height) =>
invokeRequest("resizeSpotlightWindow", { height }),
invokeRequest('resizeSpotlightWindow', { height }),
setSidebarViewMenu: (sections) =>
invokeRequest("setSidebarViewMenu", { sections }),
getAppVersion: () => invokeRequest("getAppVersion"),
};
invokeRequest('setSidebarViewMenu', { sections }),
getAppVersion: () => invokeRequest('getAppVersion')
}
export default electronAPI;
if (typeof window !== 'undefined') {
window.electronAPI = electronAPI
}
export default electronAPI