Tom Butcher 79ee30fd25
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Refactor window state handling in window.js and adjust frame dimensions in windows-work-area.js
- Removed redundant window state synchronization calls in `applyWindowsStartupWindowState` to streamline the startup process.
- Updated the `clampWindowToWorkArea` function to adjust the window frame dimensions, ensuring proper fitting within the work area.
2026-08-03 18:21:52 +01:00

399 lines
8.9 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 { 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?.()
}, 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)
}