Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
273 lines
11 KiB
JavaScript
273 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
const path = require('node:path');
|
|
const { app, BrowserWindow, shell, session, safeStorage, Tray, Menu, Notification } = require('electron');
|
|
const { ConfigStore } = require('./src/main/config-store.cjs');
|
|
const { GitService } = require('./src/main/git-service.cjs');
|
|
const { GiteaService } = require('./src/main/gitea-service.cjs');
|
|
const { RepositoryService } = require('./src/main/repository-service.cjs');
|
|
const { DeploymentService } = require('./src/main/deployment-service.cjs');
|
|
const { RepositoryMonitor } = require('./src/main/repository-monitor.cjs');
|
|
const { DiagnosticsService } = require('./src/main/diagnostics-service.cjs');
|
|
const { PreflightService } = require('./src/main/preflight-service.cjs');
|
|
const { UpdateService } = require('./src/main/update-service.cjs');
|
|
const { SshService } = require('./src/main/ssh-service.cjs');
|
|
const { UnraidDeploymentService } = require('./src/main/unraid-deployment-service.cjs');
|
|
const { AuditService } = require('./src/main/audit-service.cjs');
|
|
const { ExternalToolsService } = require('./src/main/external-tools-service.cjs');
|
|
const { registerIpc } = require('./src/main/ipc.cjs');
|
|
|
|
let mainWindow;
|
|
let repositoryMonitor;
|
|
let operationTimer;
|
|
let diagnostics;
|
|
let configStore;
|
|
let tray;
|
|
let quitCleanupStarted = false;
|
|
|
|
function broadcast(channel, payload) {
|
|
for (const window of BrowserWindow.getAllWindows()) {
|
|
if (!window.isDestroyed()) window.webContents.send(channel, payload);
|
|
}
|
|
}
|
|
|
|
function showMainWindow() {
|
|
if (!mainWindow || mainWindow.isDestroyed()) createWindow();
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
|
|
function notify(title, body) {
|
|
if (!configStore?.data.preferences.notificationsEnabled || !Notification.isSupported()) return;
|
|
const notification = new Notification({ title, body, icon: path.join(__dirname, 'build', 'icon.png') });
|
|
notification.on('click', showMainWindow);
|
|
notification.show();
|
|
}
|
|
|
|
function configureDesktopIntegration() {
|
|
const preferences = configStore?.data.preferences || {};
|
|
if (preferences.trayEnabled && !tray) {
|
|
tray = new Tray(path.join(__dirname, 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png'));
|
|
tray.setToolTip('ForgeFlow');
|
|
tray.on('double-click', showMainWindow);
|
|
} else if (!preferences.trayEnabled && tray) {
|
|
tray.destroy(); tray = null;
|
|
}
|
|
if (tray) tray.setContextMenu(Menu.buildFromTemplate([
|
|
{ label: 'Open ForgeFlow', click: showMainWindow },
|
|
{ type: 'separator' },
|
|
{ label: 'Quit', click: () => app.quit() }
|
|
]));
|
|
if (app.isPackaged && ['win32', 'darwin'].includes(process.platform)) {
|
|
app.setLoginItemSettings({ openAtLogin: Boolean(preferences.startAtLogin) });
|
|
}
|
|
}
|
|
|
|
function createWindow() {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1480,
|
|
height: 940,
|
|
minWidth: 1120,
|
|
minHeight: 720,
|
|
show: false,
|
|
backgroundColor: '#0b0e14',
|
|
title: 'ForgeFlow',
|
|
icon: path.join(__dirname, 'build', 'icon.png'),
|
|
autoHideMenuBar: true,
|
|
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.cjs'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
webSecurity: true,
|
|
spellcheck: false
|
|
}
|
|
});
|
|
|
|
mainWindow.loadFile(path.join(__dirname, 'src', 'renderer', 'index.html'));
|
|
mainWindow.once('ready-to-show', () => {
|
|
mainWindow.show();
|
|
diagnostics?.info('window.ready', { size: mainWindow.getSize() });
|
|
});
|
|
mainWindow.on('unresponsive', () => diagnostics?.warning('window.unresponsive', {}));
|
|
mainWindow.webContents.on('render-process-gone', (_event, details) => diagnostics?.error('renderer.process.gone', details));
|
|
mainWindow.webContents.on('did-fail-load', (_event, code, description, validatedUrl) => diagnostics?.error('renderer.load.failed', { code, description, validatedUrl }));
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (/^https?:\/\//i.test(url)) shell.openExternal(url).catch((error) => diagnostics?.warning('external-link.open.failed', { url, message: error.message }));
|
|
return { action: 'deny' };
|
|
});
|
|
mainWindow.webContents.on('will-navigate', (event, url) => {
|
|
if (url !== mainWindow.webContents.getURL()) event.preventDefault();
|
|
});
|
|
mainWindow.on('close', (event) => {
|
|
if (!quitCleanupStarted && configStore?.data.preferences.closeToTray && configStore?.data.preferences.trayEnabled) {
|
|
event.preventDefault();
|
|
mainWindow.hide();
|
|
}
|
|
});
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
|
callback({
|
|
responseHeaders: {
|
|
...details.responseHeaders,
|
|
'Content-Security-Policy': [
|
|
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'"
|
|
]
|
|
}
|
|
});
|
|
});
|
|
|
|
const userDataPath = app.getPath('userData');
|
|
const store = new ConfigStore(userDataPath);
|
|
configStore = store;
|
|
await store.load();
|
|
diagnostics = new DiagnosticsService({
|
|
userDataPath,
|
|
appInfo: { name: app.getName(), version: app.getVersion(), packaged: app.isPackaged },
|
|
secretProvider: () => [
|
|
store.getToken(),
|
|
...(store.data.servers || []).flatMap((server) => {
|
|
try {
|
|
const credentials = store.getServerCredentials(server.id);
|
|
return [credentials.password, credentials.passphrase];
|
|
} catch { return []; }
|
|
})
|
|
],
|
|
preferencesProvider: () => store.data.preferences
|
|
});
|
|
await diagnostics.initialize();
|
|
const audit = new AuditService({ userDataPath, appInfo: { version: app.getVersion() } });
|
|
await audit.initialize();
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
diagnostics?.error('process.uncaught-exception', error).finally(() => app.exit(1));
|
|
});
|
|
process.on('unhandledRejection', (reason) => diagnostics?.error('process.unhandled-rejection', reason instanceof Error ? reason : { reason }));
|
|
|
|
const git = new GitService();
|
|
const externalTools = new ExternalToolsService(store);
|
|
const gitea = new GiteaService(store, diagnostics);
|
|
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
|
const deployments = new DeploymentService(store, gitea, git, diagnostics);
|
|
const ssh = new SshService({ store, diagnostics });
|
|
const auditedOperationStates = new Set();
|
|
const reportOperationChange = (payload) => {
|
|
broadcast('operations:changed', payload);
|
|
const operation = payload?.operation;
|
|
if (operation && ['success', 'failed', 'rolled-back'].includes(operation.status)) {
|
|
const key = `${operation.id}:${operation.status}`;
|
|
if (!auditedOperationStates.has(key)) {
|
|
auditedOperationStates.add(key);
|
|
notify(`Deployment ${operation.status}`, `${operation.repository || 'Repository'} · ${operation.shortSha || operation.sha?.slice(0, 7) || ''}`);
|
|
audit.append('deployment.completed', { repository: operation.repository, profileId: operation.profileId, sha: operation.sha, result: operation.status, note: operation.releaseNote || '' }).catch((error) => diagnostics.warning('audit.write.failed', error));
|
|
}
|
|
}
|
|
};
|
|
const unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics, sourcePath: app.getAppPath(), onOperationChange: reportOperationChange });
|
|
const updates = new UpdateService({
|
|
store,
|
|
gitea,
|
|
diagnostics,
|
|
appInfo: { version: app.getVersion(), packaged: app.isPackaged },
|
|
sourcePath: app.getAppPath(),
|
|
userDataPath
|
|
});
|
|
const preflight = new PreflightService({
|
|
store,
|
|
git,
|
|
gitea,
|
|
deployments,
|
|
diagnostics,
|
|
userDataPath,
|
|
secureStorageAvailable: () => safeStorage.isEncryptionAvailable()
|
|
});
|
|
repositoryMonitor = new RepositoryMonitor({
|
|
store,
|
|
git,
|
|
diagnostics,
|
|
onChange: (payload) => broadcast('repositories:changed', payload)
|
|
});
|
|
repositoryMonitor.restart();
|
|
registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, audit, externalTools, monitor: repositoryMonitor, onPreferencesChanged: configureDesktopIntegration });
|
|
configureDesktopIntegration();
|
|
createWindow();
|
|
|
|
if (store.data.setupComplete && store.data.updates?.autoCheck && store.getToken()) {
|
|
setTimeout(async () => {
|
|
try {
|
|
const status = await updates.check();
|
|
broadcast('updates:changed', status);
|
|
} catch (error) {
|
|
await diagnostics.warning('updates.startup-check.failed', { message: error.message, code: error.code });
|
|
}
|
|
}, 2500).unref?.();
|
|
}
|
|
|
|
const gitAvailability = await git.isAvailable();
|
|
await diagnostics.info('app.ready', {
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
setupComplete: store.data.setupComplete,
|
|
git: gitAvailability,
|
|
secureStorageAvailable: safeStorage.isEncryptionAvailable()
|
|
});
|
|
|
|
const scheduleOperationPoll = () => {
|
|
if (operationTimer) clearTimeout(operationTimer);
|
|
const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000;
|
|
operationTimer = setTimeout(async () => {
|
|
try {
|
|
if (store.data.setupComplete) {
|
|
const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
|
|
if (active) {
|
|
const [actions, sshOperations] = await Promise.all([
|
|
store.getToken() ? deployments.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.actions.failed', error); return []; }) : [],
|
|
unraid.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.unraid.failed', error); return []; })
|
|
]);
|
|
const updated = [...actions, ...sshOperations];
|
|
if (updated.length) {
|
|
broadcast('operations:changed', { operations: updated });
|
|
for (const operation of updated.filter((item) => ['success', 'failed', 'rolled-back'].includes(item.status))) reportOperationChange({ operation });
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
await diagnostics.error('operation-monitor.tick.failed', error);
|
|
} finally {
|
|
if (!quitCleanupStarted) scheduleOperationPoll();
|
|
}
|
|
}, intervalMs);
|
|
operationTimer.unref?.();
|
|
};
|
|
scheduleOperationPoll();
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
});
|
|
}).catch(async (error) => {
|
|
console.error('[startup]', error);
|
|
await diagnostics?.error('app.startup.failed', error);
|
|
await diagnostics?.flush();
|
|
app.exit(1);
|
|
});
|
|
|
|
app.on('before-quit', (event) => {
|
|
if (quitCleanupStarted) return;
|
|
event.preventDefault();
|
|
quitCleanupStarted = true;
|
|
repositoryMonitor?.stop();
|
|
if (operationTimer) clearTimeout(operationTimer);
|
|
Promise.resolve()
|
|
.then(() => diagnostics?.info('app.quitting', {}))
|
|
.then(() => diagnostics?.flush())
|
|
.finally(() => app.quit());
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') app.quit();
|
|
});
|