working companion prototype

This commit is contained in:
2025-11-24 23:32:36 +01:00
parent e7978024d7
commit e2bea9a126
175 changed files with 23227 additions and 3519 deletions

View File

@@ -1,9 +1,13 @@
import { app } from 'electron';
import * as path from 'path';
import { InMemorySessionRepository } from '@/packages/infrastructure/repositories/InMemorySessionRepository';
import { MockBrowserAutomationAdapter } from '@/packages/infrastructure/adapters/automation/MockBrowserAutomationAdapter';
import { NutJsAutomationAdapter } from '@/packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
import { PlaywrightAutomationAdapter, AutomationAdapterMode } from '@/packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter';
import { MockAutomationEngineAdapter } from '@/packages/infrastructure/adapters/automation/MockAutomationEngineAdapter';
import { PermissionService } from '@/packages/infrastructure/adapters/automation/PermissionService';
import { StartAutomationSessionUseCase } from '@/packages/application/use-cases/StartAutomationSessionUseCase';
import { CheckAuthenticationUseCase } from '@/packages/application/use-cases/CheckAuthenticationUseCase';
import { InitiateLoginUseCase } from '@/packages/application/use-cases/InitiateLoginUseCase';
import { ClearSessionUseCase } from '@/packages/application/use-cases/ClearSessionUseCase';
import { loadAutomationConfig, getAutomationMode, AutomationMode } from '@/packages/infrastructure/config';
import { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter';
import { NoOpLogAdapter } from '@/packages/infrastructure/adapters/logging/NoOpLogAdapter';
@@ -11,6 +15,7 @@ import { loadLoggingConfig } from '@/packages/infrastructure/config/LoggingConfi
import type { ISessionRepository } from '@/packages/application/ports/ISessionRepository';
import type { IScreenAutomation } from '@/packages/application/ports/IScreenAutomation';
import type { IAutomationEngine } from '@/packages/application/ports/IAutomationEngine';
import type { IAuthenticationService } from '@/packages/application/ports/IAuthenticationService';
import type { ILogger } from '@/packages/application/ports/ILogger';
export interface BrowserConnectionResult {
@@ -18,6 +23,39 @@ export interface BrowserConnectionResult {
error?: string;
}
/**
* Resolve the path to store persistent browser session data.
* Uses Electron's userData directory for secure, per-user storage.
*
* @returns Absolute path to the iracing session directory
*/
function resolveSessionDataPath(): string {
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'iracing-session');
}
/**
* Resolve the absolute path to the template directory.
* Handles both development and production (packaged) Electron environments.
*
* @returns Absolute path to the iracing templates directory
*/
function resolveTemplatePath(): string {
// In packaged app, app.getAppPath() returns the path to the app.asar or unpacked directory
// In development, it returns the path to the app directory (apps/companion)
const appPath = app.getAppPath();
if (app.isPackaged) {
// Production: resources are in the app.asar or unpacked directory
return path.join(appPath, 'resources/templates/iracing');
}
// Development: navigate from apps/companion to project root
// __dirname is apps/companion/main (or dist equivalent)
// appPath is apps/companion
return path.join(appPath, '../../resources/templates/iracing');
}
/**
* Create logger based on environment configuration.
* In test environment, returns NoOpLogAdapter for silent logging.
@@ -32,23 +70,57 @@ function createLogger(): ILogger {
return new PinoLogAdapter(config);
}
/**
* Determine the adapter mode based on environment.
* - 'production' → 'real' (uses iRacing website selectors)
* - 'development' → 'real' (uses iRacing website selectors)
* - 'test' → 'mock' (uses data-* attribute selectors)
*/
function getAdapterMode(envMode: AutomationMode): AutomationAdapterMode {
return envMode === 'test' ? 'mock' : 'real';
}
/**
* Create screen automation adapter based on configuration mode.
*
* Mode mapping:
* - 'production' → NutJsAutomationAdapter with iRacing window
* - 'test'/'development' → MockBrowserAutomationAdapter
* - 'production' → PlaywrightAutomationAdapter with mode='real' for iRacing website
* - 'development' → PlaywrightAutomationAdapter with mode='real' for iRacing website
* - 'test' → MockBrowserAutomationAdapter
*
* @param mode - The automation mode from configuration
* @param logger - Logger instance for the adapter
* @returns IScreenAutomation adapter instance
* @returns PlaywrightAutomationAdapter instance (implements both IScreenAutomation and IAuthenticationService)
*/
function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): IScreenAutomation {
function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): PlaywrightAutomationAdapter | MockBrowserAutomationAdapter {
const config = loadAutomationConfig();
// Resolve absolute template path for Electron environment
const absoluteTemplatePath = resolveTemplatePath();
const sessionDataPath = resolveSessionDataPath();
logger.debug('Resolved paths', {
absoluteTemplatePath,
sessionDataPath,
appPath: app.getAppPath(),
isPackaged: app.isPackaged,
cwd: process.cwd()
});
const adapterMode = getAdapterMode(mode);
logger.info('Creating browser automation adapter', { envMode: mode, adapterMode });
switch (mode) {
case 'production':
return new NutJsAutomationAdapter(config.nutJs, logger.child({ adapter: 'NutJs' }));
case 'development':
return new PlaywrightAutomationAdapter(
{
headless: mode === 'production',
mode: adapterMode,
userDataDir: sessionDataPath,
},
logger.child({ adapter: 'Playwright', mode: adapterMode })
);
case 'test':
default:
@@ -61,11 +133,13 @@ export class DIContainer {
private logger: ILogger;
private sessionRepository: ISessionRepository;
private browserAutomation: IScreenAutomation;
private browserAutomation: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter;
private automationEngine: IAutomationEngine;
private startAutomationUseCase: StartAutomationSessionUseCase;
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
private initiateLoginUseCase: InitiateLoginUseCase | null = null;
private clearSessionUseCase: ClearSessionUseCase | null = null;
private automationMode: AutomationMode;
private permissionService: PermissionService;
private constructor() {
// Initialize logger first - it's needed by other components
@@ -90,9 +164,14 @@ export class DIContainer {
this.browserAutomation,
this.sessionRepository
);
this.permissionService = new PermissionService(
this.logger.child({ service: 'PermissionService' })
);
// Create authentication use cases only for real mode (PlaywrightAutomationAdapter)
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
const authService = this.browserAutomation as IAuthenticationService;
this.checkAuthenticationUseCase = new CheckAuthenticationUseCase(authService);
this.initiateLoginUseCase = new InitiateLoginUseCase(authService);
this.clearSessionUseCase = new ClearSessionUseCase(authService);
}
this.logger.info('DIContainer initialized', {
automationMode: config.mode,
@@ -103,9 +182,12 @@ export class DIContainer {
private getBrowserAutomationType(mode: AutomationMode): string {
switch (mode) {
case 'production': return 'NutJsAutomationAdapter';
case 'production':
case 'development':
return 'PlaywrightAutomationAdapter';
case 'test':
default: return 'MockBrowserAutomationAdapter';
default:
return 'MockBrowserAutomationAdapter';
}
}
@@ -140,31 +222,46 @@ export class DIContainer {
return this.logger;
}
public getPermissionService(): PermissionService {
return this.permissionService;
public getCheckAuthenticationUseCase(): CheckAuthenticationUseCase | null {
return this.checkAuthenticationUseCase;
}
public getInitiateLoginUseCase(): InitiateLoginUseCase | null {
return this.initiateLoginUseCase;
}
public getClearSessionUseCase(): ClearSessionUseCase | null {
return this.clearSessionUseCase;
}
public getAuthenticationService(): IAuthenticationService | null {
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
return this.browserAutomation as IAuthenticationService;
}
return null;
}
/**
* Initialize automation connection based on mode.
* In production mode, connects to iRacing window via nut.js.
* In test/development mode, returns success immediately (no connection needed).
* In production/development mode, connects via Playwright browser automation.
* In test mode, returns success immediately (no connection needed).
*/
public async initializeBrowserConnection(): Promise<BrowserConnectionResult> {
this.logger.info('Initializing automation connection', { mode: this.automationMode });
if (this.automationMode === 'production') {
if (this.automationMode === 'production' || this.automationMode === 'development') {
try {
const nutJsAdapter = this.browserAutomation as NutJsAutomationAdapter;
const result = await nutJsAdapter.connect();
const playwrightAdapter = this.browserAutomation as PlaywrightAutomationAdapter;
const result = await playwrightAdapter.connect();
if (!result.success) {
this.logger.error('Automation connection failed', new Error(result.error || 'Unknown error'), { mode: 'production' });
this.logger.error('Automation connection failed', new Error(result.error || 'Unknown error'), { mode: this.automationMode });
return { success: false, error: result.error };
}
this.logger.info('Automation connection established', { mode: 'production', adapter: 'NutJs' });
this.logger.info('Automation connection established', { mode: this.automationMode, adapter: 'Playwright' });
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize nut.js';
this.logger.error('Automation connection failed', error instanceof Error ? error : new Error(errorMsg), { mode: 'production' });
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize Playwright';
this.logger.error('Automation connection failed', error instanceof Error ? error : new Error(errorMsg), { mode: this.automationMode });
return {
success: false,
error: errorMsg
@@ -172,7 +269,7 @@ export class DIContainer {
}
}
this.logger.debug('Test/development mode - no automation connection needed');
this.logger.debug('Test mode - no automation connection needed');
return { success: true };
}
@@ -185,7 +282,7 @@ export class DIContainer {
if (this.browserAutomation && 'disconnect' in this.browserAutomation) {
try {
await (this.browserAutomation as NutJsAutomationAdapter).disconnect();
await (this.browserAutomation as PlaywrightAutomationAdapter).disconnect();
this.logger.info('Automation adapter disconnected');
} catch (error) {
this.logger.error('Error disconnecting automation adapter', error instanceof Error ? error : new Error('Unknown error'));

View File

@@ -2,8 +2,10 @@ import { app, BrowserWindow } from 'electron';
import * as path from 'path';
import { setupIpcHandlers } from './ipc-handlers';
function createWindow() {
const mainWindow = new BrowserWindow({
let mainWindow: BrowserWindow | null = null;
function createWindow(): BrowserWindow {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
@@ -17,14 +19,15 @@ function createWindow() {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
// Path from dist/main to dist/renderer
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
}
setupIpcHandlers(mainWindow);
return mainWindow;
}
app.whenReady().then(() => {
app.whenReady().then(async () => {
createWindow();
app.on('activate', () => {

View File

@@ -3,6 +3,7 @@ import type { BrowserWindow, IpcMainInvokeEvent } from 'electron';
import { DIContainer } from './di-container';
import type { HostedSessionConfig } from '@/packages/domain/entities/HostedSessionConfig';
import { StepId } from '@/packages/domain/value-objects/StepId';
import { AuthenticationState } from '@/packages/domain/value-objects/AuthenticationState';
let progressMonitorInterval: NodeJS.Timeout | null = null;
@@ -11,58 +12,139 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
const startAutomationUseCase = container.getStartAutomationUseCase();
const sessionRepository = container.getSessionRepository();
const automationEngine = container.getAutomationEngine();
const permissionService = container.getPermissionService();
const logger = container.getLogger();
// Permission handlers
ipcMain.handle('automation:checkPermissions', async () => {
// Authentication handlers
ipcMain.handle('auth:check', async () => {
try {
const result = await permissionService.checkPermissions();
return {
success: true,
granted: result.granted,
status: result.status,
missingPermissions: result.missingPermissions,
};
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
logger.error('Permission check failed', err);
return {
success: false,
error: err.message,
granted: false,
status: {
accessibility: false,
screenRecording: false,
platform: process.platform,
},
missingPermissions: ['Accessibility', 'Screen Recording'],
};
}
});
ipcMain.handle('automation:requestAccessibility', async () => {
try {
const granted = permissionService.requestAccessibilityPermission();
return { success: true, granted };
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
logger.error('Accessibility permission request failed', err);
return { success: false, granted: false, error: err.message };
}
});
ipcMain.handle('automation:openPermissionSettings', async (_event: IpcMainInvokeEvent, pane?: 'accessibility' | 'screenRecording') => {
try {
if (pane) {
await permissionService.openSystemPreferences(pane);
} else {
await permissionService.openPermissionsSettings();
logger.info('Checking authentication status');
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
if (!checkAuthUseCase) {
logger.warn('Authentication not available in mock mode');
return {
success: true,
state: AuthenticationState.AUTHENTICATED,
message: 'Mock mode - authentication bypassed'
};
}
// NO browser connection needed - cookie check reads JSON file directly
const result = await checkAuthUseCase.execute();
if (result.isErr()) {
logger.error('Auth check failed', result.unwrapErr());
return { success: false, error: result.unwrapErr().message };
}
const state = result.unwrap();
logger.info('Authentication check complete', { state });
return { success: true, state };
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
logger.error('Auth check failed', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('auth:login', async () => {
try {
logger.info('Starting iRacing login flow (will wait for completion)');
const authService = container.getAuthenticationService();
if (!authService) {
// Mock mode - no actual login needed
logger.warn('Auth service not available in mock mode');
return { success: true, message: 'Mock mode - login bypassed' };
}
// Use the Playwright browser for login (same browser used for automation)
// This now waits for login to complete, auto-detects success, and closes browser
const initiateLoginUseCase = container.getInitiateLoginUseCase();
if (!initiateLoginUseCase) {
logger.warn('Initiate login use case not available');
return { success: false, error: 'Login not available' };
}
// This call now blocks until login is complete or times out
const result = await initiateLoginUseCase.execute();
if (result.isErr()) {
logger.error('Login failed or timed out', result.unwrapErr());
return { success: false, error: result.unwrapErr().message };
}
logger.info('Login completed successfully');
return { success: true, message: 'Login completed successfully' };
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
logger.error('Login flow failed', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('auth:confirmLogin', async () => {
try {
logger.info('User confirmed login completion');
const authService = container.getAuthenticationService();
if (!authService) {
logger.warn('Auth service not available in mock mode');
return { success: true, state: AuthenticationState.AUTHENTICATED };
}
// Call confirmLoginComplete on the adapter if it exists
if ('confirmLoginComplete' in authService) {
const result = await (authService as any).confirmLoginComplete();
if (result.isErr()) {
logger.error('Confirm login failed', result.unwrapErr());
return { success: false, error: result.unwrapErr().message };
}
}
logger.info('Login confirmation recorded');
return { success: true, state: AuthenticationState.AUTHENTICATED };
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
logger.error('Failed to confirm login', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('auth:logout', async () => {
try {
logger.info('Clearing session (logout)');
const clearSessionUseCase = container.getClearSessionUseCase();
if (!clearSessionUseCase) {
logger.warn('Logout not available in mock mode');
return { success: true, message: 'Mock mode - logout bypassed' };
}
const result = await clearSessionUseCase.execute();
if (result.isErr()) {
logger.error('Logout failed', result.unwrapErr());
return { success: false, error: result.unwrapErr().message };
}
logger.info('Session cleared successfully');
return { success: true };
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
logger.error('Failed to open permission settings', err);
logger.error('Logout failed', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('auth:getState', async () => {
try {
const authService = container.getAuthenticationService();
if (!authService) {
return { success: true, state: AuthenticationState.AUTHENTICATED };
}
return { success: true, state: authService.getState() };
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
logger.error('Failed to get auth state', err);
return { success: false, error: err.message };
}
});
@@ -76,20 +158,6 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
clearInterval(progressMonitorInterval);
progressMonitorInterval = null;
}
// Check permissions before starting automation (macOS only)
const permissionResult = await permissionService.checkPermissions();
if (!permissionResult.granted) {
logger.warn('Automation blocked due to missing permissions', {
missingPermissions: permissionResult.missingPermissions,
});
return {
success: false,
error: `Missing required permissions: ${permissionResult.missingPermissions.join(', ')}. Please grant permissions in System Preferences and try again.`,
permissionError: true,
missingPermissions: permissionResult.missingPermissions,
};
}
// Connect to browser first (required for dev mode)
const connectionResult = await container.initializeBrowserConnection();
@@ -99,6 +167,27 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
}
logger.info('Browser connection established');
// Check authentication before starting automation (production/development mode only)
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
if (checkAuthUseCase) {
const authResult = await checkAuthUseCase.execute();
if (authResult.isOk()) {
const authState = authResult.unwrap();
if (authState !== AuthenticationState.AUTHENTICATED) {
logger.warn('Not authenticated - automation cannot proceed', { authState });
return {
success: false,
error: 'Not authenticated. Please login first.',
authRequired: true,
authState,
};
}
logger.info('Authentication verified');
} else {
logger.warn('Auth check failed, proceeding anyway', { error: authResult.unwrapErr().message });
}
}
const result = await startAutomationUseCase.execute(config);
logger.info('Automation session created', { sessionId: result.sessionId });

View File

@@ -1,17 +1,22 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
import type { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
export interface PermissionStatus {
accessibility: boolean;
screenRecording: boolean;
platform: NodeJS.Platform;
export interface AuthStatusEvent {
state: AuthenticationState;
message?: string;
error?: string;
}
export interface PermissionCheckResponse {
export interface AuthCheckResponse {
success: boolean;
granted: boolean;
status: PermissionStatus;
missingPermissions: string[];
state?: AuthenticationState;
error?: string;
}
export interface AuthActionResponse {
success: boolean;
message?: string;
error?: string;
}
@@ -20,18 +25,18 @@ export interface ElectronAPI {
success: boolean;
sessionId?: string;
error?: string;
permissionError?: boolean;
missingPermissions?: string[];
}>;
stopAutomation: (sessionId: string) => Promise<{ success: boolean; 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;
// Permission APIs
checkPermissions: () => Promise<PermissionCheckResponse>;
requestAccessibility: () => Promise<{ success: boolean; granted: boolean; error?: string }>;
openPermissionSettings: (pane?: 'accessibility' | 'screenRecording') => Promise<{ success: boolean; error?: string }>;
// Authentication APIs
onAuthStatus: (callback: (status: AuthStatusEvent) => void) => void;
checkAuth: () => Promise<AuthCheckResponse>;
initiateLogin: () => Promise<AuthActionResponse>;
confirmLogin: () => Promise<AuthActionResponse>;
logout: () => Promise<AuthActionResponse>;
}
contextBridge.exposeInMainWorld('electronAPI', {
@@ -43,9 +48,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
onSessionProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('session-progress', (_event, progress) => callback(progress));
},
// Permission APIs
checkPermissions: () => ipcRenderer.invoke('automation:checkPermissions'),
requestAccessibility: () => ipcRenderer.invoke('automation:requestAccessibility'),
openPermissionSettings: (pane?: 'accessibility' | 'screenRecording') =>
ipcRenderer.invoke('automation:openPermissionSettings', pane),
// Authentication APIs
onAuthStatus: (callback: (status: AuthStatusEvent) => void) => {
ipcRenderer.on('auth:status', (_event, status) => callback(status));
},
checkAuth: () => ipcRenderer.invoke('auth:check'),
initiateLogin: () => ipcRenderer.invoke('auth:login'),
confirmLogin: () => ipcRenderer.invoke('auth:confirmLogin'),
logout: () => ipcRenderer.invoke('auth:logout'),
} as ElectronAPI);