feat(overlay-sync): wire OverlaySyncService into DI, IPC and renderer gating

This commit is contained in:
2025-11-26 19:14:25 +01:00
parent d08f9e5264
commit 1d7c4f78d1
20 changed files with 611 additions and 561 deletions

View File

@@ -19,6 +19,9 @@ import type { IAutomationEngine } from '@/packages/application/ports/IAutomation
import type { IAuthenticationService } from '@/packages/application/ports/IAuthenticationService';
import type { ICheckoutConfirmationPort } from '@/packages/application/ports/ICheckoutConfirmationPort';
import type { ILogger } from '@/packages/application/ports/ILogger';
import type { IAutomationLifecycleEmitter } from '@/packages/infrastructure/adapters/IAutomationLifecycleEmitter';
import type { IOverlaySyncPort } from '@/packages/application/ports/IOverlaySyncPort';
import { OverlaySyncService } from '@/packages/application/services/OverlaySyncService';
export interface BrowserConnectionResult {
success: boolean;
@@ -183,7 +186,9 @@ export class DIContainer {
private confirmCheckoutUseCase: ConfirmCheckoutUseCase | null = null;
private automationMode: AutomationMode;
private browserModeConfigLoader: BrowserModeConfigLoader;
private overlaySyncService?: OverlaySyncService;
private initialized = false;
private constructor() {
// Initialize logger first - it's needed by other components
this.logger = createLogger();
@@ -194,11 +199,20 @@ export class DIContainer {
nodeEnv: process.env.NODE_ENV
});
const config = loadAutomationConfig();
// Initialize browser mode config loader as singleton
// Defer heavy initialization that may touch Electron/app paths until first use.
// Keep BrowserModeConfigLoader available immediately so callers can inspect it.
this.browserModeConfigLoader = new BrowserModeConfigLoader();
}
/**
* Lazily perform initialization that may access Electron APIs or filesystem.
* Called on first demand by methods that require the heavy components.
*/
private ensureInitialized(): void {
if (this.initialized) return;
const config = loadAutomationConfig();
this.sessionRepository = new InMemorySessionRepository();
this.browserAutomation = createBrowserAutomationAdapter(
config.mode,
@@ -221,13 +235,19 @@ export class DIContainer {
this.checkAuthenticationUseCase = new CheckAuthenticationUseCase(authService);
this.initiateLoginUseCase = new InitiateLoginUseCase(authService);
this.clearSessionUseCase = new ClearSessionUseCase(authService);
} else {
this.checkAuthenticationUseCase = null;
this.initiateLoginUseCase = null;
this.clearSessionUseCase = null;
}
this.logger.info('DIContainer initialized', {
automationMode: config.mode,
sessionRepositoryType: 'InMemorySessionRepository',
browserAutomationType: this.getBrowserAutomationType(config.mode)
});
this.initialized = true;
}
private getBrowserAutomationType(mode: AutomationMode): string {
@@ -249,14 +269,17 @@ export class DIContainer {
}
public getStartAutomationUseCase(): StartAutomationSessionUseCase {
this.ensureInitialized();
return this.startAutomationUseCase;
}
public getSessionRepository(): ISessionRepository {
this.ensureInitialized();
return this.sessionRepository;
}
public getAutomationEngine(): IAutomationEngine {
this.ensureInitialized();
return this.automationEngine;
}
@@ -265,6 +288,7 @@ export class DIContainer {
}
public getBrowserAutomation(): IScreenAutomation {
this.ensureInitialized();
return this.browserAutomation;
}
@@ -273,18 +297,22 @@ export class DIContainer {
}
public getCheckAuthenticationUseCase(): CheckAuthenticationUseCase | null {
this.ensureInitialized();
return this.checkAuthenticationUseCase;
}
public getInitiateLoginUseCase(): InitiateLoginUseCase | null {
this.ensureInitialized();
return this.initiateLoginUseCase;
}
public getClearSessionUseCase(): ClearSessionUseCase | null {
this.ensureInitialized();
return this.clearSessionUseCase;
}
public getAuthenticationService(): IAuthenticationService | null {
this.ensureInitialized();
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
return this.browserAutomation as IAuthenticationService;
}
@@ -294,6 +322,7 @@ export class DIContainer {
public setConfirmCheckoutUseCase(
checkoutConfirmationPort: ICheckoutConfirmationPort
): void {
this.ensureInitialized();
// Create ConfirmCheckoutUseCase with checkout service from browser automation
// and the provided confirmation port
this.confirmCheckoutUseCase = new ConfirmCheckoutUseCase(
@@ -303,6 +332,7 @@ export class DIContainer {
}
public getConfirmCheckoutUseCase(): ConfirmCheckoutUseCase | null {
this.ensureInitialized();
return this.confirmCheckoutUseCase;
}
@@ -312,6 +342,7 @@ export class DIContainer {
* In test mode, returns success immediately (no connection needed).
*/
public async initializeBrowserConnection(): Promise<BrowserConnectionResult> {
this.ensureInitialized();
this.logger.info('Initializing automation connection', { mode: this.automationMode });
if (this.automationMode === 'production' || this.automationMode === 'development') {
@@ -343,8 +374,9 @@ export class DIContainer {
* Should be called when the application is closing.
*/
public async shutdown(): Promise<void> {
this.ensureInitialized();
this.logger.info('DIContainer shutting down');
if (this.browserAutomation && 'disconnect' in this.browserAutomation) {
try {
await (this.browserAutomation as PlaywrightAutomationAdapter).disconnect();
@@ -353,7 +385,7 @@ export class DIContainer {
this.logger.error('Error disconnecting automation adapter', error instanceof Error ? error : new Error('Unknown error'));
}
}
this.logger.info('DIContainer shutdown complete');
}
@@ -365,6 +397,30 @@ export class DIContainer {
return this.browserModeConfigLoader;
}
public getOverlaySyncPort(): IOverlaySyncPort {
this.ensureInitialized();
if (!this.overlaySyncService) {
// Use the browser automation adapter as the lifecycle emitter when available.
const lifecycleEmitter = this.browserAutomation as unknown as IAutomationLifecycleEmitter;
// Lightweight in-process publisher (best-effort no-op). The ipc handlers will forward lifecycle events to renderer.
const publisher = {
publish: async (_event: any) => {
try {
this.logger.debug?.('OverlaySyncPublisher.publish', _event);
} catch {
// swallow
}
}
} as any;
this.overlaySyncService = new OverlaySyncService({
lifecycleEmitter,
publisher,
logger: this.logger
});
}
return this.overlaySyncService;
}
/**
* Recreate browser automation and related use-cases from the current
* BrowserModeConfigLoader state. This allows runtime changes to the
@@ -372,27 +428,28 @@ export class DIContainer {
* restarting the whole process.
*/
public refreshBrowserAutomation(): void {
this.ensureInitialized();
const config = loadAutomationConfig();
// Recreate browser automation adapter using current loader state
this.browserAutomation = createBrowserAutomationAdapter(
config.mode,
this.logger,
this.browserModeConfigLoader
);
// Recreate automation engine and start use case to pick up new adapter
this.automationEngine = new MockAutomationEngineAdapter(
this.browserAutomation,
this.sessionRepository
);
this.startAutomationUseCase = new StartAutomationSessionUseCase(
this.automationEngine,
this.browserAutomation,
this.sessionRepository
);
// Recreate authentication use-cases if adapter supports them, otherwise clear
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
const authService = this.browserAutomation as IAuthenticationService;
@@ -404,7 +461,7 @@ export class DIContainer {
this.initiateLoginUseCase = null;
this.clearSessionUseCase = null;
}
this.logger.info('Browser automation refreshed from updated BrowserModeConfigLoader', {
browserMode: this.browserModeConfigLoader.load().mode
});

View File

@@ -7,6 +7,7 @@ import { AuthenticationState } from '@/packages/domain/value-objects/Authenticat
import { ElectronCheckoutConfirmationAdapter } from '@/packages/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter';
let progressMonitorInterval: NodeJS.Timeout | null = null;
let lifecycleSubscribed = false;
export function setupIpcHandlers(mainWindow: BrowserWindow): void {
const container = DIContainer.getInstance();
@@ -326,6 +327,11 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
if (process.env.NODE_ENV === 'development') {
const loader = container.getBrowserModeConfigLoader();
loader.setDevelopmentMode(mode);
// Ensure runtime automation wiring reflects the new browser mode
if ('refreshBrowserAutomation' in container) {
// Call method to refresh adapters/use-cases that depend on browser mode
(container as any).refreshBrowserAutomation();
}
logger.info('Browser mode updated', { mode });
return { success: true, mode };
}
@@ -337,4 +343,44 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
return { success: false, error: err.message };
}
});
// Handle overlay action requests from renderer and forward to the OverlaySyncService
ipcMain.handle('overlay-action-request', async (_event: IpcMainInvokeEvent, action: any) => {
try {
const overlayPort = (container as any).getOverlaySyncPort ? container.getOverlaySyncPort() : null;
if (!overlayPort) {
logger.warn('OverlaySyncPort not available');
return { id: action?.id ?? 'unknown', status: 'failed', reason: 'OverlaySyncPort not available' };
}
const ack = await overlayPort.startAction(action);
return ack;
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
logger.error('Overlay action request failed', err);
return { id: action?.id ?? 'unknown', status: 'failed', reason: err.message };
}
});
// Subscribe to automation adapter lifecycle events and relay to renderer
try {
if (!lifecycleSubscribed) {
const browserAutomation = container.getBrowserAutomation() as any;
if (browserAutomation && typeof browserAutomation.onLifecycle === 'function') {
browserAutomation.onLifecycle((ev: any) => {
try {
if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.send('automation-event', ev);
}
} catch (e) {
logger.debug?.('Failed to forward automation-event', e);
}
});
lifecycleSubscribed = true;
logger.debug('Subscribed to adapter lifecycle events for renderer relay');
}
}
} catch (e) {
logger.debug?.('Failed to subscribe to adapter lifecycle events', e);
}
}

View File

@@ -54,6 +54,9 @@ export interface ElectronAPI {
// Checkout Confirmation APIs
onCheckoutConfirmationRequest: (callback: (request: CheckoutConfirmationRequest) => void) => () => void;
confirmCheckout: (decision: 'confirmed' | 'cancelled' | 'timeout') => void;
// Overlay / Automation events
overlayActionRequest: (action: { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }) => Promise<{ id: string; status: 'confirmed' | 'tentative' | 'failed'; reason?: string }>;
onAutomationEvent: (callback: (event: any) => void) => () => void;
}
contextBridge.exposeInMainWorld('electronAPI', {
@@ -87,4 +90,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
confirmCheckout: (decision: 'confirmed' | 'cancelled' | 'timeout') => {
ipcRenderer.send('checkout:confirm', decision);
},
// Overlay APIs
overlayActionRequest: (action: { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }) =>
ipcRenderer.invoke('overlay-action-request', action),
onAutomationEvent: (callback: (event: any) => void) => {
const listener = (_event: any, event: any) => callback(event);
ipcRenderer.on('automation-event', listener);
return () => {
ipcRenderer.removeListener('automation-event', listener);
};
},
} as ElectronAPI);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
interface SessionProgress {
sessionId: string;
@@ -36,6 +36,9 @@ const STEP_NAMES: { [key: number]: string } = {
};
export function SessionProgressMonitor({ sessionId, progress, isRunning }: SessionProgressMonitorProps) {
const [ackStatusByStep, setAckStatusByStep] = useState<Record<number, string>>({});
const [automationEventMsg, setAutomationEventMsg] = useState<string | null>(null);
const getStateColor = (state: string) => {
switch (state) {
case 'IN_PROGRESS': return '#0066cc';
@@ -56,6 +59,51 @@ export function SessionProgressMonitor({ sessionId, progress, isRunning }: Sessi
}
};
// Request overlay action when the current step changes (gate overlay rendering on ack)
useEffect(() => {
if (!progress || !sessionId) return;
const currentStep = progress.currentStep;
const action = {
id: `${progress.sessionId}-${currentStep}`,
label: STEP_NAMES[currentStep] || `Step ${currentStep}`,
meta: {},
timeoutMs: 1000
};
let mounted = true;
(async () => {
try {
// Use electronAPI overlayActionRequest to obtain ack
if ((window as any).electronAPI?.overlayActionRequest) {
const ack = await (window as any).electronAPI.overlayActionRequest(action);
if (!mounted) return;
setAckStatusByStep(prev => ({ ...prev, [currentStep]: ack.status }));
} else {
// If no IPC available, mark tentative as fallback
setAckStatusByStep(prev => ({ ...prev, [currentStep]: 'tentative' }));
}
} catch (e) {
if (!mounted) return;
setAckStatusByStep(prev => ({ ...prev, [currentStep]: 'failed' }));
}
})();
return () => { mounted = false; };
}, [progress?.currentStep, sessionId]);
// Subscribe to automation events for optional live updates
useEffect(() => {
if ((window as any).electronAPI?.onAutomationEvent) {
const off = (window as any).electronAPI.onAutomationEvent((ev: any) => {
if (ev && ev.payload && ev.payload.actionId && ev.type) {
setAutomationEventMsg(`${ev.type} ${ev.payload.actionId}`);
} else if (ev && ev.type) {
setAutomationEventMsg(ev.type);
}
});
return () => { if (typeof off === 'function') off(); };
}
return;
}, []);
if (!sessionId && !isRunning) {
return (
<div style={{ textAlign: 'center', color: '#666', paddingTop: '4rem' }}>