chrome-ai-bridge 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import { EventEmitter } from 'events';
7
+ export class DownloadManager extends EventEmitter {
8
+ cdpSession = null;
9
+ downloadDir;
10
+ page;
11
+ pendingDownloads = new Map();
12
+ downloadPromiseResolvers = new Map();
13
+ isMonitoring = false;
14
+ constructor(page, downloadDir) {
15
+ super();
16
+ this.page = page;
17
+ this.downloadDir = downloadDir;
18
+ }
19
+ /**
20
+ * Start monitoring downloads using CDP events
21
+ */
22
+ async startMonitoring() {
23
+ if (this.isMonitoring) {
24
+ return;
25
+ }
26
+ // Create CDP session
27
+ this.cdpSession = await this.page.createCDPSession();
28
+ // Enable download events
29
+ await this.cdpSession.send('Browser.setDownloadBehavior', {
30
+ behavior: 'allowAndName',
31
+ downloadPath: this.downloadDir,
32
+ eventsEnabled: true,
33
+ });
34
+ // Listen for download events
35
+ this.cdpSession.on('Browser.downloadWillBegin', (event) => {
36
+ const info = {
37
+ guid: event.guid,
38
+ url: event.url,
39
+ suggestedFilename: event.suggestedFilename,
40
+ state: 'inProgress',
41
+ receivedBytes: 0,
42
+ totalBytes: 0,
43
+ };
44
+ this.pendingDownloads.set(event.guid, info);
45
+ this.emit('started', event.suggestedFilename);
46
+ });
47
+ this.cdpSession.on('Browser.downloadProgress', (event) => {
48
+ const info = this.pendingDownloads.get(event.guid);
49
+ if (!info)
50
+ return;
51
+ info.receivedBytes = event.receivedBytes;
52
+ info.totalBytes = event.totalBytes;
53
+ info.state = event.state;
54
+ // Emit progress
55
+ if (event.totalBytes > 0) {
56
+ const percent = Math.round((event.receivedBytes / event.totalBytes) * 100);
57
+ this.emit('progress', percent, info.suggestedFilename);
58
+ }
59
+ // Handle completion
60
+ if (event.state === 'completed') {
61
+ // Construct the final path
62
+ const finalPath = `${this.downloadDir}/${info.suggestedFilename}`;
63
+ info.resolvedPath = finalPath;
64
+ this.emit('completed', finalPath);
65
+ // Resolve any waiting promises
66
+ const resolver = this.downloadPromiseResolvers.get(event.guid);
67
+ if (resolver) {
68
+ resolver.resolve(finalPath);
69
+ this.downloadPromiseResolvers.delete(event.guid);
70
+ }
71
+ }
72
+ else if (event.state === 'canceled') {
73
+ this.emit('canceled', info.suggestedFilename);
74
+ const resolver = this.downloadPromiseResolvers.get(event.guid);
75
+ if (resolver) {
76
+ resolver.reject(new Error('Download canceled'));
77
+ this.downloadPromiseResolvers.delete(event.guid);
78
+ }
79
+ }
80
+ });
81
+ this.isMonitoring = true;
82
+ }
83
+ /**
84
+ * Wait for any download to complete
85
+ * @param timeoutMs Timeout in milliseconds
86
+ * @returns Path to the downloaded file
87
+ */
88
+ async waitForDownload(timeoutMs = 60000) {
89
+ return new Promise((resolve, reject) => {
90
+ const timeoutId = setTimeout(() => {
91
+ reject(new Error(`Download timeout after ${timeoutMs}ms`));
92
+ }, timeoutMs);
93
+ // Check if there's already a pending download
94
+ const existingDownload = Array.from(this.pendingDownloads.values()).find(d => d.state === 'inProgress');
95
+ const handleCompletion = (filepath) => {
96
+ clearTimeout(timeoutId);
97
+ this.off('completed', handleCompletion);
98
+ this.off('canceled', handleCancellation);
99
+ resolve(filepath);
100
+ };
101
+ const handleCancellation = (filename) => {
102
+ clearTimeout(timeoutId);
103
+ this.off('completed', handleCompletion);
104
+ this.off('canceled', handleCancellation);
105
+ reject(new Error(`Download canceled: ${filename}`));
106
+ };
107
+ this.on('completed', handleCompletion);
108
+ this.on('canceled', handleCancellation);
109
+ // If a download is already completed, resolve immediately
110
+ const completedDownload = Array.from(this.pendingDownloads.values()).find(d => d.state === 'completed' && d.resolvedPath);
111
+ if (completedDownload?.resolvedPath) {
112
+ clearTimeout(timeoutId);
113
+ this.off('completed', handleCompletion);
114
+ this.off('canceled', handleCancellation);
115
+ resolve(completedDownload.resolvedPath);
116
+ }
117
+ });
118
+ }
119
+ /**
120
+ * Wait for a specific download by GUID
121
+ */
122
+ async waitForSpecificDownload(guid, timeoutMs = 60000) {
123
+ return new Promise((resolve, reject) => {
124
+ const timeoutId = setTimeout(() => {
125
+ this.downloadPromiseResolvers.delete(guid);
126
+ reject(new Error(`Download timeout after ${timeoutMs}ms`));
127
+ }, timeoutMs);
128
+ // Check if already completed
129
+ const info = this.pendingDownloads.get(guid);
130
+ if (info?.state === 'completed' && info.resolvedPath) {
131
+ clearTimeout(timeoutId);
132
+ resolve(info.resolvedPath);
133
+ return;
134
+ }
135
+ this.downloadPromiseResolvers.set(guid, {
136
+ resolve: (path) => {
137
+ clearTimeout(timeoutId);
138
+ resolve(path);
139
+ },
140
+ reject: (err) => {
141
+ clearTimeout(timeoutId);
142
+ reject(err);
143
+ },
144
+ });
145
+ });
146
+ }
147
+ /**
148
+ * Get current pending downloads
149
+ */
150
+ getPendingDownloads() {
151
+ return Array.from(this.pendingDownloads.values()).filter(d => d.state === 'inProgress');
152
+ }
153
+ /**
154
+ * Clear download history
155
+ */
156
+ clearHistory() {
157
+ this.pendingDownloads.clear();
158
+ }
159
+ /**
160
+ * Release resources
161
+ */
162
+ async dispose() {
163
+ if (this.cdpSession) {
164
+ try {
165
+ // Reset download behavior to default
166
+ await this.cdpSession.send('Browser.setDownloadBehavior', {
167
+ behavior: 'default',
168
+ });
169
+ await this.cdpSession.detach();
170
+ }
171
+ catch {
172
+ // Ignore errors during cleanup
173
+ }
174
+ this.cdpSession = null;
175
+ }
176
+ this.isMonitoring = false;
177
+ this.pendingDownloads.clear();
178
+ this.downloadPromiseResolvers.clear();
179
+ this.removeAllListeners();
180
+ }
181
+ }
@@ -9,6 +9,7 @@ import os from 'node:os';
9
9
  import { Jimp } from 'jimp';
