import { NativeMessageType } from 'chrome-mcp-shared'; import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; import { NATIVE_HOST, STORAGE_KEYS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '@/common/constants'; import { handleCallTool } from './tools'; import { listPublished, getFlow } from './record-replay/flow-store'; import { acquireKeepalive } from './keepalive-manager'; const LOG_PREFIX = '[NativeHost]'; let nativePort: chrome.runtime.Port | null = null; export const HOST_NAME = NATIVE_HOST.NAME; // ==================== Reconnect Configuration ==================== const RECONNECT_BASE_DELAY_MS = 500; const RECONNECT_MAX_DELAY_MS = 60_000; const RECONNECT_MAX_FAST_ATTEMPTS = 8; const RECONNECT_COOLDOWN_DELAY_MS = 5 * 60_000; // ==================== Auto-connect State ==================== let keepaliveRelease: (() => void) | null = null; let autoConnectEnabled = true; let autoConnectLoaded = false; let ensurePromise: Promise | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; let manualDisconnect = false; /** * Server status management interface */ interface ServerStatus { isRunning: boolean; port?: number; lastUpdated: number; } let currentServerStatus: ServerStatus = { isRunning: false, lastUpdated: Date.now(), }; /** * Save server status to chrome.storage */ async function saveServerStatus(status: ServerStatus): Promise { try { await chrome.storage.local.set({ [STORAGE_KEYS.SERVER_STATUS]: status }); } catch (error) { console.error(ERROR_MESSAGES.SERVER_STATUS_SAVE_FAILED, error); } } /** * Load server status from chrome.storage */ async function loadServerStatus(): Promise { try { const result = await chrome.storage.local.get([STORAGE_KEYS.SERVER_STATUS]); if (result[STORAGE_KEYS.SERVER_STATUS]) { return result[STORAGE_KEYS.SERVER_STATUS]; } } catch (error) { console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error); } return { isRunning: false, lastUpdated: Date.now(), }; } /** * Broadcast server status change to all listeners */ function broadcastServerStatusChange(status: ServerStatus): void { chrome.runtime .sendMessage({ type: BACKGROUND_MESSAGE_TYPES.SERVER_STATUS_CHANGED, payload: status, }) .catch(() => { // Ignore errors if no listeners are present }); } // ==================== Port Normalization ==================== /** * Normalize a port value to a valid port number or null. */ function normalizePort(value: unknown): number | null { const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN; if (!Number.isFinite(n)) return null; const port = Math.floor(n); if (port <= 0 || port > 65535) return null; return port; } // ==================== Reconnect Utilities ==================== /** * Add jitter to a delay value to avoid thundering herd. */ function withJitter(ms: number): number { const ratio = 0.7 + Math.random() * 0.6; return Math.max(0, Math.round(ms * ratio)); } /** * Calculate reconnect delay based on attempt number. * Uses exponential backoff with jitter, then switches to cooldown interval. */ function getReconnectDelayMs(attempt: number): number { if (attempt >= RECONNECT_MAX_FAST_ATTEMPTS) { return withJitter(RECONNECT_COOLDOWN_DELAY_MS); } const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt), RECONNECT_MAX_DELAY_MS); return withJitter(delay); } /** * Clear the reconnect timer if active. */ function clearReconnectTimer(): void { if (!reconnectTimer) return; clearTimeout(reconnectTimer); reconnectTimer = null; } /** * Reset reconnect state after successful connection. */ function resetReconnectState(): void { reconnectAttempts = 0; clearReconnectTimer(); } // ==================== Keepalive Management ==================== /** * Sync keepalive hold based on autoConnectEnabled state. * When auto-connect is enabled, we hold a keepalive reference to keep SW alive. */ function syncKeepaliveHold(): void { if (autoConnectEnabled) { if (!keepaliveRelease) { keepaliveRelease = acquireKeepalive('native-host'); console.debug(`${LOG_PREFIX} Acquired keepalive`); } return; } if (keepaliveRelease) { try { keepaliveRelease(); console.debug(`${LOG_PREFIX} Released keepalive`); } catch { // Ignore } keepaliveRelease = null; } } // ==================== Auto-connect Settings ==================== /** * Load the nativeAutoConnectEnabled setting from storage. */ async function loadNativeAutoConnectEnabled(): Promise { try { const result = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]); const raw = result[STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]; if (typeof raw === 'boolean') return raw; } catch (error) { console.warn(`${LOG_PREFIX} Failed to load nativeAutoConnectEnabled`, error); } return true; // Default to enabled } /** * Set the nativeAutoConnectEnabled setting and persist to storage. */ async function setNativeAutoConnectEnabled(enabled: boolean): Promise { autoConnectEnabled = enabled; autoConnectLoaded = true; try { await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]: enabled }); console.debug(`${LOG_PREFIX} Set nativeAutoConnectEnabled=${enabled}`); } catch (error) { console.warn(`${LOG_PREFIX} Failed to persist nativeAutoConnectEnabled`, error); } syncKeepaliveHold(); } // ==================== Port Preference ==================== /** * Get the preferred port for connecting to native server. * Priority: explicit override > user preference > last known port > default */ async function getPreferredPort(override?: unknown): Promise { const explicit = normalizePort(override); if (explicit) return explicit; try { const result = await chrome.storage.local.get([ STORAGE_KEYS.NATIVE_SERVER_PORT, STORAGE_KEYS.SERVER_STATUS, ]); const userPort = normalizePort(result[STORAGE_KEYS.NATIVE_SERVER_PORT]); if (userPort) return userPort; const status = result[STORAGE_KEYS.SERVER_STATUS] as Partial | undefined; const statusPort = normalizePort(status?.port); if (statusPort) return statusPort; } catch (error) { console.warn(`${LOG_PREFIX} Failed to read preferred port`, error); } const inMemoryPort = normalizePort(currentServerStatus.port); if (inMemoryPort) return inMemoryPort; return NATIVE_HOST.DEFAULT_PORT; } // ==================== Reconnect Scheduling ==================== /** * Schedule a reconnect attempt with exponential backoff. */ function scheduleReconnect(reason: string): void { if (nativePort) return; if (manualDisconnect) return; if (!autoConnectEnabled) return; if (reconnectTimer) return; const delay = getReconnectDelayMs(reconnectAttempts); console.debug( `${LOG_PREFIX} Reconnect scheduled in ${delay}ms (attempt=${reconnectAttempts}, reason=${reason})`, ); reconnectTimer = setTimeout(() => { reconnectTimer = null; if (nativePort) return; if (manualDisconnect || !autoConnectEnabled) return; reconnectAttempts += 1; void ensureNativeConnected(`reconnect:${reason}`).catch(() => {}); }, delay); } // ==================== Server Status Update ==================== /** * Mark server as stopped and broadcast the change. */ async function markServerStopped(reason: string): Promise { currentServerStatus = { isRunning: false, port: currentServerStatus.port, lastUpdated: Date.now(), }; try { await saveServerStatus(currentServerStatus); } catch { // Ignore } broadcastServerStatusChange(currentServerStatus); console.debug(`${LOG_PREFIX} Server marked stopped (${reason})`); } // ==================== Core Ensure Function ==================== /** * Ensure native connection is established. * This is the main entry point for auto-connect logic. * * @param trigger - Description of what triggered this call (for logging) * @param portOverride - Optional explicit port to use * @returns Whether the connection is now established */ async function ensureNativeConnected(trigger: string, portOverride?: unknown): Promise { // Concurrency protection: only one ensure flow at a time if (ensurePromise) return ensurePromise; ensurePromise = (async () => { // Load auto-connect setting if not yet loaded if (!autoConnectLoaded) { autoConnectEnabled = await loadNativeAutoConnectEnabled(); autoConnectLoaded = true; syncKeepaliveHold(); } // If auto-connect is disabled, do nothing if (!autoConnectEnabled) { console.debug(`${LOG_PREFIX} Auto-connect disabled, skipping ensure (trigger=${trigger})`); return false; } // Sync keepalive hold syncKeepaliveHold(); // Already connected if (nativePort) { console.debug(`${LOG_PREFIX} Already connected (trigger=${trigger})`); return true; } // Get the port to use const port = await getPreferredPort(portOverride); console.debug(`${LOG_PREFIX} Attempting connection on port ${port} (trigger=${trigger})`); // Attempt connection const ok = connectNativeHost(port); if (!ok) { console.warn(`${LOG_PREFIX} Connection failed (trigger=${trigger})`); scheduleReconnect(`connect_failed:${trigger}`); return false; } console.debug(`${LOG_PREFIX} Connection initiated successfully (trigger=${trigger})`); // Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation. // Chrome may return a Port but disconnect immediately if native host is missing. return true; })().finally(() => { ensurePromise = null; }); return ensurePromise; } /** * Connect to the native messaging host * @returns Whether the connection was initiated successfully */ export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT): boolean { if (nativePort) { return true; } try { nativePort = chrome.runtime.connectNative(HOST_NAME); nativePort.onMessage.addListener(async (message) => { if (message.type === NativeMessageType.PROCESS_DATA && message.requestId) { const requestId = message.requestId; const requestPayload = message.payload; nativePort?.postMessage({ responseToRequestId: requestId, payload: { status: 'success', message: SUCCESS_MESSAGES.TOOL_EXECUTED, data: requestPayload, }, }); } else if (message.type === NativeMessageType.CALL_TOOL && message.requestId) { const requestId = message.requestId; try { const result = await handleCallTool(message.payload); nativePort?.postMessage({ responseToRequestId: requestId, payload: { status: 'success', message: SUCCESS_MESSAGES.TOOL_EXECUTED, data: result, }, }); } catch (error) { nativePort?.postMessage({ responseToRequestId: requestId, payload: { status: 'error', message: ERROR_MESSAGES.TOOL_EXECUTION_FAILED, error: error instanceof Error ? error.message : String(error), }, }); } } else if (message.type === 'rr_list_published_flows' && message.requestId) { const requestId = message.requestId; try { const published = await listPublished(); const items = [] as any[]; for (const p of published) { const flow = await getFlow(p.id); if (!flow) continue; items.push({ id: p.id, slug: p.slug, version: p.version, name: p.name, description: p.description || flow.description || '', variables: flow.variables || [], meta: flow.meta || {}, }); } nativePort?.postMessage({ responseToRequestId: requestId, payload: { status: 'success', items }, }); } catch (error: any) { nativePort?.postMessage({ responseToRequestId: requestId, payload: { status: 'error', error: error?.message || String(error) }, }); } } else if (message.type === NativeMessageType.SERVER_STARTED) { const port = message.payload?.port; currentServerStatus = { isRunning: true, port: port, lastUpdated: Date.now(), }; await saveServerStatus(currentServerStatus); broadcastServerStatusChange(currentServerStatus); // Server is confirmed running - now we can reset reconnect state resetReconnectState(); console.log(`${SUCCESS_MESSAGES.SERVER_STARTED} on port ${port}`); } else if (message.type === NativeMessageType.SERVER_STOPPED) { currentServerStatus = { isRunning: false, port: currentServerStatus.port, // Keep last known port for reconnection lastUpdated: Date.now(), }; await saveServerStatus(currentServerStatus); broadcastServerStatusChange(currentServerStatus); console.log(SUCCESS_MESSAGES.SERVER_STOPPED); } else if (message.type === NativeMessageType.ERROR_FROM_NATIVE_HOST) { console.error('Error from native host:', message.payload?.message || 'Unknown error'); } else if (message.type === 'file_operation_response') { // Forward file operation response back to the requesting tool chrome.runtime.sendMessage(message).catch(() => { // Ignore if no listeners }); } }); nativePort.onDisconnect.addListener(() => { console.warn(ERROR_MESSAGES.NATIVE_DISCONNECTED, chrome.runtime.lastError); nativePort = null; // Mark server as stopped since native host disconnection means server is down void markServerStopped('native_port_disconnected'); // Handle reconnection based on disconnect reason if (manualDisconnect) { manualDisconnect = false; return; } if (!autoConnectEnabled) return; scheduleReconnect('native_port_disconnected'); }); nativePort.postMessage({ type: NativeMessageType.START, payload: { port } }); // Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation. // Chrome may return a Port but disconnect immediately if native host is missing. return true; } catch (error) { console.warn(ERROR_MESSAGES.NATIVE_CONNECTION_FAILED, error); nativePort = null; return false; } } /** * Initialize native host listeners and load initial state */ export const initNativeHostListener = () => { // Initialize server status from storage loadServerStatus() .then((status) => { currentServerStatus = status; }) .catch((error) => { console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error); }); // Auto-connect on SW activation (covers SW restart after idle termination) void ensureNativeConnected('sw_startup').catch(() => {}); // Auto-connect on Chrome browser startup chrome.runtime.onStartup.addListener(() => { void ensureNativeConnected('onStartup').catch(() => {}); }); // Auto-connect on extension install/update chrome.runtime.onInstalled.addListener(() => { void ensureNativeConnected('onInstalled').catch(() => {}); }); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { // Allow UI to call tools directly if (message && message.type === 'call_tool' && message.name) { handleCallTool({ name: message.name, args: message.args }) .then((res) => sendResponse({ success: true, result: res })) .catch((err) => sendResponse({ success: false, error: err instanceof Error ? err.message : String(err) }), ); return true; } const msgType = typeof message === 'string' ? message : message?.type; // ENSURE_NATIVE: Trigger ensure without changing autoConnectEnabled if (msgType === NativeMessageType.ENSURE_NATIVE) { const portOverride = typeof message === 'object' ? message.port : undefined; ensureNativeConnected('ui_ensure', portOverride) .then((connected) => { sendResponse({ success: true, connected, autoConnectEnabled }); }) .catch((e) => { sendResponse({ success: false, connected: nativePort !== null, error: String(e) }); }); return true; } // CONNECT_NATIVE: Explicit user connect, re-enables auto-connect if (msgType === NativeMessageType.CONNECT_NATIVE) { const portOverride = typeof message === 'object' ? message.port : undefined; const normalized = normalizePort(portOverride); (async () => { // Explicit user connect: re-enable auto-connect await setNativeAutoConnectEnabled(true); if (normalized) { // Best-effort: persist preferred port try { await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_SERVER_PORT]: normalized }); } catch { // Ignore } } return ensureNativeConnected('ui_connect', normalized ?? undefined); })() .then((connected) => { sendResponse({ success: true, connected }); }) .catch((e) => { sendResponse({ success: false, connected: nativePort !== null, error: String(e) }); }); return true; } if (msgType === NativeMessageType.PING_NATIVE) { const connected = nativePort !== null; sendResponse({ connected, autoConnectEnabled }); return true; } // DISCONNECT_NATIVE: Explicit user disconnect, disables auto-connect if (msgType === NativeMessageType.DISCONNECT_NATIVE) { (async () => { // Explicit user disconnect: disable auto-connect and stop reconnect loop await setNativeAutoConnectEnabled(false); clearReconnectTimer(); reconnectAttempts = 0; syncKeepaliveHold(); if (nativePort) { // Only set manualDisconnect if we actually have a port to disconnect. // This prevents the flag from persisting when there's no active connection. manualDisconnect = true; try { nativePort.disconnect(); } catch { // Ignore } nativePort = null; } await markServerStopped('manual_disconnect'); })() .then(() => { sendResponse({ success: true }); }) .catch((e) => { sendResponse({ success: false, error: String(e) }); }); return true; } if (message.type === BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS) { sendResponse({ success: true, serverStatus: currentServerStatus, connected: nativePort !== null, }); return true; } if (message.type === BACKGROUND_MESSAGE_TYPES.REFRESH_SERVER_STATUS) { loadServerStatus() .then((storedStatus) => { currentServerStatus = storedStatus; sendResponse({ success: true, serverStatus: currentServerStatus, connected: nativePort !== null, }); }) .catch((error) => { console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error); sendResponse({ success: false, error: ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, serverStatus: currentServerStatus, connected: nativePort !== null, }); }); return true; } // Forward file operation messages to native host if (message.type === 'forward_to_native' && message.message) { if (nativePort) { nativePort.postMessage(message.message); sendResponse({ success: true }); } else { sendResponse({ success: false, error: 'Native host not connected' }); } return true; } }); };