import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import type {
UpsertMarkerRequest,
ElementMarker,
MarkerValidationRequest,
MarkerValidationAction,
} from '@/common/element-marker-types';
import {
deleteMarker,
listAllMarkers,
listMarkersForUrl,
saveMarker,
updateMarker,
} from './element-marker-storage';
import { computerTool } from '@/entrypoints/background/tools/browser/computer';
import { clickTool } from '@/entrypoints/background/tools/browser/interaction';
import { keyboardTool } from '@/entrypoints/background/tools/browser/keyboard';
const CONTEXT_MENU_ID = 'element_marker_mark';
/**
* Extract error message from MCP tool result
*/
function extractToolError(result: any): string | undefined {
if (!result) return undefined;
// Check for error in result content array
if (Array.isArray(result.content)) {
for (const item of result.content) {
if (item?.text) {
try {
const parsed = JSON.parse(item.text);
if (parsed?.error) return parsed.error;
if (parsed?.message) return parsed.message;
} catch {
// Not JSON, use as-is
return item.text;
}
}
}
}
// Fallback to direct error field
return result.error || (result.isError ? 'unknown tool error' : undefined);
}
async function ensureContextMenu() {
try {
// Guard: contextMenus permission may be missing
if (!(chrome as any).contextMenus?.create) return;
// Remove and re-create our single menu to avoid duplication
try {
await chrome.contextMenus.remove(CONTEXT_MENU_ID);
} catch {}
await chrome.contextMenus.create({
id: CONTEXT_MENU_ID,
title: '标注元素',
contexts: ['all'],
});
} catch (e) {
console.warn('ElementMarker: ensureContextMenu failed:', e);
}
}
/**
* Check if element-marker.js is already injected in the tab
* Uses a short timeout to avoid hanging on unresponsive tabs
*/
async function isMarkerInjected(tabId: number): Promise<boolean> {
try {
const response = await Promise.race([
chrome.tabs.sendMessage(tabId, { action: 'element_marker_ping' }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 300)),
]);
return response?.status === 'pong';
} catch {
return false;
}
}
/**
* Inject element-marker.js into the tab if not already injected
*/
async function injectMarkerHelper(tabId: number) {
// Check if already injected via ping
const alreadyInjected = await isMarkerInjected(tabId);
if (!alreadyInjected) {
try {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inject-scripts/element-marker.js'],
world: 'ISOLATED',
} as any);
} catch (e) {
// Script injection may fail on some pages (e.g., chrome:// URLs)
console.warn('ElementMarker: script injection failed:', e);
}
}
try {
await chrome.tabs.sendMessage(tabId, { action: 'element_marker_start' } as any);
} catch (e) {
console.warn('ElementMarker: start overlay failed:', e);
}
}
export function initElementMarkerListeners() {
// Ensure context menu on startup
ensureContextMenu().catch(() => {});
// Respond to RR triggers refresh by re-ensuring our menu a bit later
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
try {
switch (message?.type) {
// Handle element marker start from popup
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_START: {
const tabId = message.tabId;
if (typeof tabId !== 'number') {
sendResponse({ success: false, error: 'invalid tabId' });
return true;
}
injectMarkerHelper(tabId)
.then(() => sendResponse({ success: true }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_ALL: {
listAllMarkers()
.then((markers) => sendResponse({ success: true, markers }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL: {
const url = String(message.url || '');
listMarkersForUrl(url)
.then((markers) => sendResponse({ success: true, markers }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE: {
const req = message.marker as UpsertMarkerRequest;
saveMarker(req)
.then((marker) => sendResponse({ success: true, marker }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_UPDATE: {
const marker = message.marker as ElementMarker;
updateMarker(marker)
.then(() => sendResponse({ success: true }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE: {
const id = String(message.id || '');
if (!id) {
sendResponse({ success: false, error: 'invalid id' });
return true;
}
deleteMarker(id)
.then(() => sendResponse({ success: true }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE: {
// Validate via MCP tool chain
(async () => {
const req = message as {
selector: string;
selectorType?: 'css' | 'xpath';
action: MarkerValidationAction;
listMode?: boolean;
text?: string;
keys?: string;
button?: 'left' | 'right' | 'middle';
bubbles?: boolean;
cancelable?: boolean;
modifiers?: any;
coordinates?: { x: number; y: number };
offsetX?: number;
offsetY?: number;
relativeTo?: 'element' | 'viewport';
};
// enrich typing with optional nav + scroll params
(req as any).waitForNavigation = (message as any).waitForNavigation;
(req as any).timeoutMs = (message as any).timeoutMs;
(req as any).scrollDirection = (message as any).scrollDirection;
(req as any).scrollAmount = (message as any).scrollAmount;
const selector = String(req.selector || '').trim();
const selectorType = (req.selectorType || 'css') as 'css' | 'xpath';
const action = req.action as MarkerValidationAction;
if (!selector) return sendResponse({ success: false, error: 'selector is required' });
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
if (!tab?.id) return sendResponse({ success: false, error: 'active tab not found' });
// 1) Ensure helper
try {
await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
files: ['inject-scripts/accessibility-tree-helper.js'],
world: 'ISOLATED',
} as any);
} catch {}
// 2) Resolve selector -> ref/center via helper (same as tools)
let ensured: any;
try {
ensured = await chrome.tabs.sendMessage(tab.id, {
action: 'ensureRefForSelector',
selector,
isXPath: selectorType === 'xpath',
allowMultiple: !!req.listMode,
} as any);
} catch (e) {
return sendResponse({
success: false,
error: String(e instanceof Error ? e.message : e),
});
}
if (!ensured || !ensured.success || !ensured.ref) {
return sendResponse({
success: false,
error: ensured?.error || 'failed to resolve selector',
});
}
const base = {
success: true,
resolved: true,
ref: ensured.ref,
center: ensured.center,
} as any;
// Compute optional coordinates from offsets
let coords: { x: number; y: number } | undefined = undefined;
if (
req.coordinates &&
typeof req.coordinates.x === 'number' &&
typeof req.coordinates.y === 'number'
) {
coords = { x: Math.round(req.coordinates.x), y: Math.round(req.coordinates.y) };
} else if (
req.relativeTo === 'element' &&
ensured.center &&
(typeof req.offsetX === 'number' || typeof req.offsetY === 'number')
) {
const dx = Number.isFinite(req.offsetX as any) ? (req.offsetX as number) : 0;
const dy = Number.isFinite(req.offsetY as any) ? (req.offsetY as number) : 0;
coords = { x: ensured.center.x + dx, y: ensured.center.y + dy };
}
// 3) Dispatch to appropriate tool for end-to-end validation
try {
switch (action) {
case 'hover': {
const r = await computerTool.execute(
coords
? { action: 'hover', coordinates: coords }
: ({ action: 'hover', ref: ensured.ref } as any),
);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'computer.hover', ok: !r.isError, error };
break;
}
case 'left_click': {
const r = await clickTool.execute({
...(coords ? { coordinates: coords } : { ref: ensured.ref }),
waitForNavigation: !!req.waitForNavigation,
timeout: Number.isFinite(req.timeoutMs as any)
? (req.timeoutMs as number)
: 3000,
button: (req.button || 'left') as any,
modifiers: req.modifiers || {},
} as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'interaction.click', ok: !r.isError, error };
break;
}
case 'double_click': {
const r = await clickTool.execute({
...(coords ? { coordinates: coords } : { ref: ensured.ref }),
double: true,
waitForNavigation: !!req.waitForNavigation,
timeout: Number.isFinite(req.timeoutMs as any)
? (req.timeoutMs as number)
: 3000,
button: (req.button || 'left') as any,
modifiers: req.modifiers || {},
} as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'interaction.click(double)', ok: !r.isError, error };
break;
}
case 'right_click': {
const r = await clickTool.execute({
...(coords ? { coordinates: coords } : { ref: ensured.ref }),
waitForNavigation: !!req.waitForNavigation,
timeout: Number.isFinite(req.timeoutMs as any)
? (req.timeoutMs as number)
: 3000,
button: 'right',
modifiers: req.modifiers || {},
} as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'interaction.click(right)', ok: !r.isError, error };
break;
}
case 'scroll': {
const direction = (req as any).scrollDirection || 'down';
const amount = Number.isFinite((req as any).scrollAmount)
? Number((req as any).scrollAmount)
: 300;
const payload = coords
? {
action: 'scroll',
scrollDirection: direction,
scrollAmount: amount,
coordinates: coords,
}
: ({
action: 'scroll',
scrollDirection: direction,
scrollAmount: amount,
ref: ensured.ref,
} as any);
const r = await computerTool.execute(payload as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'computer.scroll', ok: !r.isError, error };
break;
}
case 'type_text': {
const text = String(req.text || '');
const r = await computerTool.execute({ action: 'type', ref: ensured.ref, text });
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'computer.type', ok: !r.isError, error };
break;
}
case 'press_keys': {
const keys = String(req.keys || '');
// Focus first by ref to ensure key target
try {
await clickTool.execute({
ref: ensured.ref,
waitForNavigation: false,
timeout: 2000,
});
} catch {}
const r = await keyboardTool.execute({ keys, delay: 0 } as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'keyboard.simulate', ok: !r.isError, error };
break;
}
default: {
base.tool = { name: 'noop', ok: true };
}
}
} catch (e) {
console.warn('[ElementMarker] Validation failed before tool execution', e);
base.tool = {
name: 'unknown',
ok: false,
error: String(e instanceof Error ? e.message : e),
};
}
// Log tool failures for debugging
if (base.tool && base.tool.ok === false) {
console.warn('[ElementMarker] Tool validation failure', {
action,
toolName: base.tool.name,
error: base.tool.error,
selector,
selectorType,
});
}
return sendResponse(base);
})();
return true;
}
// When RR refresh (or similar) happens, re-add our menu
case BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS:
case BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER:
case BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER: {
setTimeout(() => ensureContextMenu().catch(() => {}), 300);
break;
}
}
} catch (e) {
sendResponse({ success: false, error: (e as any)?.message || String(e) });
}
return false;
});
// Context menu click routing
if ((chrome as any).contextMenus?.onClicked?.addListener) {
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
try {
if (info.menuItemId === CONTEXT_MENU_ID && tab?.id) {
await injectMarkerHelper(tab.id);
}
} catch (e) {
console.warn('ElementMarker: context menu click failed:', e);
}
});
}
}