Update
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user