442 lines
13 KiB
JavaScript
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();
|
|
});
|