Files
ForgeFlow/main.cjs
T

442 lines
13 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 { DeployKeyLifecycleService } = require("./src/main/deploy-key-lifecycle-service.cjs");
const { UnraidDeployKeyHost } = require("./src/main/unraid-deploy-key-host.cjs");
const { InventoryReviewService } = require("./src/main/inventory-review-service.cjs");
const { GitValidatorService } = require("./src/main/git-validator-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,
gitea,
diagnostics,
sourcePath: app.getAppPath(),
onOperationChange: reportOperationChange,
});
const deployKeys = new DeployKeyLifecycleService({
store,
gitea,
keyHost: new UnraidDeployKeyHost({ ssh }),
audit,
});
const inventoryReviews = new InventoryReviewService({ store, audit });
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(),
});
const gitValidator = new GitValidatorService({
git,
gitea,
diagnostics,
});
repositoryMonitor = new RepositoryMonitor({
store,
git,
diagnostics,
onChange: (payload) => broadcast("repositories:changed", payload),
});
repositoryMonitor.restart();
registerIpc({
store,
git,
gitea,
repositories,
deployments,
unraid,
deployKeys,
inventoryReviews,
ssh,
updates,
preflight,
gitValidator,
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();
});