"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 { 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 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, 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(); });