189 lines
6.3 KiB
JavaScript
189 lines
6.3 KiB
JavaScript
'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 };
|