Compare commits

..

No commits in common. "8d9455eaa94db4a57a936378eb80376486a04836" and "f59000778e014943de81d674619aefb7a3037e4e" have entirely different histories.

13 changed files with 385 additions and 633 deletions

View File

@ -124,20 +124,12 @@ html.macos-vibrancy .main-layout {
background: transparent !important; background: transparent !important;
} }
html.macos-vibrancy .light-mode .ant-layout.main-content-layout {
background: rgba(255, 255, 255, 0.88) !important;
}
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout {
background: rgba(0, 0, 0, 0.88) !important;
}
html.macos-vibrancy .light-mode .ant-layout-sider { html.macos-vibrancy .light-mode .ant-layout-sider {
background: rgba(244, 244, 244, 0.8) !important; background: rgba(255, 255, 255, 0.75) !important;
} }
html.macos-vibrancy .dark-mode .ant-layout-sider { html.macos-vibrancy .dark-mode .ant-layout-sider {
background: rgba(10, 10, 10, 0.8) !important; background: rgba(10, 10, 10, 0.65) !important;
} }
html.macos-vibrancy .light-mode .ant-layout-content { html.macos-vibrancy .light-mode .ant-layout-content {
@ -153,47 +145,14 @@ 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);
}
html.macos-vibrancy
.light-mode
.electron-sider
.ant-menu-light
.ant-menu-item-selected {
background-color: color-mix(in srgb, var(--color-primary) 80%, #ffffff3d);
color: #ffffff;
}
html.macos-vibrancy .light-mode .ant-layout.main-content-layout .ant-card {
background: rgba(255, 255, 255, 0.5) !important;
}
html.macos-vibrancy .dark-mode .ant-layout.main-content-layout .ant-card {
background: rgba(31, 31, 31, 0.5) !important;
}
.electron-navigation { .electron-navigation {
line-height: 40px; line-height: 40px;
} }
.electron-sidebar.ant-menu-inline .ant-menu-item {
padding-left: 20px !important;
}
.electron-navigation .ant-menu-overflow-item-rest { .electron-navigation .ant-menu-overflow-item-rest {
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,5 +1,4 @@
#import <Cocoa/Cocoa.h> #import <Cocoa/Cocoa.h>
#import <objc/runtime.h>
static NSString *const kElectrobunVibrancyViewIdentifier = static NSString *const kElectrobunVibrancyViewIdentifier =
@"ElectrobunVibrancyView"; @"ElectrobunVibrancyView";
@ -9,10 +8,6 @@ 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;
@ -99,58 +94,6 @@ static const void *kElectrobunChromeObserverKey = &kElectrobunChromeObserverKey;
} }
@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]] &&
@ -187,13 +130,8 @@ static ElectrobunWindowBorderView *findWindowBorderView(NSView *contentView) {
return nil; return nil;
} }
static BOOL isWindowFullScreen(NSWindow *window) { static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) {
return (window.styleMask & NSWindowStyleMaskFullScreen) != 0; gWindowCornerRadius = MAX(0.0, radius);
}
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) {
@ -201,29 +139,21 @@ static void refreshWindowChrome(NSWindow *window) {
} }
contentView.wantsLayer = YES; contentView.wantsLayer = YES;
contentView.layer.cornerRadius = radius; contentView.layer.cornerRadius = gWindowCornerRadius;
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 = radius; effectView.layer.cornerRadius = gWindowCornerRadius;
effectView.layer.masksToBounds = YES; effectView.layer.masksToBounds = YES;
} }
ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView); ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView);
if (borderView != nil) { if (borderView != nil) {
borderView.hidden = fullScreen;
if (!fullScreen) {
borderView.cornerRadius = gWindowCornerRadius; borderView.cornerRadius = gWindowCornerRadius;
[borderView setNeedsDisplay:YES]; [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) {
@ -241,6 +171,7 @@ 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) {
@ -254,76 +185,7 @@ static void ensureWindowBorder(NSWindow *window) {
relativeTo:nil]; relativeTo:nil];
} }
refreshWindowChrome(window); [borderView setNeedsDisplay:YES];
}
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) {
@ -361,7 +223,7 @@ extern "C" bool enableWindowVibrancy(void *windowPtr, double cornerRadius) {
} }
if (@available(macOS 10.14, *)) { if (@available(macOS 10.14, *)) {
[effectView setMaterial:NSVisualEffectMaterialHUDWindow]; [effectView setMaterial:NSVisualEffectMaterialUnderWindowBackground];
} else { } else {
[effectView setMaterial:NSVisualEffectMaterialSidebar]; [effectView setMaterial:NSVisualEffectMaterialSidebar];
} }
@ -381,8 +243,6 @@ 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;
@ -447,13 +307,45 @@ extern "C" bool setWindowTrafficLightsPosition(void *windowPtr, double x,
return; return;
} }
gTrafficLightsX = (CGFloat)x; NSButton *closeButton =
gTrafficLightsY = (CGFloat)yFromTop; [window standardWindowButton:NSWindowCloseButton];
ensureTrafficLightsObserver(window); NSButton *minimizeButton =
success = applyTrafficLightsPosition(window, gTrafficLightsX, gTrafficLightsY); [window standardWindowButton:NSWindowMiniaturizeButton];
if (success) { NSButton *zoomButton = [window standardWindowButton:NSWindowZoomButton];
[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,4 +1,4 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -81,16 +81,6 @@ if (buildEnv === "dev") {
console.log( console.log(
"pre-build: dev environment — skipping production renderer build (use dev:renderer for Vite)", "pre-build: dev environment — skipping production renderer build (use dev:renderer for Vite)",
); );
// Electrobun still copies dist/mainview into the app bundle; provide a stub so
// dev builds don't warn when Vite serves the renderer instead.
const stubDir = path.join(rootDir, "dist/mainview");
mkdirSync(stubDir, { recursive: true });
writeFileSync(
path.join(stubDir, "index.html"),
"<!doctype html><html><body>Dev mode — start Vite with <code>bun run dev:renderer</code>.</body></html>",
);
console.log(`pre-build: validation passed (${buildEnv})`); console.log(`pre-build: validation passed (${buildEnv})`);
process.exit(0); process.exit(0);
} }

View File

@ -1,23 +1,20 @@
import { createAppRpc } from '../desktop/rpc.js' import { createAppRpc } from "../desktop/rpc.js";
import { import { registerGlobalShortcuts, unregisterGlobalShortcuts } from "../desktop/spotlight.js";
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

@ -49,7 +49,7 @@ const DashboardLayout = ({ children }) => {
) : ( ) : (
<ProductionSidebar /> // Default to production sidebar <ProductionSidebar /> // Default to production sidebar
)} )}
<Layout style={{ padding: '24px' }} className='main-content-layout'> <Layout style={{ padding: '24px' }}>
<Content> <Content>
<Flex vertical style={{ height: '100%' }} gap='20px'> <Flex vertical style={{ height: '100%' }} gap='20px'>
<Flex justify='space-between'> <Flex justify='space-between'>

