Release ForgeFlow 0.8.1

Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
This commit is contained in:
NuklearRabbit
2026-07-26 00:42:17 +02:00
parent 971896a1d5
commit 4ad698c4eb
47 changed files with 9114 additions and 1648 deletions
+83 -14
View File
@@ -1,7 +1,7 @@
'use strict';
const path = require('node:path');
const { app, BrowserWindow, shell, session, safeStorage } = require('electron');
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');
@@ -13,12 +13,16 @@ 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) {
@@ -27,6 +31,39 @@ function broadcast(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,
@@ -58,12 +95,18 @@ function createWindow() {
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);
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 () => {
@@ -80,6 +123,7 @@ app.whenReady().then(async () => {
const userDataPath = app.getPath('userData');
const store = new ConfigStore(userDataPath);
configStore = store;
await store.load();
diagnostics = new DiagnosticsService({
userDataPath,
@@ -96,6 +140,8 @@ app.whenReady().then(async () => {
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));
@@ -103,11 +149,25 @@ app.whenReady().then(async () => {
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 unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics, sourcePath: app.getAppPath(), onOperationChange: (payload) => broadcast('operations:changed', payload) });
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,
@@ -132,7 +192,8 @@ app.whenReady().then(async () => {
onChange: (payload) => broadcast('repositories:changed', payload)
});
repositoryMonitor.restart();
registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor: repositoryMonitor });
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()) {
@@ -159,18 +220,26 @@ app.whenReady().then(async () => {
if (operationTimer) clearTimeout(operationTimer);
const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000;
operationTimer = setTimeout(async () => {
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 });
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();
}
scheduleOperationPoll();
}, intervalMs);
operationTimer.unref?.();
};