Files
ForgeFlow/src/main/ipc.cjs
T
2026-07-24 20:29:23 +02:00

410 lines
20 KiB
JavaScript

'use strict';
const path = require('node:path');
const fs = require('node:fs/promises');
const { fileURLToPath } = require('node:url');
const { ipcMain, dialog, shell, app } = require('electron');
const { matchRemoteToRepository } = require('../shared/repository-match.cjs');
const { cloneDirectoryName, resolveCloneTarget } = require('../shared/clone-target.cjs');
let diagnosticsService = null;
const TRUSTED_RENDERER_PATH = path.resolve(__dirname, '..', 'renderer', 'index.html');
function toErrorPayload(error) {
return {
message: error?.message || 'Unknown error',
code: error?.code || null,
status: error?.status || null,
recoverable: Boolean(error?.recoverable),
commitSha: error?.commitSha || null
};
}
function assertTrustedSender(event) {
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || '';
try {
const parsed = new URL(url);
if (parsed.protocol !== 'file:') throw new Error('not a file URL');
const senderPath = path.resolve(fileURLToPath(parsed));
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH)) throw new Error('unexpected renderer file');
} catch {
throw new Error('Rejected IPC request from an untrusted renderer origin.');
}
}
function register(channel, handler) {
ipcMain.handle(channel, async (event, payload) => {
const started = Date.now();
try {
assertTrustedSender(event);
const data = await handler(payload || {}, event);
await diagnosticsService?.debug('ipc.completed', { channel, durationMs: Date.now() - started });
return { ok: true, data };
} catch (error) {
await diagnosticsService?.error('ipc.failed', {
channel,
durationMs: Date.now() - started,
error: { name: error?.name, message: error?.message, code: error?.code, status: error?.status, stack: error?.stack }
});
console.error(`[${channel}]`, error);
return { ok: false, error: toErrorPayload(error) };
}
});
}
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
diagnosticsService = diagnostics;
const withRepositoryPause = async (localPath, action) => {
monitor?.pause(localPath);
try { return await action(); }
finally { monitor?.resume(localPath); }
};
const canonicalPath = async (value) => {
const resolved = path.resolve(String(value || ''));
return fs.realpath(resolved).catch(() => resolved);
};
const assertKnownRepositoryPath = async (localPath) => {
const candidate = await canonicalPath(localPath);
let knownPaths = repositories.getWatchPaths();
if (!knownPaths.length && store.data.setupComplete) {
await repositories.refresh();
knownPaths = repositories.getWatchPaths();
}
const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath));
if (!canonicalKnown.some((known) => known === candidate)) throw new Error('The requested local repository is not linked or discovered by ForgeFlow.');
return candidate;
};
const resolveRepository = async (repositoryPayload) => {
const fullName = String(repositoryPayload?.fullName || '').trim();
if (!fullName) throw new Error('Repository identity is required.');
const current = (await repositories.refresh()).find((item) => item.fullName === fullName);
if (!current) throw new Error('The repository is no longer available through the configured Gitea account.');
return current;
};
const assertProjectRoot = async (rootValue) => {
const root = await canonicalPath(rootValue);
const stat = await fs.stat(root).catch(() => null);
if (!stat?.isDirectory()) throw new Error('The selected project root no longer exists.');
return root;
};
const cloneRepositoryInto = async (fullName, projectRoot) => {
const current = await resolveRepository({ fullName });
if (current.localPath) throw new Error('This repository already has a linked local folder.');
const remoteUrl = current.preferredCloneUrl || current.cloneUrl || current.sshUrl;
if (!remoteUrl) throw new Error('Gitea did not provide a usable clone URL for this repository.');
const root = await assertProjectRoot(projectRoot);
const { target } = resolveCloneTarget(root, remoteUrl);
const status = await git.clone(remoteUrl, target);
await store.saveMapping(current.fullName, target);
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
await diagnostics.info(status.reused ? 'repository.clone.reused' : 'repository.cloned', {
fullName: current.fullName,
projectRoot: root,
target,
head: status.head,
branch: status.branch?.head
});
return {
target,
status,
reused: Boolean(status.reused),
repositories: result,
state: store.getPublicState()
};
};
register('app:bootstrap', async () => ({
appVersion: app.getVersion(),
platform: process.platform,
state: store.getPublicState(),
git: await git.isAvailable(),
diagnostics: await diagnostics.getStatus()
}));
register('dialog:select-directory', async ({ title = 'Select folder', defaultPath }) => {
const result = await dialog.showOpenDialog({ title, defaultPath, properties: ['openDirectory', 'createDirectory'] });
return result.canceled ? null : result.filePaths[0];
});
register('dialog:select-key-file', async ({ title = 'Select SSH private key', defaultPath }) => {
const result = await dialog.showOpenDialog({
title,
defaultPath,
properties: ['openFile']
});
return result.canceled ? null : result.filePaths[0];
});
register('setup:preflight', ({ baseUrl, token, roots }) => preflight.runSystem({ baseUrl, token, roots }));
register('setup:validate-gitea', ({ baseUrl, token }) => gitea.validateConnection(baseUrl, token));
register('setup:complete', async ({ baseUrl, token, workspaceRoots }) => {
const report = await preflight.runSystem({ baseUrl, token, roots: workspaceRoots });
if (!report.summary.ready || !report.giteaValidation) throw new Error('Setup readiness checks must pass before configuration can be completed.');
const validation = report.giteaValidation;
const result = await store.completeSetup({ baseUrl: validation.baseUrl, token, user: validation.user, workspaceRoots });
await diagnostics.info('setup.completed', { baseUrl: validation.baseUrl, user: validation.user?.login || null, workspaceRootCount: workspaceRoots?.length || 0, tokenPersistent: result.tokenState.persistent });
return result;
});
register('settings:update-gitea', async ({ baseUrl, token }) => {
const effectiveToken = String(token || '').trim() || store.getToken();
const validation = await gitea.validateConnection(baseUrl, effectiveToken);
const tokenState = await store.updateGitea({ baseUrl: validation.baseUrl, token, user: validation.user });
await diagnostics.info('settings.gitea.updated', { baseUrl: validation.baseUrl, user: validation.user?.login || null, tokenPersistent: tokenState.persistent, tokenPreserved: tokenState.preserved });
return { validation, tokenState, state: store.getPublicState() };
});
register('settings:set-roots', async ({ roots }) => {
store.data.workspaceRoots = [...new Set((roots || []).filter(Boolean))];
await store.save();
await diagnostics.info('settings.workspace-roots.updated', { rootCount: store.data.workspaceRoots.length, roots: store.data.workspaceRoots });
return store.getPublicState();
});
register('settings:set-appearance', async ({ appearance }) => {
if (!['dark', 'light', 'system'].includes(appearance)) throw new Error('Unsupported appearance setting.');
store.data.appearance = appearance;
await store.save();
return store.getPublicState();
});
register('settings:set-preferences', async ({ preferences }) => {
const state = await store.setPreferences(preferences);
monitor?.restart();
await diagnostics.info('settings.preferences.updated', { preferences: state.preferences });
return state;
});
register('updates:preferences', ({ updates: next }) => store.setUpdatePreferences(next));
register('updates:check', () => updates.check());
register('updates:download', () => updates.download());
register('updates:apply', async () => {
const result = await updates.apply();
setTimeout(() => app.quit(), 650).unref?.();
return result;
});
register('server:save', async ({ server, password = '', passphrase = '' }) => {
const saved = await store.saveServer(server, { password, passphrase });
await diagnostics.info('server.saved', {
serverId: saved.id,
name: saved.name,
host: saved.host,
port: saved.port,
username: saved.username,
authType: saved.authType,
basePath: saved.basePath
});
return { server: saved, state: store.getPublicState() };
});
register('server:delete', async ({ serverId }) => {
await store.deleteServer(serverId);
await diagnostics.info('server.deleted', { serverId });
return store.getPublicState();
});
register('server:test', async ({ serverId }) => {
const server = store.getServer(serverId);
if (!server) throw new Error('The configured server no longer exists.');
const result = await ssh.test(serverId, { trustOnFirstUse: !server.hostFingerprint });
if (!server.hostFingerprint) {
await store.saveServer({ ...server, hostFingerprint: result.fingerprint }, {});
result.trusted = true;
}
return { ...result, state: store.getPublicState() };
});
register('server:inspect-project', async ({ repository, profileId }) => unraid.inspect({ repository: await resolveRepository(repository), profileId }));
register('repositories:refresh', async () => {
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register('repositories:discover', async ({ roots }) => {
const paths = await repositories.discoverAll(roots || store.data.workspaceRoots);
return repositories.getLocalDescriptors(paths);
});
register('repository:favorite', async ({ fullName, favorite }) => store.setFavorite(fullName, favorite));
register('repository:link', async ({ fullName, localPath }) => {
await git.ensureRepository(localPath);
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => '');
if (!remoteUrl || !matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])) {
throw new Error(`The selected folder's origin does not match ${fullName}.`);
}
await store.saveMapping(fullName, localPath);
await diagnostics.info('repository.linked', { fullName, localPath });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register('repository:unlink', async ({ fullName }) => {
await store.removeMapping(fullName);
await diagnostics.info('repository.unlinked', { fullName });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath)));
register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged));
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stage(safePath, files)); });
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.unstage(safePath, files)); });
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commit(safePath, message, files)); });
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commitAndPush(safePath, message, files)); });
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.push(safePath)); });
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.fetch(safePath)); });
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.pullFastForward(safePath)); });
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)));
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.checkoutBranch(safePath, branch)); });
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.createBranch(safePath, branch)); });
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stash(safePath, message)); });
register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath)));
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.popStash(safePath, ref)); });
register('repository:clone', async ({ fullName, mode = 'default' }) => {
if (!['default', 'custom'].includes(mode)) throw new Error('Unsupported clone location mode.');
let projectRoot = store.data.workspaceRoots[0] || null;
if (mode === 'custom' || !projectRoot) {
const result = await dialog.showOpenDialog({
title: `Choose a project root for ${String(fullName || 'repository')}`,
defaultPath: projectRoot || undefined,
buttonLabel: 'Use this project root',
properties: ['openDirectory', 'createDirectory']
});
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
projectRoot = result.filePaths[0];
}
return cloneRepositoryInto(fullName, projectRoot);
});
register('repository:open-path', async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const error = await shell.openPath(safePath);
if (error) throw new Error(error);
return true;
});
register('external:open', async ({ url }) => {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Only HTTP and HTTPS links can be opened.');
await shell.openExternal(parsed.toString());
return true;
});
register('deployment:save-profile', async ({ fullName, profile }) => {
const saved = await store.saveDeploymentProfile(fullName, profile);
await diagnostics.info('deployment.profile.saved', { repository: fullName, profile: saved });
return { profile: saved, state: store.getPublicState() };
});
register('deployment:delete-profile', async ({ fullName, profileId }) => {
const profiles = await store.deleteDeploymentProfile(fullName, profileId);
await diagnostics.info('deployment.profile.deleted', { repository: fullName, profileId });
return { profiles, state: store.getPublicState() };
});
register('deployment:preflight', async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register('deployment:dispatch', async ({ repository, profileId, sha }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.deploy({ repository: current, profileId, sha });
return deployments.deploy({ repository: current, profileId, sha });
});
register('deployment:rollback', async ({ repository, profileId, targetSha }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.rollback({ repository: current, profileId, targetSha });
return deployments.rollback({ repository: current, profileId, targetSha });
});
register('deployment:health', ({ url }) => deployments.checkHealth(url));
register('deployment:profile-state', ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.refreshProfileState(fullName, profileId);
return deployments.refreshProfileState(fullName, profileId);
});
register('operations:refresh', async ({ operationId }) => {
if (operationId) {
const operation = store.getOperation(operationId);
if (operation?.provider === 'ssh-unraid') return operation;
return deployments.refreshOperation(operationId);
}
return deployments.refreshActiveOperations();
});
register('operations:get', ({ operationId }) => store.getOperation(operationId));
register('diagnostics:status', () => diagnostics.getStatus());
register('diagnostics:clear', () => diagnostics.clear());
register('diagnostics:open-folder', async () => {
const error = await shell.openPath(diagnostics.logDirectory);
if (error) throw new Error(error);
return true;
});
register('diagnostics:export', async ({ privacyMode = 'standard' }) => {
if (!['standard', 'strict'].includes(privacyMode)) throw new Error('Unsupported diagnostic privacy mode.');
const result = await dialog.showSaveDialog({
title: 'Export ForgeFlow diagnostic bundle',
defaultPath: path.join(app.getPath('downloads'), `ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.zip`),
filters: [{ name: 'ZIP archive', extensions: ['zip'] }]
});
if (result.canceled || !result.filePath) return null;
const repositoryState = await repositories.refresh().catch((error) => {
diagnostics.warning('diagnostics.repository-snapshot.failed', error);
return [];
});
const systemPreflight = await preflight.runSystem().catch((error) => ({ error: error.message }));
const destinationPath = path.extname(result.filePath).toLowerCase() === '.zip' ? result.filePath : `${result.filePath}.zip`;
return diagnostics.exportSupportBundle({
destinationPath,
publicState: store.getPublicState(),
repositories: repositoryState,
operations: store.data.operations,
preflight: systemPreflight,
privacyMode,
extra: { appVersion: app.getVersion(), setupComplete: store.data.setupComplete }
});
});
register('diagnostics:show-bundle', async ({ filePath }) => {
if (!diagnostics.isKnownBundlePath(filePath)) throw new Error('Only the most recently generated support bundle can be revealed.');
shell.showItemInFolder(filePath);
return true;
});
register('renderer:report', async ({ level = 'info', event = 'renderer.event', details = {} }) => {
const method = ['debug', 'info', 'warning', 'error'].includes(level) ? level : 'info';
await diagnostics[method](`renderer.${String(event || 'event').slice(0, 120)}`, details);
return true;
});
register('app:reset', async () => {
await diagnostics.info('app.reset.requested', {});
store.data = store.migrate({});
store.sessionToken = null;
await store.save();
monitor?.setPaths([]);
monitor?.restart();
return store.getPublicState();
});
}
module.exports = { registerIpc, cloneDirectoryName, assertTrustedSender, toErrorPayload };