Add desktop RPC layer replacing Electron IPC and window management.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tom Butcher 2026-07-28 22:03:03 +01:00
parent 1728c08e39
commit f0b6059e70
6 changed files with 244 additions and 250 deletions

33
src/desktop/notify.js Normal file
View File

@ -0,0 +1,33 @@
const listeners = new Map();
let sendMessage = () => {};
export function setMessageSender(sender) {
sendMessage = sender;
}
export function sendIPC(channel, data) {
sendMessage(channel, data);
}
export function onIPCData(channel, callback) {
if (!listeners.has(channel)) {
listeners.set(channel, new Set());
}
listeners.get(channel).add(callback);
}
export function removeAllListeners(channel) {
listeners.delete(channel);
}
export function dispatchIPC(channel, data) {
const channelListeners = listeners.get(channel);
if (!channelListeners) {
return;
}
for (const callback of channelListeners) {
callback(data);
}
}

152
src/desktop/rpc.js Normal file
View File

@ -0,0 +1,152 @@
import log4js from "log4js";
import { BrowserView } from "electrobun/bun";
import { startWaiting, stopWaiting } from "../spinner.js";
import { setMessageSender, sendIPC } from "./notify.js";
const logger = log4js.getLogger("RPC");
let mainWindow = null;
export function createAppRpc() {
return BrowserView.defineRPC({
maxRequestTime: 30000,
handlers: {
requests: {
getData: async () => {
startWaiting("Getting data...", logger);
try {
const socketClient = global.socketClient;
if (!socketClient) {
pushState({
authenticated: false,
connected: false,
loading: false,
host: {},
printers: [],
documentPrinters: [],
});
await stopWaiting();
return { ok: true };
}
pushState({
authenticated: socketClient.authenticated,
connected: socketClient.connected,
loading: socketClient.loading,
host: socketClient.host || {},
printers: socketClient.printerManager.printers || [],
documentPrinters:
socketClient.documentPrinterManager.documentPrinters || [],
});
await stopWaiting();
return { ok: true };
} catch (error) {
logger.error("Error getting printer data:", error);
pushState({
authenticated: false,
connected: false,
loading: false,
host: {},
printers: [],
documentPrinters: [],
});
await stopWaiting();
return { ok: false };
}
},
authenticateOTP: async ({ otp }) => {
startWaiting("Authenticating with OTP...", logger);
try {
const socketClient = global.socketClient;
if (socketClient) {
await socketClient.authenticateWithOtp(otp);
} else {
logger.error("Socket client not available for OTP authentication");
}
await stopWaiting();
return { ok: true };
} catch (error) {
await stopWaiting();
logger.error("Error during OTP authentication:", error);
return { ok: false };
}
},
minimizeWindow: async () => {
if (mainWindow && !mainWindow.isDestroyed?.()) {
mainWindow.minimize();
}
return { ok: true };
},
maximizeWindow: async () => {
if (mainWindow && !mainWindow.isDestroyed?.()) {
if (mainWindow.isMaximized?.()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
}
return { ok: true };
},
closeWindow: async () => {
if (mainWindow && !mainWindow.isDestroyed?.()) {
mainWindow.close();
}
return { ok: true };
},
},
messages: {},
},
});
}
function pushState({
authenticated,
connected,
loading,
host,
printers,
documentPrinters,
}) {
sendIPC("setAuthenticated", authenticated);
sendIPC("setConnected", connected);
sendIPC("setLoading", loading);
sendIPC("setHost", host);
sendIPC("setPrinters", printers);
sendIPC("setDocumentPrinters", documentPrinters);
}
export function registerMainWindow(window) {
mainWindow = window;
global.mainWindow = window;
}
export async function setupIPC(window = mainWindow) {
if (window) {
registerMainWindow(window);
}
if (!mainWindow) {
logger.warn("No main window available, skipping RPC setup");
return;
}
setMessageSender((channel, data) => {
try {
const send = mainWindow?.webview?.rpc?.send;
if (send) {
send[channel](data);
logger.info(`Message sent to main window on channel: ${channel}`, data);
return;
}
logger.warn(
`No RPC sender available for channel: ${channel}. Is the window ready?`,
);
} catch (error) {
logger.error(`Error sending message on channel ${channel}:`, error);
}
});
logger.info("RPC handlers ready");
}

59
src/desktop/window.js Normal file
View File

@ -0,0 +1,59 @@
import log4js from "log4js";
import { BrowserWindow, Updater } from "electrobun/bun";
import { loadConfig } from "../config.js";
import { startWaiting, stopWaiting } from "../spinner.js";
import { createAppRpc, registerMainWindow } from "./rpc.js";
const config = loadConfig();
const logger = log4js.getLogger("Desktop");
logger.level = config.logLevel;
const DEV_SERVER_PORT = 5287;
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
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" });
logger.info(`Using Vite dev server at ${DEV_SERVER_URL}`);
return DEV_SERVER_URL;
} catch {
logger.warn(
"Vite dev server not running. Start it with `bun run dev:renderer`.",
);
}
}
return "views://mainview/index.html";
}
export async function createDesktopWindow() {
const url = await getMainViewUrl();
const rpc = createAppRpc();
startWaiting("Creating desktop window...", logger);
const mainWindow = new BrowserWindow({
title: "Farm Control Server",
url,
rpc,
titleBarStyle: "hiddenInset",
trafficLightOffset: { x: 14, y: 12 },
frame: {
width: 900,
height: 600,
x: 100,
y: 100,
},
});
registerMainWindow(mainWindow);
return new Promise((resolve) => {
mainWindow.webview.on("dom-ready", async () => {
await stopWaiting();
resolve(mainWindow);
});
});
}

View File

