import Electrobun, { BrowserWindow, Updater, Utils } from 'electrobun/bun' import { applyApplicationMenu, setupApplicationMenuEvents } from './menu.js' import { applyMacOSWindowEffects, MAC_TRAFFIC_LIGHT_OFFSET } from './macos-window-effects.js' import { sendToRenderer, setMessageSender } from './notify.js' import { clampWindowToWorkArea, isWindowWorkAreaMaximized } from './windows-work-area.js' const isMacOS = process.platform === 'darwin' const isWindows = process.platform === 'win32' const WINDOWS_STARTUP_MAXIMIZE_DELAY_MS = 1500 const DEV_SERVER_PORT = 5780 const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` import { findProtocolUrl, setSingleInstanceHandlers } from './single-instance.js' let mainWindow = null let webviewDomReady = false const pendingNavigations = [] export function getMainWindow() { return mainWindow } export function showMainWindow() { if (!mainWindow) return if (mainWindow.isMinimized?.()) { mainWindow.restore?.() } mainWindow.show?.() mainWindow.activate?.() } export async function getMainViewUrl() { 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 } catch { console.warn( 'Vite dev server not running. Start it with `bun run dev:renderer`.' ) } } return 'views://mainview/index.html' } function deliverNavigation(redirectPath) { sendToRenderer('navigate', redirectPath) if (!mainWindow) return if (mainWindow.isMinimized?.()) { mainWindow.restore?.() } mainWindow.show?.() mainWindow.activate?.() } function flushPendingNavigations() { if (!mainWindow || !webviewDomReady) { return } while (pendingNavigations.length > 0) { const redirectPath = pendingNavigations.shift() setTimeout(() => deliverNavigation(redirectPath), 100) } } function sendNavigateToRenderer(redirectPath) { if (!redirectPath || typeof redirectPath !== 'string') { return } if (!mainWindow || !webviewDomReady) { pendingNavigations.push(redirectPath) return } setTimeout(() => deliverNavigation(redirectPath), 100) } export function openInternalUrl(url) { sendNavigateToRenderer(url) return true } const PROTOCOL_PREFIX = 'farmcontrol://' function parseDeepLinkPath(url) { if (!url || typeof url !== 'string') { return null } if (url.startsWith('/')) { return url } if (!url.startsWith(`${PROTOCOL_PREFIX}app`)) { return null } const redirectPath = url.slice(`${PROTOCOL_PREFIX}app`.length) || '/' const normalizedPath = redirectPath.startsWith('/') ? redirectPath : `/${redirectPath}` try { return decodeURI(normalizedPath) } catch { return normalizedPath } } export function handleDeepLink(url) { const path = parseDeepLinkPath(url) if (!path) { showMainWindow() return false } sendNavigateToRenderer(path) return true } export function handleDeepLinkFromArgv(launchUrl) { if (process.platform === 'darwin') return const url = launchUrl || findProtocolUrl(process.argv) if (url) { handleDeepLink(url) } } function broadcastWindowState() { sendToRenderer('windowState', getWindowState()) } function applyStartupWindowState(window) { if (!window) return if (isMacOS) { window.maximize?.() } setTimeout(broadcastWindowState, 100) } function syncWindowsWebviewLayout(window) { if (!window?.getSize || !window?.setSize) { return } const { width, height } = window.getSize() if (!width || !height) { return } // Re-apply the current size so the native webview relayouts to the window. window.setSize(width, height) } function handleWindowsWindowChange(window) { if (clampWindowToWorkArea(window)) { syncWindowsWebviewLayout(window) } broadcastWindowState() } function applyWindowsStartupWindowState(window) { if (!window) return setTimeout(() => { window.maximize?.() handleWindowsWindowChange(window) setTimeout(() => syncWindowsWebviewLayout(window), 0) setTimeout(() => syncWindowsWebviewLayout(window), 100) setTimeout(broadcastWindowState, 100) }, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS) } function setupWindowEvents(window) { // Electrobun emits resize/focus, not Electron's maximize/fullscreen events. const onWindowChange = isWindows ? () => handleWindowsWindowChange(window) : broadcastWindowState window.on?.('resize', onWindowChange) window.on?.('focus', broadcastWindowState) window.on?.('move', onWindowChange) } export function setupMainWindowMessaging(window = mainWindow) { if (!window) { return } setMessageSender((channel, data) => { try { 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 } const send = webview?.rpc?.send if (!send) { console.warn( `No RPC sender available for channel: ${channel}. Is the window ready?` ) return false } send[channel](data) return true } catch (error) { console.warn(`Failed to send RPC message on channel: ${channel}`, error) return false } }) } export async function createMainWindow(rpc) { const url = await getMainViewUrl() mainWindow = new BrowserWindow({ title: 'Farm Control', url, rpc, titleBarStyle: 'hiddenInset', ...(isMacOS ? { transparent: true, trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET } : {}), frame: { width: 1200, height: 800, x: 100, y: 100 } }) if (isMacOS) { applyMacOSWindowEffects(mainWindow) } setupMainWindowMessaging(mainWindow) applyApplicationMenu() setupApplicationMenuEvents({ onNavigate: sendNavigateToRenderer, onToggleDevTools: () => { mainWindow?.webview?.toggleDevTools?.() } }) setupWindowEvents(mainWindow) applyStartupWindowState(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 if (isWindows) { applyWindowsStartupWindowState(mainWindow) } flushPendingNavigations() resolve(mainWindow) }) }) } export async function setupDevAuthServer() { const env = (process.env.NODE_ENV || 'development').trim() if (env !== 'development') return 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) }) app.listen(port, () => {}) } export function setupWindowsDeepLinkHandling() { if (process.platform === 'darwin') { return } setSingleInstanceHandlers({ onDeepLink: handleDeepLink, onFocus: showMainWindow }) } export function getWindowState() { if (!mainWindow) { return { isFullScreen: false, isMaximized: false } } return { isFullScreen: mainWindow.isFullScreen?.() ?? false, isMaximized: isWindows ? isWindowWorkAreaMaximized(mainWindow) : (mainWindow.isMaximized?.() ?? false) } } export function handleWindowControl(action) { if (!mainWindow) return switch (action) { case 'minimize': mainWindow.minimize?.() break case 'maximize': if (mainWindow.isMaximized?.()) { mainWindow.unmaximize?.() } else { mainWindow.maximize?.() } if (isWindows) { handleWindowsWindowChange(mainWindow) } break case 'fullscreen': if (mainWindow.isFullScreen?.()) { mainWindow.setFullScreen?.(false) } else { mainWindow.setFullScreen?.(true) } setTimeout(broadcastWindowState, 100) break case 'close': mainWindow.close?.() break default: break } } export function sendNavigationGesture(direction) { sendToRenderer('navigationGesture', direction) } export function setupNavigationGestures(window) { if (!window) return 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') } }) } export function openExternalUrl(url) { console.log('openExternalUrl', url) Utils.openExternal(url) }