'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 };