@ -1,122 +0,0 @@
import log4js from "log4js";
import { notPrompting } from "../utils.js";
import { startWaiting, stopWaiting } from "../spinner.js";
const logger = log4js.getLogger("IPC");
let mainWindow = null;
export async function setupIPC() {
// Only import Electron if we're in an Electron environment
let ipcMain;
try {
const electron = await import("electron");
ipcMain = electron.ipcMain;
mainWindow = global.mainWindow;
} catch (error) {
logger.warn("Electron not available, skipping IPC setup");
return;
}
// Only proceed if we have ipcMain
if (!ipcMain) {
logger.warn("ipcMain not available, skipping IPC setup");
return;
}
ipcMain.on("getData", async () => {
startWaiting("Getting data...", logger);
try {
// Get the global socket client instance
const socketClient = global.socketClient;
if (!socketClient) {
sendIPC("setAuthenticated", false);
sendIPC("setConnected", false);
sendIPC("setLoading", false);
sendIPC("setHost", {});
sendIPC("setPrinters", []);
sendIPC("setDocumentPrinters", []);
await stopWaiting();
return;
}
// Send individual data pieces to match renderer expectations
sendIPC("setAuthenticated", socketClient.authenticated);
sendIPC("setConnected", socketClient.connected);
sendIPC("setLoading", socketClient.loading);
sendIPC("setHost", socketClient.host || {});
sendIPC("setPrinters", socketClient.printerManager.printers || []);
sendIPC(
"setDocumentPrinters",
socketClient.documentPrinterManager.documentPrinters || [],
);
await stopWaiting();
} catch (error) {
logger.error("Error getting printer data:", error);
sendIPC("setAuthenticated", false);
sendIPC("setConnected", false);
sendIPC("setLoading", false);
sendIPC("setHost", {});
sendIPC("setPrinters", []);
await stopWaiting();
}
});
// Window management IPC handlers
ipcMain.on("window-minimize", (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.minimize();
}
});
ipcMain.on("window-maximize", (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
}
});
ipcMain.on("window-close", (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.close();
}
});
// OTP authentication handler
ipcMain.on("authenticateOTP", async (event, otp) => {
startWaiting("Authenticating with OTP...", logger);
try {
const socketClient = global.socketClient;
if (socketClient) {
notPrompting();
await socketClient.authenticateWithOtp(otp);
} else {
logger.error("Socket client not available for OTP authentication");
}
await stopWaiting();
} catch (error) {
await stopWaiting();
logger.error("Error during OTP authentication:", error);
}
});
logger.info("IPC handlers setup complete");
}
export function sendIPC(channel, data) {
try {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, data);
logger.info(`Message sent to main window on channel: ${channel}`, data);
} else {
logger.warn(
`No main window available, cannot send message on channel: ${channel}`
);
}
} catch (error) {
logger.error(`Error sending message on channel ${channel}:`, error);
}
}

View File

@ -1,21 +0,0 @@
import { contextBridge, ipcRenderer } from "electron";
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("electronAPI", {
onIPCData: (channel, callback) => {
ipcRenderer.on(channel, (event, data) => callback(data));
},
// Send messages to main process
sendIPC: (channel, data) => {
console.log("SEND IPC", channel);
ipcRenderer.send(channel, data);
},
// Window management
minimize: () => ipcRenderer.send("window-minimize"),
maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"),
removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel);
},
});

View File

@ -1,107 +0,0 @@
/* eslint-disable no-undef */
// Remove CommonJS requires and use ES module imports
import path from "path";
import { loadConfig } from "../config.js";
import log4js from "log4js";
import { fileURLToPath } from "url";
import { startWaiting, stopWaiting } from "../spinner.js";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("Electron");
logger.level = config.logLevel;
export async function createElectronWindow() {
// Only import Electron if we're in an Electron environment
let app, BrowserWindow;
try {
const electron = (await import("electron")).default;
app = electron.app;
BrowserWindow = electron.BrowserWindow;
logger.trace("Imported electron");
} catch (error) {
logger.warn(
"Electron not available, skipping window creation. Error:",
error,
);
return;
}
// Only proceed if we have app
if (!app) {
logger.warn("Electron app not available, skipping window creation");
return;
}
// __dirname workaround for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Determine if we are running from the build directory or src
const isRunningFromBuild =
__dirname.includes(path.sep + "build") || __dirname.includes("app.asar");
return new Promise((resolve) => {
function createWindow() {
logger.debug("Creating browser window...");
// Resolve paths correctly based on environment
const preloadPath =
process.env.NODE_ENV === "development"
? path.join(__dirname, "preload.js")
: isRunningFromBuild
? path.join(__dirname, "preload.js")
: path.join(__dirname, "..", "build", "electron", "preload.js");
const win = new BrowserWindow({
width: 900,
height: 600,
resizable: true,
frame: false,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 12 },
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload: preloadPath,
},
});
// Make the window globally accessible for IPC
global.mainWindow = win;
logger.info("Preload Script", preloadPath);
if (process.env.NODE_ENV === "development") {
startWaiting("Loading development url...", logger);
win.loadURL("http://localhost:5287"); // Vite dev server
} else {
// In production, the built files will be in the build/electron directory
const htmlPath = isRunningFromBuild
? path.join(__dirname, "index.html")
: path.join(__dirname, "..", "build", "electron", "index.html");
startWaiting("Loading production file...", logger);
logger.info("Loading production file:", htmlPath);
win.loadFile(htmlPath);
}
// Resolve the promise when the window is ready
win.webContents.on("did-finish-load", async () => {
await stopWaiting();
resolve(win);
});
}
app.whenReady().then(() => {
createWindow();
app.on("activate", function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", function () {
if (process.platform !== "darwin") app.quit();
});
});
}