Tom Butcher 7ab05c8c2d
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Update Window Configuration and Code Formatting
- Changed the development server port from 5780 to 5173 for updated local development settings.
- Reformatted import statements for better readability and consistency.
- Simplified the timeout function call in handleWindowControl for improved code clarity.
2026-09-18 10:22:42 +01:00

798 lines
19 KiB
JavaScript

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)
}