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 { configureWindowMessaging, sendToRenderer, sendToWindow } from './notify.js' import { clampWindowToWorkArea, isWindowWorkAreaMaximized } from './windows-work-area.js' import { findProtocolUrl, setSingleInstanceHandlers } from './single-instance.js' import { getAppSettings } from './store.js' import { getWindowSessionFile, writeWindowSessionFile, writeWindowSessionFileSync } from './session.js' const isMacOS = process.platform === 'darwin' const isWindows = process.platform === 'win32' const WINDOWS_STARTUP_MAXIMIZE_DELAY_MS = 1500 const DEV_SERVER_PORT = 5173 const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` const DEFAULT_DASHBOARD_PATH = '/dashboard/production/overview' const PROTOCOL_PREFIX = 'farmcontrol://' const windows = new Map() let focusedWindowId = null let sharedRpc = null let appListenersBound = false let persistTimer = null export function getDefaultDashboardPath() { return DEFAULT_DASHBOARD_PATH } function createWindowId() { return `window-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } function getEntry(windowId) { return windowId ? windows.get(windowId) : null } export function getFocusedWindowId() { if (focusedWindowId && windows.has(focusedWindowId)) { return focusedWindowId } const first = windows.keys().next() return first.done ? null : first.value } export function getWindowById(windowId) { return getEntry(windowId)?.window || null } export function getMainWindow() { return getWindowById(getFocusedWindowId()) } function dispatchToWindow(windowId, channel, data) { const entry = getEntry(windowId) const window = entry?.window if (!window) return false try { const webview = window.webview const channelLiteral = JSON.stringify(channel) const payloadLiteral = JSON.stringify(data ?? null) 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 } } function setupMessaging() { configureWindowMessaging({ sendToFocused: (channel, data) => dispatchToWindow(getFocusedWindowId(), channel, data), sendToWindow: (windowId, channel, data) => dispatchToWindow(windowId, channel, data), sendToAll: (channel, data) => { let sent = false for (const id of windows.keys()) { if (dispatchToWindow(id, channel, data)) { sent = true } } return sent } }) } export function showMainWindow() { const window = getMainWindow() if (!window) return if (window.isMinimized?.()) { window.restore?.() } window.show?.() window.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(windowId, redirectPath) { sendToWindow(windowId, 'navigate', redirectPath) const window = getWindowById(windowId) if (!window) return if (window.isMinimized?.()) { window.restore?.() } window.show?.() window.activate?.() } function flushPendingNavigations(windowId) { const entry = getEntry(windowId) if (!entry?.domReady) return while (entry.pendingNavigations.length > 0) { const redirectPath = entry.pendingNavigations.shift() setTimeout(() => deliverNavigation(windowId, redirectPath), 100) } } function sendNavigateToRenderer(redirectPath) { if (!redirectPath || typeof redirectPath !== 'string') { return } const windowId = getFocusedWindowId() if (!windowId) return const entry = getEntry(windowId) if (!entry?.domReady) { entry?.pendingNavigations.push(redirectPath) return } setTimeout(() => deliverNavigation(windowId, redirectPath), 100) } export function openInternalUrl(url) { sendNavigateToRenderer(url) return true } 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 readWindowSize(window) { const size = window?.getSize?.() if (size && typeof size === 'object') { return { width: size.width ?? size[0], height: size.height ?? size[1] } } return { width: undefined, height: undefined } } function readWindowPosition(window) { const position = window?.getPosition?.() if (position && typeof position === 'object') { return { x: position.x ?? position[0], y: position.y ?? position[1] } } return { x: undefined, y: undefined } } function captureWindowLayout(windowId) { const entry = getEntry(windowId) if (!entry?.window) return const { width, height } = readWindowSize(entry.window) const { x, y } = readWindowPosition(entry.window) if (width && height) { entry.bounds = { x: x ?? entry.bounds?.x ?? 100, y: y ?? entry.bounds?.y ?? 100, width, height } } entry.isFullScreen = entry.window.isFullScreen?.() ?? false entry.isMaximized = isWindows ? isWindowWorkAreaMaximized(entry.window) : (entry.window.isMaximized?.() ?? false) } function serializeWindows() { return { windows: [...windows.values()].map((entry) => ({ windowId: entry.id, bounds: entry.bounds, isMaximized: entry.isMaximized, isFullScreen: entry.isFullScreen, activeTabId: entry.activeTabId, tabs: entry.tabs })) } } export function persistSessionNow() { clearTimeout(persistTimer) persistTimer = null writeWindowSessionFileSync(serializeWindows()) } function scheduleSessionPersist() { clearTimeout(persistTimer) persistTimer = setTimeout(() => { void writeWindowSessionFile(serializeWindows()) }, 400) } export function syncWindowTabs(windowId, { activeTabId, tabs } = {}) { const entry = getEntry(windowId) || getEntry(getFocusedWindowId()) if (!entry) return false if (Array.isArray(tabs)) { entry.tabs = tabs } if (activeTabId) { entry.activeTabId = activeTabId } captureWindowLayout(entry.id) scheduleSessionPersist() return true } export function getWindowSession(windowId) { const entry = getEntry(windowId) || getEntry(getFocusedWindowId()) if (!entry) { return { windowId: null, tabs: [], activeTabId: null } } return { windowId: entry.id, tabs: entry.tabs, activeTabId: entry.activeTabId, bounds: entry.bounds, isMaximized: entry.isMaximized, isFullScreen: entry.isFullScreen } } let pendingTabDrag = null export function beginTabDrag({ windowId, tab } = {}) { if (!tab?.id) return { ok: false } pendingTabDrag = { windowId: windowId || getFocusedWindowId(), tab, startedAt: Date.now() } return { ok: true } } export function completeTabDrop({ windowId, beforeTabId, insertBefore = false } = {}) { const pending = pendingTabDrag pendingTabDrag = null if (!pending?.tab) { return { ok: false } } const targetWindowId = windowId || getFocusedWindowId() if (pending.windowId && pending.windowId === targetWindowId) { return { ok: true, sameWindow: true } } sendToWindow(pending.windowId, 'tabMovedAway', { tabId: pending.tab.id }) return { ok: true, sameWindow: false, tab: pending.tab, sourceWindowId: pending.windowId, beforeTabId, insertBefore } } export function cancelTabDrag({ windowId } = {}) { if (!pendingTabDrag) return { ok: true } if (windowId && pendingTabDrag.windowId !== windowId) { return { ok: true } } pendingTabDrag = null return { ok: true } } function broadcastWindowState(windowId) { const id = windowId || getFocusedWindowId() if (!id) return sendToWindow(id, 'windowState', getWindowState(id)) } 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 } window.setSize(width, height) } function handleWindowsWindowChange(window, windowId) { if (clampWindowToWorkArea(window)) { syncWindowsWebviewLayout(window) } captureWindowLayout(windowId) broadcastWindowState(windowId) } function applyWindowsStartupWindowState(window, windowId) { if (!window) return setTimeout(() => { window.maximize?.() setTimeout(() => handleWindowsWindowChange(window, windowId), 100) }, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS) } function setupWindowEvents(window, windowId) { const onWindowChange = isWindows ? () => handleWindowsWindowChange(window, windowId) : () => { captureWindowLayout(windowId) broadcastWindowState(windowId) scheduleSessionPersist() } window.on?.('resize', onWindowChange) window.on?.('move', onWindowChange) window.on?.('focus', () => { focusedWindowId = windowId broadcastWindowState(windowId) }) window.on?.('close', () => { captureWindowLayout(windowId) windows.delete(windowId) if (focusedWindowId === windowId) { focusedWindowId = getFocusedWindowId() } persistSessionNow() }) } function injectWindowId(window, windowId) { try { window?.webview?.executeJavascript?.( `window.__farmcontrolWindowId = ${JSON.stringify(windowId)}` ) } catch (error) { console.warn('[window] Failed to inject window id.', error) } } function createDefaultTab() { const id = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` return { id, title: 'Overview - Production', modelName: null, history: [ { pathname: DEFAULT_DASHBOARD_PATH, search: '', hash: '' } ], historyIndex: 0 } } function offsetBounds(bounds) { if (!bounds) { return { x: 120, y: 120, width: 1200, height: 800 } } return { x: (bounds.x ?? 100) + 32, y: (bounds.y ?? 100) + 32, width: bounds.width || 1200, height: bounds.height || 800 } } export async function createDesktopWindow({ rpc = sharedRpc, windowId = createWindowId(), bounds, isMaximized = false, isFullScreen = false, tabs, activeTabId, applyDefaultMaximize = false } = {}) { if (!rpc) { throw new Error('Cannot create a desktop window without RPC.') } sharedRpc = rpc setupMessaging() const initialTabs = Array.isArray(tabs) && tabs.length > 0 ? tabs : [createDefaultTab()] const initialActiveTabId = activeTabId || initialTabs[0].id const frame = bounds || { width: 1200, height: 800, x: 100, y: 100 } const url = await getMainViewUrl() const window = new BrowserWindow({ title: 'Farm Control', url, rpc, titleBarStyle: 'hiddenInset', ...(isMacOS ? { transparent: true, trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET } : {}), frame: { width: frame.width || 1200, height: frame.height || 800, x: frame.x ?? 100, y: frame.y ?? 100 } }) const entry = { id: windowId, window, tabs: initialTabs, activeTabId: initialActiveTabId, bounds: frame, isMaximized: Boolean(isMaximized), isFullScreen: Boolean(isFullScreen), domReady: false, pendingNavigations: [] } windows.set(windowId, entry) focusedWindowId = windowId injectWindowId(window, windowId) if (isMacOS) { applyMacOSWindowEffects(window) } setupAppListeners() setupWindowEvents(window, windowId) setupNavigationGestures(window, windowId) if (applyDefaultMaximize) { applyStartupWindowState(window) } else if (isMaximized) { window.maximize?.() setTimeout(() => broadcastWindowState(windowId), 100) } else if (isFullScreen) { window.setFullScreen?.(true) setTimeout(() => broadcastWindowState(windowId), 100) } else { setTimeout(() => broadcastWindowState(windowId), 100) } return new Promise((resolve) => { window.webview.on('dom-ready', () => { entry.domReady = true injectWindowId(window, windowId) if (isWindows && applyDefaultMaximize) { applyWindowsStartupWindowState(window, windowId) } flushPendingNavigations(windowId) resolve(window) }) }) } export async function createAppWindow({ tabs, activeTabId, sourceWindowId } = {}) { const source = getEntry(sourceWindowId) || getEntry(getFocusedWindowId()) captureWindowLayout(source?.id) return createDesktopWindow({ rpc: sharedRpc, bounds: offsetBounds(source?.bounds), tabs, activeTabId, applyDefaultMaximize: false }) } function setupAppListeners() { if (appListenersBound) return appListenersBound = true if (isMacOS) { applyApplicationMenu() setupApplicationMenuEvents({ onNavigate: sendNavigateToRenderer, onToggleDevTools: () => { getMainWindow()?.webview?.toggleDevTools?.() }, onCheckForUpdates: () => { sendToRenderer('checkForUpdates') }, onNewTab: () => { sendToRenderer('newTab') }, onNewWindow: () => { sendToRenderer('newWindow') } }) } Electrobun.events.on('open-url', (event) => { const url = event?.data?.url if (url) { handleDeepLink(url) } }) } export async function startDesktopWindows(rpc) { sharedRpc = rpc setupMessaging() const settings = await getAppSettings() const resumeLastSession = settings.appResumeLastSession !== false const saved = resumeLastSession ? await getWindowSessionFile() : { windows: [] } const savedWindows = Array.isArray(saved.windows) ? saved.windows : [] if (savedWindows.length > 0) { for (const savedWindow of savedWindows) { await createDesktopWindow({ rpc, windowId: savedWindow.windowId || createWindowId(), bounds: savedWindow.bounds, isMaximized: savedWindow.isMaximized, isFullScreen: savedWindow.isFullScreen, tabs: savedWindow.tabs, activeTabId: savedWindow.activeTabId, applyDefaultMaximize: false }) } return getMainWindow() } return createDesktopWindow({ rpc, applyDefaultMaximize: true }) } export async function createMainWindow(rpc) { return startDesktopWindows(rpc) } 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(windowId) { const window = getWindowById(windowId || getFocusedWindowId()) if (!window) { return { isFullScreen: false, isMaximized: false } } return { isFullScreen: window.isFullScreen?.() ?? false, isMaximized: isWindows ? isWindowWorkAreaMaximized(window) : (window.isMaximized?.() ?? false) } } export function handleWindowControl(action, windowId) { const window = getWindowById(windowId || getFocusedWindowId()) if (!window && action !== 'quit') return switch (action) { case 'minimize': window.minimize?.() break case 'maximize': { const currentlyMaximized = isWindows ? isWindowWorkAreaMaximized(window) : (window.isMaximized?.() ?? false) if (currentlyMaximized) { window.unmaximize?.() } else { window.maximize?.() } if (isWindows) { setTimeout( () => handleWindowsWindowChange(window, windowId || getFocusedWindowId()), 100 ) } break } case 'fullscreen': if (window.isFullScreen?.()) { window.setFullScreen?.(false) } else { window.setFullScreen?.(true) } setTimeout( () => broadcastWindowState(windowId || getFocusedWindowId()), 100 ) break case 'close': window.close?.() break case 'quit': persistSessionNow() Utils.quit() break case 'toggle-devtools': window?.webview?.toggleDevTools?.() break default: break } } export function sendNavigationGesture(direction, windowId) { sendToWindow(windowId || getFocusedWindowId(), 'navigationGesture', direction) } export function setupNavigationGestures(window, windowId) { if (!window) return const id = windowId || getFocusedWindowId() if (process.platform === 'darwin') { window.on?.('swipe', (_event, direction) => { if (direction === 'left') { sendNavigationGesture('back', id) } else if (direction === 'right') { sendNavigationGesture('forward', id) } }) } window.on?.('app-command', (_event, command) => { if (command === 'browser-backward') { sendNavigationGesture('back', id) } else if (command === 'browser-forward') { sendNavigationGesture('forward', id) } }) } export function openExternalUrl(url) { console.log('openExternalUrl', url) Utils.openExternal(url) }