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