diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index 4c61de8..55d1a2e 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -125,11 +125,11 @@ html.macos-vibrancy .main-layout { } html.macos-vibrancy .light-mode .ant-layout-sider { - background: rgba(255, 255, 255, 0.75) !important; + background: rgba(255, 255, 255, 0.7) !important; } html.macos-vibrancy .dark-mode .ant-layout-sider { - background: rgba(10, 10, 10, 0.65) !important; + background: rgba(5, 5, 5, 0.7) !important; } html.macos-vibrancy .light-mode .ant-layout-content { @@ -145,6 +145,15 @@ html.macos-vibrancy .electron-sider .ant-menu-light { background: transparent !important; } +html.macos-vibrancy + .dark-mode + .electron-sider + .ant-menu-light + .ant-menu-item-selected { + background-color: color-mix(in srgb, var(--color-primary) 30%, #00000010); + color: color-mix(in srgb, var(--color-primary) 75%, #ffffff); +} + .electron-navigation { line-height: 40px; } @@ -153,6 +162,10 @@ html.macos-vibrancy .electron-sider .ant-menu-light { padding-inline: 10px; } +.electron-navigation.ant-menu-horizontal .ant-menu-item { + padding-inline: 12px; +} + .electron-sidebar .ant-menu-item, .electron-sidebar .ant-menu-submenu-title { height: 32.5px !important; diff --git a/native/macos/window-effects.mm b/native/macos/window-effects.mm index 30e2a0f..1898603 100644 --- a/native/macos/window-effects.mm +++ b/native/macos/window-effects.mm @@ -1,4 +1,5 @@ #import +#import static NSString *const kElectrobunVibrancyViewIdentifier = @"ElectrobunVibrancyView"; @@ -8,6 +9,10 @@ static NSString *const kElectrobunWindowBorderViewIdentifier = @"ElectrobunWindowBorderView"; static CGFloat gWindowCornerRadius = 15.0; +static CGFloat gTrafficLightsX = 16.0; +static CGFloat gTrafficLightsY = 12.0; + +static const void *kElectrobunChromeObserverKey = &kElectrobunChromeObserverKey; @interface ElectrobunWindowBorderView : NSView @property(nonatomic) CGFloat cornerRadius; @@ -94,6 +99,58 @@ static CGFloat gWindowCornerRadius = 15.0; } @end +@interface ElectrobunWindowChromeObserver : NSObject +@property(nonatomic, weak) NSWindow *window; +@end + +static bool applyTrafficLightsPosition(NSWindow *window, CGFloat x, + CGFloat yFromTop); +static void refreshWindowChrome(NSWindow *window); + +@implementation ElectrobunWindowChromeObserver + +- (instancetype)initWithWindow:(NSWindow *)window { + self = [super init]; + if (self) { + _window = window; + NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(refreshChrome) + name:NSWindowDidResizeNotification + object:window]; + [center addObserver:self + selector:@selector(refreshChrome) + name:NSWindowDidEndLiveResizeNotification + object:window]; + [center addObserver:self + selector:@selector(refreshChrome) + name:NSWindowDidEnterFullScreenNotification + object:window]; + [center addObserver:self + selector:@selector(refreshChrome) + name:NSWindowDidExitFullScreenNotification + object:window]; + [center addObserver:self + selector:@selector(refreshChrome) + name:NSWindowDidBecomeKeyNotification + object:window]; + } + return self; +} + +- (void)refreshChrome { + if (self.window != nil) { + refreshWindowChrome(self.window); + applyTrafficLightsPosition(self.window, gTrafficLightsX, gTrafficLightsY); + } +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +@end + static NSVisualEffectView *findVibrancyView(NSView *contentView) { for (NSView *subview in [contentView subviews]) { if ([subview isKindOfClass:[NSVisualEffectView class]] && @@ -130,8 +187,13 @@ static ElectrobunWindowBorderView *findWindowBorderView(NSView *contentView) { return nil; } -static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) { - gWindowCornerRadius = MAX(0.0, radius); +static BOOL isWindowFullScreen(NSWindow *window) { + return (window.styleMask & NSWindowStyleMaskFullScreen) != 0; +} + +static void refreshWindowChrome(NSWindow *window) { + BOOL fullScreen = isWindowFullScreen(window); + CGFloat radius = fullScreen ? 0.0 : gWindowCornerRadius; NSView *contentView = [window contentView]; if (contentView == nil) { @@ -139,23 +201,31 @@ static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) { } contentView.wantsLayer = YES; - contentView.layer.cornerRadius = gWindowCornerRadius; + contentView.layer.cornerRadius = radius; contentView.layer.masksToBounds = YES; NSVisualEffectView *effectView = findVibrancyView(contentView); if (effectView != nil) { effectView.wantsLayer = YES; - effectView.layer.cornerRadius = gWindowCornerRadius; + effectView.layer.cornerRadius = radius; effectView.layer.masksToBounds = YES; } ElectrobunWindowBorderView *borderView = findWindowBorderView(contentView); if (borderView != nil) { - borderView.cornerRadius = gWindowCornerRadius; - [borderView setNeedsDisplay:YES]; + borderView.hidden = fullScreen; + if (!fullScreen) { + borderView.cornerRadius = gWindowCornerRadius; + [borderView setNeedsDisplay:YES]; + } } } +static void applyWindowCornerRadius(NSWindow *window, CGFloat radius) { + gWindowCornerRadius = MAX(0.0, radius); + refreshWindowChrome(window); +} + static void ensureWindowBorder(NSWindow *window) { NSView *contentView = [window contentView]; if (contentView == nil) { @@ -171,7 +241,6 @@ static void ensureWindowBorder(NSWindow *window) { setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)]; } - borderView.cornerRadius = gWindowCornerRadius; [borderView setFrame:[contentView bounds]]; if ([borderView superview] == nil) { @@ -185,7 +254,76 @@ static void ensureWindowBorder(NSWindow *window) { relativeTo:nil]; } - [borderView setNeedsDisplay:YES]; + refreshWindowChrome(window); +} + +static bool applyTrafficLightsPosition(NSWindow *window, CGFloat x, + CGFloat yFromTop) { + NSButton *closeButton = [window standardWindowButton:NSWindowCloseButton]; + NSButton *minimizeButton = + [window standardWindowButton:NSWindowMiniaturizeButton]; + NSButton *zoomButton = [window standardWindowButton:NSWindowZoomButton]; + + if (closeButton == nil || minimizeButton == nil || zoomButton == nil) { + return false; + } + + NSView *buttonContainer = [closeButton superview]; + if (buttonContainer == nil) { + return false; + } + + CGFloat spacing = NSMinX(minimizeButton.frame) - NSMinX(closeButton.frame); + if (spacing <= 0) { + spacing = closeButton.frame.size.width + 6.0; + } + + BOOL flipped = [buttonContainer isFlipped]; + CGFloat targetY = yFromTop; + if (!flipped) { + targetY = buttonContainer.frame.size.height - yFromTop - + closeButton.frame.size.height; + } + targetY = MAX(0.0, targetY); + + CGFloat currentX = x; + NSArray *buttons = @[ closeButton, minimizeButton, zoomButton ]; + for (NSButton *button in buttons) { + [button setAutoresizingMask:NSViewNotSizable]; + [button setFrameOrigin:NSMakePoint(currentX, targetY)]; + currentX += spacing; + } + + return true; +} + +static void ensureTrafficLightsObserver(NSWindow *window) { + ElectrobunWindowChromeObserver *existingObserver = + objc_getAssociatedObject(window, kElectrobunChromeObserverKey); + if (existingObserver != nil) { + return; + } + + ElectrobunWindowChromeObserver *observer = + [[ElectrobunWindowChromeObserver alloc] initWithWindow:window]; + objc_setAssociatedObject(window, kElectrobunChromeObserverKey, observer, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +static void scheduleTrafficLightsPosition(NSWindow *window, NSInteger attempt) { + if (applyTrafficLightsPosition(window, gTrafficLightsX, gTrafficLightsY)) { + return; + } + + if (attempt >= 10) { + return; + } + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(50 * NSEC_PER_MSEC)), + dispatch_get_main_queue(), ^{ + scheduleTrafficLightsPosition(window, attempt + 1); + }); } extern "C" bool enableWindowVibrancy(void *windowPtr, double cornerRadius) { @@ -223,7 +361,7 @@ extern "C" bool enableWindowVibrancy(void *windowPtr, double cornerRadius) { } if (@available(macOS 10.14, *)) { - [effectView setMaterial:NSVisualEffectMaterialUnderWindowBackground]; + [effectView setMaterial:NSVisualEffectMaterialHUDWindow]; } else { [effectView setMaterial:NSVisualEffectMaterialSidebar]; } @@ -243,6 +381,8 @@ extern "C" bool enableWindowVibrancy(void *windowPtr, double cornerRadius) { ensureWindowBorder(window); applyWindowCornerRadius(window, gWindowCornerRadius); + ensureTrafficLightsObserver(window); + scheduleTrafficLightsPosition(window, 0); [window invalidateShadow]; success = YES; @@ -307,45 +447,13 @@ extern "C" bool setWindowTrafficLightsPosition(void *windowPtr, double x, return; } - NSButton *closeButton = - [window standardWindowButton:NSWindowCloseButton]; - NSButton *minimizeButton = - [window standardWindowButton:NSWindowMiniaturizeButton]; - NSButton *zoomButton = [window standardWindowButton:NSWindowZoomButton]; - - if (closeButton == nil || minimizeButton == nil || zoomButton == nil) { - return; + gTrafficLightsX = (CGFloat)x; + gTrafficLightsY = (CGFloat)yFromTop; + ensureTrafficLightsObserver(window); + success = applyTrafficLightsPosition(window, gTrafficLightsX, gTrafficLightsY); + if (success) { + [window invalidateShadow]; } - - 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; diff --git a/src/bun/index.js b/src/bun/index.js index bd792e2..eb827ca 100644 --- a/src/bun/index.js +++ b/src/bun/index.js @@ -1,20 +1,23 @@ -import { createAppRpc } from "../desktop/rpc.js"; -import { registerGlobalShortcuts, unregisterGlobalShortcuts } from "../desktop/spotlight.js"; +import { createAppRpc } from '../desktop/rpc.js' +import { + registerGlobalShortcuts, + unregisterGlobalShortcuts +} from '../desktop/spotlight.js' import { createMainWindow, handleDeepLinkFromArgv, setupDevAuthServer, - setupNavigationGestures, -} from "../desktop/window.js"; + setupNavigationGestures +} from '../desktop/window.js' -const rpc = createAppRpc(); -const mainWindow = await createMainWindow(rpc); +const rpc = createAppRpc() +const mainWindow = await createMainWindow(rpc) -setupNavigationGestures(mainWindow); -registerGlobalShortcuts(rpc); -setupDevAuthServer(); -handleDeepLinkFromArgv(); +setupNavigationGestures(mainWindow) +registerGlobalShortcuts(rpc) +setupDevAuthServer() +handleDeepLinkFromArgv() -process.on("exit", () => { - unregisterGlobalShortcuts(); -}); +process.on('exit', () => { + unregisterGlobalShortcuts() +}) diff --git a/src/components/Dashboard/common/DashboardWindowButtons.jsx b/src/components/Dashboard/common/DashboardWindowButtons.jsx index 3a6b40e..157c52f 100644 --- a/src/components/Dashboard/common/DashboardWindowButtons.jsx +++ b/src/components/Dashboard/common/DashboardWindowButtons.jsx @@ -1,5 +1,5 @@ import { useContext } from 'react' -import { Flex, Button } from 'antd' +import { Flex, Button, Divider } from 'antd' import { ElectronContext } from '../context/ElectronContext' import XMarkIcon from '../../Icons/XMarkIcon' import MinusIcon from '../../Icons/MinusIcon' @@ -36,7 +36,13 @@ const DashboardWindowButtons = () => { {platform == 'darwin' ? ( isFullScreen == false ? ( -
+ <> +
+ + ) : null ) : (
{ const [isMaximized, setIsMaximized] = useState(false) const [isFullScreen, setIsFullScreen] = useState(false) const [electronAvailable] = useState(isElectron()) - const useElectrobun = Boolean( - getDesktopAPI()?.isDesktop || - (typeof window !== 'undefined' && - window.__electrobunWebviewId && - window.__electrobunRpcSocketPort) - ) + const useElectrobun = isElectrobunDesktop() const navigate = useNavigate() const lastNavigationAtRef = useRef(0) @@ -101,7 +85,9 @@ const ElectronProvider = ({ children }) => { const openExternalUrl = (url) => { if (useElectrobun) { - getDesktopAPI().openExternalUrl(url) + void desktopBridge.openExternalUrl(url).catch((error) => { + console.warn('[ElectronContext] Failed to open external url:', error) + }) return true } if (electronAvailable && ipcRenderer) { @@ -113,7 +99,9 @@ const ElectronProvider = ({ children }) => { const openInternalUrl = (url) => { if (useElectrobun) { - getDesktopAPI().openInternalUrl(url) + void desktopBridge.openInternalUrl(url).catch((error) => { + console.warn('[ElectronContext] Failed to open internal url:', error) + }) return true } if (electronAvailable && ipcRenderer) { @@ -127,7 +115,7 @@ const ElectronProvider = ({ children }) => { if (!electronAvailable) return if (useElectrobun) { - getDesktopAPI().getOsInfo().then((info) => { + desktopBridge.getOsInfo().then((info) => { if (info?.platform) { setPlatform(info.platform) if (info.platform === 'darwin') { @@ -136,16 +124,16 @@ const ElectronProvider = ({ children }) => { } }) - getDesktopAPI().getWindowState().then(applyWindowState) + desktopBridge.getWindowState().then(applyWindowState) - const unsubWindowState = getDesktopAPI().onMessage( + const unsubWindowState = desktopBridge.onMessage( 'windowState', applyWindowState ) - const unsubNavigate = getDesktopAPI().onMessage('navigate', (url) => { + const unsubNavigate = desktopBridge.onMessage('navigate', (url) => { navigate(url) }) - const unsubNavigationGesture = getDesktopAPI().onMessage( + const unsubNavigationGesture = desktopBridge.onMessage( 'navigationGesture', navigateHistory ) @@ -205,7 +193,8 @@ const ElectronProvider = ({ children }) => { let resetTimer const handleWheel = (event) => { - if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) + return if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return if (Math.abs(event.deltaX) < 2) return if (isInHorizontalScrollContainer(event.target)) return @@ -230,7 +219,7 @@ const ElectronProvider = ({ children }) => { const handleWindowControl = (action) => { if (useElectrobun) { - getDesktopAPI().windowControl(action) + void desktopBridge.windowControl(action) return } if (electronAvailable && ipcRenderer) { @@ -240,7 +229,7 @@ const ElectronProvider = ({ children }) => { const getAuthSession = async () => { if (!electronAvailable) return null - if (useElectrobun) return await getDesktopAPI().getAuthSession() + if (useElectrobun) return await desktopBridge.getAuthSession() if (!ipcRenderer) return null return await ipcRenderer.invoke('auth-session-get') } @@ -248,7 +237,7 @@ const ElectronProvider = ({ children }) => { const setAuthSession = async (session) => { if (!electronAvailable) return false if (useElectrobun) { - const result = await getDesktopAPI().setAuthSession(session) + const result = await desktopBridge.setAuthSession(session) return result?.ok ?? false } if (!ipcRenderer) return false @@ -258,7 +247,7 @@ const ElectronProvider = ({ children }) => { const clearAuthSession = async () => { if (!electronAvailable) return false if (useElectrobun) { - const result = await getDesktopAPI().clearAuthSession() + const result = await desktopBridge.clearAuthSession() return result?.ok ?? false } if (!ipcRenderer) return false @@ -267,7 +256,7 @@ const ElectronProvider = ({ children }) => { const getAppSettings = useCallback(async () => { if (!electronAvailable) return {} - if (useElectrobun) return await getDesktopAPI().getAppSettings() + if (useElectrobun) return await desktopBridge.getAppSettings() if (!ipcRenderer) return {} return await ipcRenderer.invoke('app-settings-get') }, [electronAvailable, useElectrobun]) @@ -276,7 +265,7 @@ const ElectronProvider = ({ children }) => { async (settings) => { if (!electronAvailable) return false if (useElectrobun) { - const result = await getDesktopAPI().setAppSettings(settings) + const result = await desktopBridge.setAppSettings(settings) return result?.ok ?? false } if (!ipcRenderer) return false @@ -289,7 +278,7 @@ const ElectronProvider = ({ children }) => { async (update) => { if (!electronAvailable) return false if (useElectrobun) { - const result = await getDesktopAPI().startAppUpdate(update) + const result = await desktopBridge.startAppUpdate(update) return result?.ok ?? false } if (!ipcRenderer) return false @@ -305,7 +294,7 @@ const ElectronProvider = ({ children }) => { } if (useElectrobun) { - return getDesktopAPI().onMessage('appUpdateProgress', handler) + return desktopBridge.onMessage('appUpdateProgress', handler) } if (!ipcRenderer) return () => {} @@ -337,7 +326,7 @@ const ElectronProvider = ({ children }) => { if (!electronAvailable) return false try { if (useElectrobun) { - const result = await getDesktopAPI().resizeSpotlightWindow(height) + const result = await desktopBridge.resizeSpotlightWindow(height) return result?.ok ?? false } if (!ipcRenderer) return false @@ -355,7 +344,7 @@ const ElectronProvider = ({ children }) => { async (sections) => { if (!electronAvailable) return false if (useElectrobun) { - const result = await getDesktopAPI().setSidebarViewMenu(sections) + const result = await desktopBridge.setSidebarViewMenu(sections) return result?.ok ?? false } if (!ipcRenderer) return false @@ -366,7 +355,7 @@ const ElectronProvider = ({ children }) => { const getElectronVersion = useCallback(async () => { if (!electronAvailable) return null - if (useElectrobun) return await getDesktopAPI().getAppVersion() + if (useElectrobun) return await desktopBridge.getAppVersion() if (!ipcRenderer) return null return await ipcRenderer.invoke('electron-version') }, [electronAvailable, useElectrobun]) diff --git a/src/desktop/macos-window-effects.js b/src/desktop/macos-window-effects.js index d715dff..b953f34 100644 --- a/src/desktop/macos-window-effects.js +++ b/src/desktop/macos-window-effects.js @@ -3,8 +3,6 @@ import { existsSync, statSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -const MAC_TRAFFIC_LIGHTS_X = 14 -const MAC_TRAFFIC_LIGHTS_Y = 12 export const MAC_WINDOW_CORNER_RADIUS = 15 function resolveDylibPath() { @@ -57,10 +55,6 @@ export function applyMacOSWindowEffects(mainWindow) { setWindowCornerRadius: { args: [FFIType.ptr, FFIType.f64], returns: FFIType.bool - }, - setWindowTrafficLightsPosition: { - args: [FFIType.ptr, FFIType.f64, FFIType.f64], - returns: FFIType.bool } }) @@ -69,32 +63,16 @@ export function applyMacOSWindowEffects(mainWindow) { MAC_WINDOW_CORNER_RADIUS ) const shadowEnabled = lib.symbols.ensureWindowShadow(mainWindow.ptr) - const alignButtons = () => - lib.symbols.setWindowTrafficLightsPosition( - mainWindow.ptr, - MAC_TRAFFIC_LIGHTS_X, - MAC_TRAFFIC_LIGHTS_Y - ) - const refreshWindowChrome = () => { + + mainWindow.on?.('resize', () => { lib.symbols.setWindowCornerRadius( mainWindow.ptr, MAC_WINDOW_CORNER_RADIUS ) - alignButtons() - } - - const buttonsAlignedNow = alignButtons() - - setTimeout(() => { - refreshWindowChrome() - }, 120) - - mainWindow.on?.('resize', () => { - refreshWindowChrome() }) console.log( - `macOS vibrancy applied (vibrancy=${vibrancyEnabled}, shadow=${shadowEnabled}, trafficLights=${buttonsAlignedNow}, cornerRadius=${MAC_WINDOW_CORNER_RADIUS})` + `macOS vibrancy applied (vibrancy=${vibrancyEnabled}, shadow=${shadowEnabled}, cornerRadius=${MAC_WINDOW_CORNER_RADIUS})` ) } catch (error) { console.warn( diff --git a/src/desktop/rpc.js b/src/desktop/rpc.js index 3ab660e..da21b7f 100644 --- a/src/desktop/rpc.js +++ b/src/desktop/rpc.js @@ -1,22 +1,22 @@ -import { BrowserView } from "electrobun/bun"; -import { startAppUpdate } from "./appupdate.js"; -import { setSidebarViewMenu } from "./menu.js"; -import { sendToRenderer } from "./notify.js"; -import { resizeSpotlightWindow } from "./spotlight.js"; +import { BrowserView } from 'electrobun/bun' +import { startAppUpdate } from './appupdate.js' +import { setSidebarViewMenu } from './menu.js' +import { sendToRenderer } from './notify.js' +import { resizeSpotlightWindow } from './spotlight.js' import { clearAuthSession, getAppSettings, getAuthSession, setAppSettings, - setAuthSession, -} from "./store.js"; + setAuthSession +} from './store.js' import { getMainWindow, getWindowState, handleWindowControl, openExternalUrl, - openInternalUrl, -} from "./window.js"; + openInternalUrl +} from './window.js' export function createAppRpc() { return BrowserView.defineRPC({ @@ -24,52 +24,53 @@ export function createAppRpc() { handlers: { requests: { getOsInfo: async () => ({ - platform: process.platform, + platform: process.platform }), getWindowState: async () => getWindowState(), windowControl: async ({ action }) => { - handleWindowControl(action); - return { ok: true }; + handleWindowControl(action) + return { ok: true } }, openExternalUrl: async ({ url }) => { - openExternalUrl(url); - return { ok: true }; + openExternalUrl(url) + console.log('openExternalUrl', url) + return { ok: true } }, openInternalUrl: async ({ url }) => ({ - ok: openInternalUrl(url), + ok: openInternalUrl(url) }), getAuthSession: async () => getAuthSession(), setAuthSession: async ({ session }) => ({ - ok: await setAuthSession(session), + ok: await setAuthSession(session) }), clearAuthSession: async () => ({ - ok: await clearAuthSession(), + ok: await clearAuthSession() }), getAppSettings: async () => getAppSettings(), setAppSettings: async ({ settings }) => ({ - ok: await setAppSettings(settings), + ok: await setAppSettings(settings) }), startAppUpdate: async ({ update }) => { - const mainWindow = getMainWindow(); + const mainWindow = getMainWindow() const sendProgress = (payload) => { - sendToRenderer("appUpdateProgress", { + sendToRenderer('appUpdateProgress', { timestamp: new Date().toISOString(), - ...payload, - }); - }; + ...payload + }) + } - await startAppUpdate(mainWindow, update, sendProgress); - return { ok: true }; + await startAppUpdate(mainWindow, update, sendProgress) + return { ok: true } }, resizeSpotlightWindow: async ({ height }) => ({ - ok: resizeSpotlightWindow(height), + ok: resizeSpotlightWindow(height) }), setSidebarViewMenu: async ({ sections }) => ({ - ok: setSidebarViewMenu(sections), + ok: setSidebarViewMenu(sections) }), - getAppVersion: async () => process.env.ELECTROBUN_VERSION || "desktop", + getAppVersion: async () => process.env.ELECTROBUN_VERSION || 'desktop' }, - messages: {}, - }, - }); + messages: {} + } + }) } diff --git a/src/desktop/spotlight.js b/src/desktop/spotlight.js index 343e45b..106624f 100644 --- a/src/desktop/spotlight.js +++ b/src/desktop/spotlight.js @@ -1,33 +1,33 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { BrowserWindow, GlobalShortcut } from "electrobun/bun"; +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { BrowserWindow, GlobalShortcut } from 'electrobun/bun' -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) -const DEV_SERVER_PORT = 5780; -const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; -const SPOTLIGHT_ROUTE_PATH = "/dashboard/electron/spotlightcontent"; +const DEV_SERVER_PORT = 5780 +const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` +const SPOTLIGHT_ROUTE_PATH = '/dashboard/electron/spotlightcontent' -let spotlightWindow = null; +let spotlightWindow = null function getSpotlightRouteUrl() { - if (process.env.NODE_ENV === "development") { - return `${DEV_SERVER_URL}${SPOTLIGHT_ROUTE_PATH}`; + if (process.env.NODE_ENV === 'development') { + return `${DEV_SERVER_URL}${SPOTLIGHT_ROUTE_PATH}` } - return `views://mainview/index.html#${SPOTLIGHT_ROUTE_PATH}`; + return `views://mainview/index.html#${SPOTLIGHT_ROUTE_PATH}` } export function openSpotlightContentWindow(rpc) { if (spotlightWindow && !spotlightWindow.isDestroyed?.()) { - spotlightWindow.show?.(); - spotlightWindow.focus?.(); - return spotlightWindow; + spotlightWindow.show?.() + spotlightWindow.focus?.() + return spotlightWindow } spotlightWindow = new BrowserWindow({ - title: "Farm Control Spotlight", + title: 'Farm Control Spotlight', url: getSpotlightRouteUrl(), rpc, transparent: true, @@ -35,69 +35,72 @@ export function openSpotlightContentWindow(rpc) { width: 700, height: 40, x: 100, - y: 100, - }, - }); - - spotlightWindow.on?.("close", (event) => { - event?.preventDefault?.(); - if (spotlightWindow && !spotlightWindow.isDestroyed?.()) { - spotlightWindow.hide?.(); + y: 100 } - }); + }) - spotlightWindow.on?.("blur", () => { + spotlightWindow.on?.('close', (event) => { + event?.preventDefault?.() if (spotlightWindow && !spotlightWindow.isDestroyed?.()) { - spotlightWindow.hide?.(); + spotlightWindow.hide?.() } - }); + }) - return spotlightWindow; + spotlightWindow.on?.('blur', () => { + if (spotlightWindow && !spotlightWindow.isDestroyed?.()) { + spotlightWindow.hide?.() + } + }) + + return spotlightWindow } export function getSpotlightWindow() { - return spotlightWindow; + return spotlightWindow } export function registerGlobalShortcuts(rpc) { try { - const registered = GlobalShortcut.register("Alt+Shift+Q", () => { - openSpotlightContentWindow(rpc); - }); + const registered = GlobalShortcut.register('Alt+Shift+Q', () => { + openSpotlightContentWindow(rpc) + }) if (!registered) { - console.warn("[globalShortcut] Failed to register Alt+Shift+Q"); + console.warn('[globalShortcut] Failed to register Alt+Shift+Q') } } catch (error) { console.warn( - "[globalShortcut] Error registering Alt+Shift+Q", - error?.message || error, - ); + '[globalShortcut] Error registering Alt+Shift+Q', + error?.message || error + ) } } export function unregisterGlobalShortcuts() { try { - GlobalShortcut.unregisterAll(); + GlobalShortcut.unregisterAll() } catch (error) { console.warn( - "[globalShortcut] Error unregistering shortcuts", - error?.message || error, - ); + '[globalShortcut] Error unregistering shortcuts', + error?.message || error + ) } } export function resizeSpotlightWindow(height) { - if (!spotlightWindow || spotlightWindow.isDestroyed?.()) return false; + if (!spotlightWindow || spotlightWindow.isDestroyed?.()) return false try { - const frame = spotlightWindow.getFrame?.() || spotlightWindow.getBounds?.(); - const width = frame?.width || 700; - spotlightWindow.setSize?.(width, height); - spotlightWindow.center?.(); - return true; + const frame = spotlightWindow.getFrame?.() || spotlightWindow.getBounds?.() + const width = frame?.width || 700 + spotlightWindow.setSize?.(width, height) + spotlightWindow.center?.() + return true } catch (error) { - console.warn("[spotlight] Failed to resize window.", error?.message || error); - return false; + console.warn( + '[spotlight] Failed to resize window.', + error?.message || error + ) + return false } } diff --git a/src/desktop/window.js b/src/desktop/window.js index 34e2d33..1e4f562 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -1,258 +1,264 @@ -import Electrobun, { BrowserWindow, Updater, Utils } from "electrobun/bun"; -import { applyApplicationMenu, setupApplicationMenuEvents } from "./menu.js"; -import { applyMacOSWindowEffects } from "./macos-window-effects.js"; -import { sendToRenderer, setMessageSender } from "./notify.js"; +import Electrobun, { BrowserWindow, Updater, Utils } from 'electrobun/bun' +import { applyApplicationMenu, setupApplicationMenuEvents } from './menu.js' +import { applyMacOSWindowEffects } from './macos-window-effects.js' +import { sendToRenderer, setMessageSender } from './notify.js' -const isMacOS = process.platform === "darwin"; +const isMacOS = process.platform === 'darwin' -const DEV_SERVER_PORT = 5780; -const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; -const PROTOCOL_PREFIX = "farmcontrol://"; +const DEV_SERVER_PORT = 5780 +const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` +const PROTOCOL_PREFIX = 'farmcontrol://' -let mainWindow = null; -let webviewDomReady = false; -const pendingNavigations = []; +let mainWindow = null +let webviewDomReady = false +const pendingNavigations = [] export function getMainWindow() { - return mainWindow; + return mainWindow } export async function getMainViewUrl() { - const channel = await Updater.localInfo.channel(); - if (channel === "dev" || process.env.NODE_ENV === "development") { + const channel = await Updater.localInfo.channel() + if (channel === 'dev' || process.env.NODE_ENV === 'development') { try { - await fetch(DEV_SERVER_URL, { method: "HEAD" }); - console.log(`Using Vite dev server at ${DEV_SERVER_URL}`); - return DEV_SERVER_URL; + await fetch(DEV_SERVER_URL, { method: 'HEAD' }) + console.log(`Using Vite dev server at ${DEV_SERVER_URL}`) + return DEV_SERVER_URL } catch { console.warn( - "Vite dev server not running. Start it with `bun run dev:renderer`.", - ); + 'Vite dev server not running. Start it with `bun run dev:renderer`.' + ) } } - return "views://mainview/index.html"; + return 'views://mainview/index.html' } function deliverNavigation(redirectPath) { - sendToRenderer("navigate", redirectPath); - mainWindow?.show?.(); - mainWindow?.activate?.(); + sendToRenderer('navigate', redirectPath) + mainWindow?.show?.() + mainWindow?.activate?.() } function flushPendingNavigations() { if (!mainWindow || !webviewDomReady) { - return; + return } while (pendingNavigations.length > 0) { - const redirectPath = pendingNavigations.shift(); - setTimeout(() => deliverNavigation(redirectPath), 100); + const redirectPath = pendingNavigations.shift() + setTimeout(() => deliverNavigation(redirectPath), 100) } } function sendNavigateToRenderer(redirectPath) { - if (!redirectPath || typeof redirectPath !== "string") { - return; + if (!redirectPath || typeof redirectPath !== 'string') { + return } if (!mainWindow || !webviewDomReady) { - pendingNavigations.push(redirectPath); - return; + pendingNavigations.push(redirectPath) + return } - setTimeout(() => deliverNavigation(redirectPath), 100); + setTimeout(() => deliverNavigation(redirectPath), 100) } export function handleDeepLink(url) { - if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return; - const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, "") || "/"; - sendNavigateToRenderer(redirectPath); + if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return + const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, '') || '/' + sendNavigateToRenderer(redirectPath) } function findProtocolUrl(args) { return args.find( - (arg) => typeof arg === "string" && arg.startsWith(PROTOCOL_PREFIX), - ); + (arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX) + ) } export function handleDeepLinkFromArgv() { - if (process.platform === "darwin") return; - const url = findProtocolUrl(process.argv); - if (url) handleDeepLink(url); + if (process.platform === 'darwin') return + const url = findProtocolUrl(process.argv) + if (url) handleDeepLink(url) +} + +function broadcastWindowState() { + sendToRenderer('windowState', getWindowState()) } function setupWindowEvents(window) { - window.on?.("maximize", () => { - sendToRenderer("windowState", { isMaximized: true }); - }); - window.on?.("unmaximize", () => { - sendToRenderer("windowState", { isMaximized: false }); - }); - window.on?.("enter-full-screen", () => { - sendToRenderer("windowState", { isFullScreen: true }); - }); - window.on?.("leave-full-screen", () => { - sendToRenderer("windowState", { isFullScreen: false }); - }); + // Electrobun emits resize/focus, not Electron's maximize/fullscreen events. + window.on?.('resize', broadcastWindowState) + window.on?.('focus', broadcastWindowState) } export function setupMainWindowMessaging(window = mainWindow) { if (!window) { - return; + return } setMessageSender((channel, data) => { try { - const send = window.webview?.rpc?.send; - if (!send) { - console.warn( - `No RPC sender available for channel: ${channel}. Is the window ready?`, - ); - return false; + const webview = window.webview + const channelLiteral = JSON.stringify(channel) + const payloadLiteral = JSON.stringify(data ?? null) + + // Prefer direct JS dispatch — WebSocket RPC pushes can be deferred by + // WKWebView until the next interaction during native window transitions. + if (webview?.executeJavascript) { + webview.executeJavascript( + `window.__farmcontrolDispatchRpcMessage?.(${channelLiteral}, ${payloadLiteral})` + ) + return true } - send[channel](data); - return true; + const send = webview?.rpc?.send + if (!send) { + console.warn( + `No RPC sender available for channel: ${channel}. Is the window ready?` + ) + return false + } + + send[channel](data) + return true } catch (error) { - console.warn(`Failed to send RPC message on channel: ${channel}`, error); - return false; + console.warn(`Failed to send RPC message on channel: ${channel}`, error) + return false } - }); + }) } export async function createMainWindow(rpc) { - const url = await getMainViewUrl(); + const url = await getMainViewUrl() mainWindow = new BrowserWindow({ - title: "Farm Control", + title: 'Farm Control', url, rpc, - titleBarStyle: "hiddenInset", - trafficLightOffset: { x: 14, y: 12 }, + titleBarStyle: 'hiddenInset', ...(isMacOS ? { transparent: true } : {}), frame: { width: 1200, height: 800, x: 100, - y: 100, - }, - }); + y: 100 + } + }) if (isMacOS) { - applyMacOSWindowEffects(mainWindow); + applyMacOSWindowEffects(mainWindow) } - setupMainWindowMessaging(mainWindow); - applyApplicationMenu(); + setupMainWindowMessaging(mainWindow) + applyApplicationMenu() setupApplicationMenuEvents({ onNavigate: sendNavigateToRenderer, onToggleDevTools: () => { - mainWindow?.webview?.toggleDevTools?.(); - }, - }); - - setupWindowEvents(mainWindow); - - Electrobun.events.on("open-url", (event) => { - const url = event?.data?.url; - if (url) { - handleDeepLink(url); + mainWindow?.webview?.toggleDevTools?.() } - }); + }) + + setupWindowEvents(mainWindow) + + Electrobun.events.on('open-url', (event) => { + const url = event?.data?.url + if (url) { + handleDeepLink(url) + } + }) return new Promise((resolve) => { - mainWindow.webview.on("dom-ready", () => { - webviewDomReady = true; - flushPendingNavigations(); - resolve(mainWindow); - }); - }); + mainWindow.webview.on('dom-ready', () => { + webviewDomReady = true + flushPendingNavigations() + resolve(mainWindow) + }) + }) } export async function setupDevAuthServer() { - const env = (process.env.NODE_ENV || "development").trim(); - if (env !== "development") return; + const env = (process.env.NODE_ENV || 'development').trim() + if (env !== 'development') return - const express = (await import("express")).default; - const app = express(); - const port = 3500; + const express = (await import('express')).default + const app = express() + const port = 3500 app.use((req, res) => { - const redirectPath = req.originalUrl; - res.send( - `Open Farmcontrol to continue... (Redirect path: ${redirectPath})`, - ); - sendNavigateToRenderer(redirectPath); - }); + const redirectPath = req.originalUrl + res.send(`Open Farmcontrol to continue... (Redirect path: ${redirectPath})`) + sendNavigateToRenderer(redirectPath) + }) - app.listen(port, () => {}); + app.listen(port, () => {}) } export function openInternalUrl(url) { - sendNavigateToRenderer(url); - return true; + sendNavigateToRenderer(url) + return true } export function getWindowState() { if (!mainWindow) { - return { isFullScreen: false, isMaximized: false }; + return { isFullScreen: false, isMaximized: false } } return { isFullScreen: mainWindow.isFullScreen?.() ?? false, - isMaximized: mainWindow.isMaximized?.() ?? false, - }; + isMaximized: mainWindow.isMaximized?.() ?? false + } } export function handleWindowControl(action) { - if (!mainWindow) return; + if (!mainWindow) return switch (action) { - case "minimize": - mainWindow.minimize?.(); - break; - case "maximize": + case 'minimize': + mainWindow.minimize?.() + break + case 'maximize': if (mainWindow.isMaximized?.()) { - mainWindow.unmaximize?.(); + mainWindow.unmaximize?.() } else { - mainWindow.maximize?.(); + mainWindow.maximize?.() } - break; - case "close": - mainWindow.close?.(); - break; + break + case 'close': + mainWindow.close?.() + break default: - break; + break } } export function sendNavigationGesture(direction) { - sendToRenderer("navigationGesture", direction); + sendToRenderer('navigationGesture', direction) } export function setupNavigationGestures(window) { - if (!window) return; + if (!window) return - if (process.platform === "darwin") { - window.on?.("swipe", (_event, direction) => { - if (direction === "left") { - sendNavigationGesture("back"); - } else if (direction === "right") { - sendNavigationGesture("forward"); + if (process.platform === 'darwin') { + window.on?.('swipe', (_event, direction) => { + if (direction === 'left') { + sendNavigationGesture('back') + } else if (direction === 'right') { + sendNavigationGesture('forward') } - }); + }) } - window.on?.("app-command", (_event, command) => { - if (command === "browser-backward") { - sendNavigationGesture("back"); - } else if (command === "browser-forward") { - sendNavigationGesture("forward"); + window.on?.('app-command', (_event, command) => { + if (command === 'browser-backward') { + sendNavigationGesture('back') + } else if (command === 'browser-forward') { + sendNavigationGesture('forward') } - }); + }) } export function openExternalUrl(url) { - Utils.openExternal(url); + console.log('openExternalUrl', url) + Utils.openExternal(url) } diff --git a/src/electrobun-bridge.js b/src/electrobun-bridge.js index d2cb199..0309270 100644 --- a/src/electrobun-bridge.js +++ b/src/electrobun-bridge.js @@ -1,173 +1,191 @@ -const listeners = new Map(); -const pendingMessages = new Map(); -let rpc = null; -let initPromise = null; -let initialized = false; +const listeners = new Map() +const pendingMessages = new Map() +let rpc = null +let initPromise = null +let initialized = false export function isElectrobunDesktop() { return Boolean( - typeof window !== "undefined" && - window.__electrobunWebviewId && - window.__electrobunRpcSocketPort, - ); + typeof window !== 'undefined' && + window.__electrobunWebviewId && + window.__electrobunRpcSocketPort + ) } function dispatchMessage(channel, data) { - const channelListeners = listeners.get(channel); + const channelListeners = listeners.get(channel) if (!channelListeners?.size) { if (!pendingMessages.has(channel)) { - pendingMessages.set(channel, []); + pendingMessages.set(channel, []) } - pendingMessages.get(channel).push(data); - return; + pendingMessages.get(channel).push(data) + return } for (const callback of channelListeners) { - callback(data); + callback(data) } } +// Bun delivers push messages via executeJavascript for immediate handling. +// WebSocket RPC messages can be deferred by WKWebView during native window +// transitions (e.g. fullscreen) until the next user interaction. +if (typeof window !== 'undefined') { + window.__farmcontrolDispatchRpcMessage = dispatchMessage +} + async function setupRpc() { // electrobun/view captures webview globals at import time — import only after // Electrobun preload has set them (Vite dev can evaluate modules earlier). - const { default: Electrobun, Electroview } = await import("electrobun/view"); + const { default: Electrobun, Electroview } = await import('electrobun/view') rpc = Electroview.defineRPC({ maxRequestTime: 30000, handlers: { requests: {}, messages: { - "*": (channel, data) => { - dispatchMessage(channel, data); - }, - }, - }, - }); + '*': (channel, data) => { + dispatchMessage(channel, data) + } + } + } + }) - new Electrobun.Electroview({ rpc }); + new Electrobun.Electroview({ rpc }) } function shouldWaitForElectrobun() { if (isElectrobunDesktop()) { - return true; + return true } - if (typeof window === "undefined") { - return false; + if (typeof window === 'undefined') { + return false } if (window.__electrobunWebviewId) { - return true; + return true } - const { protocol, hostname, port } = window.location; + const { protocol, hostname, port } = window.location - if (protocol === "views:") { - return true; + if (protocol === 'views:') { + return true } // Vite dev server used by electrobun dev:app - if (hostname === "localhost" && port === "5780") { - return true; + if (hostname === 'localhost' && port === '5780') { + return true } return Boolean( window.__electrobun || - window.__electrobunEventBridge || - window.__electrobunInternalBridge, - ); + window.__electrobunEventBridge || + window.__electrobunInternalBridge + ) } export async function initElectrobunBridge() { if (initialized) { - return; + return } if (!initPromise) { initPromise = (async () => { if (!shouldWaitForElectrobun()) { - return; + return } - const deadline = Date.now() + 3000; + const deadline = Date.now() + 3000 while (Date.now() < deadline) { if (isElectrobunDesktop()) { - await setupRpc(); - window.electronAPI = electronAPI; - initialized = true; - return; + await setupRpc() + window.electronAPI = electronAPI + initialized = true + return } - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)) } console.warn( - "Electrobun bridge: webview globals not found; desktop RPC unavailable.", - ); - })(); + 'Electrobun bridge: webview globals not found; desktop RPC unavailable.' + ) + })() } - await initPromise; + await initPromise } function onMessage(channel, callback) { - if (!listeners.has(channel)) { - listeners.set(channel, new Set()); - } - listeners.get(channel).add(callback); + void initElectrobunBridge() - const queued = pendingMessages.get(channel); + if (!listeners.has(channel)) { + listeners.set(channel, new Set()) + } + listeners.get(channel).add(callback) + + const queued = pendingMessages.get(channel) if (queued?.length) { - pendingMessages.delete(channel); + pendingMessages.delete(channel) for (const payload of queued) { - callback(payload); + callback(payload) } } return () => { - listeners.get(channel)?.delete(callback); - }; + listeners.get(channel)?.delete(callback) + } } function removeAllListeners(channel) { - listeners.delete(channel); - pendingMessages.delete(channel); + listeners.delete(channel) + pendingMessages.delete(channel) } async function invokeRequest(method, params = {}) { - await initElectrobunBridge(); + await initElectrobunBridge() - if (!rpc?.request?.[method]) { - console.warn(`Unhandled RPC request: ${method}`); - return null; + if (!rpc?.request) { + console.warn(`Electrobun RPC unavailable for request: ${method}`) + return null } - return await rpc.request[method](params); + try { + return await rpc.request[method](params) + } catch (error) { + console.warn(`Electrobun RPC request failed: ${method}`, error) + return null + } } const electronAPI = { get isDesktop() { - return isElectrobunDesktop(); + return isElectrobunDesktop() }, onMessage, removeAllListeners, - getOsInfo: () => invokeRequest("getOsInfo"), - getWindowState: () => invokeRequest("getWindowState"), - windowControl: (action) => invokeRequest("windowControl", { action }), - openExternalUrl: (url) => invokeRequest("openExternalUrl", { url }), - openInternalUrl: (url) => invokeRequest("openInternalUrl", { url }), - getAuthSession: () => invokeRequest("getAuthSession"), - setAuthSession: (session) => invokeRequest("setAuthSession", { session }), - clearAuthSession: () => invokeRequest("clearAuthSession"), - getAppSettings: () => invokeRequest("getAppSettings"), - setAppSettings: (settings) => invokeRequest("setAppSettings", { settings }), - startAppUpdate: (update) => invokeRequest("startAppUpdate", { update }), + getOsInfo: () => invokeRequest('getOsInfo'), + getWindowState: () => invokeRequest('getWindowState'), + windowControl: (action) => invokeRequest('windowControl', { action }), + openExternalUrl: (url) => invokeRequest('openExternalUrl', { url }), + openInternalUrl: (url) => invokeRequest('openInternalUrl', { url }), + getAuthSession: () => invokeRequest('getAuthSession'), + setAuthSession: (session) => invokeRequest('setAuthSession', { session }), + clearAuthSession: () => invokeRequest('clearAuthSession'), + getAppSettings: () => invokeRequest('getAppSettings'), + setAppSettings: (settings) => invokeRequest('setAppSettings', { settings }), + startAppUpdate: (update) => invokeRequest('startAppUpdate', { update }), resizeSpotlightWindow: (height) => - invokeRequest("resizeSpotlightWindow", { height }), + invokeRequest('resizeSpotlightWindow', { height }), setSidebarViewMenu: (sections) => - invokeRequest("setSidebarViewMenu", { sections }), - getAppVersion: () => invokeRequest("getAppVersion"), -}; + invokeRequest('setSidebarViewMenu', { sections }), + getAppVersion: () => invokeRequest('getAppVersion') +} -export default electronAPI; +if (typeof window !== 'undefined') { + window.electronAPI = electronAPI +} + +export default electronAPI