mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-21 12:43:18 +08:00
feat(extension): add unified chrome_userscript tool
- Implement UserscriptTool with create/list/get/enable/disable/update/remove/send_command/export - Auto strategy selection (insertCSS, persistent ISOLATED/MAIN) with CSP-aware fallback - Persistence via chrome.storage; tab re-injection on navigation - Register tool and schema; add USERSCRIPTS storage key - Add Vue SFC typings (env.d.ts); fix lint/typecheck
This commit is contained in:
@@ -102,6 +102,7 @@ export const STORAGE_KEYS = {
|
||||
SEMANTIC_MODEL: 'selectedModel',
|
||||
USER_PREFERENCES: 'userPreferences',
|
||||
VECTOR_INDEX: 'vectorIndex',
|
||||
USERSCRIPTS: 'userscripts',
|
||||
} as const;
|
||||
|
||||
// Notification Configuration
|
||||
|
||||
@@ -16,3 +16,4 @@ export { fileUploadTool } from './file-upload';
|
||||
export { readPageTool } from './read-page';
|
||||
export { computerTool } from './computer';
|
||||
export { handleDialogTool } from './dialog';
|
||||
export { userscriptTool } from './userscript';
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { ExecutionWorld, STORAGE_KEYS } from '@/common/constants';
|
||||
|
||||
type UserscriptAction =
|
||||
| 'create'
|
||||
| 'list'
|
||||
| 'get'
|
||||
| 'enable'
|
||||
| 'disable'
|
||||
| 'update'
|
||||
| 'remove'
|
||||
| 'send_command'
|
||||
| 'export';
|
||||
|
||||
interface UserscriptArgsBase {
|
||||
action: UserscriptAction;
|
||||
args?: any;
|
||||
}
|
||||
|
||||
interface CreateArgs {
|
||||
script: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
matches?: string[];
|
||||
excludes?: string[];
|
||||
persist?: boolean; // default true
|
||||
runAt?: 'document_start' | 'document_end' | 'document_idle' | 'auto'; // default auto(document_idle)
|
||||
world?: 'auto' | 'ISOLATED' | 'MAIN'; // default auto(ISOLATED)
|
||||
allFrames?: boolean; // default true
|
||||
mode?: 'auto' | 'css' | 'persistent' | 'once'; // default auto
|
||||
dnrFallback?: boolean; // default true
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
type UpdateArgs = Partial<Omit<CreateArgs, 'script'>> & { id: string; script?: string };
|
||||
|
||||
interface UserscriptRecord {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
script: string;
|
||||
sourceType: 'JS' | 'CSS' | 'TM';
|
||||
matches: string[];
|
||||
excludes: string[];
|
||||
runAt: 'document_start' | 'document_end' | 'document_idle';
|
||||
world: 'ISOLATED' | 'MAIN';
|
||||
allFrames: boolean;
|
||||
persist: boolean;
|
||||
dnrFallback: boolean;
|
||||
tags?: string[];
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
installedBy?: string;
|
||||
lastError?: string;
|
||||
applyCount?: number;
|
||||
}
|
||||
|
||||
// In-memory tracking of active injections per tab
|
||||
type ActiveInjection = { kind: 'css' | 'js'; world?: 'ISOLATED' | 'MAIN' };
|
||||
const activeInjections: Map<number, Map<string, ActiveInjection>> = new Map();
|
||||
|
||||
async function loadAllRecords(): Promise<Record<string, UserscriptRecord>> {
|
||||
const res = await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS]);
|
||||
return (res[STORAGE_KEYS.USERSCRIPTS] as Record<string, UserscriptRecord>) || {};
|
||||
}
|
||||
|
||||
async function saveAllRecords(records: Record<string, UserscriptRecord>): Promise<void> {
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.USERSCRIPTS]: records });
|
||||
}
|
||||
|
||||
// Simple FNV-1a hash for deterministic IDs
|
||||
function fnv1a(str: string): string {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
|
||||
}
|
||||
// Force to unsigned and hex
|
||||
return (h >>> 0).toString(16);
|
||||
}
|
||||
|
||||
function now(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
// Basic TM header parser (subset)
|
||||
function parseUserscriptMeta(source: string): {
|
||||
meta: Record<string, string[]>;
|
||||
isTM: boolean;
|
||||
} {
|
||||
const meta: Record<string, string[]> = {};
|
||||
const start = source.indexOf('==UserScript==');
|
||||
const end = source.indexOf('==/UserScript==');
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
const block = source.slice(start, end).split(/\r?\n/);
|
||||
for (const line of block) {
|
||||
const m = line.match(/@([\w-]+)\s+(.+)/);
|
||||
if (m) {
|
||||
const k = m[1].trim();
|
||||
const v = m[2].trim();
|
||||
if (!meta[k]) meta[k] = [];
|
||||
meta[k].push(v);
|
||||
}
|
||||
}
|
||||
return { meta, isTM: true };
|
||||
}
|
||||
return { meta: {}, isTM: false };
|
||||
}
|
||||
|
||||
function pick<T>(arr: T[] | undefined): T | undefined {
|
||||
return arr && arr.length > 0 ? arr[0] : undefined;
|
||||
}
|
||||
|
||||
function deriveName(meta: Record<string, string[]>, fallback?: string): string | undefined {
|
||||
return pick(meta['name']) || fallback;
|
||||
}
|
||||
|
||||
function toBoolean(val: any, d: boolean): boolean {
|
||||
return typeof val === 'boolean' ? val : d;
|
||||
}
|
||||
|
||||
// Very light CSS heuristic
|
||||
function isLikelyCSS(source: string): boolean {
|
||||
const trimmed = source.trim();
|
||||
if (trimmed.startsWith('/*') && trimmed.includes('==UserStyle')) return true;
|
||||
if (/^[.#\w\-\s*,:>+~\n\r{}();'"%!@/]+$/.test(trimmed)) {
|
||||
// no obvious JS keywords
|
||||
if (
|
||||
!/(function|=>|var\s|let\s|const\s|document\.|window\.|\beval\b|new\s+Function)/.test(trimmed)
|
||||
) {
|
||||
// has CSS braces and colons
|
||||
const colon = (trimmed.match(/:/g) || []).length;
|
||||
const brace = (trimmed.match(/[{}]/g) || []).length;
|
||||
return colon > 0 && brace >= 2;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeMatches(matches?: string[], currentUrl?: string): string[] {
|
||||
if (matches && matches.length > 0) return matches;
|
||||
if (!currentUrl) return ['<all_urls>'];
|
||||
try {
|
||||
const u = new URL(currentUrl);
|
||||
const host = u.hostname;
|
||||
const base = host.startsWith('www.') ? host.slice(4) : host;
|
||||
return [`${u.protocol}//*.${base}/*`, `${u.protocol}//${host}/*`];
|
||||
} catch {
|
||||
return ['<all_urls>'];
|
||||
}
|
||||
}
|
||||
|
||||
// Simple URL match using chrome match patterns subset
|
||||
function matchUrl(patterns: string[], url?: string): boolean {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
for (const p of patterns) {
|
||||
if (p === '<all_urls>') return true;
|
||||
const m = p.match(/^(\*|https?:)\/\/([^/]+)\/(.*)$/);
|
||||
if (!m) continue;
|
||||
const proto = m[1];
|
||||
const host = m[2];
|
||||
const path = m[3];
|
||||
if (proto !== '*' && proto !== u.protocol.replace(':', '')) continue;
|
||||
// host wildcard
|
||||
const hostRegex = new RegExp(
|
||||
'^' +
|
||||
host
|
||||
.split('.')
|
||||
.map((h) => (h === '*' ? '[^.]+' : h.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')))
|
||||
.join('\\.') +
|
||||
'$',
|
||||
);
|
||||
if (!hostRegex.test(u.hostname)) continue;
|
||||
// path wildcard
|
||||
const pathRegex = new RegExp(
|
||||
'^' + path.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
|
||||
);
|
||||
const testPath = (u.pathname + (u.search || '') + (u.hash || '')).replace(/^\//, '');
|
||||
if (pathRegex.test(testPath)) return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getActiveTab(): Promise<chrome.tabs.Tab | null> {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tabs[0] || null;
|
||||
}
|
||||
|
||||
async function insertCssToTab(tabId: number, css: string, allFrames: boolean) {
|
||||
await chrome.scripting.insertCSS({ target: { tabId, allFrames }, css });
|
||||
}
|
||||
|
||||
async function removeCssFromTab(tabId: number, css: string, allFrames: boolean) {
|
||||
try {
|
||||
await chrome.scripting.removeCSS({ target: { tabId, allFrames }, css });
|
||||
} catch (e) {
|
||||
// ignore if not present
|
||||
}
|
||||
}
|
||||
|
||||
async function injectJsPersistent(
|
||||
tabId: number,
|
||||
code: string,
|
||||
world: 'ISOLATED' | 'MAIN',
|
||||
allFrames: boolean,
|
||||
) {
|
||||
if (world === ExecutionWorld.MAIN) {
|
||||
// Ensure bridge is present in ISOLATED
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId, allFrames },
|
||||
files: ['inject-scripts/inject-bridge.js'],
|
||||
world: ExecutionWorld.ISOLATED,
|
||||
});
|
||||
// MAIN world code with command handler wrapper
|
||||
const wrapped = `(() => {
|
||||
try {
|
||||
// Optional command API: window.__userscript_onCommand(action, payload)
|
||||
window.addEventListener('chrome-mcp:execute', (ev) => {
|
||||
const { action, payload, requestId } = ev.detail || {};
|
||||
try {
|
||||
let result;
|
||||
const handler = (window as any).__userscript_onCommand;
|
||||
if (typeof handler === 'function') {
|
||||
result = handler(action, payload);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('chrome-mcp:response', { detail: { requestId, data: result } }));
|
||||
} catch (err) {
|
||||
window.dispatchEvent(new CustomEvent('chrome-mcp:response', { detail: { requestId, error: String(err && (err as any).message || err) } }));
|
||||
}
|
||||
});
|
||||
(new Function(${JSON.stringify(code)}))();
|
||||
} catch (e) {
|
||||
console.warn('Userscript MAIN injection error:', e);
|
||||
}
|
||||
})();`;
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId, allFrames },
|
||||
func: (src) => {
|
||||
try {
|
||||
// Using Function constructor intentionally to evaluate user-provided script
|
||||
new Function(src)();
|
||||
} catch (e) {
|
||||
console.warn('Userscript MAIN wrapper execution error:', e);
|
||||
}
|
||||
},
|
||||
args: [wrapped],
|
||||
world: ExecutionWorld.MAIN,
|
||||
});
|
||||
} else {
|
||||
// ISOLATED world code with message handler
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId, allFrames },
|
||||
func: (userCode) => {
|
||||
try {
|
||||
const handlerName = '__userscript_onCommand__';
|
||||
(chrome.runtime.onMessage as any).addListener(
|
||||
(req: any, _sender: any, sendResponse: any) => {
|
||||
if (!req || req.type !== 'userscript:command') return;
|
||||
const { action, payload, scriptId } = req;
|
||||
try {
|
||||
const handler = (globalThis as any)[handlerName];
|
||||
let result;
|
||||
if (typeof handler === 'function') {
|
||||
result = handler(action, payload, scriptId);
|
||||
}
|
||||
sendResponse({ data: result });
|
||||
} catch (err) {
|
||||
sendResponse({ error: String((err && (err as any).message) || err) });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
// Using Function constructor intentionally to evaluate user-provided script
|
||||
new Function(userCode)();
|
||||
} catch (e) {
|
||||
console.warn('Userscript ISOLATED injection error:', e);
|
||||
}
|
||||
},
|
||||
args: [code],
|
||||
world: ExecutionWorld.ISOLATED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveInjection(tabId: number, id: string, inj: ActiveInjection) {
|
||||
let m = activeInjections.get(tabId);
|
||||
if (!m) {
|
||||
m = new Map();
|
||||
activeInjections.set(tabId, m);
|
||||
}
|
||||
m.set(id, inj);
|
||||
}
|
||||
|
||||
function clearActiveInjection(tabId: number, id: string) {
|
||||
const m = activeInjections.get(tabId);
|
||||
if (m) m.delete(id);
|
||||
}
|
||||
|
||||
async function reinjectForTab(tabId: number, url?: string) {
|
||||
const all = await loadAllRecords();
|
||||
for (const rec of Object.values(all)) {
|
||||
if (!rec.enabled || !rec.persist) continue;
|
||||
if (!matchUrl(rec.matches, url)) continue;
|
||||
try {
|
||||
if (rec.sourceType === 'CSS') {
|
||||
await insertCssToTab(tabId, rec.script, rec.allFrames);
|
||||
setActiveInjection(tabId, rec.id, { kind: 'css' });
|
||||
} else {
|
||||
await injectJsPersistent(tabId, rec.script, rec.world, rec.allFrames);
|
||||
setActiveInjection(tabId, rec.id, { kind: 'js', world: rec.world });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Reinject failed for tab', tabId, rec.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab update listener: re-apply enabled persistent scripts
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
if (changeInfo.status === 'complete') {
|
||||
reinjectForTab(tabId, tab.url).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
class UserscriptTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.USERSCRIPT;
|
||||
|
||||
async execute(params: UserscriptArgsBase): Promise<ToolResult> {
|
||||
try {
|
||||
const { action } = params;
|
||||
const args = params.args || {};
|
||||
|
||||
switch (action) {
|
||||
case 'create':
|
||||
return await this.create(args as CreateArgs);
|
||||
case 'list':
|
||||
return await this.list(args);
|
||||
case 'get':
|
||||
return await this.get(args);
|
||||
case 'enable':
|
||||
return await this.enable(args, true);
|
||||
case 'disable':
|
||||
return await this.enable(args, false);
|
||||
case 'update':
|
||||
return await this.update(args as UpdateArgs);
|
||||
case 'remove':
|
||||
return await this.remove(args);
|
||||
case 'send_command':
|
||||
return await this.sendCommand(args);
|
||||
case 'export':
|
||||
return await this.exportAll();
|
||||
default:
|
||||
return createErrorResponse(`Unknown action: ${String(action)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Userscript tool error:', error);
|
||||
return createErrorResponse(
|
||||
`Userscript error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async create(args: CreateArgs): Promise<ToolResult> {
|
||||
const active = await getActiveTab();
|
||||
if (!active || !active.id) return createErrorResponse('No active tab found');
|
||||
const currentUrl = active.url;
|
||||
|
||||
const { meta, isTM } = parseUserscriptMeta(args.script);
|
||||
const name = args.name || deriveName(meta, undefined);
|
||||
const description = args.description || pick(meta['description']);
|
||||
const matches = normalizeMatches(args.matches || meta['match'] || meta['include'], currentUrl);
|
||||
const excludes = args.excludes || meta['exclude'] || [];
|
||||
|
||||
const runAt: UserscriptRecord['runAt'] =
|
||||
(args.runAt && args.runAt !== 'auto' ? args.runAt : (pick(meta['run-at']) as any)) ||
|
||||
'document_idle';
|
||||
const requestedWorld =
|
||||
(args.world && args.world !== 'auto' ? args.world : (pick(meta['inject-into']) as any)) ||
|
||||
'ISOLATED';
|
||||
const allFrames = toBoolean(args.allFrames, true);
|
||||
const persist = toBoolean(args.persist, true);
|
||||
const dnrFallback = toBoolean(args.dnrFallback, true);
|
||||
const mode = args.mode || 'auto';
|
||||
|
||||
const sourceType: UserscriptRecord['sourceType'] = isTM
|
||||
? 'TM'
|
||||
: mode === 'css' || isLikelyCSS(args.script)
|
||||
? 'CSS'
|
||||
: 'JS';
|
||||
|
||||
const id = `us_${fnv1a((name || '') + '|' + args.script)}`;
|
||||
|
||||
const record: UserscriptRecord = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
script: args.script,
|
||||
sourceType,
|
||||
matches,
|
||||
excludes,
|
||||
runAt,
|
||||
world: requestedWorld === 'MAIN' ? 'MAIN' : 'ISOLATED',
|
||||
allFrames,
|
||||
persist,
|
||||
dnrFallback,
|
||||
tags: args.tags,
|
||||
enabled: true,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
applyCount: 0,
|
||||
};
|
||||
|
||||
const all = await loadAllRecords();
|
||||
all[id] = record;
|
||||
await saveAllRecords(all);
|
||||
|
||||
// Apply to current tab immediately if matches
|
||||
let applied = false;
|
||||
const fallbacks: string[] = [];
|
||||
try {
|
||||
if (sourceType === 'CSS') {
|
||||
await insertCssToTab(active.id!, record.script, record.allFrames);
|
||||
setActiveInjection(active.id!, id, { kind: 'css' });
|
||||
applied = true;
|
||||
} else {
|
||||
try {
|
||||
await injectJsPersistent(active.id!, record.script, record.world, record.allFrames);
|
||||
setActiveInjection(active.id!, id, { kind: 'js', world: record.world });
|
||||
applied = true;
|
||||
} catch (e) {
|
||||
// Try fallback to ISOLATED if MAIN blocked by CSP
|
||||
if (record.world === 'MAIN') {
|
||||
fallbacks.push('MAIN->ISOLATED');
|
||||
await injectJsPersistent(active.id!, record.script, 'ISOLATED', record.allFrames);
|
||||
setActiveInjection(active.id!, id, { kind: 'js', world: 'ISOLATED' });
|
||||
applied = true;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
all[id].lastError = e instanceof Error ? e.message : String(e);
|
||||
await saveAllRecords(all);
|
||||
}
|
||||
|
||||
const result = {
|
||||
id,
|
||||
status: all[id].lastError ? 'queued' : applied ? 'applied' : 'queued',
|
||||
strategy: {
|
||||
kind: sourceType === 'CSS' ? 'insertCSS' : `persistent_${all[id].world.toLowerCase()}`,
|
||||
runAt: all[id].runAt,
|
||||
world: all[id].world,
|
||||
allFrames: all[id].allFrames,
|
||||
fallbacksTried: fallbacks,
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async list(_args: any): Promise<ToolResult> {
|
||||
const all = await loadAllRecords();
|
||||
const items = Object.values(all).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
status: r.enabled ? 'enabled' : 'disabled',
|
||||
sourceType: r.sourceType,
|
||||
matches: r.matches,
|
||||
world: r.world,
|
||||
runAt: r.runAt,
|
||||
tags: r.tags || [],
|
||||
lastError: r.lastError,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ok: true, items }) }],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async get(args: any): Promise<ToolResult> {
|
||||
const { id } = args || {};
|
||||
if (!id) return createErrorResponse('id is required');
|
||||
const all = await loadAllRecords();
|
||||
const rec = all[id];
|
||||
if (!rec) return createErrorResponse('userscript not found');
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ok: true, record: rec }) }],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async enable(args: any, enabled: boolean): Promise<ToolResult> {
|
||||
const { id } = args || {};
|
||||
if (!id) return createErrorResponse('id is required');
|
||||
const all = await loadAllRecords();
|
||||
const rec = all[id];
|
||||
if (!rec) return createErrorResponse('userscript not found');
|
||||
rec.enabled = enabled;
|
||||
rec.updatedAt = now();
|
||||
await saveAllRecords(all);
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false };
|
||||
}
|
||||
|
||||
private async update(args: UpdateArgs): Promise<ToolResult> {
|
||||
const { id, ...rest } = args;
|
||||
if (!id) return createErrorResponse('id is required');
|
||||
const all = await loadAllRecords();
|
||||
const rec = all[id];
|
||||
if (!rec) return createErrorResponse('userscript not found');
|
||||
|
||||
if (rest.name !== undefined) rec.name = rest.name;
|
||||
if (rest.description !== undefined) rec.description = rest.description;
|
||||
if (rest.matches) rec.matches = rest.matches;
|
||||
if (rest.excludes) rec.excludes = rest.excludes;
|
||||
if (rest.runAt && rest.runAt !== 'auto') rec.runAt = rest.runAt;
|
||||
if (rest.world && rest.world !== 'auto') rec.world = rest.world as any;
|
||||
if (typeof rest.allFrames === 'boolean') rec.allFrames = rest.allFrames;
|
||||
if (typeof rest.persist === 'boolean') rec.persist = rest.persist;
|
||||
if (typeof rest.dnrFallback === 'boolean') rec.dnrFallback = rest.dnrFallback;
|
||||
if (rest.tags) rec.tags = rest.tags;
|
||||
if (typeof rest.script === 'string') rec.script = rest.script;
|
||||
rec.updatedAt = now();
|
||||
await saveAllRecords(all);
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false };
|
||||
}
|
||||
|
||||
private async remove(args: any): Promise<ToolResult> {
|
||||
const { id } = args || {};
|
||||
if (!id) return createErrorResponse('id is required');
|
||||
const all = await loadAllRecords();
|
||||
const rec = all[id];
|
||||
if (!rec) return createErrorResponse('userscript not found');
|
||||
delete all[id];
|
||||
await saveAllRecords(all);
|
||||
|
||||
// Attempt cleanup on active tab
|
||||
const active = await getActiveTab();
|
||||
if (active && active.id) {
|
||||
try {
|
||||
if (rec.sourceType === 'CSS') {
|
||||
await removeCssFromTab(active.id, rec.script, rec.allFrames);
|
||||
} else {
|
||||
// Send cleanup signal via bridge (MAIN) or ignore if isolated
|
||||
chrome.tabs.sendMessage(active.id, { type: 'chrome-mcp:cleanup' }).catch(() => {});
|
||||
}
|
||||
clearActiveInjection(active.id, rec.id);
|
||||
} catch (err) {
|
||||
console.warn('Userscript cleanup failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false };
|
||||
}
|
||||
|
||||
private async sendCommand(args: any): Promise<ToolResult> {
|
||||
const { id, payload, tabId } = args || {};
|
||||
if (!id) return createErrorResponse('id is required');
|
||||
const tab = tabId ? await chrome.tabs.get(tabId).catch(() => null) : await getActiveTab();
|
||||
if (!tab || !tab.id) return createErrorResponse('No active tab found');
|
||||
|
||||
const all = await loadAllRecords();
|
||||
const rec = all[id];
|
||||
if (!rec) return createErrorResponse('userscript not found');
|
||||
|
||||
try {
|
||||
if (rec.world === 'MAIN') {
|
||||
// Use bridge
|
||||
const result = await chrome.tabs.sendMessage(tab.id, {
|
||||
action: 'userscript:command',
|
||||
payload,
|
||||
targetWorld: 'MAIN',
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }],
|
||||
isError: false,
|
||||
};
|
||||
} else {
|
||||
// ISOLATED handler
|
||||
const result = await chrome.tabs.sendMessage(tab.id, {
|
||||
type: 'userscript:command',
|
||||
action: 'userscript:command',
|
||||
payload,
|
||||
scriptId: id,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
return createErrorResponse(
|
||||
`send_command failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async exportAll(): Promise<ToolResult> {
|
||||
const all = await loadAllRecords();
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ok: true, data: all }) }],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const userscriptTool = new UserscriptTool();
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
type Props = Record<string, never>;
|
||||
type RawBindings = Record<string, never>;
|
||||
const component: DefineComponent<Props, RawBindings, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export const TOOL_NAMES = {
|
||||
READ_PAGE: 'chrome_read_page',
|
||||
COMPUTER: 'chrome_computer',
|
||||
HANDLE_DIALOG: 'chrome_handle_dialog',
|
||||
USERSCRIPT: 'chrome_userscript',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -151,6 +152,27 @@ export const TOOL_SCHEMAS: Tool[] = [
|
||||
required: ['action'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.USERSCRIPT,
|
||||
description:
|
||||
'Unified userscript tool (create/list/get/enable/disable/update/remove/send_command/export). Paste JS/CSS/Tampermonkey script and the system will auto-select the best strategy (insertCSS / persistent script in ISOLATED or MAIN world / once by CDP) with CSP-aware fallbacks.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Operation to perform: create | list | get | enable | disable | update | remove | send_command | export',
|
||||
},
|
||||
args: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Arguments for the specified action. For create: { script, name?, description?, matches?, excludes?, persist?, runAt?, world?, allFrames?, mode?, dnrFallback?, tags? }',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.NAVIGATE,
|
||||
description: 'Navigate to a URL or refresh the current tab',
|
||||
|
||||
Reference in New Issue
Block a user