View File

@ -1,5 +1,5 @@
import { useContext } from 'react' import { useContext } from 'react'
import { Flex, Button, Divider } from 'antd' import { Flex, Button } 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,13 +36,7 @@ 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,16 +1,22 @@
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
const desktopAPI = 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() {
if (isElectrobunDesktop()) { if (desktopAPI?.isDesktop) {
return true
}
if (
typeof window !== 'undefined' &&
window.__electrobunWebviewId &&
window.__electrobunRpcSocketPort
) {
return true return true
} }
@ -55,7 +61,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 = isElectrobunDesktop() const useElectrobun = Boolean(desktopAPI?.isDesktop)
const navigate = useNavigate() const navigate = useNavigate()
const lastNavigationAtRef = useRef(0) const lastNavigationAtRef = useRef(0)
@ -85,9 +91,7 @@ const ElectronProvider = ({ children }) => {
const openExternalUrl = (url) => { const openExternalUrl = (url) => {
if (useElectrobun) { if (useElectrobun) {
void desktopBridge.openExternalUrl(url).catch((error) => { desktopAPI.openExternalUrl(url)
console.warn('[ElectronContext] Failed to open external url:', error)
})
return true return true
} }
if (electronAvailable && ipcRenderer) { if (electronAvailable && ipcRenderer) {
@ -99,9 +103,7 @@ const ElectronProvider = ({ children }) => {
const openInternalUrl = (url) => { const openInternalUrl = (url) => {
if (useElectrobun) { if (useElectrobun) {
void desktopBridge.openInternalUrl(url).catch((error) => { desktopAPI.openInternalUrl(url)
console.warn('[ElectronContext] Failed to open internal url:', error)
})
return true return true
} }
if (electronAvailable && ipcRenderer) { if (electronAvailable && ipcRenderer) {
@ -115,7 +117,7 @@ const ElectronProvider = ({ children }) => {
if (!electronAvailable) return if (!electronAvailable) return
if (useElectrobun) { if (useElectrobun) {
desktopBridge.getOsInfo().then((info) => { desktopAPI.getOsInfo().then((info) => {
if (info?.platform) { if (info?.platform) {
setPlatform(info.platform) setPlatform(info.platform)
if (info.platform === 'darwin') { if (info.platform === 'darwin') {
@ -124,16 +126,13 @@ const ElectronProvider = ({ children }) => {
} }
}) })
desktopBridge.getWindowState().then(applyWindowState) desktopAPI.getWindowState().then(applyWindowState)
const unsubWindowState = desktopBridge.onMessage( const unsubWindowState = desktopAPI.onMessage('windowState', applyWindowState)
'windowState', const unsubNavigate = desktopAPI.onMessage('navigate', (url) => {
applyWindowState
)
const unsubNavigate = desktopBridge.onMessage('navigate', (url) => {
navigate(url) navigate(url)
}) })
const unsubNavigationGesture = desktopBridge.onMessage( const unsubNavigationGesture = desktopAPI.onMessage(
'navigationGesture', 'navigationGesture',
navigateHistory navigateHistory
) )
@ -193,8 +192,7 @@ const ElectronProvider = ({ children }) => {
let resetTimer let resetTimer
const handleWheel = (event) => { const handleWheel = (event) => {
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
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
@ -219,7 +217,7 @@ const ElectronProvider = ({ children }) => {
const handleWindowControl = (action) => { const handleWindowControl = (action) => {
if (useElectrobun) { if (useElectrobun) {
void desktopBridge.windowControl(action) desktopAPI.windowControl(action)
return return
} }
if (electronAvailable && ipcRenderer) { if (electronAvailable && ipcRenderer) {
@ -229,7 +227,7 @@ const ElectronProvider = ({ children }) => {
const getAuthSession = async () => { const getAuthSession = async () => {
if (!electronAvailable) return null if (!electronAvailable) return null
if (useElectrobun) return await desktopBridge.getAuthSession() if (useElectrobun) return await desktopAPI.getAuthSession()
if (!ipcRenderer) return null if (!ipcRenderer) return null
return await ipcRenderer.invoke('auth-session-get') return await ipcRenderer.invoke('auth-session-get')
} }
@ -237,7 +235,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 desktopBridge.setAuthSession(session) const result = await desktopAPI.setAuthSession(session)
return result?.ok ?? false return result?.ok ?? false
} }
if (!ipcRenderer) return false if (!ipcRenderer) return false
@ -247,7 +245,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 desktopBridge.clearAuthSession() const result = await desktopAPI.clearAuthSession()
return result?.ok ?? false return result?.ok ?? false
} }
if (!ipcRenderer) return false if (!ipcRenderer) return false
@ -256,7 +254,7 @@ const ElectronProvider = ({ children }) => {
const getAppSettings = useCallback(async () => { const getAppSettings = useCallback(async () => {
if (!electronAvailable) return {} if (!electronAvailable) return {}
if (useElectrobun) return await desktopBridge.getAppSettings() if (useElectrobun) return await desktopAPI.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])
@ -265,7 +263,7 @@ const ElectronProvider = ({ children }) => {
async (settings) => { async (settings) => {
if (!electronAvailable) return false if (!electronAvailable) return false
if (useElectrobun) { if (useElectrobun) {
const result = await desktopBridge.setAppSettings(settings) const result = await desktopAPI.setAppSettings(settings)
return result?.ok ?? false return result?.ok ?? false
} }
if (!ipcRenderer) return false if (!ipcRenderer) return false
@ -278,7 +276,7 @@ const ElectronProvider = ({ children }) => {
async (update) => { async (update) => {
if (!electronAvailable) return false if (!electronAvailable) return false
if (useElectrobun) { if (useElectrobun) {
const result = await desktopBridge.startAppUpdate(update) const result = await desktopAPI.startAppUpdate(update)
return result?.ok ?? false return result?.ok ?? false
} }
if (!ipcRenderer) return false if (!ipcRenderer) return false
@ -294,7 +292,7 @@ const ElectronProvider = ({ children }) => {
} }
if (useElectrobun) { if (useElectrobun) {
return desktopBridge.onMessage('appUpdateProgress', handler) return desktopAPI.onMessage('appUpdateProgress', handler)
} }
if (!ipcRenderer) return () => {} if (!ipcRenderer) return () => {}
@ -326,7 +324,7 @@ const ElectronProvider = ({ children }) => {
if (!electronAvailable) return false if (!electronAvailable) return false
try { try {
if (useElectrobun) { if (useElectrobun) {
const result = await desktopBridge.resizeSpotlightWindow(height) const result = await desktopAPI.resizeSpotlightWindow(height)
return result?.ok ?? false return result?.ok ?? false
} }
if (!ipcRenderer) return false if (!ipcRenderer) return false
@ -344,7 +342,7 @@ const ElectronProvider = ({ children }) => {
async (sections) => { async (sections) => {
if (!electronAvailable) return false if (!electronAvailable) return false
if (useElectrobun) { if (useElectrobun) {
const result = await desktopBridge.setSidebarViewMenu(sections) const result = await desktopAPI.setSidebarViewMenu(sections)
return result?.ok ?? false return result?.ok ?? false
} }
if (!ipcRenderer) return false if (!ipcRenderer) return false
@ -355,7 +353,7 @@ const ElectronProvider = ({ children }) => {
const getElectronVersion = useCallback(async () => { const getElectronVersion = useCallback(async () => {
if (!electronAvailable) return null if (!electronAvailable) return null
if (useElectrobun) return await desktopBridge.getAppVersion() if (useElectrobun) return await desktopAPI.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,6 +3,8 @@ 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() {
@ -55,6 +57,10 @@ 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
} }
}) })
@ -63,16 +69,32 @@ 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 = () =>
mainWindow.on?.('resize', () => { lib.symbols.setWindowTrafficLightsPosition(
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}, cornerRadius=${MAC_WINDOW_CORNER_RADIUS})` `macOS vibrancy applied (vibrancy=${vibrancyEnabled}, shadow=${shadowEnabled}, trafficLights=${buttonsAlignedNow}, 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,53 +24,52 @@ 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);
console.log('openExternalUrl', url) return { ok: true };
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,72 +35,69 @@ export function openSpotlightContentWindow(rpc) {
width: 700, width: 700,
height: 40, height: 40,
x: 100, x: 100,
y: 100 y: 100,
} },
}) });
spotlightWindow.on?.('close', (event) => { spotlightWindow.on?.("close", (event) => {
event?.preventDefault?.() event?.preventDefault?.();
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) { if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.() spotlightWindow.hide?.();
} }
}) });
spotlightWindow.on?.('blur', () => { spotlightWindow.on?.("blur", () => {
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) { if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.() spotlightWindow.hide?.();
} }
}) });
return spotlightWindow 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( console.warn("[spotlight] Failed to resize window.", error?.message || error);
'[spotlight] Failed to resize window.', return false;
error?.message || error
)
return false
} }
} }

View File

@ -1,264 +1,258 @@
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) {
// Electrobun emits resize/focus, not Electron's maximize/fullscreen events. window.on?.("maximize", () => {
window.on?.('resize', broadcastWindowState) sendToRenderer("windowState", { isMaximized: true });
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 webview = window.webview const send = window.webview?.rpc?.send;
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
}
const send = webview?.rpc?.send
if (!send) { if (!send) {
console.warn( console.warn(
`No RPC sender available for channel: ${channel}. Is the window ready?` `No RPC sender available for channel: ${channel}. Is the window ready?`,
) );
return false return false;
} }
send[channel](data) send[channel](data);
return true 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) setupWindowEvents(mainWindow);
Electrobun.events.on('open-url', (event) => { Electrobun.events.on("open-url", (event) => {
const url = event?.data?.url const url = event?.data?.url;
if (url) { if (url) {
handleDeepLink(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(`Open Farmcontrol to continue... (Redirect path: ${redirectPath})`) res.send(
sendNavigateToRenderer(redirectPath) `Open Farmcontrol to continue... (Redirect path: ${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) {
console.log('openExternalUrl', url) Utils.openExternal(url);
Utils.openExternal(url)
} }

View File

@ -1,191 +1,103 @@
const listeners = new Map() import Electrobun, { Electroview } from "electrobun/view";
const pendingMessages = new Map()
let rpc = null
let initPromise = null
let initialized = false
export function isElectrobunDesktop() { const isDesktop = Boolean(
return Boolean( typeof window !== "undefined" &&
typeof window !== 'undefined' &&
window.__electrobunWebviewId && window.__electrobunWebviewId &&
window.__electrobunRpcSocketPort window.__electrobunRpcSocketPort,
) );
}
const listeners = new Map();
const pendingMessages = new Map();
let rpc = null;
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. if (isDesktop) {
// 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')
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() {
if (isElectrobunDesktop()) {
return true
}
if (typeof window === 'undefined') {
return false
}
if (window.__electrobunWebviewId) {
return true
}
const { protocol, hostname, port } = window.location
if (protocol === 'views:') {
return true
}
// Vite dev server used by electrobun dev:app
if (hostname === 'localhost' && port === '5780') {
return true
}
return Boolean(
window.__electrobun ||
window.__electrobunEventBridge ||
window.__electrobunInternalBridge
)
}
export async function initElectrobunBridge() {
if (initialized) {
return
}
if (!initPromise) {
initPromise = (async () => {
if (!shouldWaitForElectrobun()) {
return
}
const deadline = Date.now() + 3000
while (Date.now() < deadline) {
if (isElectrobunDesktop()) {
await setupRpc()
window.electronAPI = electronAPI
initialized = true
return
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
console.warn(
'Electrobun bridge: webview globals not found; desktop RPC unavailable.'
)
})()
}
await initPromise
} }
function onMessage(channel, callback) { function onMessage(channel, callback) {
void initElectrobunBridge()
if (!listeners.has(channel)) { if (!listeners.has(channel)) {
listeners.set(channel, new Set()) listeners.set(channel, new Set());
} }
listeners.get(channel).add(callback) listeners.get(channel).add(callback);
const queued = pendingMessages.get(channel) 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() if (!rpc?.request?.[method]) {
console.warn(`Unhandled RPC request: ${method}`);
if (!rpc?.request) { return null;
console.warn(`Electrobun RPC unavailable for request: ${method}`)
return null
} }
try { return await rpc.request[method](params);
return await rpc.request[method](params)
} catch (error) {
console.warn(`Electrobun RPC request failed: ${method}`, error)
return null
}
} }
const electronAPI = { const electronAPI = {
get isDesktop() { isDesktop,
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"),
};
if (isDesktop) {
window.electronAPI = electronAPI;
} }
if (typeof window !== 'undefined') { export default electronAPI;
window.electronAPI = electronAPI
}
export default electronAPI

View File

@ -1,11 +1,9 @@
import { initElectrobunBridge } from './electrobun-bridge.js' import './electrobun-bridge.js'
import reportWebVitals from './reportWebVitals' import reportWebVitals from './reportWebVitals'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import FarmControlApp from './App' import FarmControlApp from './App'
import React from 'react' import React from 'react'
await initElectrobunBridge()
const root = ReactDOM.createRoot(document.getElementById('root')) const root = ReactDOM.createRoot(document.getElementById('root'))
root.render( root.render(
<React.StrictMode> <React.StrictMode>