Files
Marco0300 4dfabf6433
CI / compose (push) Successful in 7m48s
add windows desktop client shell
2026-09-03 14:33:27 +02:00

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 };