feat(companion): add Electron presentation layer with React UI, IPC handlers, and automation POC

This commit is contained in:
2025-11-21 16:49:14 +01:00
parent 098bfc2c11
commit bd60bd6771
18 changed files with 3888 additions and 171 deletions

View File

@@ -0,0 +1,49 @@
import { InMemorySessionRepository } from '../../../infrastructure/repositories/InMemorySessionRepository';
import { MockBrowserAutomationAdapter } from '../../../infrastructure/adapters/automation/MockBrowserAutomationAdapter';
import { MockAutomationEngineAdapter } from '../../../infrastructure/adapters/automation/MockAutomationEngineAdapter';
import { StartAutomationSessionUseCase } from '../../../packages/application/use-cases/StartAutomationSessionUseCase';
import type { ISessionRepository } from '../../../packages/application/ports/ISessionRepository';
import type { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
import type { IAutomationEngine } from '../../../packages/application/ports/IAutomationEngine';
export class DIContainer {
private static instance: DIContainer;
private sessionRepository: ISessionRepository;
private browserAutomation: IBrowserAutomation;
private automationEngine: IAutomationEngine;
private startAutomationUseCase: StartAutomationSessionUseCase;
private constructor() {
this.sessionRepository = new InMemorySessionRepository();
this.browserAutomation = new MockBrowserAutomationAdapter();
this.automationEngine = new MockAutomationEngineAdapter(
this.browserAutomation,
this.sessionRepository
);
this.startAutomationUseCase = new StartAutomationSessionUseCase(
this.automationEngine,
this.browserAutomation,
this.sessionRepository
);
}
public static getInstance(): DIContainer {
if (!DIContainer.instance) {
DIContainer.instance = new DIContainer();
}
return DIContainer.instance;
}
public getStartAutomationUseCase(): StartAutomationSessionUseCase {
return this.startAutomationUseCase;
}
public getSessionRepository(): ISessionRepository {
return this.sessionRepository;
}
public getAutomationEngine(): IAutomationEngine {
return this.automationEngine;
}
}

View File

@@ -0,0 +1,41 @@
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
import { setupIpcHandlers } from './ipc-handlers';
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
// Path from dist/main/apps/companion/main to dist/renderer/src/apps/companion/renderer/index.html
mainWindow.loadFile(path.join(__dirname, '../../../../renderer/src/apps/companion/renderer/index.html'));
}
setupIpcHandlers(mainWindow);
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

View File

