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 { 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 { 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 { html.macos-vibrancy .light-mode .ant-layout-content {
@ -145,6 +145,15 @@ html.macos-vibrancy .electron-sider .ant-menu-light {
background: transparent !important; 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 { .electron-navigation {
line-height: 40px; line-height: 40px;
} }
@ -153,6 +162,10 @@ html.macos-vibrancy .electron-sider .ant-menu-light {
padding-inline: 10px; padding-inline: 10px;
} }
.electron-navigation.ant-menu-horizontal .ant-menu-item {
padding-inline: 12px;
}
.electron-sidebar .ant-menu-item, .electron-sidebar .ant-menu-item,
.electron-sidebar .ant-menu-submenu-title { .electron-sidebar .ant-menu-submenu-title {
height: 32.5px !important; height: 32.5px !important;

View File

@ -1,4 +1,5 @@
#import <Cocoa/Cocoa.h> #import <Cocoa/Cocoa.h>
#import <objc/runtime.h>
static NSString *const kElectrobunVibrancyViewIdentifier = static NSString *const kElectrobunVibrancyViewIdentifier =
@"ElectrobunVibrancyView"; @"ElectrobunVibrancyView";
@ -8,6 +9,10 @@ static NSString *const kElectrobunWindowBorderViewIdentifier =
@"ElectrobunWindowBorderView"; @"ElectrobunWindowBorderView";
static CGFloat gWindowCornerRadius = 15.0; static CGFloat gWindowCornerRadius = 15.0;
static CGFloat gTrafficLightsX = 16.0;
static CGFloat gTrafficLightsY = 12.0;
static const void *kElectrobunChromeObserverKey = &kElectrobunChromeObserverKey;
@interface ElectrobunWindowBorderView : NSView @interface ElectrobunWindowBorderView : NSView
@property(nonatomic) CGFloat cornerRadius; @property(nonatomic) CGFloat cornerRadius;
@ -94,6 +99,58 @@ static CGFloat gWindowCornerRadius = 15.0;
} }
@end @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) { static NSVisualEffectView *findVibrancyView(NSView *contentView) {
for (NSView *subview in [contentView subviews]) { for (NSView *subview in [contentView subviews]) {
if ([subview isKindOfClass:[NSVisualEffectView class]] && if ([subview isKindOfClass:[NSVisualEffectView class]] &&
@ -130,8 +187,13 @@ static ElectrobunWindowBorderView *findWindowBorderView(NSView *contentView) {
return nil; return nil;
} }
static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) { static BOOL isWindowFullScreen(NSWindow *window) {
gWindowCornerRadius = MAX(0.0, radius); return (window.styleMask & NSWindowStyleMaskFullScreen) != 0;
}
static void refreshWindowChrome(NSWindow *window) {
BOOL fullScreen = isWindowFullScreen(window);
CGFloat radius = fullScreen ? 0.0 : gWindowCornerRadius;
NSView *contentView = [window contentView]; NSView *contentView = [window contentView];
if (contentView == nil) { if (contentView == nil) {
@ -139,23 +201,31 @@ static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) {
} }
contentView.wantsLayer = YES; contentView.wantsLayer = YES;
contentView.layer.cornerRadius = gWindowCornerRadius; contentView.layer.cornerRadius = radius;
contentView.layer.masksToBounds = YES; contentView.layer.masksToBounds = YES;
NSVisualEffectView *effectView = findVibrancyView(contentView); NSVisualEffectView *effectView = findVibrancyView(contentView);
if (effectView != nil) { if (effectView != nil) {
effectView.wantsLayer = YES; effectView.wantsLayer = YES;
effectView.layer.cornerRadius = gWindowCornerRadius; effectView.layer.cornerRadius = radius;
effectView.layer.masksToBounds = YES; effectView.layer.masksToBounds = YES;
} }
ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView); ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView);
if (borderView != nil) { if (borderView != nil) {
borderView.cornerRadius = gWindowCornerRadius; borderView.hidden = fullScreen;
[borderView setNeedsDisplay:YES]; 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) { static void ensureWindowBorder(NSWindow *window) {
NSView *contentView = [window contentView]; NSView *contentView = [window contentView];
if (contentView == nil) { if (contentView == nil) {
@ -171,7 +241,6 @@ static void ensureWindowBorder(NSWindow *window) {
setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)]; setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)];
} }
borderView.cornerRadius = gWindowCornerRadius;
[borderView setFrame:[contentView bounds]]; [borderView setFrame:[contentView bounds]];
if ([borderView superview] == nil) { if ([borderView superview] == nil) {
@ -185,7 +254,76 @@ static void ensureWindowBorder(NSWindow *window) {
relativeTo:nil]; 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) { 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, *)) { if (@available(macOS 10.14, *)) {
[effectView setMaterial:NSVisualEffectMaterialUnderWindowBackground]; [effectView setMaterial:NSVisualEffectMaterialHUDWindow];
} else { } else {
[effectView setMaterial:NSVisualEffectMaterialSidebar]; [effectView setMaterial:NSVisualEffectMaterialSidebar];
} }
@ -243,6 +381,8 @@ extern "C" bool enableWindowVibrancy(void *windowPtr, double cornerRadius) {
ensureWindowBorder(window); ensureWindowBorder(window);
applyWindowCornerRadius(window, gWindowCornerRadius); applyWindowCornerRadius(window, gWindowCornerRadius);
ensureTrafficLightsObserver(window);
scheduleTrafficLightsPosition(window, 0);
[window invalidateShadow]; [window invalidateShadow];
success = YES; success = YES;
@ -307,45 +447,13 @@ extern "C" bool setWindowTrafficLightsPosition(void *windowPtr, double x,
return; return;
} }
NSButton *closeButton = gTrafficLightsX = (CGFloat)x;
[window standardWindowButton:NSWindowCloseButton]; gTrafficLightsY = (CGFloat)yFromTop;
NSButton *minimizeButton = ensureTrafficLightsObserver(window);
[window standardWindowButton:NSWindowMiniaturizeButton]; success = applyTrafficLightsPosition(window, gTrafficLightsX, gTrafficLightsY);
NSButton *zoomButton = [window standardWindowButton:NSWindowZoomButton]; if (success) {
[window invalidateShadow];
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; return success;

View File

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

View File

@ -1,5 +1,5 @@
import { useContext } from 'react' import { useContext } from 'react'
import { Flex, Button } from 'antd' import { Flex, Button, Divider } from 'antd'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import MinusIcon from '../../Icons/MinusIcon' import MinusIcon from '../../Icons/MinusIcon'
@ -36,7 +36,13 @@ const DashboardWindowButtons = () => {
<Flex align='center'> <Flex align='center'>
{platform == 'darwin' ? ( {platform == 'darwin' ? (
isFullScreen == false ? ( isFullScreen == false ? (
<div style={{ width: '80px' }} /> <>
<div style={{ width: '82px' }} />
<Divider
type='vertical'
style={{ height: '14px', margin: '3px 6px 0 0' }}
/>
</>
) : null ) : null
) : ( ) : (
<div <div

View File

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

View File

@ -3,8 +3,6 @@ import { existsSync, statSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
const MAC_TRAFFIC_LIGHTS_X = 14
const MAC_TRAFFIC_LIGHTS_Y = 12
export const MAC_WINDOW_CORNER_RADIUS = 15 export const MAC_WINDOW_CORNER_RADIUS = 15
function resolveDylibPath() { function resolveDylibPath() {
@ -57,10 +55,6 @@ export function applyMacOSWindowEffects(mainWindow) {
setWindowCornerRadius: { setWindowCornerRadius: {
args: [FFIType.ptr, FFIType.f64], args: [FFIType.ptr, FFIType.f64],
returns: FFIType.bool 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 MAC_WINDOW_CORNER_RADIUS
) )
const shadowEnabled = lib.symbols.ensureWindowShadow(mainWindow.ptr) const shadowEnabled = lib.symbols.ensureWindowShadow(mainWindow.ptr)
const alignButtons = () =>
lib.symbols.setWindowTrafficLightsPosition( mainWindow.on?.('resize', () => {
mainWindow.ptr,
MAC_TRAFFIC_LIGHTS_X,
MAC_TRAFFIC_LIGHTS_Y
)
const refreshWindowChrome = () => {
lib.symbols.setWindowCornerRadius( lib.symbols.setWindowCornerRadius(
mainWindow.ptr, mainWindow.ptr,
MAC_WINDOW_CORNER_RADIUS MAC_WINDOW_CORNER_RADIUS
) )
alignButtons()
}
const buttonsAlignedNow = alignButtons()
setTimeout(() => {
refreshWindowChrome()
}, 120)
mainWindow.on?.('resize', () => {
refreshWindowChrome()
}) })
console.log( 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) { } catch (error) {
console.warn( console.warn(

View File

@ -1,22 +1,22 @@
import { BrowserView } from "electrobun/bun"; import { BrowserView } from 'electrobun/bun'
import { startAppUpdate } from "./appupdate.js"; import { startAppUpdate } from './appupdate.js'
import { setSidebarViewMenu } from "./menu.js"; import { setSidebarViewMenu } from './menu.js'
import { sendToRenderer } from "./notify.js"; import { sendToRenderer } from './notify.js'
import { resizeSpotlightWindow } from "./spotlight.js"; import { resizeSpotlightWindow } from './spotlight.js'
import { import {
clearAuthSession, clearAuthSession,
getAppSettings, getAppSettings,
getAuthSession, getAuthSession,
setAppSettings, setAppSettings,
setAuthSession, setAuthSession
} from "./store.js"; } from './store.js'
import { import {
getMainWindow, getMainWindow,
getWindowState, getWindowState,
handleWindowControl, handleWindowControl,
openExternalUrl, openExternalUrl,
openInternalUrl, openInternalUrl
} from "./window.js"; } from './window.js'
export function createAppRpc() { export function createAppRpc() {
return BrowserView.defineRPC({ return BrowserView.defineRPC({
@ -24,52 +24,53 @@ export function createAppRpc() {
handlers: { handlers: {
requests: { requests: {
getOsInfo: async () => ({ getOsInfo: async () => ({
platform: process.platform, platform: process.platform
}), }),
getWindowState: async () => getWindowState(), getWindowState: async () => getWindowState(),
windowControl: async ({ action }) => { windowControl: async ({ action }) => {
handleWindowControl(action); handleWindowControl(action)
return { ok: true }; return { ok: true }
}, },
openExternalUrl: async ({ url }) => { openExternalUrl: async ({ url }) => {
openExternalUrl(url); openExternalUrl(url)
return { ok: true }; console.log('openExternalUrl', url)
return { ok: true }
}, },
openInternalUrl: async ({ url }) => ({ openInternalUrl: async ({ url }) => ({
ok: openInternalUrl(url), ok: openInternalUrl(url)
}), }),
getAuthSession: async () => getAuthSession(), getAuthSession: async () => getAuthSession(),
setAuthSession: async ({ session }) => ({ setAuthSession: async ({ session }) => ({
ok: await setAuthSession(session), ok: await setAuthSession(session)
}), }),
clearAuthSession: async () => ({ clearAuthSession: async () => ({
ok: await clearAuthSession(), ok: await clearAuthSession()
}), }),
getAppSettings: async () => getAppSettings(), getAppSettings: async () => getAppSettings(),
setAppSettings: async ({ settings }) => ({ setAppSettings: async ({ settings }) => ({
ok: await setAppSettings(settings), ok: await setAppSettings(settings)
}), }),
startAppUpdate: async ({ update }) => { startAppUpdate: async ({ update }) => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow()
const sendProgress = (payload) => { const sendProgress = (payload) => {
sendToRenderer("appUpdateProgress", { sendToRenderer('appUpdateProgress', {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...payload, ...payload
}); })
}; }
await startAppUpdate(mainWindow, update, sendProgress); await startAppUpdate(mainWindow, update, sendProgress)
return { ok: true }; return { ok: true }
}, },
resizeSpotlightWindow: async ({ height }) => ({ resizeSpotlightWindow: async ({ height }) => ({
ok: resizeSpotlightWindow(height), ok: resizeSpotlightWindow(height)
}), }),
setSidebarViewMenu: async ({ sections }) => ({ 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 path from 'node:path'
import { fileURLToPath } from "node:url"; import { fileURLToPath } from 'node:url'
import { BrowserWindow, GlobalShortcut } from "electrobun/bun"; import { BrowserWindow, GlobalShortcut } from 'electrobun/bun'
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename)
const DEV_SERVER_PORT = 5780; const DEV_SERVER_PORT = 5780
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
const SPOTLIGHT_ROUTE_PATH = "/dashboard/electron/spotlightcontent"; const SPOTLIGHT_ROUTE_PATH = '/dashboard/electron/spotlightcontent'
let spotlightWindow = null; let spotlightWindow = null
function getSpotlightRouteUrl() { function getSpotlightRouteUrl() {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === 'development') {
return `${DEV_SERVER_URL}${SPOTLIGHT_ROUTE_PATH}`; 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) { export function openSpotlightContentWindow(rpc) {
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) { if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.show?.(); spotlightWindow.show?.()
spotlightWindow.focus?.(); spotlightWindow.focus?.()
return spotlightWindow; return spotlightWindow
} }
spotlightWindow = new BrowserWindow({ spotlightWindow = new BrowserWindow({
title: "Farm Control Spotlight", title: 'Farm Control Spotlight',
url: getSpotlightRouteUrl(), url: getSpotlightRouteUrl(),
rpc, rpc,
transparent: true, transparent: true,
@ -35,69 +35,72 @@ export function openSpotlightContentWindow(rpc) {
width: 700, width: 700,
height: 40, height: 40,
x: 100, x: 100,
y: 100, y: 100
},
});
spotlightWindow.on?.("close", (event) => {
event?.preventDefault?.();
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.();
} }
}); })
spotlightWindow.on?.("blur", () => { spotlightWindow.on?.('close', (event) => {
event?.preventDefault?.()
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) { if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.(); spotlightWindow.hide?.()
} }
}); })
return spotlightWindow; spotlightWindow.on?.('blur', () => {
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.()
}
})
return spotlightWindow
} }
export function getSpotlightWindow() { export function getSpotlightWindow() {
return spotlightWindow; return spotlightWindow
} }
export function registerGlobalShortcuts(rpc) { export function registerGlobalShortcuts(rpc) {
try { try {
const registered = GlobalShortcut.register("Alt+Shift+Q", () => { const registered = GlobalShortcut.register('Alt+Shift+Q', () => {
openSpotlightContentWindow(rpc); openSpotlightContentWindow(rpc)
}); })
if (!registered) { if (!registered) {
console.warn("[globalShortcut] Failed to register Alt+Shift+Q"); console.warn('[globalShortcut] Failed to register Alt+Shift+Q')
} }
} catch (error) { } catch (error) {
console.warn( console.warn(
"[globalShortcut] Error registering Alt+Shift+Q", '[globalShortcut] Error registering Alt+Shift+Q',
error?.message || error, error?.message || error
); )
} }
} }
export function unregisterGlobalShortcuts() { export function unregisterGlobalShortcuts() {
try { try {
GlobalShortcut.unregisterAll(); GlobalShortcut.unregisterAll()
} catch (error) { } catch (error) {
console.warn( console.warn(
"[globalShortcut] Error unregistering shortcuts", '[globalShortcut] Error unregistering shortcuts',
error?.message || error, error?.message || error
); )
} }
} }
export function resizeSpotlightWindow(height) { export function resizeSpotlightWindow(height) {
if (!spotlightWindow || spotlightWindow.isDestroyed?.()) return false; if (!spotlightWindow || spotlightWindow.isDestroyed?.()) return false
try { try {
const frame = spotlightWindow.getFrame?.() || spotlightWindow.getBounds?.(); const frame = spotlightWindow.getFrame?.() || spotlightWindow.getBounds?.()
const width = frame?.width || 700; const width = frame?.width || 700
spotlightWindow.setSize?.(width, height); spotlightWindow.setSize?.(width, height)
spotlightWindow.center?.(); spotlightWindow.center?.()
return true; return true
} catch (error) { } catch (error) {
console.warn("[spotlight] Failed to resize window.", error?.message || error); console.warn(
return false; '[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 Electrobun, { BrowserWindow, Updater, Utils } from 'electrobun/bun'
import { applyApplicationMenu, setupApplicationMenuEvents } from "./menu.js"; import { applyApplicationMenu, setupApplicationMenuEvents } from './menu.js'
import { applyMacOSWindowEffects } from "./macos-window-effects.js"; import { applyMacOSWindowEffects } from './macos-window-effects.js'
import { sendToRenderer, setMessageSender } from "./notify.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_PORT = 5780
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
const PROTOCOL_PREFIX = "farmcontrol://"; const PROTOCOL_PREFIX = 'farmcontrol://'
let mainWindow = null; let mainWindow = null
let webviewDomReady = false; let webviewDomReady = false
const pendingNavigations = []; const pendingNavigations = []
export function getMainWindow() { export function getMainWindow() {
return mainWindow; return mainWindow
} }
export async function getMainViewUrl() { export async function getMainViewUrl() {
const channel = await Updater.localInfo.channel(); const channel = await Updater.localInfo.channel()
if (channel === "dev" || process.env.NODE_ENV === "development") { if (channel === 'dev' || process.env.NODE_ENV === 'development') {
try { try {
await fetch(DEV_SERVER_URL, { method: "HEAD" }); await fetch(DEV_SERVER_URL, { method: 'HEAD' })
console.log(`Using Vite dev server at ${DEV_SERVER_URL}`); console.log(`Using Vite dev server at ${DEV_SERVER_URL}`)
return DEV_SERVER_URL; return DEV_SERVER_URL
} catch { } catch {
console.warn( 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) { function deliverNavigation(redirectPath) {
sendToRenderer("navigate", redirectPath); sendToRenderer('navigate', redirectPath)
mainWindow?.show?.(); mainWindow?.show?.()
mainWindow?.activate?.(); mainWindow?.activate?.()
} }
function flushPendingNavigations() { function flushPendingNavigations() {
if (!mainWindow || !webviewDomReady) { if (!mainWindow || !webviewDomReady) {
return; return
} }
while (pendingNavigations.length > 0) { while (pendingNavigations.length > 0) {
const redirectPath = pendingNavigations.shift(); const redirectPath = pendingNavigations.shift()
setTimeout(() => deliverNavigation(redirectPath), 100); setTimeout(() => deliverNavigation(redirectPath), 100)
} }
} }
function sendNavigateToRenderer(redirectPath) { function sendNavigateToRenderer(redirectPath) {
if (!redirectPath || typeof redirectPath !== "string") { if (!redirectPath || typeof redirectPath !== 'string') {
return; return
} }
if (!mainWindow || !webviewDomReady) { if (!mainWindow || !webviewDomReady) {
pendingNavigations.push(redirectPath); pendingNavigations.push(redirectPath)
return; return
} }
setTimeout(() => deliverNavigation(redirectPath), 100); setTimeout(() => deliverNavigation(redirectPath), 100)
} }
export function handleDeepLink(url) { export function handleDeepLink(url) {
if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return; if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return
const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, "") || "/"; const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, '') || '/'
sendNavigateToRenderer(redirectPath); sendNavigateToRenderer(redirectPath)
} }
function findProtocolUrl(args) { function findProtocolUrl(args) {
return args.find( return args.find(
(arg) => typeof arg === "string" && arg.startsWith(PROTOCOL_PREFIX), (arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
); )
} }
export function handleDeepLinkFromArgv() { export function handleDeepLinkFromArgv() {
if (process.platform === "darwin") return; if (process.platform === 'darwin') return
const url = findProtocolUrl(process.argv); const url = findProtocolUrl(process.argv)
if (url) handleDeepLink(url); if (url) handleDeepLink(url)
}
function broadcastWindowState() {
sendToRenderer('windowState', getWindowState())
} }
function setupWindowEvents(window) { function setupWindowEvents(window) {
window.on?.("maximize", () => { // Electrobun emits resize/focus, not Electron's maximize/fullscreen events.
sendToRenderer("windowState", { isMaximized: true }); window.on?.('resize', broadcastWindowState)
}); window.on?.('focus', broadcastWindowState)
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 });
});
} }
export function setupMainWindowMessaging(window = mainWindow) { export function setupMainWindowMessaging(window = mainWindow) {
if (!window) { if (!window) {
return; return
} }
setMessageSender((channel, data) => { setMessageSender((channel, data) => {
try { try {
const send = window.webview?.rpc?.send; const webview = window.webview
if (!send) { const channelLiteral = JSON.stringify(channel)
console.warn( const payloadLiteral = JSON.stringify(data ?? null)
`No RPC sender available for channel: ${channel}. Is the window ready?`,
); // Prefer direct JS dispatch — WebSocket RPC pushes can be deferred by
return false; // WKWebView until the next interaction during native window transitions.
if (webview?.executeJavascript) {
webview.executeJavascript(
`window.__farmcontrolDispatchRpcMessage?.(${channelLiteral}, ${payloadLiteral})`
)
return true
} }
send[channel](data); const send = webview?.rpc?.send
return true; if (!send) {
console.warn(
`No RPC sender available for channel: ${channel}. Is the window ready?`
)
return false
}
send[channel](data)
return true
} catch (error) { } catch (error) {
console.warn(`Failed to send RPC message on channel: ${channel}`, error); console.warn(`Failed to send RPC message on channel: ${channel}`, error)
return false; return false
} }
}); })
} }
export async function createMainWindow(rpc) { export async function createMainWindow(rpc) {
const url = await getMainViewUrl(); const url = await getMainViewUrl()
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
title: "Farm Control", title: 'Farm Control',
url, url,
rpc, rpc,
titleBarStyle: "hiddenInset", titleBarStyle: 'hiddenInset',
trafficLightOffset: { x: 14, y: 12 },
...(isMacOS ? { transparent: true } : {}), ...(isMacOS ? { transparent: true } : {}),
frame: { frame: {
width: 1200, width: 1200,
height: 800, height: 800,
x: 100, x: 100,
y: 100, y: 100
}, }
}); })
if (isMacOS) { if (isMacOS) {
applyMacOSWindowEffects(mainWindow); applyMacOSWindowEffects(mainWindow)
} }
setupMainWindowMessaging(mainWindow); setupMainWindowMessaging(mainWindow)
applyApplicationMenu(); applyApplicationMenu()
setupApplicationMenuEvents({ setupApplicationMenuEvents({
onNavigate: sendNavigateToRenderer, onNavigate: sendNavigateToRenderer,
onToggleDevTools: () => { onToggleDevTools: () => {
mainWindow?.webview?.toggleDevTools?.(); mainWindow?.webview?.toggleDevTools?.()
},
});
setupWindowEvents(mainWindow);
Electrobun.events.on("open-url", (event) => {
const url = event?.data?.url;
if (url) {
handleDeepLink(url);
} }
}); })
setupWindowEvents(mainWindow)
Electrobun.events.on('open-url', (event) => {
const url = event?.data?.url
if (url) {
handleDeepLink(url)
}
})
return new Promise((resolve) => { return new Promise((resolve) => {
mainWindow.webview.on("dom-ready", () => { mainWindow.webview.on('dom-ready', () => {
webviewDomReady = true; webviewDomReady = true
flushPendingNavigations(); flushPendingNavigations()
resolve(mainWindow); resolve(mainWindow)
}); })
}); })
} }
export async function setupDevAuthServer() { export async function setupDevAuthServer() {
const env = (process.env.NODE_ENV || "development").trim(); const env = (process.env.NODE_ENV || 'development').trim()
if (env !== "development") return; if (env !== 'development') return
const express = (await import("express")).default; const express = (await import('express')).default
const app = express(); const app = express()
const port = 3500; const port = 3500
app.use((req, res) => { app.use((req, res) => {
const redirectPath = req.originalUrl; const redirectPath = req.originalUrl
res.send( res.send(`Open Farmcontrol to continue... (Redirect path: ${redirectPath})`)
`Open Farmcontrol to continue... (Redirect path: ${redirectPath})`, sendNavigateToRenderer(redirectPath)
); })
sendNavigateToRenderer(redirectPath);
});
app.listen(port, () => {}); app.listen(port, () => {})
} }
export function openInternalUrl(url) { export function openInternalUrl(url) {
sendNavigateToRenderer(url); sendNavigateToRenderer(url)
return true; return true
} }
export function getWindowState() { export function getWindowState() {
if (!mainWindow) { if (!mainWindow) {
return { isFullScreen: false, isMaximized: false }; return { isFullScreen: false, isMaximized: false }
} }
return { return {
isFullScreen: mainWindow.isFullScreen?.() ?? false, isFullScreen: mainWindow.isFullScreen?.() ?? false,
isMaximized: mainWindow.isMaximized?.() ?? false, isMaximized: mainWindow.isMaximized?.() ?? false
}; }
} }
export function handleWindowControl(action) { export function handleWindowControl(action) {
if (!mainWindow) return; if (!mainWindow) return
switch (action) { switch (action) {
case "minimize": case 'minimize':
mainWindow.minimize?.(); mainWindow.minimize?.()
break; break
case "maximize": case 'maximize':
if (mainWindow.isMaximized?.()) { if (mainWindow.isMaximized?.()) {
mainWindow.unmaximize?.(); mainWindow.unmaximize?.()
} else { } else {
mainWindow.maximize?.(); mainWindow.maximize?.()
} }
break; break
case "close": case 'close':
mainWindow.close?.(); mainWindow.close?.()
break; break
default: default:
break; break
} }
} }
export function sendNavigationGesture(direction) { export function sendNavigationGesture(direction) {
sendToRenderer("navigationGesture", direction); sendToRenderer('navigationGesture', direction)
} }
export function setupNavigationGestures(window) { export function setupNavigationGestures(window) {
if (!window) return; if (!window) return
if (process.platform === "darwin") { if (process.platform === 'darwin') {
window.on?.("swipe", (_event, direction) => { window.on?.('swipe', (_event, direction) => {
if (direction === "left") { if (direction === 'left') {
sendNavigationGesture("back"); sendNavigationGesture('back')
} else if (direction === "right") { } else if (direction === 'right') {
sendNavigationGesture("forward"); sendNavigationGesture('forward')
} }
}); })
} }
window.on?.("app-command", (_event, command) => { window.on?.('app-command', (_event, command) => {
if (command === "browser-backward") { if (command === 'browser-backward') {
sendNavigationGesture("back"); sendNavigationGesture('back')
} else if (command === "browser-forward") { } else if (command === 'browser-forward') {
sendNavigationGesture("forward"); sendNavigationGesture('forward')
} }
}); })
} }
export function openExternalUrl(url) { 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 listeners = new Map()
const pendingMessages = new Map(); const pendingMessages = new Map()
let rpc = null; let rpc = null
let initPromise = null; let initPromise = null
let initialized = false; let initialized = false
export function isElectrobunDesktop() { export function isElectrobunDesktop() {
return Boolean( return Boolean(
typeof window !== "undefined" && typeof window !== 'undefined' &&
window.__electrobunWebviewId && window.__electrobunWebviewId &&
window.__electrobunRpcSocketPort, window.__electrobunRpcSocketPort
); )
} }
function dispatchMessage(channel, data) { function dispatchMessage(channel, data) {
const channelListeners = listeners.get(channel); const channelListeners = listeners.get(channel)
if (!channelListeners?.size) { if (!channelListeners?.size) {
if (!pendingMessages.has(channel)) { if (!pendingMessages.has(channel)) {
pendingMessages.set(channel, []); pendingMessages.set(channel, [])
} }
pendingMessages.get(channel).push(data); pendingMessages.get(channel).push(data)
return; return
} }
for (const callback of channelListeners) { 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() { async function setupRpc() {
// electrobun/view captures webview globals at import time — import only after // electrobun/view captures webview globals at import time — import only after
// Electrobun preload has set them (Vite dev can evaluate modules earlier). // 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({ rpc = Electroview.defineRPC({
maxRequestTime: 30000, maxRequestTime: 30000,
handlers: { handlers: {
requests: {}, requests: {},
messages: { messages: {
"*": (channel, data) => { '*': (channel, data) => {
dispatchMessage(channel, data); dispatchMessage(channel, data)
}, }
}, }
}, }
}); })
new Electrobun.Electroview({ rpc }); new Electrobun.Electroview({ rpc })
} }
function shouldWaitForElectrobun() { function shouldWaitForElectrobun() {
if (isElectrobunDesktop()) { if (isElectrobunDesktop()) {
return true; return true
} }
if (typeof window === "undefined") { if (typeof window === 'undefined') {
return false; return false
} }
if (window.__electrobunWebviewId) { if (window.__electrobunWebviewId) {
return true; return true
} }
const { protocol, hostname, port } = window.location; const { protocol, hostname, port } = window.location
if (protocol === "views:") { if (protocol === 'views:') {
return true; return true
} }
// Vite dev server used by electrobun dev:app // Vite dev server used by electrobun dev:app
if (hostname === "localhost" && port === "5780") { if (hostname === 'localhost' && port === '5780') {
return true; return true
} }
return Boolean( return Boolean(
window.__electrobun || window.__electrobun ||
window.__electrobunEventBridge || window.__electrobunEventBridge ||
window.__electrobunInternalBridge, window.__electrobunInternalBridge
); )
} }
export async function initElectrobunBridge() { export async function initElectrobunBridge() {
if (initialized) { if (initialized) {
return; return
} }
if (!initPromise) { if (!initPromise) {
initPromise = (async () => { initPromise = (async () => {
if (!shouldWaitForElectrobun()) { if (!shouldWaitForElectrobun()) {
return; return
} }
const deadline = Date.now() + 3000; const deadline = Date.now() + 3000
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (isElectrobunDesktop()) { if (isElectrobunDesktop()) {
await setupRpc(); await setupRpc()
window.electronAPI = electronAPI; window.electronAPI = electronAPI
initialized = true; initialized = true
return; return
} }
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50))
} }
console.warn( 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) { function onMessage(channel, callback) {
if (!listeners.has(channel)) { void initElectrobunBridge()
listeners.set(channel, new Set());
}
listeners.get(channel).add(callback);
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) { if (queued?.length) {
pendingMessages.delete(channel); pendingMessages.delete(channel)
for (const payload of queued) { for (const payload of queued) {
callback(payload); callback(payload)
} }
} }
return () => { return () => {
listeners.get(channel)?.delete(callback); listeners.get(channel)?.delete(callback)
}; }
} }
function removeAllListeners(channel) { function removeAllListeners(channel) {
listeners.delete(channel); listeners.delete(channel)
pendingMessages.delete(channel); pendingMessages.delete(channel)
} }
async function invokeRequest(method, params = {}) { async function invokeRequest(method, params = {}) {
await initElectrobunBridge(); await initElectrobunBridge()
if (!rpc?.request?.[method]) { if (!rpc?.request) {
console.warn(`Unhandled RPC request: ${method}`); console.warn(`Electrobun RPC unavailable for request: ${method}`)
return null; 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 = { const electronAPI = {
get isDesktop() { get isDesktop() {
return isElectrobunDesktop(); return isElectrobunDesktop()
}, },
onMessage, onMessage,
removeAllListeners, removeAllListeners,
getOsInfo: () => invokeRequest("getOsInfo"), getOsInfo: () => invokeRequest('getOsInfo'),
getWindowState: () => invokeRequest("getWindowState"), getWindowState: () => invokeRequest('getWindowState'),
windowControl: (action) => invokeRequest("windowControl", { action }), windowControl: (action) => invokeRequest('windowControl', { action }),
openExternalUrl: (url) => invokeRequest("openExternalUrl", { url }), openExternalUrl: (url) => invokeRequest('openExternalUrl', { url }),
openInternalUrl: (url) => invokeRequest("openInternalUrl", { url }), openInternalUrl: (url) => invokeRequest('openInternalUrl', { url }),
getAuthSession: () => invokeRequest("getAuthSession"), getAuthSession: () => invokeRequest('getAuthSession'),
setAuthSession: (session) => invokeRequest("setAuthSession", { session }), setAuthSession: (session) => invokeRequest('setAuthSession', { session }),
clearAuthSession: () => invokeRequest("clearAuthSession"), clearAuthSession: () => invokeRequest('clearAuthSession'),
getAppSettings: () => invokeRequest("getAppSettings"), getAppSettings: () => invokeRequest('getAppSettings'),
setAppSettings: (settings) => invokeRequest("setAppSettings", { settings }), setAppSettings: (settings) => invokeRequest('setAppSettings', { settings }),
startAppUpdate: (update) => invokeRequest("startAppUpdate", { update }), startAppUpdate: (update) => invokeRequest('startAppUpdate', { update }),
resizeSpotlightWindow: (height) => resizeSpotlightWindow: (height) =>
invokeRequest("resizeSpotlightWindow", { height }), invokeRequest('resizeSpotlightWindow', { height }),
setSidebarViewMenu: (sections) => setSidebarViewMenu: (sections) =>
invokeRequest("setSidebarViewMenu", { sections }), invokeRequest('setSidebarViewMenu', { sections }),
getAppVersion: () => invokeRequest("getAppVersion"), getAppVersion: () => invokeRequest('getAppVersion')
}; }
export default electronAPI; if (typeof window !== 'undefined') {
window.electronAPI = electronAPI
}
export default electronAPI