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:
@@ -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?.();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user