@@ -0,0 +1,81 @@
import { ipcMain, BrowserWindow } from 'electron';
import { DIContainer } from './di-container';
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
import { StepId } from '../../../packages/domain/value-objects/StepId';
export function setupIpcHandlers(mainWindow: BrowserWindow): void {
const container = DIContainer.getInstance();
const startAutomationUseCase = container.getStartAutomationUseCase();
const sessionRepository = container.getSessionRepository();
const automationEngine = container.getAutomationEngine();
ipcMain.handle('start-automation', async (_event, config: HostedSessionConfig) => {
try {
const result = await startAutomationUseCase.execute(config);
const session = await sessionRepository.findById(result.sessionId);
if (session) {
// Start the automation by executing step 1
await automationEngine.executeStep(StepId.create(1), config);
}
// Set up progress monitoring
const checkInterval = setInterval(async () => {
const updatedSession = await sessionRepository.findById(result.sessionId);
if (!updatedSession) {
clearInterval(checkInterval);
return;
}
mainWindow.webContents.send('session-progress', {
sessionId: result.sessionId,
currentStep: updatedSession.currentStep.value,
state: updatedSession.state.value,
completedSteps: Array.from({ length: updatedSession.currentStep.value - 1 }, (_, i) => i + 1),
hasError: updatedSession.errorMessage !== undefined,
errorMessage: updatedSession.errorMessage || null
});
if (updatedSession.state.value === 'COMPLETED' ||
updatedSession.state.value === 'FAILED' ||
updatedSession.state.value === 'STOPPED_AT_STEP_18') {
clearInterval(checkInterval);
}
}, 100);
return {
success: true,
sessionId: result.sessionId
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
});
ipcMain.handle('get-session-status', async (_event, sessionId: string) => {
const session = await sessionRepository.findById(sessionId);
if (!session) {
return { found: false };
}
return {
found: true,
currentStep: session.currentStep.value,
state: session.state.value,
completedSteps: Array.from({ length: session.currentStep.value - 1 }, (_, i) => i + 1),
hasError: session.errorMessage !== undefined,
errorMessage: session.errorMessage || null
};
});
ipcMain.handle('pause-automation', async (_event, _sessionId: string) => {
return { success: false, error: 'Pause not implemented in POC' };
});
ipcMain.handle('resume-automation', async (_event, _sessionId: string) => {
return { success: false, error: 'Resume not implemented in POC' };
});
}

View File

@@ -0,0 +1,20 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
export interface ElectronAPI {
startAutomation: (config: HostedSessionConfig) => Promise<{ success: boolean; sessionId?: string; error?: string }>;
getSessionStatus: (sessionId: string) => Promise<any>;
pauseAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
resumeAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
onSessionProgress: (callback: (progress: any) => void) => void;
}
contextBridge.exposeInMainWorld('electronAPI', {
startAutomation: (config: HostedSessionConfig) => ipcRenderer.invoke('start-automation', config),
getSessionStatus: (sessionId: string) => ipcRenderer.invoke('get-session-status', sessionId),
pauseAutomation: (sessionId: string) => ipcRenderer.invoke('pause-automation', sessionId),
resumeAutomation: (sessionId: string) => ipcRenderer.invoke('resume-automation', sessionId),
onSessionProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('session-progress', (_event, progress) => callback(progress));
}
} as ElectronAPI);

View File

@@ -0,0 +1,80 @@
import React, { useState, useEffect } from 'react';
import { SessionCreationForm } from './components/SessionCreationForm';
import { SessionProgressMonitor } from './components/SessionProgressMonitor';
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
interface SessionProgress {
sessionId: string;
currentStep: number;
state: string;
completedSteps: number[];
hasError: boolean;
errorMessage: string | null;
}
export function App() {
const [sessionId, setSessionId] = useState<string | null>(null);
const [progress, setProgress] = useState<SessionProgress | null>(null);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (window.electronAPI) {
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
setProgress(newProgress);
if (newProgress.state === 'COMPLETED' ||
newProgress.state === 'FAILED' ||
newProgress.state === 'STOPPED_AT_STEP_18') {
setIsRunning(false);
}
});
}
}, []);
const handleStartAutomation = async (config: HostedSessionConfig) => {
setIsRunning(true);
const result = await window.electronAPI.startAutomation(config);
if (result.success && result.sessionId) {
setSessionId(result.sessionId);
} else {
setIsRunning(false);
alert(`Failed to start automation: ${result.error}`);
}
};
return (
<div style={{
display: 'flex',
minHeight: '100vh',
backgroundColor: '#1a1a1a'
}}>
<div style={{
flex: 1,
padding: '2rem',
borderRight: '1px solid #333'
}}>
<h1 style={{ marginBottom: '2rem', color: '#fff' }}>
GridPilot Companion
</h1>
<p style={{ marginBottom: '2rem', color: '#aaa' }}>
Hosted Session Automation POC
</p>
<SessionCreationForm
onSubmit={handleStartAutomation}
disabled={isRunning}
/>
</div>
<div style={{
flex: 1,
padding: '2rem',
backgroundColor: '#0d0d0d'
}}>
<SessionProgressMonitor
sessionId={sessionId}
progress={progress}
isRunning={isRunning}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,177 @@
import React, { useState } from 'react';
import type { HostedSessionConfig } from '../../../../packages/domain/entities/HostedSessionConfig';
interface SessionCreationFormProps {
onSubmit: (config: HostedSessionConfig) => void;
disabled: boolean;
}
export function SessionCreationForm({ onSubmit, disabled }: SessionCreationFormProps) {
const [config, setConfig] = useState<HostedSessionConfig>({
sessionName: 'POC Test Session',
serverName: 'POC Server',
password: 'test123',
adminPassword: 'admin123',
maxDrivers: 40,
trackId: 'watkins-glen',
carIds: ['porsche-911-gt3-r'],
weatherType: 'dynamic',
timeOfDay: 'afternoon',
sessionDuration: 60,
practiceLength: 15,
qualifyingLength: 10,
warmupLength: 5,
raceLength: 30,
startType: 'standing',
restarts: 'single-file',
damageModel: 'realistic',
trackState: 'auto'
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(config);
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.5rem',
backgroundColor: '#2a2a2a',
border: '1px solid #444',
borderRadius: '4px',
color: '#e0e0e0',
fontSize: '14px'
};
const labelStyle: React.CSSProperties = {
display: 'block',
marginBottom: '0.5rem',
color: '#aaa',
fontSize: '14px'
};
const fieldStyle: React.CSSProperties = {
marginBottom: '1rem'
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: '500px' }}>
<h2 style={{ marginBottom: '1.5rem', color: '#fff' }}>Session Configuration</h2>
<div style={fieldStyle}>
<label style={labelStyle}>Session Name</label>
<input
type="text"
value={config.sessionName}
onChange={(e) => setConfig({ ...config, sessionName: e.target.value })}
style={inputStyle}
disabled={disabled}
required
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Server Name</label>
<input
type="text"
value={config.serverName}
onChange={(e) => setConfig({ ...config, serverName: e.target.value })}
style={inputStyle}
disabled={disabled}
required
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Max Drivers</label>
<input
type="number"
value={config.maxDrivers}
onChange={(e) => setConfig({ ...config, maxDrivers: parseInt(e.target.value) })}
style={inputStyle}
disabled={disabled}
min="1"
max="60"
required
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Track</label>
<input
type="text"
value={config.trackId}
onChange={(e) => setConfig({ ...config, trackId: e.target.value })}
style={inputStyle}
disabled={disabled}
required
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Weather Type</label>
<select
value={config.weatherType}
onChange={(e) => setConfig({ ...config, weatherType: e.target.value as any })}
style={inputStyle}
disabled={disabled}
>
<option value="static">Static</option>
<option value="dynamic">Dynamic</option>
</select>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Time of Day</label>
<select
value={config.timeOfDay}
onChange={(e) => setConfig({ ...config, timeOfDay: e.target.value as any })}
style={inputStyle}
disabled={disabled}
>
<option value="morning">Morning</option>
<option value="afternoon">Afternoon</option>
<option value="evening">Evening</option>
<option value="night">Night</option>
</select>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Race Length (minutes)</label>
<input
type="number"
value={config.raceLength}
onChange={(e) => setConfig({ ...config, raceLength: parseInt(e.target.value) })}
style={inputStyle}
disabled={disabled}
min="1"
required
/>
</div>
<button
type="submit"
disabled={disabled}
style={{
width: '100%',
padding: '0.75rem',
backgroundColor: disabled ? '#444' : '#0066cc',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
fontWeight: 'bold',
cursor: disabled ? 'not-allowed' : 'pointer',
marginTop: '1rem'
}}
>
{disabled ? 'Automation Running...' : 'Start Automation'}
</button>
<p style={{ marginTop: '1rem', fontSize: '12px', color: '#888', lineHeight: '1.5' }}>
Note: This POC will execute steps 1-18 and stop at step 18 (Track Conditions) for safety.
The actual session creation will not be completed.
</p>
</form>
);
}

