Publish curated ForgeFlow source from 2ed1787c0b52
This commit is contained in:
commit
f60b269686
254 files changed
+46204
No files matched your search
@@ -0,0 +1,470 @@
|
||||
"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");
|
||||
const {
|
||||
installOutputPipeGuards,
|
||||
isBrokenPipeError,
|
||||
} = require("./src/main/process-error-policy.cjs");
|
||||
|
||||
let mainWindow;
|
||||
let repositoryMonitor;
|
||||
let sshService;
|
||||
let operationTimer;
|
||||
let diagnostics;
|
||||
let configStore;
|
||||
let tray;
|
||||
let quitCleanupStarted = false;
|
||||
const reportBrokenOutputPipe = (error) => {
|
||||
const report = diagnostics?.warning("process.output-pipe.closed", {
|
||||
code: error?.code || null,
|
||||
message: error?.message || "The parent output pipe was closed.",
|
||||
});
|
||||
report?.catch(() => {});
|
||||
};
|
||||
installOutputPipeGuards({ onBrokenPipe: reportBrokenOutputPipe });
|
||||
const ownsSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!ownsSingleInstanceLock) app.quit();
|
||||
else app.on("second-instance", () => showMainWindow());
|
||||
|
||||
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 () => {
|
||||
if (!ownsSingleInstanceLock) return;
|
||||
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) => {
|
||||
if (isBrokenPipeError(error)) {
|
||||
reportBrokenOutputPipe(error);
|
||||
return;
|
||||
}
|
||||
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 });
|
||||
sshService = ssh;
|
||||
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)) {
|
||||
// One entry per completed deployment, so the set is trimmed rather
|
||||
// than kept for the lifetime of the process.
|
||||
if (auditedOperationStates.size >= 500) {
|
||||
auditedOperationStates.delete(auditedOperationStates.values().next().value);
|
||||
}
|
||||
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,
|
||||
store,
|
||||
});
|
||||
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) => {
|
||||
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();
|
||||
sshService?.closeAll();
|
||||
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();
|
||||
});
|
||||
Reference in new issue
Block a user