10
10
  import z from 'zod';
11
11
  import { GEMINI_CONFIG } from '../config.js';
12
+ import { DownloadManager } from '../download-manager.js';
12
13
  import { getLoginStatus, waitForLoginStatus, LoginStatus, } from '../login-helper.js';
13
14
  import { ToolCategories } from './categories.js';
14
15
  import { defineTool } from './ToolDefinition.js';
@@ -84,30 +85,6 @@ async function cropWatermark(inputPath, outputPath, margin = DEFAULT_CROP_MARGIN
84
85
  await image.write(outputPath);
85
86
  return { width: newWidth, height: newHeight };
86
87
  }
87
- /**
88
- * Wait for download to complete and return the file path
89
- */
90
- async function waitForDownload(downloadDir, timeoutMs = 60000) {
91
- const startTime = Date.now();
92
- const checkInterval = 500;
93
- // Get initial files
94
- const initialFiles = new Set(await fs.promises.readdir(downloadDir));
95
- while (Date.now() - startTime < timeoutMs) {
96
- await new Promise(resolve => setTimeout(resolve, checkInterval));
97
- const currentFiles = await fs.promises.readdir(downloadDir);
98
- const newFiles = currentFiles.filter(f => !initialFiles.has(f) &&
99
- !f.endsWith('.crdownload') &&
100
- !f.endsWith('.tmp'));
101
- if (newFiles.length > 0) {
102
- // Return the newest file (by modification time)
103
- const filePaths = newFiles.map(f => path.join(downloadDir, f));
104
- const stats = await Promise.all(filePaths.map(async (p) => ({ path: p, mtime: (await fs.promises.stat(p)).mtime })));
105
- stats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
106
- return stats[0].path;
107
- }
108
- }
109
- throw new Error(`Download timeout after ${timeoutMs}ms`);
110
- }
111
88
  export const askGeminiImage = defineTool({
112
89
  name: 'ask_gemini_image',
113
90
  description: 'Generate image using Gemini (Nano Banana / 3 Preview) via browser. ' +
@@ -140,15 +117,6 @@ export const askGeminiImage = defineTool({
140
117
  handler: async (request, response, context) => {
141
118
  const { prompt, outputPath, cropMargin = DEFAULT_CROP_MARGIN, skipCrop = false, } = request.params;
142
119
  const page = await getOrCreateGeminiPage(context);
143
- // Set up download directory
144
- const downloadDir = path.join(os.tmpdir(), 'gemini-image-downloads');
145
- await fs.promises.mkdir(downloadDir, { recursive: true });
146
- // Configure download behavior
147
- const client = await page.createCDPSession();
148
- await client.send('Page.setDownloadBehavior', {
149
- behavior: 'allow',
150
- downloadPath: downloadDir,
151
- });
152
120
  try {
153
121
  response.appendResponseLine('Geminiに接続中...');
154
122
  // Navigate to Gemini
@@ -260,83 +228,121 @@ export const askGeminiImage = defineTool({
260
228
  response.appendResponseLine('❌ 画像生成タイムアウト (3分)');
261
229
  return;
262
230
  }
263
- // Try to download the image
231
+ // Try to download the image using CDP-based download manager
264
232
  response.appendResponseLine('📥 画像をダウンロード中...');
265
- // Method 1: Click download button
266
- const downloadClicked = await page.evaluate(() => {
267
- // Look for three-dot menu or download button on the image
268
- const buttons = Array.from(document.querySelectorAll('button, [role="menuitem"]'));
269
- // First try direct download button
270
- let downloadBtn = buttons.find(b => b.textContent?.includes('ダウンロード') ||
271
- b.textContent?.includes('Download'));
272
- if (downloadBtn) {
273
- downloadBtn.click();
274
- return 'direct';
275
- }
276
- // Try three-dot menu on generated images
277
- const moreButtons = buttons.filter(b => b.getAttribute('aria-label')?.includes('more') ||
278
- b.getAttribute('aria-label')?.includes('その他') ||
279
- b.querySelector('mat-icon[data-mat-icon-name="more_vert"]'));
280
- if (moreButtons.length > 0) {
281
- // Click the last one (likely the image menu)
282
- moreButtons[moreButtons.length - 1].click();
283
- return 'menu-opened';
284
- }
285
- return null;
286
- });
287
- if (downloadClicked === 'menu-opened') {
288
- // Wait for menu to open and click download
289
- await new Promise(resolve => setTimeout(resolve, 500));
290
- await page.evaluate(() => {
291
- const menuItems = Array.from(document.querySelectorAll('[role="menuitem"], button'));
292
- const downloadItem = menuItems.find(item => item.textContent?.includes('ダウンロード') ||
293
- item.textContent?.includes('Download'));
294
- if (downloadItem) {
295
- downloadItem.click();
233
+ // Set up download manager with CDP events
234
+ const userDownloadsDir = path.join(os.homedir(), 'Downloads');
235
+ const downloadManager = new DownloadManager(page, userDownloadsDir);
236
+ try {
237
+ await downloadManager.startMonitoring();
238
+ // Listen for progress updates
239
+ downloadManager.on('progress', (percent, filename) => {
240
+ if (percent % 25 === 0) {
241
+ response.appendResponseLine(`📥 ダウンロード中... ${percent}% (${filename})`);
296
242
  }
297
243
  });
298
- }
299
- // Wait for download to complete
300
- let downloadedPath;
301
- try {
302
- downloadedPath = await waitForDownload(downloadDir, 30000);
303
- response.appendResponseLine(`✅ ダウンロード完了: ${path.basename(downloadedPath)}`);
304
- }
305
- catch (error) {
306
- response.appendResponseLine('❌ ダウンロード待機タイムアウト');
307
- response.appendResponseLine('ヒント: ブラウザで手動でダウンロードしてください');
308
- return;
309
- }
310
- // Ensure output directory exists
311
- const outputDir = path.dirname(outputPath);
312
- await fs.promises.mkdir(outputDir, { recursive: true });
313
- // Crop watermark or copy directly
314
- if (skipCrop) {
315
- await fs.promises.copyFile(downloadedPath, outputPath);
316
- response.appendResponseLine(`📄 画像保存(クロップなし): ${outputPath}`);
317
- }
318
- else {
319
- response.appendResponseLine(`✂️ ウォーターマークをクロップ中 (margin: ${cropMargin}px)...`);
244
+ downloadManager.on('started', (filename) => {
245
+ response.appendResponseLine(`📥 ダウンロード開始: ${filename}`);
246
+ });
247
+ // Click download button - Gemini uses "フルサイズの画像をダウンロード" button
248
+ // Improved selector: prioritize aria-describedby for more reliable detection
249
+ const downloadClicked = await page.evaluate(() => {
250
+ const buttons = Array.from(document.querySelectorAll('button'));
251
+ // First, try to find button with aria-describedby pointing to "フルサイズ"
252
+ let downloadBtn = buttons.find(b => {
253
+ const describedBy = b.getAttribute('aria-describedby');
254
+ if (describedBy) {
255
+ const descEl = document.getElementById(describedBy);
256
+ const desc = descEl?.textContent || '';
257
+ return desc.includes('フルサイズ') || desc.includes('ダウンロード');
258
+ }
259
+ return false;
260
+ });
261
+ // Fallback: look for text/aria-label containing download keywords
262
+ if (!downloadBtn) {
263
+ downloadBtn = buttons.find(b => {
264
+ const text = b.textContent || '';
265
+ const ariaLabel = b.getAttribute('aria-label') || '';
266
+ // Avoid "Cancel download" buttons
267
+ const lowerText = text.toLowerCase();
268
+ const lowerLabel = ariaLabel.toLowerCase();
269
+ if (lowerText.includes('cancel') || lowerLabel.includes('cancel') ||
270
+ lowerText.includes('キャンセル') || lowerLabel.includes('キャンセル')) {
271
+ return false;
272
+ }
273
+ return (text.includes('フルサイズ') ||
274
+ text.includes('ダウンロード') ||
275
+ ariaLabel.includes('ダウンロード') ||
276
+ ariaLabel.includes('download'));
277
+ });
278
+ }
279
+ if (downloadBtn) {
280
+ downloadBtn.click();
281
+ return true;
282
+ }
283
+ return false;
284
+ });
285
+ if (!downloadClicked) {
286
+ response.appendResponseLine('⚠️ ダウンロードボタンが見つかりません');
287
+ response.appendResponseLine('ヒント: ブラウザで画像を右クリックして保存してください');
288
+ return;
289
+ }
290
+ // Wait for download to complete using CDP events (more reliable than file polling)
291
+ response.appendResponseLine('⏳ CDP経由でダウンロード完了を待機中...');
292
+ let downloadedPath;
320
293
  try {
321
- const { width, height } = await cropWatermark(downloadedPath, outputPath, cropMargin);
322
- response.appendResponseLine(`✅ クロップ完了: ${width}x${height}px → ${outputPath}`);
294
+ downloadedPath = await downloadManager.waitForDownload(60000); // 60 seconds
295
+ response.appendResponseLine(`✅ ダウンロード完了: ${path.basename(downloadedPath)}`);
296
+ }
297
+ catch (downloadError) {
298
+ const errMsg = downloadError instanceof Error ? downloadError.message : String(downloadError);
299
+ if (errMsg.includes('timeout')) {
300
+ response.appendResponseLine('❌ ダウンロードタイムアウト (60秒)');
301
+ response.appendResponseLine('💡 ヒント: ブラウザで画像を右クリックして「画像を保存」してください');
302
+ }
303
+ else if (errMsg.includes('canceled')) {
304
+ response.appendResponseLine('❌ ダウンロードがキャンセルされました');
305
+ }
306
+ else {
307
+ response.appendResponseLine(`❌ ダウンロードエラー: ${errMsg}`);
308
+ }
309
+ return;
323
310
  }
324
- catch (error) {
325
- const msg = error instanceof Error ? error.message : String(error);
326
- response.appendResponseLine(`⚠️ クロップ失敗: ${msg}`);
327
- response.appendResponseLine('元の画像をそのまま保存します...');
311
+ // Ensure output directory exists
312
+ const outputDir = path.dirname(outputPath);
313
+ await fs.promises.mkdir(outputDir, { recursive: true });
314
+ // Crop watermark or copy directly
315
+ if (skipCrop) {
328
316
  await fs.promises.copyFile(downloadedPath, outputPath);
317
+ response.appendResponseLine(`📄 画像保存(クロップなし): ${outputPath}`);
329
318
  }
319
+ else {
320
+ response.appendResponseLine(`✂️ ウォーターマークをクロップ中 (margin: ${cropMargin}px)...`);
321
+ try {
322
+ const { width, height } = await cropWatermark(downloadedPath, outputPath, cropMargin);
323
+ response.appendResponseLine(`✅ クロップ完了: ${width}x${height}px → ${outputPath}`);
324
+ }
325
+ catch (cropError) {
326
+ const msg = cropError instanceof Error ? cropError.message : String(cropError);
327
+ response.appendResponseLine(`⚠️ クロップ失敗: ${msg}`);
328
+ response.appendResponseLine('元の画像をそのまま保存します...');
329
+ await fs.promises.copyFile(downloadedPath, outputPath);
330
+ }
331
+ }
332
+ // Cleanup temp file
333
+ try {
334
+ await fs.promises.unlink(downloadedPath);
335
+ }
336
+ catch {
337
+ // Ignore cleanup errors
338
+ }
339
+ response.appendResponseLine('\n🎉 画像生成完了!');
340
+ response.appendResponseLine(`📁 出力: ${outputPath}`);
330
341
  }
331
- // Cleanup temp file
332
- try {
333
- await fs.promises.unlink(downloadedPath);
334
- }
335
- catch {
336
- // Ignore cleanup errors
342
+ finally {
343
+ // Ensure download manager is always disposed
344
+ await downloadManager.dispose();
337
345
  }
338
- response.appendResponseLine('\n🎉 画像生成完了!');
339
- response.appendResponseLine(`📁 出力: ${outputPath}`);
340
346
  }
341
347
  catch (error) {
342
348
  const msg = error instanceof Error ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-ai-bridge",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "MCP server bridging Chrome browser and AI assistants (ChatGPT, Gemini). Browser automation + AI consultation.",
5
5
  "type": "module",
6
6
  "bin": "./scripts/cli.mjs",