View File

@@ -0,0 +1,208 @@
import React from 'react';
interface SessionProgress {
sessionId: string;
currentStep: number;
state: string;
completedSteps: number[];
hasError: boolean;
errorMessage: string | null;
}
interface SessionProgressMonitorProps {
sessionId: string | null;
progress: SessionProgress | null;
isRunning: boolean;
}
const STEP_NAMES: { [key: number]: string } = {
1: 'Navigate to Hosted Racing',
2: 'Click Create a Race',
3: 'Fill Race Information',
4: 'Configure Server Details',
5: 'Set Admins',
6: 'Add Admin',
7: 'Set Time Limits',
8: 'Set Cars',
9: 'Add Car',
10: 'Set Car Classes',
11: 'Set Track',
12: 'Add Track',
13: 'Configure Track Options',
14: 'Set Time of Day',
15: 'Configure Weather',
16: 'Set Race Options',
17: 'Configure Team Driving',
18: 'Set Track Conditions'
};
export function SessionProgressMonitor({ sessionId, progress, isRunning }: SessionProgressMonitorProps) {
const getStateColor = (state: string) => {
switch (state) {
case 'IN_PROGRESS': return '#0066cc';
case 'COMPLETED': return '#28a745';
case 'FAILED': return '#dc3545';
case 'STOPPED_AT_STEP_18': return '#ffc107';
default: return '#6c757d';
}
};
const getStateLabel = (state: string) => {
switch (state) {
case 'IN_PROGRESS': return 'Running';
case 'COMPLETED': return 'Completed';
case 'FAILED': return 'Failed';
case 'STOPPED_AT_STEP_18': return 'Stopped at Step 18';
default: return state;
}
};
if (!sessionId && !isRunning) {
return (
<div style={{ textAlign: 'center', color: '#666', paddingTop: '4rem' }}>
<h2 style={{ marginBottom: '1rem' }}>Session Progress</h2>
<p>Configure and start an automation session to see progress here.</p>
</div>
);
}
return (
<div>
<h2 style={{ marginBottom: '1.5rem', color: '#fff' }}>Session Progress</h2>
{sessionId && (
<div style={{
marginBottom: '2rem',
padding: '1rem',
backgroundColor: '#1a1a1a',
borderRadius: '4px',
border: '1px solid #333'
}}>
<div style={{ marginBottom: '0.5rem', color: '#aaa', fontSize: '14px' }}>
Session ID
</div>
<div style={{ fontFamily: 'monospace', fontSize: '12px', color: '#fff' }}>
{sessionId}
</div>
</div>
)}
{progress && (
<>
<div style={{
marginBottom: '2rem',
padding: '1rem',
backgroundColor: '#1a1a1a',
borderRadius: '4px',
border: '1px solid #333'
}}>
<div style={{ marginBottom: '0.5rem', color: '#aaa', fontSize: '14px' }}>
Status
</div>
<div style={{
fontSize: '18px',
fontWeight: 'bold',
color: getStateColor(progress.state)
}}>
{getStateLabel(progress.state)}
</div>
</div>
{progress.state === 'STOPPED_AT_STEP_18' && (
<div style={{
marginBottom: '2rem',
padding: '1rem',
backgroundColor: '#332b00',
border: '1px solid #ffc107',
borderRadius: '4px',
color: '#ffc107'
}}>
<strong> Safety Stop</strong>
<p style={{ marginTop: '0.5rem', fontSize: '14px', lineHeight: '1.5' }}>
Automation stopped at step 18 (Track Conditions) as configured.
This prevents accidental session creation during POC demonstration.
</p>
</div>
)}
{progress.hasError && progress.errorMessage && (
<div style={{
marginBottom: '2rem',
padding: '1rem',
backgroundColor: '#2d0a0a',
border: '1px solid #dc3545',
borderRadius: '4px',
color: '#dc3545'
}}>
<strong>Error</strong>
<p style={{ marginTop: '0.5rem', fontSize: '14px' }}>
{progress.errorMessage}
</p>
</div>
)}
<div style={{ marginBottom: '1rem', color: '#aaa', fontSize: '14px' }}>
Progress: {progress.completedSteps.length} / 18 steps
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{Object.entries(STEP_NAMES).map(([stepNum, stepName]) => {
const step = parseInt(stepNum);
const isCompleted = progress.completedSteps.includes(step);
const isCurrent = progress.currentStep === step;
return (
<div
key={step}
style={{
padding: '0.75rem',
backgroundColor: isCurrent ? '#1a3a1a' : isCompleted ? '#0d2b0d' : '#1a1a1a',
border: `1px solid ${isCurrent ? '#28a745' : isCompleted ? '#1d4d1d' : '#333'}`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '1rem'
}}
>
<div style={{
width: '30px',
height: '30px',
borderRadius: '50%',
backgroundColor: isCompleted ? '#28a745' : isCurrent ? '#0066cc' : '#333',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '14px',
fontWeight: 'bold',
flexShrink: 0
}}>
{isCompleted ? '✓' : step}
</div>
<div style={{ flex: 1 }}>
<div style={{
color: isCurrent ? '#28a745' : isCompleted ? '#aaa' : '#666',
fontSize: '14px',
fontWeight: isCurrent ? 'bold' : 'normal'
}}>
{stepName}
</div>
</div>
{isCurrent && (
<div style={{
fontSize: '12px',
color: '#0066cc',
fontWeight: 'bold'
}}>
IN PROGRESS
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,19 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #1a1a1a;
color: #e0e0e0;
}
#root {
min-height: 100vh;
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GridPilot - Hosted Session Automation POC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/apps/companion/renderer/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,9 @@
import type { ElectronAPI } from '../main/preload';
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};

View File

@@ -0,0 +1,96 @@
import { IAutomationEngine, ValidationResult } from '../../../packages/application/ports/IAutomationEngine';
import { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
import { StepId } from '../../../packages/domain/value-objects/StepId';
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
import { ISessionRepository } from '../../../packages/application/ports/ISessionRepository';
export class MockAutomationEngineAdapter implements IAutomationEngine {
private automationInterval: NodeJS.Timeout | null = null;
constructor(
private readonly browserAutomation: IBrowserAutomation,
private readonly sessionRepository: ISessionRepository
) {}
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
if (!config.sessionName || config.sessionName.trim() === '') {
return { isValid: false, error: 'Session name is required' };
}
if (!config.trackId || config.trackId.trim() === '') {
return { isValid: false, error: 'Track ID is required' };
}
if (!config.carIds || config.carIds.length === 0) {
return { isValid: false, error: 'At least one car must be selected' };
}
return { isValid: true };
}
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void> {
const sessions = await this.sessionRepository.findAll();
const session = sessions[0];
if (!session) {
throw new Error('No active session found');
}
// Start session if it's at step 1 and pending
if (session.state.isPending() && stepId.value === 1) {
session.start();
await this.sessionRepository.update(session);
// Start automated progression
this.startAutomation(config);
}
}
private startAutomation(config: HostedSessionConfig): void {
this.automationInterval = setInterval(async () => {
try {
const sessions = await this.sessionRepository.findAll();
const session = sessions[0];
if (!session || !session.state.isInProgress()) {
if (this.automationInterval) {
clearInterval(this.automationInterval);
this.automationInterval = null;
}
return;
}
const currentStep = session.currentStep;
// Execute current step (simulate browser automation)
if (typeof (this.browserAutomation as any).executeStep === 'function') {
await (this.browserAutomation as any).executeStep(currentStep, config);
} else {
// Fallback to basic operations
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
}
// Transition to next step if not final
if (!currentStep.isFinalStep()) {
session.transitionToStep(currentStep.next());
await this.sessionRepository.update(session);
} else {
// Stop at step 18
if (this.automationInterval) {
clearInterval(this.automationInterval);
this.automationInterval = null;
}
}
} catch (error) {
console.error('Automation error:', error);
if (this.automationInterval) {
clearInterval(this.automationInterval);
this.automationInterval = null;
}
}
}, 500); // Execute each step every 500ms
}
public stopAutomation(): void {
if (this.automationInterval) {
clearInterval(this.automationInterval);
this.automationInterval = null;
}
}
}

View File

@@ -1,5 +1,20 @@
export interface HostedSessionConfig {
sessionName: string;
serverName: string;
password: string;
adminPassword: string;
maxDrivers: number;
trackId: string;
carIds: string[];
weatherType: 'static' | 'dynamic';
timeOfDay: 'morning' | 'afternoon' | 'evening' | 'night';
sessionDuration: number;
practiceLength: number;
qualifyingLength: number;
warmupLength: number;
raceLength: number;
startType: 'standing' | 'rolling';
restarts: 'single-file' | 'double-file';
damageModel: 'off' | 'limited' | 'realistic';
trackState: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum';
}