feat(overlay-sync): wire OverlaySyncService into DI, IPC and renderer gating
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user