hangwin--mcp-chrome
628 行
20 KiB
TypeScript
628 行
20 KiB
TypeScript
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<boolean> | null = null;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | 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<void> {
|
|
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<ServerStatus> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<number> {
|
|
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<ServerStatus> | 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<void> {
|
|
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<boolean> {
|
|
// 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;
|
|
}
|
|
});
|
|
};
|