/** * SSH IPC Handlers - Manages SSH connection lifecycle from renderer requests. * * Channels: * - ssh:connect - Connect to SSH host, create new context * - ssh:disconnect - Disconnect and switch back to local context * - ssh:getState - Get current connection state * - ssh:test - Test connection without switching */ import { createLogger } from '@shared/utils/logger'; // Channel constants (mirrored from preload/constants/ipcChannels.ts to respect module boundaries)const SSH_CONNECT = 'ssh:connect';const SSH_DISCONNECT = 'ssh:disconnect';const SSH_GET_STATE = 'ssh:getState';const SSH_TEST = 'ssh:test';const SSH_GET_CONFIG_HOSTS = 'ssh:getConfigHosts';const SSH_RESOLVE_HOST = 'ssh:resolveHost';const SSH_SAVE_LAST_CONNECTION = 'ssh:saveLastConnection';const SSH_GET_LAST_CONNECTION = 'ssh:getLastConnection';import * as path from 'path'; import { configManager, ServiceContext } from '../services'; import type { ServiceContextRegistry, SshConnectionConfig, SshConnectionManager, SshConnectionStatus,} from '../services';import type { SshLastConnection } from '@shared/types';import type { IpcMain } from 'electron'; const logger = createLogger('IPC:ssh'); // =============================================================================// Module State// ============================================================================= let connectionManager: SshConnectionManager;let registry: ServiceContextRegistry;let onContextRewire: (context: ServiceContext) => void; // =============================================================================// Initialization// ============================================================================= /** * Initialize SSH handlers with required services. * @param manager - The SSH connection manager instance * @param contextRegistry - The service context registry * @param onRewire - Rewire-only callback (no renderer notification) for renderer-initiated switches */export function initializeSshHandlers( manager: SshConnectionManager, contextRegistry: ServiceContextRegistry, onRewire: (context: ServiceContext) => void): void { connectionManager = manager; registry = contextRegistry; onContextRewire = onRewire;} // =============================================================================// Handler Registration// ============================================================================= export function registerSshHandlers(ipcMain: IpcMain): void { ipcMain.handle(SSH_CONNECT, async (_event, config: SshConnectionConfig) => { try { // Connect to SSH host await connectionManager.connect(config); // Get provider and remote path const provider = connectionManager.getProvider(); const remoteProjectsPath = connectionManager.getRemoteProjectsPath() ?? undefined; const remoteTodosPath = remoteProjectsPath ? path.join(path.dirname(remoteProjectsPath), 'todos') : undefined; // Generate context ID const contextId = `ssh-${config.host}`; // Destroy existing SSH context if any (reconnection case) if (registry.has(contextId)) { logger.info(`Destroying existing SSH context: ${contextId}`); registry.destroy(contextId); } // Create new SSH context const sshContext = new ServiceContext({ id: contextId, type: 'ssh', fsProvider: provider, projectsDir: remoteProjectsPath, todosDir: remoteTodosPath, }); // Register and start SSH context registry.registerContext(sshContext); sshContext.start(); // Switch to SSH context registry.switch(contextId); // Re-wire file watcher events only (renderer's connectSsh() handles state) onContextRewire(sshContext); return { success: true, data: connectionManager.getStatus() }; } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error('SSH connect failed:', message); return { success: false, error: message }; } }); ipcMain.handle(SSH_DISCONNECT, async () => { try { // Get current SSH context ID before disconnecting const currentContextId = registry.getActiveContextId(); const isSshContext = currentContextId.startsWith('ssh-'); // Disconnect from SSH connectionManager.disconnect(); // If we were on an SSH context, destroy it if (isSshContext) { // Switch back to local first (this also starts local file watcher) registry.switch('local'); // Destroy the SSH context registry.destroy(currentContextId); // Re-wire file watcher events only (renderer's disconnectSsh() handles state) const localContext = registry.getActive(); onContextRewire(localContext); } return { success: true, data: connectionManager.getStatus() }; } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error('SSH disconnect failed:', message); return { success: false, error: message }; } }); ipcMain.handle(SSH_GET_STATE, async (): Promise<SshConnectionStatus> => { return connectionManager.getStatus(); }); ipcMain.handle(SSH_TEST, async (_event, config: SshConnectionConfig) => { try { const result = await connectionManager.testConnection(config); return { success: true, data: result }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { success: false, error: message }; } }); ipcMain.handle(SSH_GET_CONFIG_HOSTS, async () => { try { const hosts = await connectionManager.getConfigHosts(); return { success: true, data: hosts }; } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error('Failed to get SSH config hosts:', message); return { success: true, data: [] }; } }); ipcMain.handle(SSH_RESOLVE_HOST, async (_event, alias: string) => { try { const entry = await connectionManager.resolveHostConfig(alias); return { success: true, data: entry }; } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error(`Failed to resolve SSH host "${alias}":`, message); return { success: true, data: null }; } }); ipcMain.handle(SSH_SAVE_LAST_CONNECTION, async (_event, config: SshLastConnection) => { try { configManager.updateConfig('ssh', { lastConnection: { host: config.host, port: config.port, username: config.username, authMethod: config.authMethod, privateKeyPath: config.privateKeyPath, }, }); return { success: true }; } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error('Failed to save SSH connection:', message); return { success: false, error: message }; } }); ipcMain.handle(SSH_GET_LAST_CONNECTION, async () => { try { const config = configManager.getConfig(); return { success: true, data: config.ssh.lastConnection }; } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error('Failed to get last SSH connection:', message); return { success: true, data: null }; } }); logger.info('SSH handlers registered');} export function removeSshHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(SSH_CONNECT); ipcMain.removeHandler(SSH_DISCONNECT); ipcMain.removeHandler(SSH_GET_STATE); ipcMain.removeHandler(SSH_TEST); ipcMain.removeHandler(SSH_GET_CONFIG_HOSTS); ipcMain.removeHandler(SSH_RESOLVE_HOST); ipcMain.removeHandler(SSH_SAVE_LAST_CONNECTION); ipcMain.removeHandler(SSH_GET_LAST_CONNECTION);}
/**
* SSH IPC Handlers - Manages SSH connection lifecycle from renderer requests.
*
* Channels:
* - ssh:connect - Connect to SSH host, create new context
* - ssh:disconnect - Disconnect and switch back to local context
* - ssh:getState - Get current connection state
* - ssh:test - Test connection without switching
*/
import { createLogger } from '@shared/utils/logger';
// Channel constants (mirrored from preload/constants/ipcChannels.ts to respect module boundaries)
const SSH_CONNECT = 'ssh:connect';
const SSH_DISCONNECT = 'ssh:disconnect';
const SSH_GET_STATE = 'ssh:getState';
const SSH_TEST = 'ssh:test';
const SSH_GET_CONFIG_HOSTS = 'ssh:getConfigHosts';
const SSH_RESOLVE_HOST = 'ssh:resolveHost';
const SSH_SAVE_LAST_CONNECTION = 'ssh:saveLastConnection';
const SSH_GET_LAST_CONNECTION = 'ssh:getLastConnection';
import * as path from 'path';
import { configManager, ServiceContext } from '../services';
import type {
ServiceContextRegistry,
SshConnectionConfig,
SshConnectionManager,
SshConnectionStatus,
} from '../services';
import type { SshLastConnection } from '@shared/types';
import type { IpcMain } from 'electron';
const logger = createLogger('IPC:ssh');
// =============================================================================
// Module State
// =============================================================================
let connectionManager: SshConnectionManager;
let registry: ServiceContextRegistry;
let onContextRewire: (context: ServiceContext) => void;
// =============================================================================
// Initialization
// =============================================================================
/**
* Initialize SSH handlers with required services.
* @param manager - The SSH connection manager instance
* @param contextRegistry - The service context registry
* @param onRewire - Rewire-only callback (no renderer notification) for renderer-initiated switches
*/
export function initializeSshHandlers(
manager: SshConnectionManager,
contextRegistry: ServiceContextRegistry,
onRewire: (context: ServiceContext) => void
): void {
connectionManager = manager;
registry = contextRegistry;
onContextRewire = onRewire;
}
// =============================================================================
// Handler Registration
// =============================================================================
export function registerSshHandlers(ipcMain: IpcMain): void {
ipcMain.handle(SSH_CONNECT, async (_event, config: SshConnectionConfig) => {
try {
// Connect to SSH host
await connectionManager.connect(config);
// Get provider and remote path
const provider = connectionManager.getProvider();
const remoteProjectsPath = connectionManager.getRemoteProjectsPath() ?? undefined;
const remoteTodosPath = remoteProjectsPath
? path.join(path.dirname(remoteProjectsPath), 'todos')
: undefined;
// Generate context ID
const contextId = `ssh-${config.host}`;
// Destroy existing SSH context if any (reconnection case)
if (registry.has(contextId)) {
logger.info(`Destroying existing SSH context: ${contextId}`);
registry.destroy(contextId);
}
// Create new SSH context
const sshContext = new ServiceContext({
id: contextId,
type: 'ssh',
fsProvider: provider,
projectsDir: remoteProjectsPath,
todosDir: remoteTodosPath,
});
// Register and start SSH context
registry.registerContext(sshContext);
sshContext.start();
// Switch to SSH context
registry.switch(contextId);
// Re-wire file watcher events only (renderer's connectSsh() handles state)
onContextRewire(sshContext);
return { success: true, data: connectionManager.getStatus() };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('SSH connect failed:', message);
return { success: false, error: message };
}
});
ipcMain.handle(SSH_DISCONNECT, async () => {
try {
// Get current SSH context ID before disconnecting
const currentContextId = registry.getActiveContextId();
const isSshContext = currentContextId.startsWith('ssh-');
// Disconnect from SSH
connectionManager.disconnect();
// If we were on an SSH context, destroy it
if (isSshContext) {
// Switch back to local first (this also starts local file watcher)
registry.switch('local');
// Destroy the SSH context
registry.destroy(currentContextId);
// Re-wire file watcher events only (renderer's disconnectSsh() handles state)
const localContext = registry.getActive();
onContextRewire(localContext);
}
return { success: true, data: connectionManager.getStatus() };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('SSH disconnect failed:', message);
return { success: false, error: message };
}
});
ipcMain.handle(SSH_GET_STATE, async (): Promise<SshConnectionStatus> => {
return connectionManager.getStatus();
});
ipcMain.handle(SSH_TEST, async (_event, config: SshConnectionConfig) => {
try {
const result = await connectionManager.testConnection(config);
return { success: true, data: result };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
}
});
ipcMain.handle(SSH_GET_CONFIG_HOSTS, async () => {
try {
const hosts = await connectionManager.getConfigHosts();
return { success: true, data: hosts };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('Failed to get SSH config hosts:', message);
return { success: true, data: [] };
}
});
ipcMain.handle(SSH_RESOLVE_HOST, async (_event, alias: string) => {
try {
const entry = await connectionManager.resolveHostConfig(alias);
return { success: true, data: entry };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error(`Failed to resolve SSH host "${alias}":`, message);
return { success: true, data: null };
}
});
ipcMain.handle(SSH_SAVE_LAST_CONNECTION, async (_event, config: SshLastConnection) => {
try {
configManager.updateConfig('ssh', {
lastConnection: {
host: config.host,
port: config.port,
username: config.username,
authMethod: config.authMethod,
privateKeyPath: config.privateKeyPath,
},
});
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('Failed to save SSH connection:', message);
return { success: false, error: message };
}
});
ipcMain.handle(SSH_GET_LAST_CONNECTION, async () => {
try {
const config = configManager.getConfig();
return { success: true, data: config.ssh.lastConnection };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('Failed to get last SSH connection:', message);
return { success: true, data: null };
}
});
logger.info('SSH handlers registered');
}
export function removeSshHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(SSH_CONNECT);
ipcMain.removeHandler(SSH_DISCONNECT);
ipcMain.removeHandler(SSH_GET_STATE);
ipcMain.removeHandler(SSH_TEST);
ipcMain.removeHandler(SSH_GET_CONFIG_HOSTS);
ipcMain.removeHandler(SSH_RESOLVE_HOST);
ipcMain.removeHandler(SSH_SAVE_LAST_CONNECTION);
ipcMain.removeHandler(SSH_GET_LAST_CONNECTION);
}