'use strict'; const path = require('node:path'); const { pathToFileURL } = require('node:url'); const { app, BrowserWindow, Menu, shell, session, ipcMain } = require('electron'); const { validateBackendUrl } = require('./url-validation'); const BACKEND_URL_KEY = 'backendUrl'; let backendUrl = ''; let mainWindow = null; let settingsWindow = null; function bundledUiPath() { return app.isPackaged ? path.join(process.resourcesPath, 'web', 'index.html') : path.resolve(__dirname, '..', 'web', 'index.html'); } function configuredWebUrl() { const argument = process.argv.find((item) => item.startsWith('--web-url=')); if (!argument) return null; const result = validateBackendUrl(argument.slice('--web-url='.length)); return result.valid ? result.value : null; } function loadableUi() { return configuredWebUrl() || pathToFileURL(bundledUiPath()).toString(); } function createWindowOptions(width = 1440, height = 900) { return { width, height, minWidth: 960, minHeight: 640, show: false, webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false, navigateOnDragDrop: false } }; } function openExternalLink(event, url) { if (!/^https?:\/\//i.test(url)) return; event.preventDefault(); void shell.openExternal(url); } function wireExternalNavigation(window) { window.webContents.setWindowOpenHandler(({ url }) => { openExternalLink({ preventDefault() {} }, url); return { action: 'deny' }; }); window.webContents.on('will-navigate', (event, url) => { const current = window.webContents.getURL(); if (url.startsWith('file://')) return; if (url.startsWith('http://') || url.startsWith('https://')) { const currentOrigin = current.startsWith('http') ? new URL(current).origin : ''; if (currentOrigin === new URL(url).origin) return; } openExternalLink(event, url); }); } function injectBackendConfig(window) { const serialized = JSON.stringify(backendUrl); return window.webContents.executeJavaScript( `window.__PROSPECT_CONFIG__ = Object.freeze(Object.assign({}, window.__PROSPECT_CONFIG__ || {}, { apiBase: ${serialized} }));`, true ); } async function loadMainUi() { if (!mainWindow) return; await mainWindow.loadURL(loadableUi()); await injectBackendConfig(mainWindow); } function createMainWindow() { mainWindow = new BrowserWindow(createWindowOptions()); wireExternalNavigation(mainWindow); mainWindow.once('ready-to-show', () => mainWindow.show()); mainWindow.on('closed', () => { mainWindow = null; }); void loadMainUi().catch(() => mainWindow?.webContents.executeJavaScript('location.reload()')); return mainWindow; } function createSettingsWindow() { if (settingsWindow && !settingsWindow.isDestroyed()) { settingsWindow.focus(); return settingsWindow; } settingsWindow = new BrowserWindow({ ...createWindowOptions(560, 500), resizable: false, title: 'ProspectOS connection settings' }); wireExternalNavigation(settingsWindow); settingsWindow.once('ready-to-show', () => settingsWindow.show()); settingsWindow.on('closed', () => { settingsWindow = null; }); void settingsWindow.loadFile(path.join(__dirname, 'connection.html')); return settingsWindow; } function installMenu() { Menu.setApplicationMenu(Menu.buildFromTemplate([ { label: 'ProspectOS', submenu: [ { label: 'Connection settings…', click: () => createSettingsWindow() }, { type: 'separator' }, { label: 'Clear session and cache', click: () => clearSession() }, { role: 'quit' } ] }, { label: 'View', submenu: [ { label: 'Reload', accelerator: 'CmdOrCtrl+R', click: () => mainWindow?.reload() }, { label: 'Reconnect', accelerator: 'CmdOrCtrl+Shift+R', click: () => reconnect() }, { role: 'toggleDevTools' } ] } ])); } async function clearSession() { await session.defaultSession.clearStorageData(); await session.defaultSession.clearCache(); if (mainWindow && !mainWindow.isDestroyed()) await mainWindow.reload(); } async function reconnect() { if (!mainWindow || mainWindow.isDestroyed()) return; await mainWindow.webContents.session.clearCache(); await loadMainUi(); } function registerIpc() { ipcMain.handle('backend:get', () => backendUrl); ipcMain.handle('backend:set', async (_event, value) => { const result = validateBackendUrl(value); if (!result.valid) return result; backendUrl = result.value; // This is the only persisted setting, and it is explicitly non-secret. const storePath = path.join(app.getPath('userData'), 'connection.json'); require('node:fs').writeFileSync(storePath, JSON.stringify({ backendUrl }), { mode: 0o600 }); require('node:fs').chmodSync(storePath, 0o600); if (mainWindow && !mainWindow.isDestroyed()) await loadMainUi(); if (settingsWindow && !settingsWindow.isDestroyed()) settingsWindow.close(); if (!mainWindow) createMainWindow(); return { valid: true, value: backendUrl }; }); ipcMain.handle('window:reload', () => mainWindow?.reload()); ipcMain.handle('window:reconnect', () => reconnect()); ipcMain.handle('session:clear', () => clearSession()); ipcMain.handle('settings:open', () => createSettingsWindow()); } function readStoredBackendUrl() { const argument = process.argv.find((item) => item.startsWith('--api-base=')); const configured = argument ? argument.slice('--api-base='.length) : process.env.PROSPECT_API_BASE; if (configured) { const result = validateBackendUrl(configured); if (result.valid) return result.value; } try { const storePath = path.join(app.getPath('userData'), 'connection.json'); const parsed = JSON.parse(require('node:fs').readFileSync(storePath, 'utf8')); const result = validateBackendUrl(parsed.backendUrl); return result.valid ? result.value : ''; } catch { return ''; } } app.whenReady().then(() => { backendUrl = readStoredBackendUrl(); registerIpc(); installMenu(); if (backendUrl) createMainWindow(); else createSettingsWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { if (backendUrl) createMainWindow(); else createSettingsWindow(); } }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); module.exports = { createWindowOptions, loadableUi };