This is page 2 of 10. Use http://codebase.md/hangwin/mcp-chrome?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ └── workflows
│ └── build-release.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│ └── extensions.json
├── app
│ ├── chrome-extension
│ │ ├── _locales
│ │ │ ├── de
│ │ │ │ └── messages.json
│ │ │ ├── en
│ │ │ │ └── messages.json
│ │ │ ├── ja
│ │ │ │ └── messages.json
│ │ │ ├── ko
│ │ │ │ └── messages.json
│ │ │ ├── zh_CN
│ │ │ │ └── messages.json
│ │ │ └── zh_TW
│ │ │ └── messages.json
│ │ ├── .env.example
│ │ ├── assets
│ │ │ └── vue.svg
│ │ ├── common
│ │ │ ├── constants.ts
│ │ │ ├── message-types.ts
│ │ │ └── tool-handler.ts
│ │ ├── entrypoints
│ │ │ ├── background
│ │ │ │ ├── index.ts
│ │ │ │ ├── native-host.ts
│ │ │ │ ├── semantic-similarity.ts
│ │ │ │ ├── storage-manager.ts
│ │ │ │ └── tools
│ │ │ │ ├── base-browser.ts
│ │ │ │ ├── browser
│ │ │ │ │ ├── bookmark.ts
│ │ │ │ │ ├── common.ts
│ │ │ │ │ ├── console.ts
│ │ │ │ │ ├── file-upload.ts
│ │ │ │ │ ├── history.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── inject-script.ts
│ │ │ │ │ ├── interaction.ts
│ │ │ │ │ ├── keyboard.ts
│ │ │ │ │ ├── network-capture-debugger.ts
│ │ │ │ │ ├── network-capture-web-request.ts
│ │ │ │ │ ├── network-request.ts
│ │ │ │ │ ├── screenshot.ts
│ │ │ │ │ ├── vector-search.ts
│ │ │ │ │ ├── web-fetcher.ts
│ │ │ │ │ └── window.ts
│ │ │ │ └── index.ts
│ │ │ ├── content.ts
│ │ │ ├── offscreen
│ │ │ │ ├── index.html
│ │ │ │ └── main.ts
│ │ │ └── popup
│ │ │ ├── App.vue
│ │ │ ├── components
│ │ │ │ ├── ConfirmDialog.vue
│ │ │ │ ├── icons
│ │ │ │ │ ├── BoltIcon.vue
│ │ │ │ │ ├── CheckIcon.vue
│ │ │ │ │ ├── DatabaseIcon.vue
│ │ │ │ │ ├── DocumentIcon.vue
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── TabIcon.vue
│ │ │ │ │ ├── TrashIcon.vue
│ │ │ │ │ └── VectorIcon.vue
│ │ │ │ ├── ModelCacheManagement.vue
│ │ │ │ └── ProgressIndicator.vue
│ │ │ ├── index.html
│ │ │ ├── main.ts
│ │ │ └── style.css
│ │ ├── eslint.config.js
│ │ ├── inject-scripts
│ │ │ ├── click-helper.js
│ │ │ ├── fill-helper.js
│ │ │ ├── inject-bridge.js
│ │ │ ├── interactive-elements-helper.js
│ │ │ ├── keyboard-helper.js
│ │ │ ├── network-helper.js
│ │ │ ├── screenshot-helper.js
│ │ │ └── web-fetcher-helper.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── icon
│ │ │ │ ├── 128.png
│ │ │ │ ├── 16.png
│ │ │ │ ├── 32.png
│ │ │ │ ├── 48.png
│ │ │ │ └── 96.png
│ │ │ ├── libs
│ │ │ │ └── ort.min.js
│ │ │ └── wxt.svg
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ ├── utils
│ │ │ ├── content-indexer.ts
│ │ │ ├── i18n.ts
│ │ │ ├── image-utils.ts
│ │ │ ├── lru-cache.ts
│ │ │ ├── model-cache-manager.ts
│ │ │ ├── offscreen-manager.ts
│ │ │ ├── semantic-similarity-engine.ts
│ │ │ ├── simd-math-engine.ts
│ │ │ ├── text-chunker.ts
│ │ │ └── vector-database.ts
│ │ ├── workers
│ │ │ ├── ort-wasm-simd-threaded.jsep.mjs
│ │ │ ├── ort-wasm-simd-threaded.jsep.wasm
│ │ │ ├── ort-wasm-simd-threaded.mjs
│ │ │ ├── ort-wasm-simd-threaded.wasm
│ │ │ ├── simd_math_bg.wasm
│ │ │ ├── simd_math.js
│ │ │ └── similarity.worker.js
│ │ └── wxt.config.ts
│ └── native-server
│ ├── debug.sh
│ ├── install.md
│ ├── jest.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── cli.ts
│ │ ├── constant
│ │ │ └── index.ts
│ │ ├── file-handler.ts
│ │ ├── index.ts
│ │ ├── mcp
│ │ │ ├── mcp-server-stdio.ts
│ │ │ ├── mcp-server.ts
│ │ │ ├── register-tools.ts
│ │ │ └── stdio-config.json
│ │ ├── native-messaging-host.ts
│ │ ├── scripts
│ │ │ ├── browser-config.ts
│ │ │ ├── build.ts
│ │ │ ├── constant.ts
│ │ │ ├── postinstall.ts
│ │ │ ├── register-dev.ts
│ │ │ ├── register.ts
│ │ │ ├── run_host.bat
│ │ │ ├── run_host.sh
│ │ │ └── utils.ts
│ │ ├── server
│ │ │ ├── index.ts
│ │ │ └── server.test.ts
│ │ └── util
│ │ └── logger.ts
│ └── tsconfig.json
├── commitlint.config.cjs
├── docs
│ ├── ARCHITECTURE_zh.md
│ ├── ARCHITECTURE.md
│ ├── CHANGELOG.md
│ ├── CONTRIBUTING_zh.md
│ ├── CONTRIBUTING.md
│ ├── TOOLS_zh.md
│ ├── TOOLS.md
│ ├── TROUBLESHOOTING_zh.md
│ ├── TROUBLESHOOTING.md
│ └── WINDOWS_INSTALL_zh.md
├── eslint.config.js
├── LICENSE
├── package.json
├── packages
│ ├── shared
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── tools.ts
│ │ │ └── types.ts
│ │ └── tsconfig.json
│ └── wasm-simd
│ ├── .gitignore
│ ├── BUILD.md
│ ├── Cargo.toml
│ ├── package.json
│ ├── README.md
│ └── src
│ └── lib.rs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prompt
│ ├── content-analize.md
│ ├── excalidraw-prompt.md
│ └── modify-web.md
├── README_zh.md
├── README.md
├── releases
│ ├── chrome-extension
│ │ └── latest
│ │ └── chrome-mcp-server-lastest.zip
│ └── README.md
└── test-inject-script.js
```
# Files
--------------------------------------------------------------------------------
/app/native-server/src/file-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as fs from 'fs';
2 | import * as path from 'path';
3 | import * as os from 'os';
4 | import * as crypto from 'crypto';
5 | import fetch from 'node-fetch';
6 |
7 | /**
8 | * File handler for managing file uploads through the native messaging host
9 | */
10 | export class FileHandler {
11 | private tempDir: string;
12 |
13 | constructor() {
14 | // Create a temp directory for file operations
15 | this.tempDir = path.join(os.tmpdir(), 'chrome-mcp-uploads');
16 | if (!fs.existsSync(this.tempDir)) {
17 | fs.mkdirSync(this.tempDir, { recursive: true });
18 | }
19 | }
20 |
21 | /**
22 | * Handle file preparation request from the extension
23 | */
24 | async handleFileRequest(request: any): Promise<any> {
25 | const { action, fileUrl, base64Data, fileName, filePath } = request;
26 |
27 | try {
28 | switch (action) {
29 | case 'prepareFile':
30 | if (fileUrl) {
31 | return await this.downloadFile(fileUrl, fileName);
32 | } else if (base64Data) {
33 | return await this.saveBase64File(base64Data, fileName);
34 | } else if (filePath) {
35 | return await this.verifyFile(filePath);
36 | }
37 | break;
38 |
39 | case 'cleanupFile':
40 | return await this.cleanupFile(filePath);
41 |
42 | default:
43 | return {
44 | success: false,
45 | error: `Unknown file action: ${action}`,
46 | };
47 | }
48 | } catch (error) {
49 | return {
50 | success: false,
51 | error: error instanceof Error ? error.message : String(error),
52 | };
53 | }
54 | }
55 |
56 | /**
57 | * Download a file from URL and save to temp directory
58 | */
59 | private async downloadFile(fileUrl: string, fileName?: string): Promise<any> {
60 | try {
61 | const response = await fetch(fileUrl);
62 | if (!response.ok) {
63 | throw new Error(`Failed to download file: ${response.statusText}`);
64 | }
65 |
66 | // Generate filename if not provided
67 | const finalFileName = fileName || this.generateFileName(fileUrl);
68 | const filePath = path.join(this.tempDir, finalFileName);
69 |
70 | // Get the file buffer
71 | const buffer = await response.buffer();
72 |
73 | // Save to file
74 | fs.writeFileSync(filePath, buffer);
75 |
76 | return {
77 | success: true,
78 | filePath: filePath,
79 | fileName: finalFileName,
80 | size: buffer.length,
81 | };
82 | } catch (error) {
83 | throw new Error(`Failed to download file from URL: ${error}`);
84 | }
85 | }
86 |
87 | /**
88 | * Save base64 data as a file
89 | */
90 | private async saveBase64File(base64Data: string, fileName?: string): Promise<any> {
91 | try {
92 | // Remove data URL prefix if present
93 | const base64Content = base64Data.replace(/^data:.*?;base64,/, '');
94 |
95 | // Convert base64 to buffer
96 | const buffer = Buffer.from(base64Content, 'base64');
97 |
98 | // Generate filename if not provided
99 | const finalFileName = fileName || `upload-${Date.now()}.bin`;
100 | const filePath = path.join(this.tempDir, finalFileName);
101 |
102 | // Save to file
103 | fs.writeFileSync(filePath, buffer);
104 |
105 | return {
106 | success: true,
107 | filePath: filePath,
108 | fileName: finalFileName,
109 | size: buffer.length,
110 | };
111 | } catch (error) {
112 | throw new Error(`Failed to save base64 file: ${error}`);
113 | }
114 | }
115 |
116 | /**
117 | * Verify that a file exists and is accessible
118 | */
119 | private async verifyFile(filePath: string): Promise<any> {
120 | try {
121 | // Check if file exists
122 | if (!fs.existsSync(filePath)) {
123 | throw new Error(`File does not exist: ${filePath}`);
124 | }
125 |
126 | // Get file stats
127 | const stats = fs.statSync(filePath);
128 |
129 | // Check if it's actually a file
130 | if (!stats.isFile()) {
131 | throw new Error(`Path is not a file: ${filePath}`);
132 | }
133 |
134 | // Check if file is readable
135 | fs.accessSync(filePath, fs.constants.R_OK);
136 |
137 | return {
138 | success: true,
139 | filePath: filePath,
140 | fileName: path.basename(filePath),
141 | size: stats.size,
142 | };
143 | } catch (error) {
144 | throw new Error(`Failed to verify file: ${error}`);
145 | }
146 | }
147 |
148 | /**
149 | * Clean up a temporary file
150 | */
151 | private async cleanupFile(filePath: string): Promise<any> {
152 | try {
153 | // Only allow cleanup of files in our temp directory
154 | if (!filePath.startsWith(this.tempDir)) {
155 | return {
156 | success: false,
157 | error: 'Can only cleanup files in temp directory',
158 | };
159 | }
160 |
161 | if (fs.existsSync(filePath)) {
162 | fs.unlinkSync(filePath);
163 | }
164 |
165 | return {
166 | success: true,
167 | message: 'File cleaned up successfully',
168 | };
169 | } catch (error) {
170 | return {
171 | success: false,
172 | error: `Failed to cleanup file: ${error}`,
173 | };
174 | }
175 | }
176 |
177 | /**
178 | * Generate a filename from URL or create a unique one
179 | */
180 | private generateFileName(url?: string): string {
181 | if (url) {
182 | try {
183 | const urlObj = new URL(url);
184 | const pathname = urlObj.pathname;
185 | const basename = path.basename(pathname);
186 | if (basename && basename !== '/') {
187 | // Add random suffix to avoid collisions
188 | const ext = path.extname(basename);
189 | const name = path.basename(basename, ext);
190 | const randomSuffix = crypto.randomBytes(4).toString('hex');
191 | return `${name}-${randomSuffix}${ext}`;
192 | }
193 | } catch {
194 | // Invalid URL, fall through to generate random name
195 | }
196 | }
197 |
198 | // Generate random filename
199 | return `upload-${crypto.randomBytes(8).toString('hex')}.bin`;
200 | }
201 |
202 | /**
203 | * Clean up old temporary files (older than 1 hour)
204 | */
205 | cleanupOldFiles(): void {
206 | try {
207 | const now = Date.now();
208 | const oneHour = 60 * 60 * 1000;
209 |
210 | const files = fs.readdirSync(this.tempDir);
211 | for (const file of files) {
212 | const filePath = path.join(this.tempDir, file);
213 | const stats = fs.statSync(filePath);
214 | if (now - stats.mtimeMs > oneHour) {
215 | fs.unlinkSync(filePath);
216 | console.log(`Cleaned up old temp file: ${file}`);
217 | }
218 | }
219 | } catch (error) {
220 | console.error('Error cleaning up old files:', error);
221 | }
222 | }
223 | }
224 |
225 | export default new FileHandler();
```
--------------------------------------------------------------------------------
/app/chrome-extension/utils/image-utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Image processing utility functions
3 | */
4 |
5 | /**
6 | * Create ImageBitmap from data URL (for OffscreenCanvas)
7 | * @param dataUrl Image data URL
8 | * @returns Created ImageBitmap object
9 | */
10 | export async function createImageBitmapFromUrl(dataUrl: string): Promise<ImageBitmap> {
11 | const response = await fetch(dataUrl);
12 | const blob = await response.blob();
13 | return await createImageBitmap(blob);
14 | }
15 |
16 | /**
17 | * Stitch multiple image parts (dataURL) onto a single canvas
18 | * @param parts Array of image parts, each containing dataUrl and y coordinate
19 | * @param totalWidthPx Total width (pixels)
20 | * @param totalHeightPx Total height (pixels)
21 | * @returns Stitched canvas
22 | */
23 | export async function stitchImages(
24 | parts: { dataUrl: string; y: number }[],
25 | totalWidthPx: number,
26 | totalHeightPx: number,
27 | ): Promise<OffscreenCanvas> {
28 | const canvas = new OffscreenCanvas(totalWidthPx, totalHeightPx);
29 | const ctx = canvas.getContext('2d');
30 |
31 | if (!ctx) {
32 | throw new Error('Unable to get canvas context');
33 | }
34 |
35 | ctx.fillStyle = '#FFFFFF';
36 | ctx.fillRect(0, 0, canvas.width, canvas.height);
37 |
38 | for (const part of parts) {
39 | try {
40 | const img = await createImageBitmapFromUrl(part.dataUrl);
41 | const sx = 0;
42 | const sy = 0;
43 | const sWidth = img.width;
44 | let sHeight = img.height;
45 | const dy = part.y;
46 |
47 | if (dy + sHeight > totalHeightPx) {
48 | sHeight = totalHeightPx - dy;
49 | }
50 |
51 | if (sHeight <= 0) continue;
52 |
53 | ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, dy, sWidth, sHeight);
54 | } catch (error) {
55 | console.error('Error stitching image part:', error, part);
56 | }
57 | }
58 | return canvas;
59 | }
60 |
61 | /**
62 | * Crop image (from dataURL) to specified rectangle and resize
63 | * @param originalDataUrl Original image data URL
64 | * @param cropRectPx Crop rectangle (physical pixels)
65 | * @param dpr Device pixel ratio
66 | * @param targetWidthOpt Optional target output width (CSS pixels)
67 | * @param targetHeightOpt Optional target output height (CSS pixels)
68 | * @returns Cropped canvas
69 | */
70 | export async function cropAndResizeImage(
71 | originalDataUrl: string,
72 | cropRectPx: { x: number; y: number; width: number; height: number },
73 | dpr: number = 1,
74 | targetWidthOpt?: number,
75 | targetHeightOpt?: number,
76 | ): Promise<OffscreenCanvas> {
77 | const img = await createImageBitmapFromUrl(originalDataUrl);
78 |
79 | let sx = cropRectPx.x;
80 | let sy = cropRectPx.y;
81 | let sWidth = cropRectPx.width;
82 | let sHeight = cropRectPx.height;
83 |
84 | // Ensure crop area is within image boundaries
85 | if (sx < 0) {
86 | sWidth += sx;
87 | sx = 0;
88 | }
89 | if (sy < 0) {
90 | sHeight += sy;
91 | sy = 0;
92 | }
93 | if (sx + sWidth > img.width) {
94 | sWidth = img.width - sx;
95 | }
96 | if (sy + sHeight > img.height) {
97 | sHeight = img.height - sy;
98 | }
99 |
100 | if (sWidth <= 0 || sHeight <= 0) {
101 | throw new Error(
102 | 'Invalid calculated crop size (<=0). Element may not be visible or fully captured.',
103 | );
104 | }
105 |
106 | const finalCanvasWidthPx = targetWidthOpt ? targetWidthOpt * dpr : sWidth;
107 | const finalCanvasHeightPx = targetHeightOpt ? targetHeightOpt * dpr : sHeight;
108 |
109 | const canvas = new OffscreenCanvas(finalCanvasWidthPx, finalCanvasHeightPx);
110 | const ctx = canvas.getContext('2d');
111 |
112 | if (!ctx) {
113 | throw new Error('Unable to get canvas context');
114 | }
115 |
116 | ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, finalCanvasWidthPx, finalCanvasHeightPx);
117 |
118 | return canvas;
119 | }
120 |
121 | /**
122 | * Convert canvas to data URL
123 | * @param canvas Canvas
124 | * @param format Image format
125 | * @param quality JPEG quality (0-1)
126 | * @returns Data URL
127 | */
128 | export async function canvasToDataURL(
129 | canvas: OffscreenCanvas,
130 | format: string = 'image/png',
131 | quality?: number,
132 | ): Promise<string> {
133 | const blob = await canvas.convertToBlob({
134 | type: format,
135 | quality: format === 'image/jpeg' ? quality : undefined,
136 | });
137 |
138 | return new Promise((resolve, reject) => {
139 | const reader = new FileReader();
140 | reader.onloadend = () => resolve(reader.result as string);
141 | reader.onerror = reject;
142 | reader.readAsDataURL(blob);
143 | });
144 | }
145 |
146 | /**
147 | * Compresses an image by scaling it and converting it to a target format with a specific quality.
148 | * This is the most effective way to reduce image data size for transport or storage.
149 | *
150 | * @param {string} imageDataUrl - The original image data URL (e.g., from captureVisibleTab).
151 | * @param {object} options - Compression options.
152 | * @param {number} [options.scale=1.0] - The scaling factor for dimensions (e.g., 0.7 for 70%).
153 | * @param {number} [options.quality=0.8] - The quality for lossy formats like JPEG (0.0 to 1.0).
154 | * @param {string} [options.format='image/jpeg'] - The target image format.
155 | * @returns {Promise<{dataUrl: string, mimeType: string}>} A promise that resolves to the compressed image data URL and its MIME type.
156 | */
157 | export async function compressImage(
158 | imageDataUrl: string,
159 | options: { scale?: number; quality?: number; format?: 'image/jpeg' | 'image/webp' },
160 | ): Promise<{ dataUrl: string; mimeType: string }> {
161 | const { scale = 1.0, quality = 0.8, format = 'image/jpeg' } = options;
162 |
163 | // 1. Create an ImageBitmap from the original data URL for efficient drawing.
164 | const imageBitmap = await createImageBitmapFromUrl(imageDataUrl);
165 |
166 | // 2. Calculate the new dimensions based on the scale factor.
167 | const newWidth = Math.round(imageBitmap.width * scale);
168 | const newHeight = Math.round(imageBitmap.height * scale);
169 |
170 | // 3. Use OffscreenCanvas for performance, as it doesn't need to be in the DOM.
171 | const canvas = new OffscreenCanvas(newWidth, newHeight);
172 | const ctx = canvas.getContext('2d');
173 |
174 | if (!ctx) {
175 | throw new Error('Failed to get 2D context from OffscreenCanvas');
176 | }
177 |
178 | // 4. Draw the original image onto the smaller canvas, effectively resizing it.
179 | ctx.drawImage(imageBitmap, 0, 0, newWidth, newHeight);
180 |
181 | // 5. Export the canvas content to the target format with the specified quality.
182 | // This is the step that performs the data compression.
183 | const compressedDataUrl = await canvas.convertToBlob({ type: format, quality: quality });
184 |
185 | // A helper to convert blob to data URL since OffscreenCanvas.toDataURL is not standard yet
186 | // on all execution contexts (like service workers).
187 | const dataUrl = await new Promise<string>((resolve) => {
188 | const reader = new FileReader();
189 | reader.onloadend = () => resolve(reader.result as string);
190 | reader.readAsDataURL(compressedDataUrl);
191 | });
192 |
193 | return { dataUrl, mimeType: format };
194 | }
195 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/ModelCacheManagement.vue:
--------------------------------------------------------------------------------
```vue
1 | <template>
2 | <div class="model-cache-section">
3 | <h2 class="section-title">{{ getMessage('modelCacheManagementLabel') }}</h2>
4 |
5 | <!-- Cache Statistics Grid -->
6 | <div class="stats-grid">
7 | <div class="stats-card">
8 | <div class="stats-header">
9 | <p class="stats-label">{{ getMessage('cacheSizeLabel') }}</p>
10 | <span class="stats-icon orange">
11 | <DatabaseIcon />
12 | </span>
13 | </div>
14 | <p class="stats-value">{{ cacheStats?.totalSizeMB || 0 }} MB</p>
15 | </div>
16 |
17 | <div class="stats-card">
18 | <div class="stats-header">
19 | <p class="stats-label">{{ getMessage('cacheEntriesLabel') }}</p>
20 | <span class="stats-icon purple">
21 | <VectorIcon />
22 | </span>
23 | </div>
24 | <p class="stats-value">{{ cacheStats?.entryCount || 0 }}</p>
25 | </div>
26 | </div>
27 |
28 | <!-- Cache Entries Details -->
29 | <div v-if="cacheStats && cacheStats.entries.length > 0" class="cache-details">
30 | <h3 class="cache-details-title">{{ getMessage('cacheDetailsLabel') }}</h3>
31 | <div class="cache-entries">
32 | <div v-for="entry in cacheStats.entries" :key="entry.url" class="cache-entry">
33 | <div class="entry-info">
34 | <div class="entry-url">{{ getModelNameFromUrl(entry.url) }}</div>
35 | <div class="entry-details">
36 | <span class="entry-size">{{ entry.sizeMB }} MB</span>
37 | <span class="entry-age">{{ entry.age }}</span>
38 | <span v-if="entry.expired" class="entry-expired">{{ getMessage('expiredLabel') }}</span>
39 | </div>
40 | </div>
41 | </div>
42 | </div>
43 | </div>
44 |
45 | <!-- No Cache Message -->
46 | <div v-else-if="cacheStats && cacheStats.entries.length === 0" class="no-cache">
47 | <p>{{ getMessage('noCacheDataMessage') }}</p>
48 | </div>
49 |
50 | <!-- Loading State -->
51 | <div v-else-if="!cacheStats" class="loading-cache">
52 | <p>{{ getMessage('loadingCacheInfoStatus') }}</p>
53 | </div>
54 |
55 | <!-- Progress Indicator -->
56 | <ProgressIndicator
57 | v-if="isManagingCache"
58 | :visible="isManagingCache"
59 | :text="isManagingCache ? getMessage('processingCacheStatus') : ''"
60 | :showSpinner="true"
61 | />
62 |
63 | <!-- Action Buttons -->
64 | <div class="cache-actions">
65 | <div class="secondary-button" :disabled="isManagingCache" @click="$emit('cleanup-cache')">
66 | <span class="stats-icon"><DatabaseIcon /></span>
67 | <span>{{
68 | isManagingCache ? getMessage('cleaningStatus') : getMessage('cleanExpiredCacheButton')
69 | }}</span>
70 | </div>
71 |
72 | <div class="danger-button" :disabled="isManagingCache" @click="$emit('clear-all-cache')">
73 | <span class="stats-icon"><TrashIcon /></span>
74 | <span>{{ isManagingCache ? getMessage('clearingStatus') : getMessage('clearAllCacheButton') }}</span>
75 | </div>
76 | </div>
77 | </div>
78 | </template>
79 |
80 | <script lang="ts" setup>
81 | import ProgressIndicator from './ProgressIndicator.vue';
82 | import { DatabaseIcon, VectorIcon, TrashIcon } from './icons';
83 | import { getMessage } from '@/utils/i18n';
84 |
85 | interface CacheEntry {
86 | url: string;
87 | size: number;
88 | sizeMB: number;
89 | timestamp: number;
90 | age: string;
91 | expired: boolean;
92 | }
93 |
94 | interface CacheStats {
95 | totalSize: number;
96 | totalSizeMB: number;
97 | entryCount: number;
98 | entries: CacheEntry[];
99 | }
100 |
101 | interface Props {
102 | cacheStats: CacheStats | null;
103 | isManagingCache: boolean;
104 | }
105 |
106 | interface Emits {
107 | (e: 'cleanup-cache'): void;
108 | (e: 'clear-all-cache'): void;
109 | }
110 |
111 | defineProps<Props>();
112 | defineEmits<Emits>();
113 |
114 | const getModelNameFromUrl = (url: string) => {
115 | // Extract model name from HuggingFace URL
116 | const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)/);
117 | if (match) {
118 | return match[1];
119 | }
120 | return url.split('/').pop() || url;
121 | };
122 | </script>
123 |
124 | <style scoped>
125 | .model-cache-section {
126 | margin-bottom: 24px;
127 | }
128 |
129 | .section-title {
130 | font-size: 16px;
131 | font-weight: 600;
132 | color: #374151;
133 | margin-bottom: 12px;
134 | }
135 |
136 | .stats-grid {
137 | display: grid;
138 | grid-template-columns: 1fr 1fr;
139 | gap: 12px;
140 | margin-bottom: 16px;
141 | }
142 |
143 | .stats-card {
144 | background: white;
145 | border-radius: 12px;
146 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
147 | padding: 16px;
148 | }
149 |
150 | .stats-header {
151 | display: flex;
152 | align-items: center;
153 | justify-content: space-between;
154 | margin-bottom: 8px;
155 | }
156 |
157 | .stats-label {
158 | font-size: 14px;
159 | font-weight: 500;
160 | color: #64748b;
161 | }
162 |
163 | .stats-icon {
164 | padding: 8px;
165 | border-radius: 8px;
166 | width: 36px;
167 | height: 36px;
168 | }
169 |
170 | .stats-icon.orange {
171 | background: #fed7aa;
172 | color: #ea580c;
173 | }
174 |
175 | .stats-icon.purple {
176 | background: #e9d5ff;
177 | color: #9333ea;
178 | }
179 |
180 | .stats-value {
181 | font-size: 30px;
182 | font-weight: 700;
183 | color: #0f172a;
184 | margin: 0;
185 | }
186 |
187 | .cache-details {
188 | margin-bottom: 16px;
189 | }
190 |
191 | .cache-details-title {
192 | font-size: 14px;
193 | font-weight: 600;
194 | color: #374151;
195 | margin: 0 0 12px 0;
196 | }
197 |
198 | .cache-entries {
199 | display: flex;
200 | flex-direction: column;
201 | gap: 8px;
202 | }
203 |
204 | .cache-entry {
205 | background: white;
206 | border: 1px solid #e5e7eb;
207 | border-radius: 8px;
208 | padding: 12px;
209 | }
210 |
211 | .entry-info {
212 | display: flex;
213 | justify-content: space-between;
214 | align-items: center;
215 | }
216 |
217 | .entry-url {
218 | font-weight: 500;
219 | color: #1f2937;
220 | font-size: 14px;
221 | }
222 |
223 | .entry-details {
224 | display: flex;
225 | gap: 8px;
226 | align-items: center;
227 | font-size: 12px;
228 | }
229 |
230 | .entry-size {
231 | background: #dbeafe;
232 | color: #1e40af;
233 | padding: 2px 6px;
234 | border-radius: 4px;
235 | }
236 |
237 | .entry-age {
238 | color: #6b7280;
239 | }
240 |
241 | .entry-expired {
242 | background: #fee2e2;
243 | color: #dc2626;
244 | padding: 2px 6px;
245 | border-radius: 4px;
246 | }
247 |
248 | .no-cache,
249 | .loading-cache {
250 | text-align: center;
251 | color: #6b7280;
252 | padding: 20px;
253 | background: #f8fafc;
254 | border-radius: 8px;
255 | border: 1px solid #e2e8f0;
256 | margin-bottom: 16px;
257 | }
258 |
259 | .cache-actions {
260 | display: flex;
261 | flex-direction: column;
262 | gap: 12px;
263 | }
264 |
265 | .secondary-button {
266 | background: #f1f5f9;
267 | color: #475569;
268 | border: 1px solid #cbd5e1;
269 | padding: 8px 16px;
270 | border-radius: 8px;
271 | font-size: 14px;
272 | font-weight: 500;
273 | cursor: pointer;
274 | transition: all 0.2s ease;
275 | display: flex;
276 | align-items: center;
277 | gap: 8px;
278 | width: 100%;
279 | justify-content: center;
280 | user-select: none;
281 | cursor: pointer;
282 | }
283 |
284 | .secondary-button:hover:not(:disabled) {
285 | background: #e2e8f0;
286 | border-color: #94a3b8;
287 | }
288 |
289 | .secondary-button:disabled {
290 | opacity: 0.5;
291 | cursor: not-allowed;
292 | }
293 |
294 | .danger-button {
295 | width: 100%;
296 | display: flex;
297 | align-items: center;
298 | justify-content: center;
299 | gap: 8px;
300 | background: white;
301 | border: 1px solid #d1d5db;
302 | color: #374151;
303 | font-weight: 600;
304 | padding: 12px 16px;
305 | border-radius: 8px;
306 | cursor: pointer;
307 | user-select: none;
308 | transition: all 0.2s ease;
309 | }
310 |
311 | .danger-button:hover:not(:disabled) {
312 | border-color: #ef4444;
313 | color: #dc2626;
314 | }
315 |
316 | .danger-button:disabled {
317 | opacity: 0.6;
318 | cursor: not-allowed;
319 | }
320 | </style>
321 |
```
--------------------------------------------------------------------------------
/app/native-server/src/cli.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { program } from 'commander';
4 | import * as fs from 'fs';
5 | import * as path from 'path';
6 | import {
7 | tryRegisterUserLevelHost,
8 | colorText,
9 | registerWithElevatedPermissions,
10 | ensureExecutionPermissions,
11 | } from './scripts/utils';
12 | import { BrowserType, parseBrowserType, detectInstalledBrowsers } from './scripts/browser-config';
13 |
14 | // Import writeNodePath from postinstall
15 | async function writeNodePath(): Promise<void> {
16 | try {
17 | const nodePath = process.execPath;
18 | const nodePathFile = path.join(__dirname, 'node_path.txt');
19 |
20 | console.log(colorText(`Writing Node.js path: ${nodePath}`, 'blue'));
21 | fs.writeFileSync(nodePathFile, nodePath, 'utf8');
22 | console.log(colorText('✓ Node.js path written for run_host scripts', 'green'));
23 | } catch (error: any) {
24 | console.warn(colorText(`⚠️ Failed to write Node.js path: ${error.message}`, 'yellow'));
25 | }
26 | }
27 |
28 | program
29 | .version(require('../package.json').version)
30 | .description('Mcp Chrome Bridge - Local service for communicating with Chrome extension');
31 |
32 | // Register Native Messaging host
33 | program
34 | .command('register')
35 | .description('Register Native Messaging host')
36 | .option('-f, --force', 'Force re-registration')
37 | .option('-s, --system', 'Use system-level installation (requires administrator/sudo privileges)')
38 | .option('-b, --browser <browser>', 'Register for specific browser (chrome, chromium, or all)')
39 | .option('-d, --detect', 'Auto-detect installed browsers')
40 | .action(async (options) => {
41 | try {
42 | // Write Node.js path for run_host scripts
43 | await writeNodePath();
44 |
45 | // Determine which browsers to register
46 | let targetBrowsers: BrowserType[] | undefined;
47 |
48 | if (options.browser) {
49 | if (options.browser.toLowerCase() === 'all') {
50 | targetBrowsers = [BrowserType.CHROME, BrowserType.CHROMIUM];
51 | console.log(colorText('Registering for all supported browsers...', 'blue'));
52 | } else {
53 | const browserType = parseBrowserType(options.browser);
54 | if (!browserType) {
55 | console.error(
56 | colorText(
57 | `Invalid browser: ${options.browser}. Use 'chrome', 'chromium', or 'all'`,
58 | 'red',
59 | ),
60 | );
61 | process.exit(1);
62 | }
63 | targetBrowsers = [browserType];
64 | }
65 | } else if (options.detect) {
66 | targetBrowsers = detectInstalledBrowsers();
67 | if (targetBrowsers.length === 0) {
68 | console.log(
69 | colorText(
70 | 'No supported browsers detected, will register for Chrome and Chromium',
71 | 'yellow',
72 | ),
73 | );
74 | targetBrowsers = undefined; // Will use default behavior
75 | }
76 | }
77 | // If neither option specified, tryRegisterUserLevelHost will detect browsers
78 |
79 | // Detect if running with root/administrator privileges
80 | const isRoot = process.getuid && process.getuid() === 0; // Unix/Linux/Mac
81 |
82 | let isAdmin = false;
83 | if (process.platform === 'win32') {
84 | try {
85 | isAdmin = require('is-admin')(); // Windows requires additional package
86 | } catch (error) {
87 | console.warn(
88 | colorText('Warning: Unable to detect administrator privileges on Windows', 'yellow'),
89 | );
90 | isAdmin = false;
91 | }
92 | }
93 |
94 | const hasElevatedPermissions = isRoot || isAdmin;
95 |
96 | // If --system option is specified or running with root/administrator privileges
97 | if (options.system || hasElevatedPermissions) {
98 | // TODO: Update registerWithElevatedPermissions to support multiple browsers
99 | await registerWithElevatedPermissions();
100 | console.log(
101 | colorText('System-level Native Messaging host registered successfully!', 'green'),
102 | );
103 | console.log(
104 | colorText(
105 | 'You can now use connectNative in Chrome extension to connect to this service.',
106 | 'blue',
107 | ),
108 | );
109 | } else {
110 | // Regular user-level installation
111 | console.log(colorText('Registering user-level Native Messaging host...', 'blue'));
112 | const success = await tryRegisterUserLevelHost(targetBrowsers);
113 |
114 | if (success) {
115 | console.log(colorText('Native Messaging host registered successfully!', 'green'));
116 | console.log(
117 | colorText(
118 | 'You can now use connectNative in Chrome extension to connect to this service.',
119 | 'blue',
120 | ),
121 | );
122 | } else {
123 | console.log(
124 | colorText(
125 | 'User-level registration failed, please try the following methods:',
126 | 'yellow',
127 | ),
128 | );
129 | console.log(colorText(' 1. sudo mcp-chrome-bridge register', 'yellow'));
130 | console.log(colorText(' 2. mcp-chrome-bridge register --system', 'yellow'));
131 | process.exit(1);
132 | }
133 | }
134 | } catch (error: any) {
135 | console.error(colorText(`Registration failed: ${error.message}`, 'red'));
136 | process.exit(1);
137 | }
138 | });
139 |
140 | // Fix execution permissions
141 | program
142 | .command('fix-permissions')
143 | .description('Fix execution permissions for native host files')
144 | .action(async () => {
145 | try {
146 | console.log(colorText('Fixing execution permissions...', 'blue'));
147 | await ensureExecutionPermissions();
148 | console.log(colorText('✓ Execution permissions fixed successfully!', 'green'));
149 | } catch (error: any) {
150 | console.error(colorText(`Failed to fix permissions: ${error.message}`, 'red'));
151 | process.exit(1);
152 | }
153 | });
154 |
155 | // Update port in stdio-config.json
156 | program
157 | .command('update-port <port>')
158 | .description('Update the port number in stdio-config.json')
159 | .action(async (port: string) => {
160 | try {
161 | const portNumber = parseInt(port, 10);
162 | if (isNaN(portNumber) || portNumber < 1 || portNumber > 65535) {
163 | console.error(colorText('Error: Port must be a valid number between 1 and 65535', 'red'));
164 | process.exit(1);
165 | }
166 |
167 | const configPath = path.join(__dirname, 'mcp', 'stdio-config.json');
168 |
169 | if (!fs.existsSync(configPath)) {
170 | console.error(colorText(`Error: Configuration file not found at ${configPath}`, 'red'));
171 | process.exit(1);
172 | }
173 |
174 | const configData = fs.readFileSync(configPath, 'utf8');
175 | const config = JSON.parse(configData);
176 |
177 | const currentUrl = new URL(config.url);
178 | currentUrl.port = portNumber.toString();
179 | config.url = currentUrl.toString();
180 |
181 | fs.writeFileSync(configPath, JSON.stringify(config, null, 4));
182 |
183 | console.log(colorText(`✓ Port updated successfully to ${portNumber}`, 'green'));
184 | console.log(colorText(`Updated URL: ${config.url}`, 'blue'));
185 | } catch (error: any) {
186 | console.error(colorText(`Failed to update port: ${error.message}`, 'red'));
187 | process.exit(1);
188 | }
189 | });
190 |
191 | program.parse(process.argv);
192 |
193 | // If no command provided, show help
194 | if (!process.argv.slice(2).length) {
195 | program.outputHelp();
196 | }
197 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/inject-script.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
2 | import { BaseBrowserToolExecutor } from '../base-browser';
3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
4 | import { ExecutionWorld } from '@/common/constants';
5 |
6 | interface InjectScriptParam {
7 | url?: string;
8 | }
9 | interface ScriptConfig {
10 | type: ExecutionWorld;
11 | jsScript: string;
12 | }
13 |
14 | interface SendCommandToInjectScriptToolParam {
15 | tabId?: number;
16 | eventName: string;
17 | payload?: string;
18 | }
19 |
20 | const injectedTabs = new Map();
21 | class InjectScriptTool extends BaseBrowserToolExecutor {
22 | name = TOOL_NAMES.BROWSER.INJECT_SCRIPT;
23 | async execute(args: InjectScriptParam & ScriptConfig): Promise<ToolResult> {
24 | try {
25 | const { url, type, jsScript } = args;
26 | let tab;
27 |
28 | if (!type || !jsScript) {
29 | return createErrorResponse('Param [type] and [jsScript] is required');
30 | }
31 |
32 | if (url) {
33 | // If URL is provided, check if it's already open
34 | console.log(`Checking if URL is already open: ${url}`);
35 | const allTabs = await chrome.tabs.query({});
36 |
37 | // Find tab with matching URL
38 | const matchingTabs = allTabs.filter((t) => {
39 | // Normalize URLs for comparison (remove trailing slashes)
40 | const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url;
41 | const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;
42 | return tabUrl === targetUrl;
43 | });
44 |
45 | if (matchingTabs.length > 0) {
46 | // Use existing tab
47 | tab = matchingTabs[0];
48 | console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`);
49 | } else {
50 | // Create new tab with the URL
51 | console.log(`No existing tab found with URL: ${url}, creating new tab`);
52 | tab = await chrome.tabs.create({ url, active: true });
53 |
54 | // Wait for page to load
55 | console.log('Waiting for page to load...');
56 | await new Promise((resolve) => setTimeout(resolve, 3000));
57 | }
58 | } else {
59 | // Use active tab
60 | const tabs = await chrome.tabs.query({ active: true });
61 | if (!tabs[0]) {
62 | return createErrorResponse('No active tab found');
63 | }
64 | tab = tabs[0];
65 | }
66 |
67 | if (!tab.id) {
68 | return createErrorResponse('Tab has no ID');
69 | }
70 |
71 | // Make sure tab is active
72 | await chrome.tabs.update(tab.id, { active: true });
73 |
74 | const res = await handleInject(tab.id!, { ...args });
75 |
76 | return {
77 | content: [
78 | {
79 | type: 'text',
80 | text: JSON.stringify(res),
81 | },
82 | ],
83 | isError: false,
84 | };
85 | } catch (error) {
86 | console.error('Error in InjectScriptTool.execute:', error);
87 | return createErrorResponse(
88 | `Inject script error: ${error instanceof Error ? error.message : String(error)}`,
89 | );
90 | }
91 | }
92 | }
93 |
94 | class SendCommandToInjectScriptTool extends BaseBrowserToolExecutor {
95 | name = TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT;
96 | async execute(args: SendCommandToInjectScriptToolParam): Promise<ToolResult> {
97 | try {
98 | const { tabId, eventName, payload } = args;
99 |
100 | if (!eventName) {
101 | return createErrorResponse('Param [eventName] is required');
102 | }
103 |
104 | if (tabId) {
105 | const tabExists = await isTabExists(tabId);
106 | if (!tabExists) {
107 | return createErrorResponse('The tab:[tabId] is not exists');
108 | }
109 | }
110 |
111 | let finalTabId: number | undefined = tabId;
112 |
113 | if (finalTabId === undefined) {
114 | // Use active tab
115 | const tabs = await chrome.tabs.query({ active: true });
116 | if (!tabs[0]) {
117 | return createErrorResponse('No active tab found');
118 | }
119 | finalTabId = tabs[0].id;
120 | }
121 |
122 | if (!finalTabId) {
123 | return createErrorResponse('No active tab found');
124 | }
125 |
126 | if (!injectedTabs.has(finalTabId)) {
127 | throw new Error('No script injected in this tab.');
128 | }
129 | const result = await chrome.tabs.sendMessage(finalTabId, {
130 | action: eventName,
131 | payload,
132 | targetWorld: injectedTabs.get(finalTabId).type, // The bridge uses this to decide whether to forward to MAIN world.
133 | });
134 |
135 | return {
136 | content: [
137 | {
138 | type: 'text',
139 | text: JSON.stringify(result),
140 | },
141 | ],
142 | isError: false,
143 | };
144 | } catch (error) {
145 | console.error('Error in InjectScriptTool.execute:', error);
146 | return createErrorResponse(
147 | `Inject script error: ${error instanceof Error ? error.message : String(error)}`,
148 | );
149 | }
150 | }
151 | }
152 |
153 | async function isTabExists(tabId: number) {
154 | try {
155 | await chrome.tabs.get(tabId);
156 | return true;
157 | } catch (error) {
158 | // An error is thrown if the tab doesn't exist.
159 | return false;
160 | }
161 | }
162 |
163 | /**
164 | * @description Handles the injection of user scripts into a specific tab.
165 | * @param {number} tabId - The ID of the target tab.
166 | * @param {object} scriptConfig - The configuration object for the script.
167 | */
168 | async function handleInject(tabId: number, scriptConfig: ScriptConfig) {
169 | if (injectedTabs.has(tabId)) {
170 | // If already injected, run cleanup first to ensure a clean state.
171 | console.log(`Tab ${tabId} already has injections. Cleaning up first.`);
172 | await handleCleanup(tabId);
173 | }
174 | const { type, jsScript } = scriptConfig;
175 | const hasMain = type === ExecutionWorld.MAIN;
176 |
177 | if (hasMain) {
178 | // The bridge is essential for MAIN world communication and cleanup.
179 | await chrome.scripting.executeScript({
180 | target: { tabId },
181 | files: ['inject-scripts/inject-bridge.js'],
182 | world: ExecutionWorld.ISOLATED,
183 | });
184 | await chrome.scripting.executeScript({
185 | target: { tabId },
186 | func: (code) => new Function(code)(),
187 | args: [jsScript],
188 | world: ExecutionWorld.MAIN,
189 | });
190 | } else {
191 | await chrome.scripting.executeScript({
192 | target: { tabId },
193 | func: (code) => new Function(code)(),
194 | args: [jsScript],
195 | world: ExecutionWorld.ISOLATED,
196 | });
197 | }
198 | injectedTabs.set(tabId, scriptConfig);
199 | console.log(`Scripts successfully injected into tab ${tabId}.`);
200 | return { injected: true };
201 | }
202 |
203 | /**
204 | * @description Triggers the cleanup process in a specific tab.
205 | * @param {number} tabId - The ID of the target tab.
206 | */
207 | async function handleCleanup(tabId: number) {
208 | if (!injectedTabs.has(tabId)) return;
209 | // Send cleanup signal. The bridge will forward it to the MAIN world.
210 | chrome.tabs
211 | .sendMessage(tabId, { type: 'chrome-mcp:cleanup' })
212 | .catch((err) =>
213 | console.warn(`Could not send cleanup message to tab ${tabId}. It might have been closed.`),
214 | );
215 |
216 | injectedTabs.delete(tabId);
217 | console.log(`Cleanup signal sent to tab ${tabId}. State cleared.`);
218 | }
219 |
220 | export const injectScriptTool = new InjectScriptTool();
221 | export const sendCommandToInjectScriptTool = new SendCommandToInjectScriptTool();
222 |
223 | // --- Automatic Cleanup Listeners ---
224 | chrome.tabs.onRemoved.addListener((tabId) => {
225 | if (injectedTabs.has(tabId)) {
226 | console.log(`Tab ${tabId} closed. Cleaning up state.`);
227 | injectedTabs.delete(tabId);
228 | }
229 | });
230 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/inject-scripts/click-helper.js:
--------------------------------------------------------------------------------
```javascript
1 | /* eslint-disable */
2 | // click-helper.js
3 | // This script is injected into the page to handle click operations
4 |
5 | if (window.__CLICK_HELPER_INITIALIZED__) {
6 | // Already initialized, skip
7 | } else {
8 | window.__CLICK_HELPER_INITIALIZED__ = true;
9 | /**
10 | * Click on an element matching the selector or at specific coordinates
11 | * @param {string} selector - CSS selector for the element to click
12 | * @param {boolean} waitForNavigation - Whether to wait for navigation to complete after click
13 | * @param {number} timeout - Timeout in milliseconds for waiting for the element or navigation
14 | * @param {Object} coordinates - Optional coordinates for clicking at a specific position
15 | * @param {number} coordinates.x - X coordinate relative to the viewport
16 | * @param {number} coordinates.y - Y coordinate relative to the viewport
17 | * @returns {Promise<Object>} - Result of the click operation
18 | */
19 | async function clickElement(
20 | selector,
21 | waitForNavigation = false,
22 | timeout = 5000,
23 | coordinates = null,
24 | ) {
25 | try {
26 | let element = null;
27 | let elementInfo = null;
28 | let clickX, clickY;
29 |
30 | if (coordinates && typeof coordinates.x === 'number' && typeof coordinates.y === 'number') {
31 | clickX = coordinates.x;
32 | clickY = coordinates.y;
33 |
34 | element = document.elementFromPoint(clickX, clickY);
35 |
36 | if (element) {
37 | const rect = element.getBoundingClientRect();
38 | elementInfo = {
39 | tagName: element.tagName,
40 | id: element.id,
41 | className: element.className,
42 | text: element.textContent?.trim().substring(0, 100) || '',
43 | href: element.href || null,
44 | type: element.type || null,
45 | isVisible: true,
46 | rect: {
47 | x: rect.x,
48 | y: rect.y,
49 | width: rect.width,
50 | height: rect.height,
51 | top: rect.top,
52 | right: rect.right,
53 | bottom: rect.bottom,
54 | left: rect.left,
55 | },
56 | clickMethod: 'coordinates',
57 | clickPosition: { x: clickX, y: clickY },
58 | };
59 | } else {
60 | elementInfo = {
61 | clickMethod: 'coordinates',
62 | clickPosition: { x: clickX, y: clickY },
63 | warning: 'No element found at the specified coordinates',
64 | };
65 | }
66 | } else {
67 | element = document.querySelector(selector);
68 | if (!element) {
69 | return {
70 | error: `Element with selector "${selector}" not found`,
71 | };
72 | }
73 |
74 | const rect = element.getBoundingClientRect();
75 | elementInfo = {
76 | tagName: element.tagName,
77 | id: element.id,
78 | className: element.className,
79 | text: element.textContent?.trim().substring(0, 100) || '',
80 | href: element.href || null,
81 | type: element.type || null,
82 | isVisible: true,
83 | rect: {
84 | x: rect.x,
85 | y: rect.y,
86 | width: rect.width,
87 | height: rect.height,
88 | top: rect.top,
89 | right: rect.right,
90 | bottom: rect.bottom,
91 | left: rect.left,
92 | },
93 | clickMethod: 'selector',
94 | };
95 |
96 | // First sroll so that the element is in view, then check visibility.
97 | element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' });
98 | await new Promise((resolve) => setTimeout(resolve, 100));
99 | elementInfo.isVisible = isElementVisible(element);
100 | if (!elementInfo.isVisible) {
101 | return {
102 | error: `Element with selector "${selector}" is not visible`,
103 | elementInfo,
104 | };
105 | }
106 |
107 | const updatedRect = element.getBoundingClientRect();
108 | clickX = updatedRect.left + updatedRect.width / 2;
109 | clickY = updatedRect.top + updatedRect.height / 2;
110 | }
111 |
112 | let navigationPromise;
113 | if (waitForNavigation) {
114 | navigationPromise = new Promise((resolve) => {
115 | const beforeUnloadListener = () => {
116 | window.removeEventListener('beforeunload', beforeUnloadListener);
117 | resolve(true);
118 | };
119 | window.addEventListener('beforeunload', beforeUnloadListener);
120 |
121 | setTimeout(() => {
122 | window.removeEventListener('beforeunload', beforeUnloadListener);
123 | resolve(false);
124 | }, timeout);
125 | });
126 | }
127 |
128 | if (element && elementInfo.clickMethod === 'selector') {
129 | element.click();
130 | } else {
131 | simulateClick(clickX, clickY);
132 | }
133 |
134 | // Wait for navigation if needed
135 | let navigationOccurred = false;
136 | if (waitForNavigation) {
137 | navigationOccurred = await navigationPromise;
138 | }
139 |
140 | return {
141 | success: true,
142 | message: 'Element clicked successfully',
143 | elementInfo,
144 | navigationOccurred,
145 | };
146 | } catch (error) {
147 | return {
148 | error: `Error clicking element: ${error.message}`,
149 | };
150 | }
151 | }
152 |
153 | /**
154 | * Simulate a mouse click at specific coordinates
155 | * @param {number} x - X coordinate relative to the viewport
156 | * @param {number} y - Y coordinate relative to the viewport
157 | */
158 | function simulateClick(x, y) {
159 | const clickEvent = new MouseEvent('click', {
160 | view: window,
161 | bubbles: true,
162 | cancelable: true,
163 | clientX: x,
164 | clientY: y,
165 | });
166 |
167 | const element = document.elementFromPoint(x, y);
168 |
169 | if (element) {
170 | element.dispatchEvent(clickEvent);
171 | } else {
172 | document.dispatchEvent(clickEvent);
173 | }
174 | }
175 |
176 | /**
177 | * Check if an element is visible
178 | * @param {Element} element - The element to check
179 | * @returns {boolean} - Whether the element is visible
180 | */
181 | function isElementVisible(element) {
182 | if (!element) return false;
183 |
184 | const style = window.getComputedStyle(element);
185 | if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
186 | return false;
187 | }
188 |
189 | const rect = element.getBoundingClientRect();
190 | if (rect.width === 0 || rect.height === 0) {
191 | return false;
192 | }
193 |
194 | if (
195 | rect.bottom < 0 ||
196 | rect.top > window.innerHeight ||
197 | rect.right < 0 ||
198 | rect.left > window.innerWidth
199 | ) {
200 | return false;
201 | }
202 |
203 | const centerX = rect.left + rect.width / 2;
204 | const centerY = rect.top + rect.height / 2;
205 |
206 | const elementAtPoint = document.elementFromPoint(centerX, centerY);
207 | if (!elementAtPoint) return false;
208 |
209 | return element === elementAtPoint || element.contains(elementAtPoint);
210 | }
211 |
212 | // Listen for messages from the extension
213 | chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
214 | if (request.action === 'clickElement') {
215 | clickElement(
216 | request.selector,
217 | request.waitForNavigation,
218 | request.timeout,
219 | request.coordinates,
220 | )
221 | .then(sendResponse)
222 | .catch((error) => {
223 | sendResponse({
224 | error: `Unexpected error: ${error.message}`,
225 | });
226 | });
227 | return true; // Indicates async response
228 | } else if (request.action === 'chrome_click_element_ping') {
229 | sendResponse({ status: 'pong' });
230 | return false;
231 | }
232 | });
233 | }
234 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/history.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
2 | import { BaseBrowserToolExecutor } from '../base-browser';
3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
4 | import {
5 | parseISO,
6 | subDays,
7 | subWeeks,
8 | subMonths,
9 | subYears,
10 | startOfToday,
11 | startOfYesterday,
12 | isValid,
13 | format,
14 | } from 'date-fns';
15 |
16 | interface HistoryToolParams {
17 | text?: string;
18 | startTime?: string;
19 | endTime?: string;
20 | maxResults?: number;
21 | excludeCurrentTabs?: boolean;
22 | }
23 |
24 | interface HistoryItem {
25 | id: string;
26 | url?: string;
27 | title?: string;
28 | lastVisitTime?: number; // Timestamp in milliseconds
29 | visitCount?: number;
30 | typedCount?: number;
31 | }
32 |
33 | interface HistoryResult {
34 | items: HistoryItem[];
35 | totalCount: number;
36 | timeRange: {
37 | startTime: number;
38 | endTime: number;
39 | startTimeFormatted: string;
40 | endTimeFormatted: string;
41 | };
42 | query?: string;
43 | }
44 |
45 | class HistoryTool extends BaseBrowserToolExecutor {
46 | name = TOOL_NAMES.BROWSER.HISTORY;
47 | private static readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
48 |
49 | /**
50 | * Parse a date string into milliseconds since epoch.
51 | * Returns null if the date string is invalid.
52 | * Supports:
53 | * - ISO date strings (e.g., "2023-10-31", "2023-10-31T14:30:00.000Z")
54 | * - Relative times: "1 day ago", "2 weeks ago", "3 months ago", "1 year ago"
55 | * - Special keywords: "now", "today", "yesterday"
56 | */
57 | private parseDateString(dateStr: string | undefined | null): number | null {
58 | if (!dateStr) {
59 | // If an empty or null string is passed, it might mean "no specific date",
60 | // depending on how you want to treat it. Returning null is safer.
61 | return null;
62 | }
63 |
64 | const now = new Date();
65 | const lowerDateStr = dateStr.toLowerCase().trim();
66 |
67 | if (lowerDateStr === 'now') return now.getTime();
68 | if (lowerDateStr === 'today') return startOfToday().getTime();
69 | if (lowerDateStr === 'yesterday') return startOfYesterday().getTime();
70 |
71 | const relativeMatch = lowerDateStr.match(
72 | /^(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago$/,
73 | );
74 | if (relativeMatch) {
75 | const amount = parseInt(relativeMatch[1], 10);
76 | const unit = relativeMatch[2];
77 | let resultDate: Date;
78 | if (unit.startsWith('day')) resultDate = subDays(now, amount);
79 | else if (unit.startsWith('week')) resultDate = subWeeks(now, amount);
80 | else if (unit.startsWith('month')) resultDate = subMonths(now, amount);
81 | else if (unit.startsWith('year')) resultDate = subYears(now, amount);
82 | else return null; // Should not happen with the regex
83 | return resultDate.getTime();
84 | }
85 |
86 | // Try parsing as ISO or other common date string formats
87 | // Native Date constructor can be unreliable for non-standard formats.
88 | // date-fns' parseISO is good for ISO 8601.
89 | // For other formats, date-fns' parse function is more flexible.
90 | let parsedDate = parseISO(dateStr); // Handles "2023-10-31" or "2023-10-31T10:00:00"
91 | if (isValid(parsedDate)) {
92 | return parsedDate.getTime();
93 | }
94 |
95 | // Fallback to new Date() for other potential formats, but with caution
96 | parsedDate = new Date(dateStr);
97 | if (isValid(parsedDate) && dateStr.includes(parsedDate.getFullYear().toString())) {
98 | return parsedDate.getTime();
99 | }
100 |
101 | console.warn(`Could not parse date string: ${dateStr}`);
102 | return null;
103 | }
104 |
105 | /**
106 | * Format a timestamp as a human-readable date string
107 | */
108 | private formatDate(timestamp: number): string {
109 | // Using date-fns for consistent and potentially localized formatting
110 | return format(timestamp, 'yyyy-MM-dd HH:mm:ss');
111 | }
112 |
113 | async execute(args: HistoryToolParams): Promise<ToolResult> {
114 | try {
115 | console.log('Executing HistoryTool with args:', args);
116 |
117 | const {
118 | text = '',
119 | maxResults = 100, // Default to 100 results
120 | excludeCurrentTabs = false,
121 | } = args;
122 |
123 | const now = Date.now();
124 | let startTimeMs: number;
125 | let endTimeMs: number;
126 |
127 | // Parse startTime
128 | if (args.startTime) {
129 | const parsedStart = this.parseDateString(args.startTime);
130 | if (parsedStart === null) {
131 | return createErrorResponse(
132 | `Invalid format for start time: "${args.startTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`,
133 | );
134 | }
135 | startTimeMs = parsedStart;
136 | } else {
137 | // Default to 24 hours ago if startTime is not provided
138 | startTimeMs = now - HistoryTool.ONE_DAY_MS;
139 | }
140 |
141 | // Parse endTime
142 | if (args.endTime) {
143 | const parsedEnd = this.parseDateString(args.endTime);
144 | if (parsedEnd === null) {
145 | return createErrorResponse(
146 | `Invalid format for end time: "${args.endTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`,
147 | );
148 | }
149 | endTimeMs = parsedEnd;
150 | } else {
151 | // Default to current time if endTime is not provided
152 | endTimeMs = now;
153 | }
154 |
155 | // Validate time range
156 | if (startTimeMs > endTimeMs) {
157 | return createErrorResponse('Start time cannot be after end time.');
158 | }
159 |
160 | console.log(
161 | `Searching history from ${this.formatDate(startTimeMs)} to ${this.formatDate(endTimeMs)} for query "${text}"`,
162 | );
163 |
164 | const historyItems = await chrome.history.search({
165 | text,
166 | startTime: startTimeMs,
167 | endTime: endTimeMs,
168 | maxResults,
169 | });
170 |
171 | console.log(`Found ${historyItems.length} history items before filtering current tabs.`);
172 |
173 | let filteredItems = historyItems;
174 | if (excludeCurrentTabs && historyItems.length > 0) {
175 | const currentTabs = await chrome.tabs.query({});
176 | const openUrls = new Set<string>();
177 |
178 | currentTabs.forEach((tab) => {
179 | if (tab.url) {
180 | openUrls.add(tab.url);
181 | }
182 | });
183 |
184 | if (openUrls.size > 0) {
185 | filteredItems = historyItems.filter((item) => !(item.url && openUrls.has(item.url)));
186 | console.log(
187 | `Filtered out ${historyItems.length - filteredItems.length} items that are currently open. ${filteredItems.length} items remaining.`,
188 | );
189 | }
190 | }
191 |
192 | const result: HistoryResult = {
193 | items: filteredItems.map((item) => ({
194 | id: item.id,
195 | url: item.url,
196 | title: item.title,
197 | lastVisitTime: item.lastVisitTime,
198 | visitCount: item.visitCount,
199 | typedCount: item.typedCount,
200 | })),
201 | totalCount: filteredItems.length,
202 | timeRange: {
203 | startTime: startTimeMs,
204 | endTime: endTimeMs,
205 | startTimeFormatted: this.formatDate(startTimeMs),
206 | endTimeFormatted: this.formatDate(endTimeMs),
207 | },
208 | };
209 |
210 | if (text) {
211 | result.query = text;
212 | }
213 |
214 | return {
215 | content: [
216 | {
217 | type: 'text',
218 | text: JSON.stringify(result, null, 2),
219 | },
220 | ],
221 | isError: false,
222 | };
223 | } catch (error) {
224 | console.error('Error in HistoryTool.execute:', error);
225 | return createErrorResponse(
226 | `Error retrieving browsing history: ${error instanceof Error ? error.message : String(error)}`,
227 | );
228 | }
229 | }
230 | }
231 |
232 | export const historyTool = new HistoryTool();
233 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/utils/text-chunker.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Text chunking utility
3 | * Based on semantic chunking strategy, splits long text into small chunks suitable for vectorization
4 | */
5 |
6 | export interface TextChunk {
7 | text: string;
8 | source: string;
9 | index: number;
10 | wordCount: number;
11 | }
12 |
13 | export interface ChunkingOptions {
14 | maxWordsPerChunk?: number;
15 | overlapSentences?: number;
16 | minChunkLength?: number;
17 | includeTitle?: boolean;
18 | }
19 |
20 | export class TextChunker {
21 | private readonly defaultOptions: Required<ChunkingOptions> = {
22 | maxWordsPerChunk: 80,
23 | overlapSentences: 1,
24 | minChunkLength: 20,
25 | includeTitle: true,
26 | };
27 |
28 | public chunkText(content: string, title?: string, options?: ChunkingOptions): TextChunk[] {
29 | const opts = { ...this.defaultOptions, ...options };
30 | const chunks: TextChunk[] = [];
31 |
32 | if (opts.includeTitle && title?.trim() && title.trim().length > 5) {
33 | chunks.push({
34 | text: title.trim(),
35 | source: 'title',
36 | index: 0,
37 | wordCount: title.trim().split(/\s+/).length,
38 | });
39 | }
40 |
41 | const cleanContent = content.trim();
42 | if (!cleanContent) {
43 | return chunks;
44 | }
45 |
46 | const sentences = this.splitIntoSentences(cleanContent);
47 |
48 | if (sentences.length === 0) {
49 | return this.fallbackChunking(cleanContent, chunks, opts);
50 | }
51 |
52 | const hasLongSentences = sentences.some(
53 | (s: string) => s.split(/\s+/).length > opts.maxWordsPerChunk,
54 | );
55 |
56 | if (hasLongSentences) {
57 | return this.mixedChunking(sentences, chunks, opts);
58 | }
59 |
60 | return this.groupSentencesIntoChunks(sentences, chunks, opts);
61 | }
62 |
63 | private splitIntoSentences(content: string): string[] {
64 | const processedContent = content
65 | .replace(/([。!?])\s*/g, '$1\n')
66 | .replace(/([.!?])\s+(?=[A-Z])/g, '$1\n')
67 | .replace(/([.!?]["'])\s+(?=[A-Z])/g, '$1\n')
68 | .replace(/([.!?])\s*$/gm, '$1\n')
69 | .replace(/([。!?][""])\s*/g, '$1\n')
70 | .replace(/\n\s*\n/g, '\n');
71 |
72 | const sentences = processedContent
73 | .split('\n')
74 | .map((s) => s.trim())
75 | .filter((s) => s.length > 15);
76 |
77 | if (sentences.length < 3 && content.length > 500) {
78 | return this.aggressiveSentenceSplitting(content);
79 | }
80 |
81 | return sentences;
82 | }
83 |
84 | private aggressiveSentenceSplitting(content: string): string[] {
85 | const sentences = content
86 | .replace(/([.!?。!?])/g, '$1\n')
87 | .replace(/([;;::])/g, '$1\n')
88 | .replace(/([))])\s*(?=[\u4e00-\u9fa5A-Z])/g, '$1\n')
89 | .split('\n')
90 | .map((s) => s.trim())
91 | .filter((s) => s.length > 15);
92 |
93 | const maxWordsPerChunk = 80;
94 | const finalSentences: string[] = [];
95 |
96 | for (const sentence of sentences) {
97 | const words = sentence.split(/\s+/);
98 | if (words.length <= maxWordsPerChunk) {
99 | finalSentences.push(sentence);
100 | } else {
101 | const overlapWords = 5;
102 | for (let i = 0; i < words.length; i += maxWordsPerChunk - overlapWords) {
103 | const chunkWords = words.slice(i, i + maxWordsPerChunk);
104 | const chunkText = chunkWords.join(' ');
105 | if (chunkText.length > 15) {
106 | finalSentences.push(chunkText);
107 | }
108 | }
109 | }
110 | }
111 |
112 | return finalSentences;
113 | }
114 |
115 | /**
116 | * Group sentences into chunks
117 | */
118 | private groupSentencesIntoChunks(
119 | sentences: string[],
120 | existingChunks: TextChunk[],
121 | options: Required<ChunkingOptions>,
122 | ): TextChunk[] {
123 | const chunks = [...existingChunks];
124 | let chunkIndex = chunks.length;
125 |
126 | let i = 0;
127 | while (i < sentences.length) {
128 | let currentChunkText = '';
129 | let currentWordCount = 0;
130 | let sentencesUsed = 0;
131 |
132 | while (i + sentencesUsed < sentences.length && currentWordCount < options.maxWordsPerChunk) {
133 | const sentence = sentences[i + sentencesUsed];
134 | const sentenceWords = sentence.split(/\s+/).length;
135 |
136 | if (currentWordCount + sentenceWords > options.maxWordsPerChunk && currentWordCount > 0) {
137 | break;
138 | }
139 |
140 | currentChunkText += (currentChunkText ? ' ' : '') + sentence;
141 | currentWordCount += sentenceWords;
142 | sentencesUsed++;
143 | }
144 |
145 | if (currentChunkText.trim().length > options.minChunkLength) {
146 | chunks.push({
147 | text: currentChunkText.trim(),
148 | source: `content_chunk_${chunkIndex}`,
149 | index: chunkIndex,
150 | wordCount: currentWordCount,
151 | });
152 | chunkIndex++;
153 | }
154 |
155 | i += Math.max(1, sentencesUsed - options.overlapSentences);
156 | }
157 | return chunks;
158 | }
159 |
160 | /**
161 | * Mixed chunking method (handles long sentences)
162 | */
163 | private mixedChunking(
164 | sentences: string[],
165 | existingChunks: TextChunk[],
166 | options: Required<ChunkingOptions>,
167 | ): TextChunk[] {
168 | const chunks = [...existingChunks];
169 | let chunkIndex = chunks.length;
170 |
171 | for (const sentence of sentences) {
172 | const sentenceWords = sentence.split(/\s+/).length;
173 |
174 | if (sentenceWords <= options.maxWordsPerChunk) {
175 | chunks.push({
176 | text: sentence.trim(),
177 | source: `sentence_chunk_${chunkIndex}`,
178 | index: chunkIndex,
179 | wordCount: sentenceWords,
180 | });
181 | chunkIndex++;
182 | } else {
183 | const words = sentence.split(/\s+/);
184 | for (let i = 0; i < words.length; i += options.maxWordsPerChunk) {
185 | const chunkWords = words.slice(i, i + options.maxWordsPerChunk);
186 | const chunkText = chunkWords.join(' ');
187 |
188 | if (chunkText.length > options.minChunkLength) {
189 | chunks.push({
190 | text: chunkText,
191 | source: `long_sentence_chunk_${chunkIndex}_part_${Math.floor(i / options.maxWordsPerChunk)}`,
192 | index: chunkIndex,
193 | wordCount: chunkWords.length,
194 | });
195 | }
196 | }
197 | chunkIndex++;
198 | }
199 | }
200 |
201 | return chunks;
202 | }
203 |
204 | /**
205 | * Fallback chunking (when sentence splitting fails)
206 | */
207 | private fallbackChunking(
208 | content: string,
209 | existingChunks: TextChunk[],
210 | options: Required<ChunkingOptions>,
211 | ): TextChunk[] {
212 | const chunks = [...existingChunks];
213 | let chunkIndex = chunks.length;
214 |
215 | const paragraphs = content
216 | .split(/\n\s*\n/)
217 | .filter((p) => p.trim().length > options.minChunkLength);
218 |
219 | if (paragraphs.length > 1) {
220 | paragraphs.forEach((paragraph, index) => {
221 | const cleanParagraph = paragraph.trim();
222 | if (cleanParagraph.length > 0) {
223 | const words = cleanParagraph.split(/\s+/);
224 | const maxWordsPerChunk = 150;
225 |
226 | for (let i = 0; i < words.length; i += maxWordsPerChunk) {
227 | const chunkWords = words.slice(i, i + maxWordsPerChunk);
228 | const chunkText = chunkWords.join(' ');
229 |
230 | if (chunkText.length > options.minChunkLength) {
231 | chunks.push({
232 | text: chunkText,
233 | source: `paragraph_${index}_chunk_${Math.floor(i / maxWordsPerChunk)}`,
234 | index: chunkIndex,
235 | wordCount: chunkWords.length,
236 | });
237 | chunkIndex++;
238 | }
239 | }
240 | }
241 | });
242 | } else {
243 | const words = content.trim().split(/\s+/);
244 | const maxWordsPerChunk = 150;
245 |
246 | for (let i = 0; i < words.length; i += maxWordsPerChunk) {
247 | const chunkWords = words.slice(i, i + maxWordsPerChunk);
248 | const chunkText = chunkWords.join(' ');
249 |
250 | if (chunkText.length > options.minChunkLength) {
251 | chunks.push({
252 | text: chunkText,
253 | source: `content_chunk_${Math.floor(i / maxWordsPerChunk)}`,
254 | index: chunkIndex,
255 | wordCount: chunkWords.length,
256 | });
257 | chunkIndex++;
258 | }
259 | }
260 | }
261 |
262 | return chunks;
263 | }
264 | }
265 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/web-fetcher.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
2 | import { BaseBrowserToolExecutor } from '../base-browser';
3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
4 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
5 |
6 | interface WebFetcherToolParams {
7 | htmlContent?: boolean; // get the visible HTML content of the current page. default: false
8 | textContent?: boolean; // get the visible text content of the current page. default: true
9 | url?: string; // optional URL to fetch content from (if not provided, uses active tab)
10 | selector?: string; // optional CSS selector to get content from a specific element
11 | }
12 |
13 | class WebFetcherTool extends BaseBrowserToolExecutor {
14 | name = TOOL_NAMES.BROWSER.WEB_FETCHER;
15 |
16 | /**
17 | * Execute web fetcher operation
18 | */
19 | async execute(args: WebFetcherToolParams): Promise<ToolResult> {
20 | // Handle mutually exclusive parameters: if htmlContent is true, textContent is forced to false
21 | const htmlContent = args.htmlContent === true;
22 | const textContent = htmlContent ? false : args.textContent !== false; // Default is true, unless htmlContent is true or textContent is explicitly set to false
23 | const url = args.url;
24 | const selector = args.selector;
25 |
26 | console.log(`Starting web fetcher with options:`, {
27 | htmlContent,
28 | textContent,
29 | url,
30 | selector,
31 | });
32 |
33 | try {
34 | // Get tab to fetch content from
35 | let tab;
36 |
37 | if (url) {
38 | // If URL is provided, check if it's already open
39 | console.log(`Checking if URL is already open: ${url}`);
40 | const allTabs = await chrome.tabs.query({});
41 |
42 | // Find tab with matching URL
43 | const matchingTabs = allTabs.filter((t) => {
44 | // Normalize URLs for comparison (remove trailing slashes)
45 | const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url;
46 | const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;
47 | return tabUrl === targetUrl;
48 | });
49 |
50 | if (matchingTabs.length > 0) {
51 | // Use existing tab
52 | tab = matchingTabs[0];
53 | console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`);
54 | } else {
55 | // Create new tab with the URL
56 | console.log(`No existing tab found with URL: ${url}, creating new tab`);
57 | tab = await chrome.tabs.create({ url, active: true });
58 |
59 | // Wait for page to load
60 | console.log('Waiting for page to load...');
61 | await new Promise((resolve) => setTimeout(resolve, 3000));
62 | }
63 | } else {
64 | // Use active tab
65 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
66 | if (!tabs[0]) {
67 | return createErrorResponse('No active tab found');
68 | }
69 | tab = tabs[0];
70 | }
71 |
72 | if (!tab.id) {
73 | return createErrorResponse('Tab has no ID');
74 | }
75 |
76 | // Make sure tab is active
77 | await chrome.tabs.update(tab.id, { active: true });
78 |
79 | // Prepare result object
80 | const result: any = {
81 | success: true,
82 | url: tab.url,
83 | title: tab.title,
84 | };
85 |
86 | await this.injectContentScript(tab.id, ['inject-scripts/web-fetcher-helper.js']);
87 |
88 | // Get HTML content if requested
89 | if (htmlContent) {
90 | const htmlResponse = await this.sendMessageToTab(tab.id, {
91 | action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_HTML_CONTENT,
92 | selector: selector,
93 | });
94 |
95 | if (htmlResponse.success) {
96 | result.htmlContent = htmlResponse.htmlContent;
97 | } else {
98 | console.error('Failed to get HTML content:', htmlResponse.error);
99 | result.htmlContentError = htmlResponse.error;
100 | }
101 | }
102 |
103 | // Get text content if requested (and htmlContent is not true)
104 | if (textContent) {
105 | const textResponse = await this.sendMessageToTab(tab.id, {
106 | action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_TEXT_CONTENT,
107 | selector: selector,
108 | });
109 |
110 | if (textResponse.success) {
111 | result.textContent = textResponse.textContent;
112 |
113 | // Include article metadata if available
114 | if (textResponse.article) {
115 | result.article = {
116 | title: textResponse.article.title,
117 | byline: textResponse.article.byline,
118 | siteName: textResponse.article.siteName,
119 | excerpt: textResponse.article.excerpt,
120 | lang: textResponse.article.lang,
121 | };
122 | }
123 |
124 | // Include page metadata if available
125 | if (textResponse.metadata) {
126 | result.metadata = textResponse.metadata;
127 | }
128 | } else {
129 | console.error('Failed to get text content:', textResponse.error);
130 | result.textContentError = textResponse.error;
131 | }
132 | }
133 |
134 | // Interactive elements feature has been removed
135 |
136 | return {
137 | content: [
138 | {
139 | type: 'text',
140 | text: JSON.stringify(result),
141 | },
142 | ],
143 | isError: false,
144 | };
145 | } catch (error) {
146 | console.error('Error in web fetcher:', error);
147 | return createErrorResponse(
148 | `Error fetching web content: ${error instanceof Error ? error.message : String(error)}`,
149 | );
150 | }
151 | }
152 | }
153 |
154 | export const webFetcherTool = new WebFetcherTool();
155 |
156 | interface GetInteractiveElementsToolParams {
157 | textQuery?: string; // Text to search for within interactive elements (fuzzy search)
158 | selector?: string; // CSS selector to filter interactive elements
159 | includeCoordinates?: boolean; // Include element coordinates in the response (default: true)
160 | types?: string[]; // Types of interactive elements to include (default: all types)
161 | }
162 |
163 | class GetInteractiveElementsTool extends BaseBrowserToolExecutor {
164 | name = TOOL_NAMES.BROWSER.GET_INTERACTIVE_ELEMENTS;
165 |
166 | /**
167 | * Execute get interactive elements operation
168 | */
169 | async execute(args: GetInteractiveElementsToolParams): Promise<ToolResult> {
170 | const { textQuery, selector, includeCoordinates = true, types } = args;
171 |
172 | console.log(`Starting get interactive elements with options:`, args);
173 |
174 | try {
175 | // Get current tab
176 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
177 | if (!tabs[0]) {
178 | return createErrorResponse('No active tab found');
179 | }
180 |
181 | const tab = tabs[0];
182 | if (!tab.id) {
183 | return createErrorResponse('Active tab has no ID');
184 | }
185 |
186 | // Ensure content script is injected
187 | await this.injectContentScript(tab.id, ['inject-scripts/interactive-elements-helper.js']);
188 |
189 | // Send message to content script
190 | const result = await this.sendMessageToTab(tab.id, {
191 | action: TOOL_MESSAGE_TYPES.GET_INTERACTIVE_ELEMENTS,
192 | textQuery,
193 | selector,
194 | includeCoordinates,
195 | types,
196 | });
197 |
198 | if (!result.success) {
199 | return createErrorResponse(result.error || 'Failed to get interactive elements');
200 | }
201 |
202 | return {
203 | content: [
204 | {
205 | type: 'text',
206 | text: JSON.stringify({
207 | success: true,
208 | elements: result.elements,
209 | count: result.elements.length,
210 | query: {
211 | textQuery,
212 | selector,
213 | types: types || 'all',
214 | },
215 | }),
216 | },
217 | ],
218 | isError: false,
219 | };
220 | } catch (error) {
221 | console.error('Error in get interactive elements operation:', error);
222 | return createErrorResponse(
223 | `Error getting interactive elements: ${error instanceof Error ? error.message : String(error)}`,
224 | );
225 | }
226 | }
227 | }
228 |
229 | export const getInteractiveElementsTool = new GetInteractiveElementsTool();
230 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/native-host.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { NativeMessageType } from 'chrome-mcp-shared';
2 | import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
3 | import {
4 | NATIVE_HOST,
5 | ICONS,
6 | NOTIFICATIONS,
7 | STORAGE_KEYS,
8 | ERROR_MESSAGES,
9 | SUCCESS_MESSAGES,
10 | } from '@/common/constants';
11 | import { handleCallTool } from './tools';
12 |
13 | let nativePort: chrome.runtime.Port | null = null;
14 | export const HOST_NAME = NATIVE_HOST.NAME;
15 |
16 | /**
17 | * Server status management interface
18 | */
19 | interface ServerStatus {
20 | isRunning: boolean;
21 | port?: number;
22 | lastUpdated: number;
23 | }
24 |
25 | let currentServerStatus: ServerStatus = {
26 | isRunning: false,
27 | lastUpdated: Date.now(),
28 | };
29 |
30 | /**
31 | * Save server status to chrome.storage
32 | */
33 | async function saveServerStatus(status: ServerStatus): Promise<void> {
34 | try {
35 | await chrome.storage.local.set({ [STORAGE_KEYS.SERVER_STATUS]: status });
36 | } catch (error) {
37 | console.error(ERROR_MESSAGES.SERVER_STATUS_SAVE_FAILED, error);
38 | }
39 | }
40 |
41 | /**
42 | * Load server status from chrome.storage
43 | */
44 | async function loadServerStatus(): Promise<ServerStatus> {
45 | try {
46 | const result = await chrome.storage.local.get([STORAGE_KEYS.SERVER_STATUS]);
47 | if (result[STORAGE_KEYS.SERVER_STATUS]) {
48 | return result[STORAGE_KEYS.SERVER_STATUS];
49 | }
50 | } catch (error) {
51 | console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
52 | }
53 | return {
54 | isRunning: false,
55 | lastUpdated: Date.now(),
56 | };
57 | }
58 |
59 | /**
60 | * Broadcast server status change to all listeners
61 | */
62 | function broadcastServerStatusChange(status: ServerStatus): void {
63 | chrome.runtime
64 | .sendMessage({
65 | type: BACKGROUND_MESSAGE_TYPES.SERVER_STATUS_CHANGED,
66 | payload: status,
67 | })
68 | .catch(() => {
69 | // Ignore errors if no listeners are present
70 | });
71 | }
72 |
73 | /**
74 | * Connect to the native messaging host
75 | */
76 | export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT) {
77 | if (nativePort) {
78 | return;
79 | }
80 |
81 | try {
82 | nativePort = chrome.runtime.connectNative(HOST_NAME);
83 |
84 | nativePort.onMessage.addListener(async (message) => {
85 | // chrome.notifications.create({
86 | // type: NOTIFICATIONS.TYPE,
87 | // iconUrl: chrome.runtime.getURL(ICONS.NOTIFICATION),
88 | // title: 'Message from native host',
89 | // message: `Received data from host: ${JSON.stringify(message)}`,
90 | // priority: NOTIFICATIONS.PRIORITY,
91 | // });
92 |
93 | if (message.type === NativeMessageType.PROCESS_DATA && message.requestId) {
94 | const requestId = message.requestId;
95 | const requestPayload = message.payload;
96 |
97 | nativePort?.postMessage({
98 | responseToRequestId: requestId,
99 | payload: {
100 | status: 'success',
101 | message: SUCCESS_MESSAGES.TOOL_EXECUTED,
102 | data: requestPayload,
103 | },
104 | });
105 | } else if (message.type === NativeMessageType.CALL_TOOL && message.requestId) {
106 | const requestId = message.requestId;
107 | try {
108 | const result = await handleCallTool(message.payload);
109 | nativePort?.postMessage({
110 | responseToRequestId: requestId,
111 | payload: {
112 | status: 'success',
113 | message: SUCCESS_MESSAGES.TOOL_EXECUTED,
114 | data: result,
115 | },
116 | });
117 | } catch (error) {
118 | nativePort?.postMessage({
119 | responseToRequestId: requestId,
120 | payload: {
121 | status: 'error',
122 | message: ERROR_MESSAGES.TOOL_EXECUTION_FAILED,
123 | error: error instanceof Error ? error.message : String(error),
124 | },
125 | });
126 | }
127 | } else if (message.type === NativeMessageType.SERVER_STARTED) {
128 | const port = message.payload?.port;
129 | currentServerStatus = {
130 | isRunning: true,
131 | port: port,
132 | lastUpdated: Date.now(),
133 | };
134 | await saveServerStatus(currentServerStatus);
135 | broadcastServerStatusChange(currentServerStatus);
136 | console.log(`${SUCCESS_MESSAGES.SERVER_STARTED} on port ${port}`);
137 | } else if (message.type === NativeMessageType.SERVER_STOPPED) {
138 | currentServerStatus = {
139 | isRunning: false,
140 | port: currentServerStatus.port, // Keep last known port for reconnection
141 | lastUpdated: Date.now(),
142 | };
143 | await saveServerStatus(currentServerStatus);
144 | broadcastServerStatusChange(currentServerStatus);
145 | console.log(SUCCESS_MESSAGES.SERVER_STOPPED);
146 | } else if (message.type === NativeMessageType.ERROR_FROM_NATIVE_HOST) {
147 | console.error('Error from native host:', message.payload?.message || 'Unknown error');
148 | } else if (message.type === 'file_operation_response') {
149 | // Forward file operation response back to the requesting tool
150 | chrome.runtime.sendMessage(message).catch(() => {
151 | // Ignore if no listeners
152 | });
153 | }
154 | });
155 |
156 | nativePort.onDisconnect.addListener(() => {
157 | console.error(ERROR_MESSAGES.NATIVE_DISCONNECTED, chrome.runtime.lastError);
158 | nativePort = null;
159 | });
160 |
161 | nativePort.postMessage({ type: NativeMessageType.START, payload: { port } });
162 | } catch (error) {
163 | console.error(ERROR_MESSAGES.NATIVE_CONNECTION_FAILED, error);
164 | }
165 | }
166 |
167 | /**
168 | * Initialize native host listeners and load initial state
169 | */
170 | export const initNativeHostListener = () => {
171 | // Initialize server status from storage
172 | loadServerStatus()
173 | .then((status) => {
174 | currentServerStatus = status;
175 | })
176 | .catch((error) => {
177 | console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
178 | });
179 |
180 | chrome.runtime.onStartup.addListener(connectNativeHost);
181 |
182 | chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
183 | if (
184 | message === NativeMessageType.CONNECT_NATIVE ||
185 | message.type === NativeMessageType.CONNECT_NATIVE
186 | ) {
187 | const port =
188 | typeof message === 'object' && message.port ? message.port : NATIVE_HOST.DEFAULT_PORT;
189 | connectNativeHost(port);
190 | sendResponse({ success: true, port });
191 | return true;
192 | }
193 |
194 | if (message.type === NativeMessageType.PING_NATIVE) {
195 | const connected = nativePort !== null;
196 | sendResponse({ connected });
197 | return true;
198 | }
199 |
200 | if (message.type === NativeMessageType.DISCONNECT_NATIVE) {
201 | if (nativePort) {
202 | nativePort.disconnect();
203 | nativePort = null;
204 | sendResponse({ success: true });
205 | } else {
206 | sendResponse({ success: false, error: 'No active connection' });
207 | }
208 | return true;
209 | }
210 |
211 | if (message.type === BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS) {
212 | sendResponse({
213 | success: true,
214 | serverStatus: currentServerStatus,
215 | connected: nativePort !== null,
216 | });
217 | return true;
218 | }
219 |
220 | if (message.type === BACKGROUND_MESSAGE_TYPES.REFRESH_SERVER_STATUS) {
221 | loadServerStatus()
222 | .then((storedStatus) => {
223 | currentServerStatus = storedStatus;
224 | sendResponse({
225 | success: true,
226 | serverStatus: currentServerStatus,
227 | connected: nativePort !== null,
228 | });
229 | })
230 | .catch((error) => {
231 | console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
232 | sendResponse({
233 | success: false,
234 | error: ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED,
235 | serverStatus: currentServerStatus,
236 | connected: nativePort !== null,
237 | });
238 | });
239 | return true;
240 | }
241 |
242 | // Forward file operation messages to native host
243 | if (message.type === 'forward_to_native' && message.message) {
244 | if (nativePort) {
245 | nativePort.postMessage(message.message);
246 | sendResponse({ success: true });
247 | } else {
248 | sendResponse({ success: false, error: 'Native host not connected' });
249 | }
250 | return true;
251 | }
252 | });
253 | };
254 |
```
--------------------------------------------------------------------------------
/app/native-server/src/scripts/browser-config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as fs from 'fs';
2 | import * as os from 'os';
3 | import * as path from 'path';
4 | import { execSync } from 'child_process';
5 | import { HOST_NAME } from './constant';
6 |
7 | export enum BrowserType {
8 | CHROME = 'chrome',
9 | CHROMIUM = 'chromium',
10 | }
11 |
12 | export interface BrowserConfig {
13 | type: BrowserType;
14 | displayName: string;
15 | userManifestPath: string;
16 | systemManifestPath: string;
17 | registryKey?: string; // Windows only
18 | systemRegistryKey?: string; // Windows only
19 | }
20 |
21 | /**
22 | * Get the user-level manifest path for a specific browser
23 | */
24 | function getUserManifestPathForBrowser(browser: BrowserType): string {
25 | const platform = os.platform();
26 |
27 | if (platform === 'win32') {
28 | const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
29 | switch (browser) {
30 | case BrowserType.CHROME:
31 | return path.join(appData, 'Google', 'Chrome', 'NativeMessagingHosts', `${HOST_NAME}.json`);
32 | case BrowserType.CHROMIUM:
33 | return path.join(appData, 'Chromium', 'NativeMessagingHosts', `${HOST_NAME}.json`);
34 | default:
35 | return path.join(appData, 'Google', 'Chrome', 'NativeMessagingHosts', `${HOST_NAME}.json`);
36 | }
37 | } else if (platform === 'darwin') {
38 | const home = os.homedir();
39 | switch (browser) {
40 | case BrowserType.CHROME:
41 | return path.join(
42 | home,
43 | 'Library',
44 | 'Application Support',
45 | 'Google',
46 | 'Chrome',
47 | 'NativeMessagingHosts',
48 | `${HOST_NAME}.json`,
49 | );
50 | case BrowserType.CHROMIUM:
51 | return path.join(
52 | home,
53 | 'Library',
54 | 'Application Support',
55 | 'Chromium',
56 | 'NativeMessagingHosts',
57 | `${HOST_NAME}.json`,
58 | );
59 | default:
60 | return path.join(
61 | home,
62 | 'Library',
63 | 'Application Support',
64 | 'Google',
65 | 'Chrome',
66 | 'NativeMessagingHosts',
67 | `${HOST_NAME}.json`,
68 | );
69 | }
70 | } else {
71 | // Linux
72 | const home = os.homedir();
73 | switch (browser) {
74 | case BrowserType.CHROME:
75 | return path.join(
76 | home,
77 | '.config',
78 | 'google-chrome',
79 | 'NativeMessagingHosts',
80 | `${HOST_NAME}.json`,
81 | );
82 | case BrowserType.CHROMIUM:
83 | return path.join(home, '.config', 'chromium', 'NativeMessagingHosts', `${HOST_NAME}.json`);
84 | default:
85 | return path.join(
86 | home,
87 | '.config',
88 | 'google-chrome',
89 | 'NativeMessagingHosts',
90 | `${HOST_NAME}.json`,
91 | );
92 | }
93 | }
94 | }
95 |
96 | /**
97 | * Get the system-level manifest path for a specific browser
98 | */
99 | function getSystemManifestPathForBrowser(browser: BrowserType): string {
100 | const platform = os.platform();
101 |
102 | if (platform === 'win32') {
103 | const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
104 | switch (browser) {
105 | case BrowserType.CHROME:
106 | return path.join(
107 | programFiles,
108 | 'Google',
109 | 'Chrome',
110 | 'NativeMessagingHosts',
111 | `${HOST_NAME}.json`,
112 | );
113 | case BrowserType.CHROMIUM:
114 | return path.join(programFiles, 'Chromium', 'NativeMessagingHosts', `${HOST_NAME}.json`);
115 | default:
116 | return path.join(
117 | programFiles,
118 | 'Google',
119 | 'Chrome',
120 | 'NativeMessagingHosts',
121 | `${HOST_NAME}.json`,
122 | );
123 | }
124 | } else if (platform === 'darwin') {
125 | switch (browser) {
126 | case BrowserType.CHROME:
127 | return path.join(
128 | '/Library',
129 | 'Google',
130 | 'Chrome',
131 | 'NativeMessagingHosts',
132 | `${HOST_NAME}.json`,
133 | );
134 | case BrowserType.CHROMIUM:
135 | return path.join(
136 | '/Library',
137 | 'Application Support',
138 | 'Chromium',
139 | 'NativeMessagingHosts',
140 | `${HOST_NAME}.json`,
141 | );
142 | default:
143 | return path.join(
144 | '/Library',
145 | 'Google',
146 | 'Chrome',
147 | 'NativeMessagingHosts',
148 | `${HOST_NAME}.json`,
149 | );
150 | }
151 | } else {
152 | // Linux
153 | switch (browser) {
154 | case BrowserType.CHROME:
155 | return path.join('/etc', 'opt', 'chrome', 'native-messaging-hosts', `${HOST_NAME}.json`);
156 | case BrowserType.CHROMIUM:
157 | return path.join('/etc', 'chromium', 'native-messaging-hosts', `${HOST_NAME}.json`);
158 | default:
159 | return path.join('/etc', 'opt', 'chrome', 'native-messaging-hosts', `${HOST_NAME}.json`);
160 | }
161 | }
162 | }
163 |
164 | /**
165 | * Get Windows registry keys for a browser
166 | */
167 | function getRegistryKeys(browser: BrowserType): { user: string; system: string } | undefined {
168 | if (os.platform() !== 'win32') return undefined;
169 |
170 | const browserPaths: Record<BrowserType, { user: string; system: string }> = {
171 | [BrowserType.CHROME]: {
172 | user: `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`,
173 | system: `HKLM\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`,
174 | },
175 | [BrowserType.CHROMIUM]: {
176 | user: `HKCU\\Software\\Chromium\\NativeMessagingHosts\\${HOST_NAME}`,
177 | system: `HKLM\\Software\\Chromium\\NativeMessagingHosts\\${HOST_NAME}`,
178 | },
179 | };
180 |
181 | return browserPaths[browser];
182 | }
183 |
184 | /**
185 | * Get browser configuration
186 | */
187 | export function getBrowserConfig(browser: BrowserType): BrowserConfig {
188 | const registryKeys = getRegistryKeys(browser);
189 |
190 | return {
191 | type: browser,
192 | displayName: browser.charAt(0).toUpperCase() + browser.slice(1),
193 | userManifestPath: getUserManifestPathForBrowser(browser),
194 | systemManifestPath: getSystemManifestPathForBrowser(browser),
195 | registryKey: registryKeys?.user,
196 | systemRegistryKey: registryKeys?.system,
197 | };
198 | }
199 |
200 | /**
201 | * Detect installed browsers on the system
202 | */
203 | export function detectInstalledBrowsers(): BrowserType[] {
204 | const detectedBrowsers: BrowserType[] = [];
205 | const platform = os.platform();
206 |
207 | if (platform === 'win32') {
208 | // Check Windows registry for installed browsers
209 | const browsers: Array<{ type: BrowserType; registryPath: string }> = [
210 | { type: BrowserType.CHROME, registryPath: 'HKLM\\SOFTWARE\\Google\\Chrome' },
211 | { type: BrowserType.CHROMIUM, registryPath: 'HKLM\\SOFTWARE\\Chromium' },
212 | ];
213 |
214 | for (const browser of browsers) {
215 | try {
216 | execSync(`reg query "${browser.registryPath}" 2>nul`, { stdio: 'pipe' });
217 | detectedBrowsers.push(browser.type);
218 | } catch {
219 | // Browser not installed
220 | }
221 | }
222 | } else if (platform === 'darwin') {
223 | // Check macOS Applications folder
224 | const browsers: Array<{ type: BrowserType; appPath: string }> = [
225 | { type: BrowserType.CHROME, appPath: '/Applications/Google Chrome.app' },
226 | { type: BrowserType.CHROMIUM, appPath: '/Applications/Chromium.app' },
227 | ];
228 |
229 | for (const browser of browsers) {
230 | if (fs.existsSync(browser.appPath)) {
231 | detectedBrowsers.push(browser.type);
232 | }
233 | }
234 | } else {
235 | // Check Linux paths using which command
236 | const browsers: Array<{ type: BrowserType; commands: string[] }> = [
237 | { type: BrowserType.CHROME, commands: ['google-chrome', 'google-chrome-stable'] },
238 | { type: BrowserType.CHROMIUM, commands: ['chromium', 'chromium-browser'] },
239 | ];
240 |
241 | for (const browser of browsers) {
242 | for (const cmd of browser.commands) {
243 | try {
244 | execSync(`which ${cmd} 2>/dev/null`, { stdio: 'pipe' });
245 | detectedBrowsers.push(browser.type);
246 | break; // Found one command, no need to check others
247 | } catch {
248 | // Command not found
249 | }
250 | }
251 | }
252 | }
253 |
254 | return detectedBrowsers;
255 | }
256 |
257 | /**
258 | * Get all supported browser configs
259 | */
260 | export function getAllBrowserConfigs(): BrowserConfig[] {
261 | return Object.values(BrowserType).map((browser) => getBrowserConfig(browser));
262 | }
263 |
264 | /**
265 | * Parse browser type from string
266 | */
267 | export function parseBrowserType(browserStr: string): BrowserType | undefined {
268 | const normalized = browserStr.toLowerCase();
269 | return Object.values(BrowserType).find((type) => type === normalized);
270 | }
271 |
```
--------------------------------------------------------------------------------
/packages/wasm-simd/src/lib.rs:
--------------------------------------------------------------------------------
```rust
1 | use wasm_bindgen::prelude::*;
2 | use wide::f32x4;
3 |
4 | // 设置 panic hook 以便在浏览器中调试
5 | #[wasm_bindgen(start)]
6 | pub fn main() {
7 | console_error_panic_hook::set_once();
8 | }
9 |
10 | #[wasm_bindgen]
11 | pub struct SIMDMath;
12 |
13 | #[wasm_bindgen]
14 | impl SIMDMath {
15 | #[wasm_bindgen(constructor)]
16 | pub fn new() -> SIMDMath {
17 | SIMDMath
18 | }
19 |
20 | // 辅助函数:仅计算点积 (SIMD)
21 | #[inline]
22 | fn dot_product_simd_only(&self, vec_a: &[f32], vec_b: &[f32]) -> f32 {
23 | let len = vec_a.len();
24 | let simd_lanes = 4;
25 | let simd_len = len - (len % simd_lanes);
26 | let mut dot_sum_simd = f32x4::ZERO;
27 |
28 | for i in (0..simd_len).step_by(simd_lanes) {
29 | // 使用 try_from 和 new 方法,这是 wide 库的正确 API
30 | let a_array: [f32; 4] = vec_a[i..i + simd_lanes].try_into().unwrap();
31 | let b_array: [f32; 4] = vec_b[i..i + simd_lanes].try_into().unwrap();
32 | let a_chunk = f32x4::new(a_array);
33 | let b_chunk = f32x4::new(b_array);
34 | dot_sum_simd = a_chunk.mul_add(b_chunk, dot_sum_simd);
35 | }
36 |
37 | let mut dot_product = dot_sum_simd.reduce_add();
38 | for i in simd_len..len {
39 | dot_product += vec_a[i] * vec_b[i];
40 | }
41 | dot_product
42 | }
43 |
44 | #[wasm_bindgen]
45 | pub fn cosine_similarity(&self, vec_a: &[f32], vec_b: &[f32]) -> f32 {
46 | if vec_a.len() != vec_b.len() || vec_a.is_empty() {
47 | return 0.0;
48 | }
49 |
50 | let len = vec_a.len();
51 | let simd_lanes = 4;
52 | let simd_len = len - (len % simd_lanes);
53 |
54 | let mut dot_sum_simd = f32x4::ZERO;
55 | let mut norm_a_sum_simd = f32x4::ZERO;
56 | let mut norm_b_sum_simd = f32x4::ZERO;
57 |
58 | // SIMD 处理
59 | for i in (0..simd_len).step_by(simd_lanes) {
60 | let a_array: [f32; 4] = vec_a[i..i + simd_lanes].try_into().unwrap();
61 | let b_array: [f32; 4] = vec_b[i..i + simd_lanes].try_into().unwrap();
62 | let a_chunk = f32x4::new(a_array);
63 | let b_chunk = f32x4::new(b_array);
64 |
65 | // 使用 Fused Multiply-Add (FMA)
66 | dot_sum_simd = a_chunk.mul_add(b_chunk, dot_sum_simd);
67 | norm_a_sum_simd = a_chunk.mul_add(a_chunk, norm_a_sum_simd);
68 | norm_b_sum_simd = b_chunk.mul_add(b_chunk, norm_b_sum_simd);
69 | }
70 |
71 | // 水平求和
72 | let mut dot_product = dot_sum_simd.reduce_add();
73 | let mut norm_a_sq = norm_a_sum_simd.reduce_add();
74 | let mut norm_b_sq = norm_b_sum_simd.reduce_add();
75 |
76 | // 处理剩余元素
77 | for i in simd_len..len {
78 | dot_product += vec_a[i] * vec_b[i];
79 | norm_a_sq += vec_a[i] * vec_a[i];
80 | norm_b_sq += vec_b[i] * vec_b[i];
81 | }
82 |
83 | // 优化的数值稳定性处理
84 | let norm_a = norm_a_sq.sqrt();
85 | let norm_b = norm_b_sq.sqrt();
86 |
87 | if norm_a == 0.0 || norm_b == 0.0 {
88 | return 0.0;
89 | }
90 |
91 | let magnitude = norm_a * norm_b;
92 | // 限制结果在 [-1.0, 1.0] 范围内,处理浮点精度误差
93 | (dot_product / magnitude).max(-1.0).min(1.0)
94 | }
95 |
96 | #[wasm_bindgen]
97 | pub fn batch_similarity(&self, vectors: &[f32], query: &[f32], vector_dim: usize) -> Vec<f32> {
98 | if vector_dim == 0 { return Vec::new(); }
99 | if vectors.len() % vector_dim != 0 { return Vec::new(); }
100 | if query.len() != vector_dim { return Vec::new(); }
101 |
102 | let num_vectors = vectors.len() / vector_dim;
103 | let mut results = Vec::with_capacity(num_vectors);
104 |
105 | // 预计算查询向量的范数
106 | let query_norm_sq = self.compute_norm_squared_simd(query);
107 | if query_norm_sq == 0.0 {
108 | return vec![0.0; num_vectors];
109 | }
110 | let query_norm = query_norm_sq.sqrt();
111 |
112 | for i in 0..num_vectors {
113 | let start = i * vector_dim;
114 | let vector_slice = &vectors[start..start + vector_dim];
115 |
116 | // dot_product_and_norm_simd 计算 vector_slice (vec_a) 的范数
117 | let (dot_product, vector_norm_sq) = self.dot_product_and_norm_simd(vector_slice, query);
118 |
119 | if vector_norm_sq == 0.0 {
120 | results.push(0.0);
121 | } else {
122 | let vector_norm = vector_norm_sq.sqrt();
123 | let similarity = dot_product / (vector_norm * query_norm);
124 | results.push(similarity.max(-1.0).min(1.0));
125 | }
126 | }
127 | results
128 | }
129 |
130 | // 辅助函数:SIMD 计算范数平方
131 | #[inline]
132 | fn compute_norm_squared_simd(&self, vec: &[f32]) -> f32 {
133 | let len = vec.len();
134 | let simd_lanes = 4;
135 | let simd_len = len - (len % simd_lanes);
136 | let mut norm_sum_simd = f32x4::ZERO;
137 |
138 | for i in (0..simd_len).step_by(simd_lanes) {
139 | let array: [f32; 4] = vec[i..i + simd_lanes].try_into().unwrap();
140 | let chunk = f32x4::new(array);
141 | norm_sum_simd = chunk.mul_add(chunk, norm_sum_simd);
142 | }
143 |
144 | let mut norm_sq = norm_sum_simd.reduce_add();
145 | for i in simd_len..len {
146 | norm_sq += vec[i] * vec[i];
147 | }
148 | norm_sq
149 | }
150 |
151 | // 辅助函数:同时计算点积和vec_a的范数平方
152 | #[inline]
153 | fn dot_product_and_norm_simd(&self, vec_a: &[f32], vec_b: &[f32]) -> (f32, f32) {
154 | let len = vec_a.len(); // 假设 vec_a.len() == vec_b.len()
155 | let simd_lanes = 4;
156 | let simd_len = len - (len % simd_lanes);
157 |
158 | let mut dot_sum_simd = f32x4::ZERO;
159 | let mut norm_a_sum_simd = f32x4::ZERO;
160 |
161 | for i in (0..simd_len).step_by(simd_lanes) {
162 | let a_array: [f32; 4] = vec_a[i..i + simd_lanes].try_into().unwrap();
163 | let b_array: [f32; 4] = vec_b[i..i + simd_lanes].try_into().unwrap();
164 | let a_chunk = f32x4::new(a_array);
165 | let b_chunk = f32x4::new(b_array);
166 |
167 | dot_sum_simd = a_chunk.mul_add(b_chunk, dot_sum_simd);
168 | norm_a_sum_simd = a_chunk.mul_add(a_chunk, norm_a_sum_simd);
169 | }
170 |
171 | let mut dot_product = dot_sum_simd.reduce_add();
172 | let mut norm_a_sq = norm_a_sum_simd.reduce_add();
173 |
174 | for i in simd_len..len {
175 | dot_product += vec_a[i] * vec_b[i];
176 | norm_a_sq += vec_a[i] * vec_a[i];
177 | }
178 | (dot_product, norm_a_sq)
179 | }
180 |
181 | // 批量矩阵相似度计算 - 优化版
182 | #[wasm_bindgen]
183 | pub fn similarity_matrix(&self, vectors_a: &[f32], vectors_b: &[f32], vector_dim: usize) -> Vec<f32> {
184 | if vector_dim == 0 || vectors_a.len() % vector_dim != 0 || vectors_b.len() % vector_dim != 0 {
185 | return Vec::new();
186 | }
187 |
188 | let num_a = vectors_a.len() / vector_dim;
189 | let num_b = vectors_b.len() / vector_dim;
190 | let mut results = Vec::with_capacity(num_a * num_b);
191 |
192 | // 1. 预计算 vectors_a 的范数
193 | let norms_a: Vec<f32> = (0..num_a)
194 | .map(|i| {
195 | let start = i * vector_dim;
196 | let vec_a_slice = &vectors_a[start..start + vector_dim];
197 | self.compute_norm_squared_simd(vec_a_slice).sqrt()
198 | })
199 | .collect();
200 |
201 | // 2. 预计算 vectors_b 的范数
202 | let norms_b: Vec<f32> = (0..num_b)
203 | .map(|j| {
204 | let start = j * vector_dim;
205 | let vec_b_slice = &vectors_b[start..start + vector_dim];
206 | self.compute_norm_squared_simd(vec_b_slice).sqrt()
207 | })
208 | .collect();
209 |
210 | for i in 0..num_a {
211 | let start_a = i * vector_dim;
212 | let vec_a = &vectors_a[start_a..start_a + vector_dim];
213 | let norm_a = norms_a[i];
214 |
215 | if norm_a == 0.0 {
216 | // 如果 norm_a 为 0,所有相似度都为 0
217 | for _ in 0..num_b {
218 | results.push(0.0);
219 | }
220 | continue;
221 | }
222 |
223 | for j in 0..num_b {
224 | let start_b = j * vector_dim;
225 | let vec_b = &vectors_b[start_b..start_b + vector_dim];
226 | let norm_b = norms_b[j];
227 |
228 | if norm_b == 0.0 {
229 | results.push(0.0);
230 | continue;
231 | }
232 |
233 | // 使用专用的点积函数
234 | let dot_product = self.dot_product_simd_only(vec_a, vec_b);
235 | let magnitude = norm_a * norm_b;
236 |
237 | // magnitude 不应该为零,因为已经检查了 norm_a/norm_b
238 | let similarity = (dot_product / magnitude).max(-1.0).min(1.0);
239 | results.push(similarity);
240 | }
241 | }
242 |
243 | results
244 | }
245 | }
246 |
```
--------------------------------------------------------------------------------
/test-inject-script.js:
--------------------------------------------------------------------------------
```javascript
1 | (() => {
2 | const SCRIPT_ID = 'excalidraw-control-script';
3 | if (window[SCRIPT_ID]) {
4 | return;
5 | }
6 | function getExcalidrawAPIFromDOM(domElement) {
7 | if (!domElement) {
8 | return null;
9 | }
10 | const reactFiberKey = Object.keys(domElement).find(
11 | (key) => key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$'),
12 | );
13 | if (!reactFiberKey) {
14 | return null;
15 | }
16 | let fiberNode = domElement[reactFiberKey];
17 | if (!fiberNode) {
18 | return null;
19 | }
20 | function isExcalidrawAPI(obj) {
21 | return (
22 | typeof obj === 'object' &&
23 | obj !== null &&
24 | typeof obj.updateScene === 'function' &&
25 | typeof obj.getSceneElements === 'function' &&
26 | typeof obj.getAppState === 'function'
27 | );
28 | }
29 | function findApiInObject(objToSearch) {
30 | if (isExcalidrawAPI(objToSearch)) {
31 | return objToSearch;
32 | }
33 | if (typeof objToSearch === 'object' && objToSearch !== null) {
34 | for (const key in objToSearch) {
35 | if (Object.prototype.hasOwnProperty.call(objToSearch, key)) {
36 | const found = findApiInObject(objToSearch[key]);
37 | if (found) {
38 | return found;
39 | }
40 | }
41 | }
42 | }
43 | return null;
44 | }
45 | let excalidrawApiInstance = null;
46 | let attempts = 0;
47 | const MAX_TRAVERSAL_ATTEMPTS = 25;
48 | while (fiberNode && attempts < MAX_TRAVERSAL_ATTEMPTS) {
49 | if (fiberNode.stateNode && fiberNode.stateNode.props) {
50 | const api = findApiInObject(fiberNode.stateNode.props);
51 | if (api) {
52 | excalidrawApiInstance = api;
53 | break;
54 | }
55 | if (isExcalidrawAPI(fiberNode.stateNode.props.excalidrawAPI)) {
56 | excalidrawApiInstance = fiberNode.stateNode.props.excalidrawAPI;
57 | break;
58 | }
59 | }
60 | if (fiberNode.memoizedProps) {
61 | const api = findApiInObject(fiberNode.memoizedProps);
62 | if (api) {
63 | excalidrawApiInstance = api;
64 | break;
65 | }
66 | if (isExcalidrawAPI(fiberNode.memoizedProps.excalidrawAPI)) {
67 | excalidrawApiInstance = fiberNode.memoizedProps.excalidrawAPI;
68 | break;
69 | }
70 | }
71 |
72 | if (fiberNode.tag === 1 && fiberNode.stateNode && fiberNode.stateNode.state) {
73 | const api = findApiInObject(fiberNode.stateNode.state);
74 | if (api) {
75 | excalidrawApiInstance = api;
76 | break;
77 | }
78 | }
79 |
80 | if (
81 | fiberNode.tag === 0 ||
82 | fiberNode.tag === 2 ||
83 | fiberNode.tag === 14 ||
84 | fiberNode.tag === 15 ||
85 | fiberNode.tag === 11
86 | ) {
87 | if (fiberNode.memoizedState) {
88 | let currentHook = fiberNode.memoizedState;
89 | let hookAttempts = 0;
90 | const MAX_HOOK_ATTEMPTS = 15;
91 | while (currentHook && hookAttempts < MAX_HOOK_ATTEMPTS) {
92 | const api = findApiInObject(currentHook.memoizedState);
93 | if (api) {
94 | excalidrawApiInstance = api;
95 | break;
96 | }
97 | currentHook = currentHook.next;
98 | hookAttempts++;
99 | }
100 | if (excalidrawApiInstance) break;
101 | }
102 | }
103 | if (fiberNode.stateNode) {
104 | const api = findApiInObject(fiberNode.stateNode);
105 | if (api && api !== fiberNode.stateNode.props && api !== fiberNode.stateNode.state) {
106 | excalidrawApiInstance = api;
107 | break;
108 | }
109 | }
110 | if (
111 | fiberNode.tag === 9 &&
112 | fiberNode.memoizedProps &&
113 | typeof fiberNode.memoizedProps.value !== 'undefined'
114 | ) {
115 | const api = findApiInObject(fiberNode.memoizedProps.value);
116 | if (api) {
117 | excalidrawApiInstance = api;
118 | break;
119 | }
120 | }
121 |
122 | if (fiberNode.return) {
123 | fiberNode = fiberNode.return;
124 | } else {
125 | break;
126 | }
127 | attempts++;
128 | }
129 |
130 | if (excalidrawApiInstance) {
131 | window.excalidrawAPI = excalidrawApiInstance;
132 | console.log('现在您可以通过 `window.foundExcalidrawAPI` 在控制台访问它。');
133 | } else {
134 | console.error('在检查组件树后未能找到 excalidrawAPI。');
135 | }
136 | return excalidrawApiInstance;
137 | }
138 |
139 | function createFullExcalidrawElement(skeleton) {
140 | const id = Math.random().toString(36).substring(2, 9);
141 |
142 | const seed = Math.floor(Math.random() * 2 ** 31);
143 | const versionNonce = Math.floor(Math.random() * 2 ** 31);
144 |
145 | const defaults = {
146 | isDeleted: false,
147 | fillStyle: 'hachure',
148 | strokeWidth: 1,
149 | strokeStyle: 'solid',
150 | roughness: 1,
151 | opacity: 100,
152 | angle: 0,
153 | groupIds: [],
154 | strokeColor: '#000000',
155 | backgroundColor: 'transparent',
156 | version: 1,
157 | locked: false,
158 | };
159 |
160 | const fullElement = {
161 | id: id,
162 | seed: seed,
163 | versionNonce: versionNonce,
164 | updated: Date.now(),
165 | ...defaults,
166 | ...skeleton,
167 | };
168 |
169 | return fullElement;
170 | }
171 |
172 | let targetElementForAPI = document.querySelector('.excalidraw-app');
173 |
174 | if (targetElementForAPI) {
175 | getExcalidrawAPIFromDOM(targetElementForAPI);
176 | }
177 |
178 | const eventHandler = {
179 | getSceneElements: () => {
180 | try {
181 | return window.excalidrawAPI.getSceneElements();
182 | } catch (error) {
183 | return {
184 | error: true,
185 | msg: JSON.stringify(error),
186 | };
187 | }
188 | },
189 | addElement: (param) => {
190 | try {
191 | const existingElements = window.excalidrawAPI.getSceneElements();
192 | const newElements = [...existingElements];
193 | param.eles.forEach((ele, idx) => {
194 | const newEle = createFullExcalidrawElement(ele);
195 | newEle.index = `a${existingElements.length + idx + 1}`;
196 | newElements.push(newEle);
197 | });
198 | console.log('newElements ==>', newElements);
199 | const appState = window.excalidrawAPI.getAppState();
200 | window.excalidrawAPI.updateScene({
201 | elements: newElements,
202 | appState: appState,
203 | commitToHistory: true,
204 | });
205 | return {
206 | success: true,
207 | };
208 | } catch (error) {
209 | return {
210 | error: true,
211 | msg: JSON.stringify(error),
212 | };
213 | }
214 | },
215 | deleteElement: (param) => {
216 | try {
217 | const existingElements = window.excalidrawAPI.getSceneElements();
218 | const newElements = [...existingElements];
219 | const idx = newElements.findIndex((e) => e.id === param.id);
220 | if (idx >= 0) {
221 | newElements.splice(idx, 1);
222 | const appState = window.excalidrawAPI.getAppState();
223 | window.excalidrawAPI.updateScene({
224 | elements: newElements,
225 | appState: appState,
226 | commitToHistory: true,
227 | });
228 | return {
229 | success: true,
230 | };
231 | } else {
232 | return {
233 | error: true,
234 | msg: 'element not found',
235 | };
236 | }
237 | } catch (error) {
238 | return {
239 | error: true,
240 | msg: JSON.stringify(error),
241 | };
242 | }
243 | },
244 | updateElement: (param) => {
245 | try {
246 | const existingElements = window.excalidrawAPI.getSceneElements();
247 | const resIds = [];
248 | for (let i = 0; i < param.length; i++) {
249 | const idx = existingElements.findIndex((e) => e.id === param[i].id);
250 | if (idx >= 0) {
251 | resIds.push[idx];
252 | window.excalidrawAPI.mutateElement(existingElements[idx], { ...param[i] });
253 | }
254 | }
255 | return {
256 | success: true,
257 | msg: `已更新元素:${resIds.join(',')}`,
258 | };
259 | } catch (error) {
260 | return {
261 | error: true,
262 | msg: JSON.stringify(error),
263 | };
264 | }
265 | },
266 | cleanup: () => {
267 | try {
268 | window.excalidrawAPI.resetScene();
269 | return {
270 | success: true,
271 | };
272 | } catch (error) {
273 | return {
274 | error: true,
275 | msg: JSON.stringify(error),
276 | };
277 | }
278 | },
279 | };
280 |
281 | const handleExecution = (event) => {
282 | const { action, payload, requestId } = event.detail;
283 | const param = JSON.parse(payload || '{}');
284 | let data, error;
285 | try {
286 | const handler = eventHandler[action];
287 | if (!handler) {
288 | error = 'event name not found';
289 | }
290 | data = handler(param);
291 | } catch (e) {
292 | error = e.message;
293 | }
294 | window.dispatchEvent(
295 | new CustomEvent('chrome-mcp:response', { detail: { requestId, data, error } }),
296 | );
297 | };
298 |
299 | // --- Lifecycle Functions ---
300 | const initialize = () => {
301 | window.addEventListener('chrome-mcp:execute', handleExecution);
302 | window.addEventListener('chrome-mcp:cleanup', cleanup);
303 | window[SCRIPT_ID] = true;
304 | };
305 |
306 | const cleanup = () => {
307 | window.removeEventListener('chrome-mcp:execute', handleExecution);
308 | window.removeEventListener('chrome-mcp:cleanup', cleanup);
309 | delete window[SCRIPT_ID];
310 | delete window.excalidrawAPI;
311 | };
312 |
313 | initialize();
314 | })();
315 |
```
--------------------------------------------------------------------------------
/app/native-server/src/scripts/postinstall.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import fs from 'fs';
4 | import os from 'os';
5 | import path from 'path';
6 | import { COMMAND_NAME } from './constant';
7 | import { colorText, tryRegisterUserLevelHost } from './utils';
8 |
9 | // Check if this script is run directly
10 | const isDirectRun = require.main === module;
11 |
12 | // Detect global installation for both npm and pnpm
13 | function detectGlobalInstall(): boolean {
14 | // npm uses npm_config_global
15 | if (process.env.npm_config_global === 'true') {
16 | return true;
17 | }
18 |
19 | // pnpm detection methods
20 | // Method 1: Check if PNPM_HOME is set and current path contains it
21 | if (process.env.PNPM_HOME && __dirname.includes(process.env.PNPM_HOME)) {
22 | return true;
23 | }
24 |
25 | // Method 2: Check if we're in a global pnpm directory structure
26 | // pnpm global packages are typically installed in ~/.local/share/pnpm/global/5/node_modules
27 | // Windows: %APPDATA%\pnpm\global\5\node_modules
28 | const globalPnpmPatterns =
29 | process.platform === 'win32'
30 | ? ['\\pnpm\\global\\', '\\pnpm-global\\', '\\AppData\\Roaming\\pnpm\\']
31 | : ['/pnpm/global/', '/.local/share/pnpm/', '/pnpm-global/'];
32 |
33 | if (globalPnpmPatterns.some((pattern) => __dirname.includes(pattern))) {
34 | return true;
35 | }
36 |
37 | // Method 3: Check npm_config_prefix for pnpm
38 | if (process.env.npm_config_prefix && __dirname.includes(process.env.npm_config_prefix)) {
39 | return true;
40 | }
41 |
42 | // Method 4: Windows-specific global installation paths
43 | if (process.platform === 'win32') {
44 | const windowsGlobalPatterns = [
45 | '\\npm\\node_modules\\',
46 | '\\AppData\\Roaming\\npm\\node_modules\\',
47 | '\\Program Files\\nodejs\\node_modules\\',
48 | '\\nodejs\\node_modules\\',
49 | ];
50 |
51 | if (windowsGlobalPatterns.some((pattern) => __dirname.includes(pattern))) {
52 | return true;
53 | }
54 | }
55 |
56 | return false;
57 | }
58 |
59 | const isGlobalInstall = detectGlobalInstall();
60 |
61 | /**
62 | * Write Node.js path for run_host scripts to avoid fragile relative paths
63 | */
64 | async function writeNodePath(): Promise<void> {
65 | try {
66 | const nodePath = process.execPath;
67 | const nodePathFile = path.join(__dirname, '..', 'node_path.txt');
68 |
69 | console.log(colorText(`Writing Node.js path: ${nodePath}`, 'blue'));
70 | fs.writeFileSync(nodePathFile, nodePath, 'utf8');
71 | console.log(colorText('✓ Node.js path written for run_host scripts', 'green'));
72 | } catch (error: any) {
73 | console.warn(colorText(`⚠️ Failed to write Node.js path: ${error.message}`, 'yellow'));
74 | }
75 | }
76 |
77 | /**
78 | * 确保执行权限(无论是否为全局安装)
79 | */
80 | async function ensureExecutionPermissions(): Promise<void> {
81 | if (process.platform === 'win32') {
82 | // Windows 平台处理
83 | await ensureWindowsFilePermissions();
84 | return;
85 | }
86 |
87 | // Unix/Linux 平台处理
88 | const filesToCheck = [
89 | path.join(__dirname, '..', 'index.js'),
90 | path.join(__dirname, '..', 'run_host.sh'),
91 | path.join(__dirname, '..', 'cli.js'),
92 | ];
93 |
94 | for (const filePath of filesToCheck) {
95 | if (fs.existsSync(filePath)) {
96 | try {
97 | fs.chmodSync(filePath, '755');
98 | console.log(
99 | colorText(`✓ Set execution permissions for ${path.basename(filePath)}`, 'green'),
100 | );
101 | } catch (err: any) {
102 | console.warn(
103 | colorText(
104 | `⚠️ Unable to set execution permissions for ${path.basename(filePath)}: ${err.message}`,
105 | 'yellow',
106 | ),
107 | );
108 | }
109 | } else {
110 | console.warn(colorText(`⚠️ File not found: ${filePath}`, 'yellow'));
111 | }
112 | }
113 | }
114 |
115 | /**
116 | * Windows 平台文件权限处理
117 | */
118 | async function ensureWindowsFilePermissions(): Promise<void> {
119 | const filesToCheck = [
120 | path.join(__dirname, '..', 'index.js'),
121 | path.join(__dirname, '..', 'run_host.bat'),
122 | path.join(__dirname, '..', 'cli.js'),
123 | ];
124 |
125 | for (const filePath of filesToCheck) {
126 | if (fs.existsSync(filePath)) {
127 | try {
128 | // 检查文件是否为只读,如果是则移除只读属性
129 | const stats = fs.statSync(filePath);
130 | if (!(stats.mode & parseInt('200', 8))) {
131 | // 检查写权限
132 | // 尝试移除只读属性
133 | fs.chmodSync(filePath, stats.mode | parseInt('200', 8));
134 | console.log(
135 | colorText(`✓ Removed read-only attribute from ${path.basename(filePath)}`, 'green'),
136 | );
137 | }
138 |
139 | // 验证文件可读性
140 | fs.accessSync(filePath, fs.constants.R_OK);
141 | console.log(
142 | colorText(`✓ Verified file accessibility for ${path.basename(filePath)}`, 'green'),
143 | );
144 | } catch (err: any) {
145 | console.warn(
146 | colorText(
147 | `⚠️ Unable to verify file permissions for ${path.basename(filePath)}: ${err.message}`,
148 | 'yellow',
149 | ),
150 | );
151 | }
152 | } else {
153 | console.warn(colorText(`⚠️ File not found: ${filePath}`, 'yellow'));
154 | }
155 | }
156 | }
157 |
158 | async function tryRegisterNativeHost(): Promise<void> {
159 | try {
160 | console.log(colorText('Attempting to register Chrome Native Messaging host...', 'blue'));
161 |
162 | // Always ensure execution permissions, regardless of installation type
163 | await ensureExecutionPermissions();
164 |
165 | if (isGlobalInstall) {
166 | // First try user-level installation (no elevated permissions required)
167 | const userLevelSuccess = await tryRegisterUserLevelHost();
168 |
169 | if (!userLevelSuccess) {
170 | // User-level installation failed, suggest using register command
171 | console.log(
172 | colorText(
173 | 'User-level installation failed, system-level installation may be needed',
174 | 'yellow',
175 | ),
176 | );
177 | console.log(
178 | colorText('Please run the following command for system-level installation:', 'blue'),
179 | );
180 | console.log(` ${COMMAND_NAME} register --system`);
181 | printManualInstructions();
182 | }
183 | } else {
184 | // Local installation mode, don't attempt automatic registration
185 | console.log(
186 | colorText('Local installation detected, skipping automatic registration', 'yellow'),
187 | );
188 | printManualInstructions();
189 | }
190 | } catch (error) {
191 | console.log(
192 | colorText(
193 | `注册过程中出现错误: ${error instanceof Error ? error.message : String(error)}`,
194 | 'red',
195 | ),
196 | );
197 | printManualInstructions();
198 | }
199 | }
200 |
201 | /**
202 | * 打印手动安装指南
203 | */
204 | function printManualInstructions(): void {
205 | console.log('\n' + colorText('===== Manual Registration Guide =====', 'blue'));
206 |
207 | console.log(colorText('1. Try user-level installation (recommended):', 'yellow'));
208 | if (isGlobalInstall) {
209 | console.log(` ${COMMAND_NAME} register`);
210 | } else {
211 | console.log(` npx ${COMMAND_NAME} register`);
212 | }
213 |
214 | console.log(
215 | colorText('\n2. If user-level installation fails, try system-level installation:', 'yellow'),
216 | );
217 |
218 | console.log(colorText(' Use --system parameter (auto-elevate permissions):', 'yellow'));
219 | if (isGlobalInstall) {
220 | console.log(` ${COMMAND_NAME} register --system`);
221 | } else {
222 | console.log(` npx ${COMMAND_NAME} register --system`);
223 | }
224 |
225 | console.log(colorText('\n Or use administrator privileges directly:', 'yellow'));
226 | if (os.platform() === 'win32') {
227 | console.log(
228 | colorText(
229 | ' Please run Command Prompt or PowerShell as administrator and execute:',
230 | 'yellow',
231 | ),
232 | );
233 | if (isGlobalInstall) {
234 | console.log(` ${COMMAND_NAME} register`);
235 | } else {
236 | console.log(` npx ${COMMAND_NAME} register`);
237 | }
238 | } else {
239 | console.log(colorText(' Please run the following command in terminal:', 'yellow'));
240 | if (isGlobalInstall) {
241 | console.log(` sudo ${COMMAND_NAME} register`);
242 | } else {
243 | console.log(` sudo npx ${COMMAND_NAME} register`);
244 | }
245 | }
246 |
247 | console.log(
248 | '\n' +
249 | colorText(
250 | 'Ensure Chrome extension is installed and refresh the extension to connect to local service.',
251 | 'blue',
252 | ),
253 | );
254 | }
255 |
256 | /**
257 | * 主函数
258 | */
259 | async function main(): Promise<void> {
260 | console.log(colorText(`Installing ${COMMAND_NAME}...`, 'green'));
261 |
262 | // Debug information
263 | console.log(colorText('Installation environment debug info:', 'blue'));
264 | console.log(` __dirname: ${__dirname}`);
265 | console.log(` npm_config_global: ${process.env.npm_config_global}`);
266 | console.log(` PNPM_HOME: ${process.env.PNPM_HOME}`);
267 | console.log(` npm_config_prefix: ${process.env.npm_config_prefix}`);
268 | console.log(` isGlobalInstall: ${isGlobalInstall}`);
269 |
270 | // Always ensure execution permissions first
271 | await ensureExecutionPermissions();
272 |
273 | // Write Node.js path for run_host scripts to use
274 | await writeNodePath();
275 |
276 | // If global installation, try automatic registration
277 | if (isGlobalInstall) {
278 | await tryRegisterNativeHost();
279 | } else {
280 | console.log(colorText('Local installation detected', 'yellow'));
281 | printManualInstructions();
282 | }
283 | }
284 |
285 | // Only execute main function when running this script directly
286 | if (isDirectRun) {
287 | main().catch((error) => {
288 | console.error(
289 | colorText(
290 | `Installation script error: ${error instanceof Error ? error.message : String(error)}`,
291 | 'red',
292 | ),
293 | );
294 | });
295 | }
296 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/vector-search.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Vectorized tab content search tool
3 | * Uses vector database for efficient semantic search
4 | */
5 |
6 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
7 | import { BaseBrowserToolExecutor } from '../base-browser';
8 | import { TOOL_NAMES } from 'chrome-mcp-shared';
9 | import { ContentIndexer } from '@/utils/content-indexer';
10 | import { LIMITS, ERROR_MESSAGES } from '@/common/constants';
11 | import type { SearchResult } from '@/utils/vector-database';
12 |
13 | interface VectorSearchResult {
14 | tabId: number;
15 | url: string;
16 | title: string;
17 | semanticScore: number;
18 | matchedSnippet: string;
19 | chunkSource: string;
20 | timestamp: number;
21 | }
22 |
23 | /**
24 | * Tool for vectorized search of tab content using semantic similarity
25 | */
26 | class VectorSearchTabsContentTool extends BaseBrowserToolExecutor {
27 | name = TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT;
28 | private contentIndexer: ContentIndexer;
29 | private isInitialized = false;
30 |
31 | constructor() {
32 | super();
33 | this.contentIndexer = new ContentIndexer({
34 | autoIndex: true,
35 | maxChunksPerPage: LIMITS.MAX_SEARCH_RESULTS,
36 | skipDuplicates: true,
37 | });
38 | }
39 |
40 | private async initializeIndexer(): Promise<void> {
41 | try {
42 | await this.contentIndexer.initialize();
43 | this.isInitialized = true;
44 | console.log('VectorSearchTabsContentTool: Content indexer initialized successfully');
45 | } catch (error) {
46 | console.error('VectorSearchTabsContentTool: Failed to initialize content indexer:', error);
47 | this.isInitialized = false;
48 | }
49 | }
50 |
51 | async execute(args: { query: string }): Promise<ToolResult> {
52 | try {
53 | const { query } = args;
54 |
55 | if (!query || query.trim().length === 0) {
56 | return createErrorResponse(
57 | ERROR_MESSAGES.INVALID_PARAMETERS + ': Query parameter is required and cannot be empty',
58 | );
59 | }
60 |
61 | console.log(`VectorSearchTabsContentTool: Starting vector search with query: "${query}"`);
62 |
63 | // Check semantic engine status
64 | if (!this.contentIndexer.isSemanticEngineReady()) {
65 | if (this.contentIndexer.isSemanticEngineInitializing()) {
66 | return createErrorResponse(
67 | 'Vector search engine is still initializing (model downloading). Please wait a moment and try again.',
68 | );
69 | } else {
70 | // Try to initialize
71 | console.log('VectorSearchTabsContentTool: Initializing content indexer...');
72 | await this.initializeIndexer();
73 |
74 | // Check semantic engine status again
75 | if (!this.contentIndexer.isSemanticEngineReady()) {
76 | return createErrorResponse('Failed to initialize vector search engine');
77 | }
78 | }
79 | }
80 |
81 | // Execute vector search, get more results for deduplication
82 | const searchResults = await this.contentIndexer.searchContent(query, 50);
83 |
84 | // Convert search results format
85 | const vectorSearchResults = this.convertSearchResults(searchResults);
86 |
87 | // Deduplicate by tab, keep only the highest similarity fragment per tab
88 | const deduplicatedResults = this.deduplicateByTab(vectorSearchResults);
89 |
90 | // Sort by similarity and get top 10 results
91 | const topResults = deduplicatedResults
92 | .sort((a, b) => b.semanticScore - a.semanticScore)
93 | .slice(0, 10);
94 |
95 | // Get index statistics
96 | const stats = this.contentIndexer.getStats();
97 |
98 | const result = {
99 | success: true,
100 | totalTabsSearched: stats.totalTabs,
101 | matchedTabsCount: topResults.length,
102 | vectorSearchEnabled: true,
103 | indexStats: {
104 | totalDocuments: stats.totalDocuments,
105 | totalTabs: stats.totalTabs,
106 | indexedPages: stats.indexedPages,
107 | semanticEngineReady: stats.semanticEngineReady,
108 | semanticEngineInitializing: stats.semanticEngineInitializing,
109 | },
110 | matchedTabs: topResults.map((result) => ({
111 | tabId: result.tabId,
112 | url: result.url,
113 | title: result.title,
114 | semanticScore: result.semanticScore,
115 | matchedSnippets: [result.matchedSnippet],
116 | chunkSource: result.chunkSource,
117 | timestamp: result.timestamp,
118 | })),
119 | };
120 |
121 | console.log(
122 | `VectorSearchTabsContentTool: Found ${topResults.length} results with vector search`,
123 | );
124 |
125 | return {
126 | content: [
127 | {
128 | type: 'text',
129 | text: JSON.stringify(result, null, 2),
130 | },
131 | ],
132 | isError: false,
133 | };
134 | } catch (error) {
135 | console.error('VectorSearchTabsContentTool: Search failed:', error);
136 | return createErrorResponse(
137 | `Vector search failed: ${error instanceof Error ? error.message : String(error)}`,
138 | );
139 | }
140 | }
141 |
142 | /**
143 | * Ensure all tabs are indexed
144 | */
145 | private async ensureTabsIndexed(tabs: chrome.tabs.Tab[]): Promise<void> {
146 | const indexPromises = tabs
147 | .filter((tab) => tab.id)
148 | .map(async (tab) => {
149 | try {
150 | await this.contentIndexer.indexTabContent(tab.id!);
151 | } catch (error) {
152 | console.warn(`VectorSearchTabsContentTool: Failed to index tab ${tab.id}:`, error);
153 | }
154 | });
155 |
156 | await Promise.allSettled(indexPromises);
157 | }
158 |
159 | /**
160 | * Convert search results format
161 | */
162 | private convertSearchResults(searchResults: SearchResult[]): VectorSearchResult[] {
163 | return searchResults.map((result) => ({
164 | tabId: result.document.tabId,
165 | url: result.document.url,
166 | title: result.document.title,
167 | semanticScore: result.similarity,
168 | matchedSnippet: this.extractSnippet(result.document.chunk.text),
169 | chunkSource: result.document.chunk.source,
170 | timestamp: result.document.timestamp,
171 | }));
172 | }
173 |
174 | /**
175 | * Deduplicate by tab, keep only the highest similarity fragment per tab
176 | */
177 | private deduplicateByTab(results: VectorSearchResult[]): VectorSearchResult[] {
178 | const tabMap = new Map<number, VectorSearchResult>();
179 |
180 | for (const result of results) {
181 | const existingResult = tabMap.get(result.tabId);
182 |
183 | // If this tab has no result yet, or current result has higher similarity, update it
184 | if (!existingResult || result.semanticScore > existingResult.semanticScore) {
185 | tabMap.set(result.tabId, result);
186 | }
187 | }
188 |
189 | return Array.from(tabMap.values());
190 | }
191 |
192 | /**
193 | * Extract text snippet for display
194 | */
195 | private extractSnippet(text: string, maxLength: number = 200): string {
196 | if (text.length <= maxLength) {
197 | return text;
198 | }
199 |
200 | // Try to truncate at sentence boundary
201 | const truncated = text.substring(0, maxLength);
202 | const lastSentenceEnd = Math.max(
203 | truncated.lastIndexOf('.'),
204 | truncated.lastIndexOf('!'),
205 | truncated.lastIndexOf('?'),
206 | truncated.lastIndexOf('。'),
207 | truncated.lastIndexOf('!'),
208 | truncated.lastIndexOf('?'),
209 | );
210 |
211 | if (lastSentenceEnd > maxLength * 0.7) {
212 | return truncated.substring(0, lastSentenceEnd + 1);
213 | }
214 |
215 | // If no suitable sentence boundary found, truncate at word boundary
216 | const lastSpaceIndex = truncated.lastIndexOf(' ');
217 | if (lastSpaceIndex > maxLength * 0.8) {
218 | return truncated.substring(0, lastSpaceIndex) + '...';
219 | }
220 |
221 | return truncated + '...';
222 | }
223 |
224 | /**
225 | * Get index statistics
226 | */
227 | public async getIndexStats() {
228 | if (!this.isInitialized) {
229 | // Don't automatically initialize - just return basic stats
230 | return {
231 | totalDocuments: 0,
232 | totalTabs: 0,
233 | indexSize: 0,
234 | indexedPages: 0,
235 | isInitialized: false,
236 | semanticEngineReady: false,
237 | semanticEngineInitializing: false,
238 | };
239 | }
240 | return this.contentIndexer.getStats();
241 | }
242 |
243 | /**
244 | * Manually rebuild index
245 | */
246 | public async rebuildIndex(): Promise<void> {
247 | if (!this.isInitialized) {
248 | await this.initializeIndexer();
249 | }
250 |
251 | try {
252 | // Clear existing indexes
253 | await this.contentIndexer.clearAllIndexes();
254 |
255 | // Get all tabs and reindex
256 | const windows = await chrome.windows.getAll({ populate: true });
257 | const allTabs: chrome.tabs.Tab[] = [];
258 |
259 | for (const window of windows) {
260 | if (window.tabs) {
261 | allTabs.push(...window.tabs);
262 | }
263 | }
264 |
265 | const validTabs = allTabs.filter(
266 | (tab) =>
267 | tab.id &&
268 | tab.url &&
269 | !tab.url.startsWith('chrome://') &&
270 | !tab.url.startsWith('chrome-extension://') &&
271 | !tab.url.startsWith('edge://') &&
272 | !tab.url.startsWith('about:'),
273 | );
274 |
275 | await this.ensureTabsIndexed(validTabs);
276 |
277 | console.log(`VectorSearchTabsContentTool: Rebuilt index for ${validTabs.length} tabs`);
278 | } catch (error) {
279 | console.error('VectorSearchTabsContentTool: Failed to rebuild index:', error);
280 | throw error;
281 | }
282 | }
283 |
284 | /**
285 | * Manually index specified tab
286 | */
287 | public async indexTab(tabId: number): Promise<void> {
288 | if (!this.isInitialized) {
289 | await this.initializeIndexer();
290 | }
291 |
292 | await this.contentIndexer.indexTabContent(tabId);
293 | }
294 |
295 | /**
296 | * Remove index for specified tab
297 | */
298 | public async removeTabIndex(tabId: number): Promise<void> {
299 | if (!this.isInitialized) {
300 | return;
301 | }
302 |
303 | await this.contentIndexer.removeTabIndex(tabId);
304 | }
305 | }
306 |
307 | // Export tool instance
308 | export const vectorSearchTabsContentTool = new VectorSearchTabsContentTool();
309 |
```
--------------------------------------------------------------------------------
/app/native-server/src/native-messaging-host.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { stdin, stdout } from 'process';
2 | import { Server } from './server';
3 | import { v4 as uuidv4 } from 'uuid';
4 | import { NativeMessageType } from 'chrome-mcp-shared';
5 | import { TIMEOUTS } from './constant';
6 | import fileHandler from './file-handler';
7 |
8 | interface PendingRequest {
9 | resolve: (value: any) => void;
10 | reject: (reason?: any) => void;
11 | timeoutId: NodeJS.Timeout;
12 | }
13 |
14 | export class NativeMessagingHost {
15 | private associatedServer: Server | null = null;
16 | private pendingRequests: Map<string, PendingRequest> = new Map();
17 |
18 | public setServer(serverInstance: Server): void {
19 | this.associatedServer = serverInstance;
20 | }
21 |
22 | // add message handler to wait for start server
23 | public start(): void {
24 | try {
25 | this.setupMessageHandling();
26 | } catch (error: any) {
27 | process.exit(1);
28 | }
29 | }
30 |
31 | private setupMessageHandling(): void {
32 | let buffer = Buffer.alloc(0);
33 | let expectedLength = -1;
34 |
35 | stdin.on('readable', () => {
36 | let chunk;
37 | while ((chunk = stdin.read()) !== null) {
38 | buffer = Buffer.concat([buffer, chunk]);
39 |
40 | if (expectedLength === -1 && buffer.length >= 4) {
41 | expectedLength = buffer.readUInt32LE(0);
42 | buffer = buffer.slice(4);
43 | }
44 |
45 | if (expectedLength !== -1 && buffer.length >= expectedLength) {
46 | const messageBuffer = buffer.slice(0, expectedLength);
47 | buffer = buffer.slice(expectedLength);
48 |
49 | try {
50 | const message = JSON.parse(messageBuffer.toString());
51 | this.handleMessage(message);
52 | } catch (error: any) {
53 | this.sendError(`Failed to parse message: ${error.message}`);
54 | }
55 | expectedLength = -1; // reset to get next data
56 | }
57 | }
58 | });
59 |
60 | stdin.on('end', () => {
61 | this.cleanup();
62 | });
63 |
64 | stdin.on('error', () => {
65 | this.cleanup();
66 | });
67 | }
68 |
69 | private async handleMessage(message: any): Promise<void> {
70 | if (!message || typeof message !== 'object') {
71 | this.sendError('Invalid message format');
72 | return;
73 | }
74 |
75 | if (message.responseToRequestId) {
76 | const requestId = message.responseToRequestId;
77 | const pending = this.pendingRequests.get(requestId);
78 |
79 | if (pending) {
80 | clearTimeout(pending.timeoutId);
81 | if (message.error) {
82 | pending.reject(new Error(message.error));
83 | } else {
84 | pending.resolve(message.payload);
85 | }
86 | this.pendingRequests.delete(requestId);
87 | } else {
88 | // just ignore
89 | }
90 | return;
91 | }
92 |
93 | // Handle directive messages from Chrome
94 | try {
95 | switch (message.type) {
96 | case NativeMessageType.START:
97 | await this.startServer(message.payload?.port || 3000);
98 | break;
99 | case NativeMessageType.STOP:
100 | await this.stopServer();
101 | break;
102 | // Keep ping/pong for simple liveness detection, but this differs from request-response pattern
103 | case 'ping_from_extension':
104 | this.sendMessage({ type: 'pong_to_extension' });
105 | break;
106 | case 'file_operation':
107 | await this.handleFileOperation(message);
108 | break;
109 | default:
110 | // Double check when message type is not supported
111 | if (!message.responseToRequestId) {
112 | this.sendError(
113 | `Unknown message type or non-response message: ${message.type || 'no type'}`,
114 | );
115 | }
116 | }
117 | } catch (error: any) {
118 | this.sendError(`Failed to handle directive message: ${error.message}`);
119 | }
120 | }
121 |
122 | /**
123 | * Handle file operations from the extension
124 | */
125 | private async handleFileOperation(message: any): Promise<void> {
126 | try {
127 | const result = await fileHandler.handleFileRequest(message.payload);
128 |
129 | if (message.requestId) {
130 | // Send response back with the request ID
131 | this.sendMessage({
132 | type: 'file_operation_response',
133 | responseToRequestId: message.requestId,
134 | payload: result,
135 | });
136 | } else {
137 | // No request ID, just send result
138 | this.sendMessage({
139 | type: 'file_operation_result',
140 | payload: result,
141 | });
142 | }
143 | } catch (error: any) {
144 | const errorResponse = {
145 | success: false,
146 | error: error.message || 'Unknown error during file operation',
147 | };
148 |
149 | if (message.requestId) {
150 | this.sendMessage({
151 | type: 'file_operation_response',
152 | responseToRequestId: message.requestId,
153 | error: errorResponse.error,
154 | });
155 | } else {
156 | this.sendError(`File operation failed: ${errorResponse.error}`);
157 | }
158 | }
159 | }
160 |
161 | /**
162 | * Send request to Chrome and wait for response
163 | * @param messagePayload Data to send to Chrome
164 | * @param timeoutMs Timeout for waiting response (milliseconds)
165 | * @returns Promise, resolves to Chrome's returned payload on success, rejects on failure
166 | */
167 | public sendRequestToExtensionAndWait(
168 | messagePayload: any,
169 | messageType: string = 'request_data',
170 | timeoutMs: number = TIMEOUTS.DEFAULT_REQUEST_TIMEOUT,
171 | ): Promise<any> {
172 | return new Promise((resolve, reject) => {
173 | const requestId = uuidv4(); // Generate unique request ID
174 |
175 | const timeoutId = setTimeout(() => {
176 | this.pendingRequests.delete(requestId); // Remove from Map after timeout
177 | reject(new Error(`Request timed out after ${timeoutMs}ms`));
178 | }, timeoutMs);
179 |
180 | // Store request's resolve/reject functions and timeout ID
181 | this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
182 |
183 | // Send message with requestId to Chrome
184 | this.sendMessage({
185 | type: messageType, // Define a request type, e.g. 'request_data'
186 | payload: messagePayload,
187 | requestId: requestId, // <--- Key: include request ID
188 | });
189 | });
190 | }
191 |
192 | /**
193 | * Start Fastify server (now accepts Server instance)
194 | */
195 | private async startServer(port: number): Promise<void> {
196 | if (!this.associatedServer) {
197 | this.sendError('Internal error: server instance not set');
198 | return;
199 | }
200 | try {
201 | if (this.associatedServer.isRunning) {
202 | this.sendMessage({
203 | type: NativeMessageType.ERROR,
204 | payload: { message: 'Server is already running' },
205 | });
206 | return;
207 | }
208 |
209 | await this.associatedServer.start(port, this);
210 |
211 | this.sendMessage({
212 | type: NativeMessageType.SERVER_STARTED,
213 | payload: { port },
214 | });
215 |
216 | } catch (error: any) {
217 | this.sendError(`Failed to start server: ${error.message}`);
218 | }
219 | }
220 |
221 | /**
222 | * Stop Fastify server
223 | */
224 | private async stopServer(): Promise<void> {
225 | if (!this.associatedServer) {
226 | this.sendError('Internal error: server instance not set');
227 | return;
228 | }
229 | try {
230 | // Check status through associatedServer
231 | if (!this.associatedServer.isRunning) {
232 | this.sendMessage({
233 | type: NativeMessageType.ERROR,
234 | payload: { message: 'Server is not running' },
235 | });
236 | return;
237 | }
238 |
239 | await this.associatedServer.stop();
240 | // this.serverStarted = false; // Server should update its own status after successful stop
241 |
242 | this.sendMessage({ type: NativeMessageType.SERVER_STOPPED }); // Distinguish from previous 'stopped'
243 | } catch (error: any) {
244 | this.sendError(`Failed to stop server: ${error.message}`);
245 | }
246 | }
247 |
248 | /**
249 | * Send message to Chrome extension
250 | */
251 | public sendMessage(message: any): void {
252 | try {
253 | const messageString = JSON.stringify(message);
254 | const messageBuffer = Buffer.from(messageString);
255 | const headerBuffer = Buffer.alloc(4);
256 | headerBuffer.writeUInt32LE(messageBuffer.length, 0);
257 | // Ensure atomic write
258 | stdout.write(Buffer.concat([headerBuffer, messageBuffer]), (err) => {
259 | if (err) {
260 | // Consider how to handle write failure, may affect request completion
261 | } else {
262 | // Message sent successfully, no action needed
263 | }
264 | });
265 | } catch (error: any) {
266 | // Catch JSON.stringify or Buffer operation errors
267 | // If preparation stage fails, associated request may never be sent
268 | // Need to consider whether to reject corresponding Promise (if called within sendRequestToExtensionAndWait)
269 | }
270 | }
271 |
272 | /**
273 | * Send error message to Chrome extension (mainly for sending non-request-response type errors)
274 | */
275 | private sendError(errorMessage: string): void {
276 | this.sendMessage({
277 | type: NativeMessageType.ERROR_FROM_NATIVE_HOST, // Use more explicit type
278 | payload: { message: errorMessage },
279 | });
280 | }
281 |
282 |
283 |
284 | /**
285 | * Clean up resources
286 | */
287 | private cleanup(): void {
288 | // Reject all pending requests
289 | this.pendingRequests.forEach((pending) => {
290 | clearTimeout(pending.timeoutId);
291 | pending.reject(new Error('Native host is shutting down or Chrome disconnected.'));
292 | });
293 | this.pendingRequests.clear();
294 |
295 | if (this.associatedServer && this.associatedServer.isRunning) {
296 | this.associatedServer
297 | .stop()
298 | .then(() => {
299 | process.exit(0);
300 | })
301 | .catch(() => {
302 | process.exit(1);
303 | });
304 | } else {
305 | process.exit(0);
306 | }
307 | }
308 | }
309 |
310 | const nativeMessagingHostInstance = new NativeMessagingHost();
311 | export default nativeMessagingHostInstance;
312 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/utils/i18n.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Chrome Extension i18n utility
3 | * Provides safe access to chrome.i18n.getMessage with fallbacks
4 | */
5 |
6 | // Fallback messages for when Chrome APIs aren't available (English)
7 | const fallbackMessages: Record<string, string> = {
8 | // Extension metadata
9 | extensionName: 'chrome-mcp-server',
10 | extensionDescription: 'Exposes browser capabilities with your own chrome',
11 |
12 | // Section headers
13 | nativeServerConfigLabel: 'Native Server Configuration',
14 | semanticEngineLabel: 'Semantic Engine',
15 | embeddingModelLabel: 'Embedding Model',
16 | indexDataManagementLabel: 'Index Data Management',
17 | modelCacheManagementLabel: 'Model Cache Management',
18 |
19 | // Status labels
20 | statusLabel: 'Status',
21 | runningStatusLabel: 'Running Status',
22 | connectionStatusLabel: 'Connection Status',
23 | lastUpdatedLabel: 'Last Updated:',
24 |
25 | // Connection states
26 | connectButton: 'Connect',
27 | disconnectButton: 'Disconnect',
28 | connectingStatus: 'Connecting...',
29 | connectedStatus: 'Connected',
30 | disconnectedStatus: 'Disconnected',
31 | detectingStatus: 'Detecting...',
32 |
33 | // Server states
34 | serviceRunningStatus: 'Service Running (Port: {0})',
35 | serviceNotConnectedStatus: 'Service Not Connected',
36 | connectedServiceNotStartedStatus: 'Connected, Service Not Started',
37 |
38 | // Configuration labels
39 | mcpServerConfigLabel: 'MCP Server Configuration',
40 | connectionPortLabel: 'Connection Port',
41 | refreshStatusButton: 'Refresh Status',
42 | copyConfigButton: 'Copy Configuration',
43 |
44 | // Action buttons
45 | retryButton: 'Retry',
46 | cancelButton: 'Cancel',
47 | confirmButton: 'Confirm',
48 | saveButton: 'Save',
49 | closeButton: 'Close',
50 | resetButton: 'Reset',
51 |
52 | // Progress states
53 | initializingStatus: 'Initializing...',
54 | processingStatus: 'Processing...',
55 | loadingStatus: 'Loading...',
56 | clearingStatus: 'Clearing...',
57 | cleaningStatus: 'Cleaning...',
58 | downloadingStatus: 'Downloading...',
59 |
60 | // Semantic engine states
61 | semanticEngineReadyStatus: 'Semantic Engine Ready',
62 | semanticEngineInitializingStatus: 'Semantic Engine Initializing...',
63 | semanticEngineInitFailedStatus: 'Semantic Engine Initialization Failed',
64 | semanticEngineNotInitStatus: 'Semantic Engine Not Initialized',
65 | initSemanticEngineButton: 'Initialize Semantic Engine',
66 | reinitializeButton: 'Reinitialize',
67 |
68 | // Model states
69 | downloadingModelStatus: 'Downloading Model... {0}%',
70 | switchingModelStatus: 'Switching Model...',
71 | modelLoadedStatus: 'Model Loaded',
72 | modelFailedStatus: 'Model Failed to Load',
73 |
74 | // Model descriptions
75 | lightweightModelDescription: 'Lightweight Multilingual Model',
76 | betterThanSmallDescription: 'Slightly larger than e5-small, but better performance',
77 | multilingualModelDescription: 'Multilingual Semantic Model',
78 |
79 | // Performance levels
80 | fastPerformance: 'Fast',
81 | balancedPerformance: 'Balanced',
82 | accuratePerformance: 'Accurate',
83 |
84 | // Error messages
85 | networkErrorMessage: 'Network connection error, please check network and retry',
86 | modelCorruptedErrorMessage: 'Model file corrupted or incomplete, please retry download',
87 | unknownErrorMessage: 'Unknown error, please check if your network can access HuggingFace',
88 | permissionDeniedErrorMessage: 'Permission denied',
89 | timeoutErrorMessage: 'Operation timed out',
90 |
91 | // Data statistics
92 | indexedPagesLabel: 'Indexed Pages',
93 | indexSizeLabel: 'Index Size',
94 | activeTabsLabel: 'Active Tabs',
95 | vectorDocumentsLabel: 'Vector Documents',
96 | cacheSizeLabel: 'Cache Size',
97 | cacheEntriesLabel: 'Cache Entries',
98 |
99 | // Data management
100 | clearAllDataButton: 'Clear All Data',
101 | clearAllCacheButton: 'Clear All Cache',
102 | cleanExpiredCacheButton: 'Clean Expired Cache',
103 | exportDataButton: 'Export Data',
104 | importDataButton: 'Import Data',
105 |
106 | // Dialog titles
107 | confirmClearDataTitle: 'Confirm Clear Data',
108 | settingsTitle: 'Settings',
109 | aboutTitle: 'About',
110 | helpTitle: 'Help',
111 |
112 | // Dialog messages
113 | clearDataWarningMessage:
114 | 'This operation will clear all indexed webpage content and vector data, including:',
115 | clearDataList1: 'All webpage text content index',
116 | clearDataList2: 'Vector embedding data',
117 | clearDataList3: 'Search history and cache',
118 | clearDataIrreversibleWarning:
119 | 'This operation is irreversible! After clearing, you need to browse webpages again to rebuild the index.',
120 | confirmClearButton: 'Confirm Clear',
121 |
122 | // Cache states
123 | cacheDetailsLabel: 'Cache Details',
124 | noCacheDataMessage: 'No cache data',
125 | loadingCacheInfoStatus: 'Loading cache information...',
126 | processingCacheStatus: 'Processing cache...',
127 | expiredLabel: 'Expired',
128 |
129 | // Browser integration
130 | bookmarksBarLabel: 'Bookmarks Bar',
131 | newTabLabel: 'New Tab',
132 | currentPageLabel: 'Current Page',
133 |
134 | // Accessibility
135 | menuLabel: 'Menu',
136 | navigationLabel: 'Navigation',
137 | mainContentLabel: 'Main Content',
138 |
139 | // Future features
140 | languageSelectorLabel: 'Language',
141 | themeLabel: 'Theme',
142 | lightTheme: 'Light',
143 | darkTheme: 'Dark',
144 | autoTheme: 'Auto',
145 | advancedSettingsLabel: 'Advanced Settings',
146 | debugModeLabel: 'Debug Mode',
147 | verboseLoggingLabel: 'Verbose Logging',
148 |
149 | // Notifications
150 | successNotification: 'Operation completed successfully',
151 | warningNotification: 'Warning: Please review before proceeding',
152 | infoNotification: 'Information',
153 | configCopiedNotification: 'Configuration copied to clipboard',
154 | dataClearedNotification: 'Data cleared successfully',
155 |
156 | // Units
157 | bytesUnit: 'bytes',
158 | kilobytesUnit: 'KB',
159 | megabytesUnit: 'MB',
160 | gigabytesUnit: 'GB',
161 | itemsUnit: 'items',
162 | pagesUnit: 'pages',
163 |
164 | // Legacy keys for backwards compatibility
165 | nativeServerConfig: 'Native Server Configuration',
166 | runningStatus: 'Running Status',
167 | refreshStatus: 'Refresh Status',
168 | lastUpdated: 'Last Updated:',
169 | mcpServerConfig: 'MCP Server Configuration',
170 | connectionPort: 'Connection Port',
171 | connecting: 'Connecting...',
172 | disconnect: 'Disconnect',
173 | connect: 'Connect',
174 | semanticEngine: 'Semantic Engine',
175 | embeddingModel: 'Embedding Model',
176 | retry: 'Retry',
177 | indexDataManagement: 'Index Data Management',
178 | clearing: 'Clearing...',
179 | clearAllData: 'Clear All Data',
180 | copyConfig: 'Copy Configuration',
181 | serviceRunning: 'Service Running (Port: {0})',
182 | connectedServiceNotStarted: 'Connected, Service Not Started',
183 | serviceNotConnected: 'Service Not Connected',
184 | detecting: 'Detecting...',
185 | lightweightModel: 'Lightweight Multilingual Model',
186 | betterThanSmall: 'Slightly larger than e5-small, but better performance',
187 | multilingualModel: 'Multilingual Semantic Model',
188 | fast: 'Fast',
189 | balanced: 'Balanced',
190 | accurate: 'Accurate',
191 | semanticEngineReady: 'Semantic Engine Ready',
192 | semanticEngineInitializing: 'Semantic Engine Initializing...',
193 | semanticEngineInitFailed: 'Semantic Engine Initialization Failed',
194 | semanticEngineNotInit: 'Semantic Engine Not Initialized',
195 | downloadingModel: 'Downloading Model... {0}%',
196 | switchingModel: 'Switching Model...',
197 | networkError: 'Network connection error, please check network and retry',
198 | modelCorrupted: 'Model file corrupted or incomplete, please retry download',
199 | unknownError: 'Unknown error, please check if your network can access HuggingFace',
200 | reinitialize: 'Reinitialize',
201 | initializing: 'Initializing...',
202 | initSemanticEngine: 'Initialize Semantic Engine',
203 | indexedPages: 'Indexed Pages',
204 | indexSize: 'Index Size',
205 | activeTabs: 'Active Tabs',
206 | vectorDocuments: 'Vector Documents',
207 | confirmClearData: 'Confirm Clear Data',
208 | clearDataWarning:
209 | 'This operation will clear all indexed webpage content and vector data, including:',
210 | clearDataIrreversible:
211 | 'This operation is irreversible! After clearing, you need to browse webpages again to rebuild the index.',
212 | confirmClear: 'Confirm Clear',
213 | cancel: 'Cancel',
214 | confirm: 'Confirm',
215 | processing: 'Processing...',
216 | modelCacheManagement: 'Model Cache Management',
217 | cacheSize: 'Cache Size',
218 | cacheEntries: 'Cache Entries',
219 | cacheDetails: 'Cache Details',
220 | noCacheData: 'No cache data',
221 | loadingCacheInfo: 'Loading cache information...',
222 | processingCache: 'Processing cache...',
223 | cleaning: 'Cleaning...',
224 | cleanExpiredCache: 'Clean Expired Cache',
225 | clearAllCache: 'Clear All Cache',
226 | expired: 'Expired',
227 | bookmarksBar: 'Bookmarks Bar',
228 | };
229 |
230 | /**
231 | * Safe i18n message getter with fallback support
232 | * @param key Message key
233 | * @param substitutions Optional substitution values
234 | * @returns Localized message or fallback
235 | */
236 | export function getMessage(key: string, substitutions?: string[]): string {
237 | try {
238 | // Check if Chrome extension APIs are available
239 | if (typeof chrome !== 'undefined' && chrome.i18n && chrome.i18n.getMessage) {
240 | const message = chrome.i18n.getMessage(key, substitutions);
241 | if (message) {
242 | return message;
243 | }
244 | }
245 | } catch (error) {
246 | console.warn(`Failed to get i18n message for key "${key}":`, error);
247 | }
248 |
249 | // Fallback to English messages
250 | let fallback = fallbackMessages[key] || key;
251 |
252 | // Handle substitutions in fallback messages
253 | if (substitutions && substitutions.length > 0) {
254 | substitutions.forEach((value, index) => {
255 | fallback = fallback.replace(`{${index}}`, value);
256 | });
257 | }
258 |
259 | return fallback;
260 | }
261 |
262 | /**
263 | * Check if Chrome extension i18n APIs are available
264 | */
265 | export function isI18nAvailable(): boolean {
266 | try {
267 | return (
268 | typeof chrome !== 'undefined' && chrome.i18n && typeof chrome.i18n.getMessage === 'function'
269 | );
270 | } catch {
271 | return false;
272 | }
273 | }
274 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
2 | import { BaseBrowserToolExecutor } from '../base-browser';
3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
4 |
5 | interface FileUploadToolParams {
6 | selector: string; // CSS selector for the file input element
7 | filePath?: string; // Local file path
8 | fileUrl?: string; // URL to download file from
9 | base64Data?: string; // Base64 encoded file data
10 | fileName?: string; // Optional filename when using base64 or URL
11 | multiple?: boolean; // Whether to allow multiple files
12 | }
13 |
14 | /**
15 | * Tool for uploading files to web forms using Chrome DevTools Protocol
16 | * Similar to Playwright's setInputFiles implementation
17 | */
18 | class FileUploadTool extends BaseBrowserToolExecutor {
19 | name = TOOL_NAMES.BROWSER.FILE_UPLOAD;
20 | private activeDebuggers: Map<number, boolean> = new Map();
21 |
22 | constructor() {
23 | super();
24 | // Clean up debuggers on tab removal
25 | chrome.tabs.onRemoved.addListener((tabId) => {
26 | if (this.activeDebuggers.has(tabId)) {
27 | this.cleanupDebugger(tabId);
28 | }
29 | });
30 | }
31 |
32 | /**
33 | * Execute file upload operation using Chrome DevTools Protocol
34 | */
35 | async execute(args: FileUploadToolParams): Promise<ToolResult> {
36 | const { selector, filePath, fileUrl, base64Data, fileName, multiple = false } = args;
37 |
38 | console.log(`Starting file upload operation with options:`, args);
39 |
40 | // Validate input
41 | if (!selector) {
42 | return createErrorResponse('Selector is required for file upload');
43 | }
44 |
45 | if (!filePath && !fileUrl && !base64Data) {
46 | return createErrorResponse(
47 | 'One of filePath, fileUrl, or base64Data must be provided',
48 | );
49 | }
50 |
51 | let tabId: number | undefined;
52 |
53 | try {
54 | // Get current tab
55 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
56 | if (!tabs[0]?.id) {
57 | return createErrorResponse('No active tab found');
58 | }
59 | tabId = tabs[0].id;
60 |
61 | // Prepare file paths
62 | let files: string[] = [];
63 |
64 | if (filePath) {
65 | // Direct file path provided
66 | files = [filePath];
67 | } else if (fileUrl || base64Data) {
68 | // For URL or base64, we need to use the native messaging host
69 | // to download or save the file temporarily
70 | const tempFilePath = await this.prepareFileFromRemote({
71 | fileUrl,
72 | base64Data,
73 | fileName: fileName || 'uploaded-file',
74 | });
75 | if (!tempFilePath) {
76 | return createErrorResponse('Failed to prepare file for upload');
77 | }
78 | files = [tempFilePath];
79 | }
80 |
81 | // Attach debugger to the tab
82 | await this.attachDebugger(tabId);
83 |
84 | // Enable necessary CDP domains
85 | await chrome.debugger.sendCommand({ tabId }, 'DOM.enable', {});
86 | await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable', {});
87 |
88 | // Get the document
89 | const { root } = await chrome.debugger.sendCommand(
90 | { tabId },
91 | 'DOM.getDocument',
92 | { depth: -1, pierce: true },
93 | ) as { root: { nodeId: number } };
94 |
95 | // Find the file input element using the selector
96 | const { nodeId } = await chrome.debugger.sendCommand(
97 | { tabId },
98 | 'DOM.querySelector',
99 | {
100 | nodeId: root.nodeId,
101 | selector: selector,
102 | },
103 | ) as { nodeId: number };
104 |
105 | if (!nodeId || nodeId === 0) {
106 | throw new Error(`Element with selector "${selector}" not found`);
107 | }
108 |
109 | // Verify it's actually a file input
110 | const { node } = await chrome.debugger.sendCommand(
111 | { tabId },
112 | 'DOM.describeNode',
113 | { nodeId },
114 | ) as { node: { nodeName: string; attributes?: string[] } };
115 |
116 | if (node.nodeName !== 'INPUT') {
117 | throw new Error(`Element with selector "${selector}" is not an input element`);
118 | }
119 |
120 | // Check if it's a file input by looking for type="file" in attributes
121 | const attributes = node.attributes || [];
122 | let isFileInput = false;
123 | for (let i = 0; i < attributes.length; i += 2) {
124 | if (attributes[i] === 'type' && attributes[i + 1] === 'file') {
125 | isFileInput = true;
126 | break;
127 | }
128 | }
129 |
130 | if (!isFileInput) {
131 | throw new Error(`Element with selector "${selector}" is not a file input (type="file")`);
132 | }
133 |
134 | // Set the files on the input element
135 | // This is the key CDP command that Playwright and Puppeteer use
136 | await chrome.debugger.sendCommand(
137 | { tabId },
138 | 'DOM.setFileInputFiles',
139 | {
140 | nodeId: nodeId,
141 | files: files,
142 | },
143 | );
144 |
145 | // Trigger change event to ensure the page reacts to the file upload
146 | await chrome.debugger.sendCommand(
147 | { tabId },
148 | 'Runtime.evaluate',
149 | {
150 | expression: `
151 | (function() {
152 | const element = document.querySelector('${selector.replace(/'/g, "\\'")}');
153 | if (element) {
154 | const event = new Event('change', { bubbles: true });
155 | element.dispatchEvent(event);
156 | return true;
157 | }
158 | return false;
159 | })()
160 | `,
161 | },
162 | );
163 |
164 | // Clean up debugger
165 | await this.detachDebugger(tabId);
166 |
167 | return {
168 | content: [
169 | {
170 | type: 'text',
171 | text: JSON.stringify({
172 | success: true,
173 | message: 'File(s) uploaded successfully',
174 | files: files,
175 | selector: selector,
176 | fileCount: files.length,
177 | }),
178 | },
179 | ],
180 | isError: false,
181 | };
182 | } catch (error) {
183 | console.error('Error in file upload operation:', error);
184 |
185 | // Clean up debugger if attached
186 | if (tabId !== undefined && this.activeDebuggers.has(tabId)) {
187 | await this.detachDebugger(tabId);
188 | }
189 |
190 | return createErrorResponse(
191 | `Error uploading file: ${error instanceof Error ? error.message : String(error)}`,
192 | );
193 | }
194 | }
195 |
196 | /**
197 | * Attach debugger to a tab
198 | */
199 | private async attachDebugger(tabId: number): Promise<void> {
200 | // Check if debugger is already attached
201 | const targets = await chrome.debugger.getTargets();
202 | const existingTarget = targets.find(
203 | (t) => t.tabId === tabId && t.attached,
204 | );
205 |
206 | if (existingTarget) {
207 | if (existingTarget.extensionId === chrome.runtime.id) {
208 | // Our extension already attached
209 | console.log('Debugger already attached by this extension');
210 | return;
211 | } else {
212 | throw new Error(
213 | 'Debugger is already attached to this tab by another extension or DevTools',
214 | );
215 | }
216 | }
217 |
218 | // Attach debugger
219 | await chrome.debugger.attach({ tabId }, '1.3');
220 | this.activeDebuggers.set(tabId, true);
221 | console.log(`Debugger attached to tab ${tabId}`);
222 | }
223 |
224 | /**
225 | * Detach debugger from a tab
226 | */
227 | private async detachDebugger(tabId: number): Promise<void> {
228 | if (!this.activeDebuggers.has(tabId)) {
229 | return;
230 | }
231 |
232 | try {
233 | await chrome.debugger.detach({ tabId });
234 | console.log(`Debugger detached from tab ${tabId}`);
235 | } catch (error) {
236 | console.warn(`Error detaching debugger from tab ${tabId}:`, error);
237 | } finally {
238 | this.activeDebuggers.delete(tabId);
239 | }
240 | }
241 |
242 | /**
243 | * Clean up debugger connection
244 | */
245 | private cleanupDebugger(tabId: number): void {
246 | this.activeDebuggers.delete(tabId);
247 | }
248 |
249 | /**
250 | * Prepare file from URL or base64 data using native messaging host
251 | */
252 | private async prepareFileFromRemote(options: {
253 | fileUrl?: string;
254 | base64Data?: string;
255 | fileName: string;
256 | }): Promise<string | null> {
257 | const { fileUrl, base64Data, fileName } = options;
258 |
259 | return new Promise((resolve) => {
260 | const requestId = `file-upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
261 | const timeout = setTimeout(() => {
262 | console.error('File preparation request timed out');
263 | resolve(null);
264 | }, 30000); // 30 second timeout
265 |
266 | // Create listener for the response
267 | const handleMessage = (message: any) => {
268 | if (message.type === 'file_operation_response' &&
269 | message.responseToRequestId === requestId) {
270 | clearTimeout(timeout);
271 | chrome.runtime.onMessage.removeListener(handleMessage);
272 |
273 | if (message.payload?.success && message.payload?.filePath) {
274 | resolve(message.payload.filePath);
275 | } else {
276 | console.error('Native host failed to prepare file:', message.error || message.payload?.error);
277 | resolve(null);
278 | }
279 | }
280 | };
281 |
282 | // Add listener
283 | chrome.runtime.onMessage.addListener(handleMessage);
284 |
285 | // Send message to background script to forward to native host
286 | chrome.runtime.sendMessage({
287 | type: 'forward_to_native',
288 | message: {
289 | type: 'file_operation',
290 | requestId: requestId,
291 | payload: {
292 | action: 'prepareFile',
293 | fileUrl,
294 | base64Data,
295 | fileName,
296 | },
297 | },
298 | }).catch((error) => {
299 | console.error('Error sending message to background:', error);
300 | clearTimeout(timeout);
301 | chrome.runtime.onMessage.removeListener(handleMessage);
302 | resolve(null);
303 | });
304 | });
305 | }
306 | }
307 |
308 | export const fileUploadTool = new FileUploadTool();
```