farmcontrol-ui/src/electrobun-bridge.js
Tom Butcher d16aa373be
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Implement Navigation Tabs and Dashboard Enhancements
- Introduced NavigationTabsContext to manage tab state and navigation within the dashboard.
- Added DashboardTabs component for improved tabbed navigation, allowing users to add, close, and reorder tabs.
- Enhanced Dashboard layout to conditionally render tab panes based on the active tab, improving user experience.
- Updated various components to utilize navigation tab context, ensuring consistent title management and tab interactions.
- Implemented drag-and-drop functionality for tabs, enabling users to rearrange their workspace effectively.
- Refactored CSS styles for dashboard tabs and panes to ensure proper layout and responsiveness across devices.
2026-09-17 20:39:50 +01:00

281 lines
7.3 KiB
JavaScript

const listeners = new Map()
const pendingMessages = new Map()
const pendingRequests = new Map()
const RPC_REQUEST_TIMEOUT_MS = 30000
const RPC_RESPONSE_CHANNEL = 'rpcResponse'
let rpc = null
let initPromise = null
let initialized = false
let nextRequestId = 0
export function getDesktopWindowId() {
if (typeof window === 'undefined') return null
if (window.__farmcontrolWindowId) return window.__farmcontrolWindowId
return null
}
export function isElectrobunDesktop() {
return Boolean(
typeof window !== 'undefined' &&
window.__electrobunWebviewId &&
window.__electrobunRpcSocketPort
)
}
export function isElectrobunBridgeReady() {
return initialized && Boolean(rpc?.request)
}
function dispatchMessage(channel, data) {
if (channel === RPC_RESPONSE_CHANNEL) {
const pendingRequest = pendingRequests.get(data?.id)
if (!pendingRequest) return
pendingRequests.delete(data.id)
clearTimeout(pendingRequest.timeout)
if (data.success) {
pendingRequest.resolve(data.result)
} else {
pendingRequest.reject(
new Error(data.error || 'Desktop RPC request failed.')
)
}
return
}
const channelListeners = listeners.get(channel)
if (!channelListeners?.size) {
if (!pendingMessages.has(channel)) {
pendingMessages.set(channel, [])
}
pendingMessages.get(channel).push(data)
return
}
for (const callback of channelListeners) {
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')
rpc = Electroview.defineRPC({
maxRequestTime: 30000,
handlers: {
requests: {},
messages: {
'*': (channel, data) => {
dispatchMessage(channel, data)
}
}
}
})
new Electrobun.Electroview({ rpc })
}
function shouldWaitForElectrobun() {
if (isElectrobunDesktop()) {
return true
}
if (typeof window === 'undefined') {
return false
}
if (window.__electrobunWebviewId) {
return true
}
const { protocol, hostname, port } = window.location
if (protocol === 'views:') {
return true
}
// Vite dev server used by electrobun dev:app
if (hostname === 'localhost' && port === '5780') {
return true
}
return Boolean(
window.__electrobun ||
window.__electrobunEventBridge ||
window.__electrobunInternalBridge
)
}
export async function initElectrobunBridge() {
if (initialized) {
return
}
if (!initPromise) {
initPromise = (async () => {
if (!shouldWaitForElectrobun()) {
return
}
const deadline = Date.now() + 3000
while (Date.now() < deadline) {
if (isElectrobunDesktop()) {
await setupRpc()
window.electronAPI = electronAPI
initialized = true
return
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
console.warn(
'Electrobun bridge: webview globals not found; desktop RPC unavailable.'
)
})()
}
await initPromise
}
function onMessage(channel, callback) {
void initElectrobunBridge()
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)
for (const payload of queued) {
callback(payload)
}
}
return () => {
listeners.get(channel)?.delete(callback)
}
}
function removeAllListeners(channel) {
listeners.delete(channel)
pendingMessages.delete(channel)
}
function invokeNativeRequest(method, params) {
const nativeBridge = window.__electrobunBunBridge
if (!nativeBridge?.postMessage) {
return null
}
const id = `${Date.now()}-${++nextRequestId}`
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingRequests.delete(id)
reject(new Error(`Desktop RPC request timed out: ${method}`))
}, RPC_REQUEST_TIMEOUT_MS)
pendingRequests.set(id, { resolve, reject, timeout })
try {
nativeBridge.postMessage(
JSON.stringify({
type: 'message',
id: 'rendererRequest',
payload: {
id,
method,
params,
windowId: params?.windowId || getDesktopWindowId()
}
})
)
} catch (error) {
clearTimeout(timeout)
pendingRequests.delete(id)
reject(error)
}
})
}
async function invokeRequest(method, params = {}) {
await initElectrobunBridge()
if (!rpc?.request) {
console.warn(`Electrobun RPC unavailable for request: ${method}`)
return null
}
const windowId = getDesktopWindowId()
const payload =
windowId && params.windowId == null ? { ...params, windowId } : params
try {
const nativeRequest = invokeNativeRequest(method, payload)
if (nativeRequest) {
return await nativeRequest
}
return await rpc.request[method](payload)
} catch (error) {
console.warn(`Electrobun RPC request failed: ${method}`, error)
return null
}
}
const electronAPI = {
get isDesktop() {
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 }),
checkAppUpdateResult: () => invokeRequest('checkAppUpdateResult'),
checkDuplicateInstallations: () =>
invokeRequest('checkDuplicateInstallations'),
removeDuplicateInstallations: () =>
invokeRequest('removeDuplicateInstallations'),
resizeSpotlightWindow: (height) =>
invokeRequest('resizeSpotlightWindow', { height }),
setSidebarViewMenu: (sections) =>
invokeRequest('setSidebarViewMenu', { sections }),
getAppVersion: () => invokeRequest('getAppVersion'),
getAppEngine: () => invokeRequest('getAppEngine'),
syncWindowTabs: (payload) => invokeRequest('syncWindowTabs', payload),
getWindowSession: (payload) => invokeRequest('getWindowSession', payload),
createAppWindow: (payload) => invokeRequest('createAppWindow', payload),
beginTabDrag: (payload) => invokeRequest('beginTabDrag', payload),
completeTabDrop: (payload) => invokeRequest('completeTabDrop', payload),
cancelTabDrag: (payload) => invokeRequest('cancelTabDrag', payload)
}
if (typeof window !== 'undefined') {
window.electronAPI = electronAPI
}
export default electronAPI