Release ForgeFlow 0.8.2 with binary auto-update

This commit is contained in:
NuklearRabbit
2026-07-26 00:58:24 +02:00
parent 4ad698c4eb
commit 3e5e3d2a8b
16 changed files with 1778 additions and 522 deletions
+338 -187
View File
@@ -1,21 +1,34 @@
'use strict';
"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');
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;
@@ -39,28 +52,48 @@ function showMainWindow() {
}
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);
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);
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;
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) });
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),
});
}
}
@@ -71,202 +104,320 @@ function createWindow() {
minWidth: 1120,
minHeight: 720,
show: false,
backgroundColor: '#0b0e14',
title: 'ForgeFlow',
icon: path.join(__dirname, 'build', 'icon.png'),
backgroundColor: "#0b0e14",
title: "ForgeFlow",
icon: path.join(__dirname, "build", "icon.png"),
autoHideMenuBar: true,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
spellcheck: false
}
spellcheck: false,
},
});
mainWindow.loadFile(path.join(__dirname, 'src', 'renderer', 'index.html'));
mainWindow.once('ready-to-show', () => {
mainWindow.loadFile(path.join(__dirname, "src", "renderer", "index.html"));
mainWindow.once("ready-to-show", () => {
mainWindow.show();
diagnostics?.info('window.ready', { size: mainWindow.getSize() });
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.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' };
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) => {
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) {
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'"
]
}
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) => {
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,
executablePath: process.execPath,
portableExecutablePath: process.env.PORTABLE_EXECUTABLE_FILE || null,
},
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 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 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 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 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 });
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();
}
} catch (error) {
await diagnostics.error('operation-monitor.tick.failed', error);
} finally {
if (!quitCleanupStarted) scheduleOperationPoll();
}
}, intervalMs);
operationTimer.unref?.();
};
scheduleOperationPoll();
}, intervalMs);
operationTimer.unref?.();
};
scheduleOperationPoll();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
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);
});
}).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) => {
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?.info("app.quitting", {}))
.then(() => diagnostics?.flush())
.finally(() => app.quit());
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});