cdp-tunnel 3.5.0 → 3.6.0

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.
@@ -20,6 +20,15 @@ var SpecialHandler = (function() {
20
20
  return (wm && wm.config && wm.config.tag) || null;
21
21
  }
22
22
 
23
+ // 从 state 读取 key 名称(用作分组名),doGroup 等分组操作调用
24
+ function _getGroupName(clientId, context) {
25
+ var state = context ? context._state : null;
26
+ if (state && clientId && state.getGroupNameForClient) {
27
+ return state.getGroupNameForClient(clientId);
28
+ }
29
+ return null;
30
+ }
31
+
23
32
  function muteTabIfNeeded(tabId) {
24
33
  Config.getAutoMute(function(enabled) {
25
34
  if (!enabled) return;
@@ -297,7 +306,7 @@ var SpecialHandler = (function() {
297
306
  return;
298
307
  }
299
308
  }
300
- var baseName = CDPUtils.getGroupBaseName(groupClientId, _getConnectionTag(context), context ? context.mode : null);
309
+ var baseName = CDPUtils.getGroupBaseName(groupClientId, _getConnectionTag(context), context ? context.mode : null, _getGroupName(groupClientId, context));
301
310
 
302
311
  Logger.info('[TabGroup] Grouping tab immediately for:', baseName);
303
312
  doGroup(tabId, groupClientId, baseName, 0, callback, context);
@@ -465,7 +474,8 @@ var SpecialHandler = (function() {
465
474
  chrome.tabs.query({ groupId: groupId }, function(tabs) {
466
475
  if (chrome.runtime.lastError || !tabs) return;
467
476
 
468
- var baseName = CDPUtils.getGroupBaseName(clientId, connectionTag, mode);
477
+ var groupName = (state && state.getGroupNameForClient) ? state.getGroupNameForClient(clientId) : null;
478
+ var baseName = CDPUtils.getGroupBaseName(clientId, connectionTag, mode, groupName);
469
479
  var newName = baseName + ' (' + tabs.length + ')';
470
480
 
471
481
  if (chrome.runtime.lastError || tabs.length === 0) return;
@@ -308,6 +308,15 @@ ConnectionState.prototype.getTagForClient = function(clientId) {
308
308
  return this.clientIdToTag.get(clientId) || null;
309
309
  };
310
310
 
311
+ ConnectionState.prototype.setGroupNameForClient = function(clientId, groupName) {
312
+ if (!this.clientIdToGroupName) this.clientIdToGroupName = new Map();
313
+ if (clientId && groupName) this.clientIdToGroupName.set(clientId, groupName);
314
+ };
315
+
316
+ ConnectionState.prototype.getGroupNameForClient = function(clientId) {
317
+ return (this.clientIdToGroupName && this.clientIdToGroupName.get(clientId)) || null;
318
+ };
319
+
311
320
  ConnectionState.prototype.addCDPClient = function(clientId, info) {
312
321
  var exists = false;
313
322
  for (var i = 0; i < this.cdpClients.length; i++) {
@@ -244,7 +244,12 @@ var WebSocketConnection = (function() {
244
244
  if (message.__connectionTag) {
245
245
  self.state.setTagForClient(message.clientId, message.__connectionTag);
246
246
  }
247
- self._createGroupForClient(message.clientId, message.__mode);
247
+ // 存 groupName,供 doGroup 等后续分组操作使用
248
+ if (message.__groupName) {
249
+ self.state.setGroupNameForClient(message.clientId, message.__groupName);
250
+ }
251
+ // __groupName 来自 API Key 名称,用作 Chrome 分组名(一眼看出是谁的浏览器)
252
+ self._createGroupForClient(message.clientId, message.__mode, message.__groupName);
248
253
  self._broadcastStateUpdate();
249
254
  break;
250
255
 
@@ -555,7 +560,7 @@ var WebSocketConnection = (function() {
555
560
  });
556
561
  };
557
562
 
558
- WebSocketConnection.prototype._createGroupForClient = function(clientId, mode) {
563
+ WebSocketConnection.prototype._createGroupForClient = function(clientId, mode, groupName) {
559
564
  var self = this;
560
565
  if (!clientId || !chrome.tabGroups) return;
561
566
  if (mode === 'takeover') {
@@ -581,7 +586,7 @@ var WebSocketConnection = (function() {
581
586
  self.state.setGroupCreationPromise(clientId, readyPromise);
582
587
 
583
588
  var tag = (self.state.getTagForClient ? self.state.getTagForClient(clientId) : null) || (self.config ? self.config.tag : null);
584
- var baseName = CDPUtils.getGroupBaseName(clientId, tag, mode);
589
+ var baseName = CDPUtils.getGroupBaseName(clientId, tag, mode, groupName);
585
590
  chrome.tabs.query({ currentWindow: true }, function(tabs) {
586
591
  if (!tabs || tabs.length === 0) {
587
592
  Logger.warn('[WS:' + self.connectionId + '] No tabs found for group creation');
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "CDP Bridge",
4
- "version": "3.5.0",
4
+ "version": "3.6.0",
5
5
  "description": "Chrome DevTools Protocol Bridge for Playwright/Puppeteer automation",
6
6
  "permissions": [
7
7
  "debugger",
@@ -1,5 +1,5 @@
1
1
  var Config = {
2
- WS_URL: 'ws://localhost:43226/plugin',
2
+ WS_URL: 'ws://localhost:14576/plugin',
3
3
  RECONNECT_DELAY: 3000,
4
4
  DEBUGGER_VERSION: '1.3',
5
5
  HEARTBEAT_INTERVAL: 25000,
@@ -42,8 +42,14 @@ var CDPUtils = (function() {
42
42
  return 0;
43
43
  }
44
44
 
45
- function buildGroupName(clientId, connectionTag, mode) {
45
+ function buildGroupName(clientId, connectionTag, mode, groupName) {
46
46
  if (!clientId) return 'CDP';
47
+ // 如果传了 groupName(来自 API Key 的名称),直接用它作为分组名
48
+ // Chrome 分组显示为 "CDP-张三的浏览器",一眼看出是谁的浏览器
49
+ if (groupName) {
50
+ var prefixG = (mode === 'takeover') ? 'TAKE-' : 'CDP-';
51
+ return prefixG + groupName;
52
+ }
47
53
  var hash = 0;
48
54
  for (var i = 0; i < clientId.length; i++) {
49
55
  var chr = clientId.charCodeAt(i);
@@ -56,8 +62,8 @@ var CDPUtils = (function() {
56
62
  return prefix + tag + suffix;
57
63
  }
58
64
 
59
- function getGroupBaseName(clientId, connectionTag, mode) {
60
- return buildGroupName(clientId, connectionTag, mode);
65
+ function getGroupBaseName(clientId, connectionTag, mode, groupName) {
66
+ return buildGroupName(clientId, connectionTag, mode, groupName);
61
67
  }
62
68
 
63
69
  function findGroupByName(allGroups, baseName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-tunnel",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "Bridge Chrome's debugger API to WebSocket — control your existing browser with Playwright/Puppeteer via CDP",
5
5
  "main": "server/proxy-server.js",
6
6
  "bin": "./cli/index.js",
@@ -0,0 +1,481 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CDP Tunnel 管理控制台</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; color: #1f2937; }
10
+ .header { background: #1a1a2e; color: white; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
11
+ .header h1 { font-size: 18px; }
12
+ .header .token-area { display: flex; gap: 8px; align-items: center; }
13
+ .header input { background: #16213e; border: 1px solid #0f3460; color: white; padding: 6px 10px; border-radius: 4px; width: 200px; font-size: 13px; }
14
+ .header button { background: #0f3460; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; }
15
+ .container { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
16
+ .section { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
17
+ .section h2 { font-size: 16px; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid #e5e7eb; }
18
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
19
+ th { text-align: left; padding: 8px 10px; background: #f9fafb; color: #6b7280; font-weight: 600; border-bottom: 1px solid #e5e7eb; }
20
+ td { padding: 10px; border-bottom: 1px solid #f3f4f6; }
21
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; }
22
+ .badge-online { background: #d1fae5; color: #065f46; }
23
+ .badge-offline { background: #fee2e2; color: #991b1b; }
24
+ .btn { padding: 4px 10px; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
25
+ .btn-primary { background: #3b82f6; color: white; }
26
+ .btn-danger { background: #ef4444; color: white; }
27
+ .btn-copy { background: #e5e7eb; color: #374151; }
28
+ .btn:hover { opacity: 0.85; }
29
+ .mono { font-family: 'Monaco', 'Courier New', monospace; font-size: 12px; }
30
+ .muted { color: #9ca3af; }
31
+ .empty { text-align: center; padding: 30px; color: #9ca3af; }
32
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
33
+ .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: none; justify-content: center; align-items: center; z-index: 100; }
34
+ .modal-overlay.show { display: flex; }
35
+ .modal { background: white; padding: 24px; border-radius: 8px; max-width: 500px; width: 90%; }
36
+ .modal h3 { margin-bottom: 16px; }
37
+ .modal input, .modal textarea { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px; margin-bottom: 12px; font-family: 'Monaco', monospace; font-size: 13px; }
38
+ .modal textarea { height: 80px; resize: vertical; }
39
+ .cdp-result { background: #1a1a2e; color: #4ade80; padding: 12px; border-radius: 4px; font-family: 'Monaco', monospace; font-size: 12px; max-height: 300px; overflow: auto; margin-top: 12px; white-space: pre-wrap; word-break: break-all; }
40
+ .screenshot-preview { max-width: 100%; border: 1px solid #e5e7eb; border-radius: 4px; margin-top: 12px; }
41
+ .toast { position: fixed; bottom: 20px; right: 20px; background: #1a1a2e; color: white; padding: 12px 20px; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); display: none; z-index: 200; }
42
+ .toast.show { display: block; }
43
+ </style>
44
+ </head>
45
+ <body>
46
+
47
+ <div class="header">
48
+ <h1>🎯 CDP Tunnel 管理控制台</h1>
49
+ <div class="token-area">
50
+ <input type="password" id="tokenInput" placeholder="Admin Token(可选)">
51
+ <button onclick="saveToken()">保存</button>
52
+ <button onclick="refreshAll()">🔄 刷新</button>
53
+ </div>
54
+ </div>
55
+
56
+ <div class="container">
57
+
58
+ <!-- 在线浏览器 -->
59
+ <div class="section">
60
+ <h2>📱 在线浏览器 <span id="browserCount" class="muted"></span></h2>
61
+ <div class="actions" style="margin-bottom:12px;">
62
+ <button class="btn btn-primary" onclick="showCreateBrowserModal()" style="font-weight:600;">➕ 创建浏览器(新分组)</button>
63
+ </div>
64
+ <div id="browsersTable"><div class="empty">加载中...</div></div>
65
+ </div>
66
+
67
+ <!-- API Key 管理 -->
68
+ <div class="section">
69
+ <h2>🔑 API Key 管理</h2>
70
+ <div class="actions">
71
+ <button class="btn btn-primary" onclick="showCreateKeyModal()">➕ 创建 Key</button>
72
+ </div>
73
+ <div id="keysTable"><div class="empty">加载中...</div></div>
74
+ </div>
75
+
76
+ <!-- CDP 快捷操作 -->
77
+ <div class="section">
78
+ <h2>⚡ CDP 快捷操作 <span id="selectedBrowser" class="muted" style="font-size:13px;font-weight:normal;"></span></h2>
79
+ <div id="cdpActions" style="display:none;">
80
+ <!-- 快捷操作 -->
81
+ <div style="margin-bottom:12px; padding:10px; background:#f9fafb; border-radius:6px;">
82
+ <div style="font-size:12px; color:#6b7280; margin-bottom:8px;">⚡ 快捷操作(点一下直接执行)</div>
83
+ <div class="actions">
84
+ <button class="btn btn-copy" onclick="quickOpen('https://www.baidu.com')">🔍 打开百度</button>
85
+ <button class="btn btn-copy" onclick="quickOpen('https://www.example.com')">📰 打开 Example</button>
86
+ <button class="btn btn-copy" onclick="quickInfo('url')">📋 看 URL</button>
87
+ <button class="btn btn-copy" onclick="quickInfo('title')">📊 看 Title</button>
88
+ <button class="btn btn-copy" onclick="quickRefresh()">🔄 刷新页面</button>
89
+ <button class="btn btn-copy" onclick="cdpScreenshot()">📸 截图</button>
90
+ <button class="btn" style="background:#f59e0b;color:white;font-weight:600;" onclick="runDemo()">🎬 一键演示</button>
91
+ </div>
92
+ </div>
93
+ <!-- 高级操作 -->
94
+ <div style="margin-bottom:12px; padding:10px; background:#f0f9ff; border-radius:6px;">
95
+ <div style="font-size:12px; color:#6b7280; margin-bottom:8px;">🔧 Tab 管理</div>
96
+ <div class="actions">
97
+ <button class="btn btn-primary" onclick="cdpListTabs()">📋 Tab 列表</button>
98
+ <button class="btn btn-primary" onclick="cdpNewTab()">🆕 新建标签</button>
99
+ <button class="btn" style="background:#dc2626;color:white;" onclick="cdpCloseBrowser()">🔌 关闭浏览器</button>
100
+ </div>
101
+ <div id="tabList" style="margin-top:10px;"></div>
102
+ </div>
103
+ <div class="actions">
104
+ <button class="btn btn-primary" onclick="cdpEvaluate()">📝 执行 JS</button>
105
+ <button class="btn btn-primary" onclick="cdpScreenshot()">📸 截图</button>
106
+ </div>
107
+ <div id="cdpResult"></div>
108
+ </div>
109
+ <div id="cdpEmpty" class="empty">👆 先在上方浏览器列表点"选择"按钮</div>
110
+ </div>
111
+
112
+ </div>
113
+
114
+ <!-- 创建浏览器弹窗 -->
115
+ <div class="modal-overlay" id="createBrowserModal">
116
+ <div class="modal" style="max-width:600px;">
117
+ <h3>🆕 创建浏览器(新分组)</h3>
118
+ <p class="muted" style="font-size:13px; margin-bottom:12px;">创建后会生成一个专用地址。把这个地址填到用户 Chrome 扩展的配置页,扩展连上后这里就会显示为在线浏览器。</p>
119
+ <input type="text" id="browserName" placeholder="浏览器名称(如:张三的电脑)" style="margin-bottom:12px;">
120
+ <div id="browserCreateResult" style="display:none; margin-bottom:12px; padding:12px; background:#f0f9ff; border-radius:6px;"></div>
121
+ <div style="display:flex; gap:8px; justify-content:flex-end;">
122
+ <button class="btn" style="background:#e5e7eb;" onclick="closeModal('createBrowserModal')">关闭</button>
123
+ <button class="btn btn-primary" id="createBrowserBtn" onclick="createBrowser()">创建</button>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <!-- 创建 Key 弹窗 -->
129
+ <div class="modal-overlay" id="createKeyModal">
130
+ <div class="modal">
131
+ <h3>创建 API Key</h3>
132
+ <input type="text" id="keyName" placeholder="名称(如:我的浏览器)">
133
+ <div style="display:flex; gap:8px; justify-content:flex-end;">
134
+ <button class="btn" style="background:#e5e7eb;" onclick="closeModal('createKeyModal')">取消</button>
135
+ <button class="btn btn-primary" onclick="createKey()">创建</button>
136
+ </div>
137
+ </div>
138
+ </div>
139
+
140
+ <!-- 执行 JS 弹窗 -->
141
+ <div class="modal-overlay" id="evaluateModal">
142
+ <div class="modal">
143
+ <h3>执行 JavaScript</h3>
144
+ <textarea id="jsExpression" placeholder="document.title"></textarea>
145
+ <div style="display:flex; gap:8px; justify-content:flex-end;">
146
+ <button class="btn" style="background:#e5e7eb;" onclick="closeModal('evaluateModal')">取消</button>
147
+ <button class="btn btn-primary" onclick="runEvaluate()">执行</button>
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- 新建标签弹窗 -->
153
+ <div class="modal-overlay" id="newtabModal">
154
+ <div class="modal">
155
+ <h3>新建标签页</h3>
156
+ <input type="text" id="newtabUrl" placeholder="https://example.com">
157
+ <div style="display:flex; gap:8px; justify-content:flex-end;">
158
+ <button class="btn" style="background:#e5e7eb;" onclick="closeModal('newtabModal')">取消</button>
159
+ <button class="btn btn-primary" onclick="runNewTab()">创建</button>
160
+ </div>
161
+ </div>
162
+ </div>
163
+
164
+ <div class="toast" id="toast"></div>
165
+
166
+ <script>
167
+ let TOKEN = localStorage.getItem('admin_token') || '';
168
+
169
+ // ===== 初始化 =====
170
+ window.onload = () => {
171
+ document.getElementById('tokenInput').value = TOKEN;
172
+ refreshAll();
173
+ // 自动刷新浏览器列表(5秒)
174
+ setInterval(loadBrowsers, 5000);
175
+ };
176
+
177
+ function saveToken() {
178
+ TOKEN = document.getElementById('tokenInput').value;
179
+ localStorage.setItem('admin_token', TOKEN);
180
+ toast('Token 已保存');
181
+ refreshAll();
182
+ }
183
+
184
+ function api(path, method = 'GET', body = null) {
185
+ const headers = { 'Content-Type': 'application/json' };
186
+ if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN;
187
+ return fetch('/admin/api/' + path, { method, headers, body: body ? JSON.stringify(body) : null })
188
+ .then(r => r.json().then(d => ({ ok: r.ok, status: r.status, data: d })));
189
+ }
190
+
191
+ // ===== 浏览器列表 =====
192
+ async function loadBrowsers() {
193
+ try {
194
+ const { ok, data } = await api('browsers');
195
+ if (!ok) { document.getElementById('browsersTable').innerHTML = '<div class="empty">' + (data.error || '加载失败') + '</div>'; return; }
196
+ document.getElementById('browserCount').textContent = `(${data.length} 个)`;
197
+ if (!data.length) { document.getElementById('browsersTable').innerHTML = '<div class="empty">没有浏览器连接</div>'; return; }
198
+ let html = '<table><tr><th>状态</th><th>名称</th><th>UA</th><th>扩展版本</th><th>连接时间</th><th>操作</th></tr>';
199
+ data.forEach(b => {
200
+ const time = b.connectedAt ? new Date(b.connectedAt).toLocaleTimeString() : '-';
201
+ const ua = (b.userAgent || '').slice(0, 40) || b.browserName || '-';
202
+ const selected = SELECTED_PLUGIN === b.pluginId ? ' style="background:#eff6ff;"' : '';
203
+ html += `<tr${selected}>
204
+ <td><span class="badge badge-online">在线</span></td>
205
+ <td>${b.pluginName || '-'}</td>
206
+ <td class="mono" title="${b.userAgent}">${ua}</td>
207
+ <td><span class="badge badge-online">${b.extVersion}</span></td>
208
+ <td class="muted">${time}</td>
209
+ <td>
210
+ <button class="btn ${SELECTED_PLUGIN === b.pluginId ? 'btn-primary' : 'btn-copy'}" onclick="selectBrowser('${b.pluginId}','${b.pluginName || 'Browser'}')">
211
+ ${SELECTED_PLUGIN === b.pluginId ? '✓ 已选中' : '选择'}
212
+ </button>
213
+ </td>
214
+ </tr>`;
215
+ });
216
+ document.getElementById('browsersTable').innerHTML = html + '</table>';
217
+ } catch (e) { document.getElementById('browsersTable').innerHTML = '<div class="empty">加载失败: ' + e.message + '</div>'; }
218
+ }
219
+
220
+ let SELECTED_PLUGIN = null;
221
+ function selectBrowser(pluginId, name) {
222
+ SELECTED_PLUGIN = pluginId;
223
+ document.getElementById('selectedBrowser').textContent = '已选: ' + name;
224
+ document.getElementById('cdpActions').style.display = 'block';
225
+ loadBrowsers(); // 刷新高亮
226
+ toast('已选择浏览器:' + name);
227
+ }
228
+
229
+ // ===== Key 管理 =====
230
+ async function loadKeys() {
231
+ try {
232
+ const { ok, data } = await api('keys');
233
+ if (!ok) { document.getElementById('keysTable').innerHTML = '<div class="empty">' + (data.error || '加载失败') + '</div>'; return; }
234
+ if (!data.length) { document.getElementById('keysTable').innerHTML = '<div class="empty">没有 key,点上方按钮创建</div>'; return; }
235
+ let html = '<table><tr><th>名称</th><th>状态</th><th>Key ID</th><th>最后使用</th><th>创建时间</th><th>操作</th></tr>';
236
+ data.forEach(k => {
237
+ const last = k.last_used_at ? new Date(k.last_used_at).toLocaleString() : '从未';
238
+ const created = k.created_at ? new Date(k.created_at).toLocaleDateString() : '-';
239
+ html += `<tr>
240
+ <td>${k.name}</td>
241
+ <td>${k.active ? '<span class="badge badge-online">有效</span>' : '<span class="badge badge-offline">已吊销</span>'}</td>
242
+ <td class="mono muted">${k.id}</td>
243
+ <td class="muted">${last}</td>
244
+ <td class="muted">${created}</td>
245
+ <td>${k.active ? '<button class="btn btn-danger" onclick="revokeKey(\'' + k.id + '\')">吊销</button>' : ''}</td>
246
+ </tr>`;
247
+ });
248
+ document.getElementById('keysTable').innerHTML = html + '</table>';
249
+ } catch (e) { document.getElementById('keysTable').innerHTML = '<div class="empty">加载失败: ' + e.message + '</div>'; }
250
+ }
251
+
252
+ function showCreateKeyModal() { document.getElementById('keyName').value = ''; document.getElementById('createKeyModal').classList.add('show'); }
253
+
254
+ // ===== 创建浏览器(= 创建 key + 生成完整地址)=====
255
+ function showCreateBrowserModal() {
256
+ document.getElementById('browserName').value = '';
257
+ document.getElementById('browserCreateResult').style.display = 'none';
258
+ document.getElementById('createBrowserBtn').style.display = '';
259
+ document.getElementById('createBrowserModal').classList.add('show');
260
+ }
261
+
262
+ async function createBrowser() {
263
+ const name = document.getElementById('browserName').value || ('browser-' + Date.now().toString(36));
264
+ const { ok, data } = await api('keys', 'POST', { name });
265
+ if (!ok) { toast('创建失败: ' + (data.error || ''), true); return; }
266
+
267
+ // 用当前域名生成完整地址(用户直接复制给扩展配置页)
268
+ const host = location.host; // cdp.shanbox.19930810.xyz:8443
269
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
270
+ const pluginUrl = `${proto}://${host}/plugin?key=${data.key}`;
271
+ const clientUrl = `${proto}://${host}/client?key=${data.key}`;
272
+
273
+ document.getElementById('createBrowserBtn').style.display = 'none';
274
+ const result = document.getElementById('browserCreateResult');
275
+ result.style.display = 'block';
276
+ result.innerHTML = `
277
+ <div style="font-weight:600; margin-bottom:8px;">✅ 浏览器 "${name}" 创建成功!</div>
278
+ <div style="margin-bottom:10px;">
279
+ <div style="font-size:12px; color:#6b7280; margin-bottom:4px;">📋 扩展配置地址(填到用户 Chrome 扩展配置页):</div>
280
+ <div class="mono" style="background:white; padding:8px; border-radius:4px; border:1px solid #e5e7eb; word-break:break-all;">${pluginUrl}</div>
281
+ <button class="btn btn-copy" style="margin-top:4px;" onclick="copyText('${pluginUrl}')">📋 复制扩展地址</button>
282
+ </div>
283
+ <div>
284
+ <div style="font-size:12px; color:#6b7280; margin-bottom:4px;">🔌 CDP 客户端地址(给 Playwright/Puppeteer 用):</div>
285
+ <div class="mono" style="background:white; padding:8px; border-radius:4px; border:1px solid #e5e7eb; word-break:break-all;">${clientUrl}</div>
286
+ <button class="btn btn-copy" style="margin-top:4px;" onclick="copyText('${clientUrl}')">📋 复制客户端地址</button>
287
+ </div>
288
+ <div class="muted" style="font-size:12px; margin-top:8px;">💡 用户填好扩展地址后,Chrome 里会自动出现 "${name}" 分组。扩展连上后上方"在线浏览器"列表会显示它。</div>
289
+ `;
290
+ loadKeys(); // 刷新 key 列表
291
+ }
292
+
293
+ async function createKey() {
294
+ const name = document.getElementById('keyName').value || ('browser-' + Date.now().toString(36));
295
+ const { ok, data } = await api('keys', 'POST', { name });
296
+ closeModal('createKeyModal');
297
+ if (!ok) { toast('创建失败: ' + (data.error || ''), true); return; }
298
+ // 显示完整的 key 和连接地址
299
+ let html = `<div style="margin-bottom:12px;">✅ Key 创建成功!</div>`;
300
+ html += `<div style="margin-bottom:8px;"><strong>Key:</strong><br><span class="mono">${data.key}</span> <button class="btn btn-copy" onclick="copyText('${data.key}')">📋</button></div>`;
301
+ html += `<div style="margin-bottom:8px;"><strong>扩展连接地址(填进扩展配置页):</strong><br><span class="mono">${data.pluginUrl}</span> <button class="btn btn-copy" onclick="copyText('${data.pluginUrl}')">📋</button></div>`;
302
+ html += `<div><strong>CDP 客户端地址:</strong><br><span class="mono">${data.clientUrl}</span> <button class="btn btn-copy" onclick="copyText('${data.clientUrl}')">📋</button></div>`;
303
+ html += `<div style="margin-top:12px;text-align:right;"><button class="btn btn-primary" onclick="document.getElementById('cdpResult').innerHTML=''; loadKeys();">知道了</button></div>`;
304
+ document.getElementById('cdpResult').innerHTML = html;
305
+ loadKeys();
306
+ }
307
+
308
+ async function revokeKey(id) {
309
+ if (!confirm('确定吊销这个 key?已吊销的 key 无法恢复。')) return;
310
+ const { ok } = await api('keys/' + id, 'DELETE');
311
+ toast(ok ? '已吊销' : '吊销失败', !ok);
312
+ loadKeys();
313
+ }
314
+
315
+ // ===== CDP 操作 =====
316
+ function cdpEvaluate() { if (!checkSelected()) return; document.getElementById('evaluateModal').classList.add('show'); }
317
+ function cdpScreenshot() { if (!checkSelected()) return; runCdp('screenshot'); }
318
+ function cdpNewTab() { if (!checkSelected()) return; document.getElementById('newtabModal').classList.add('show'); }
319
+
320
+ function checkSelected() {
321
+ if (!SELECTED_PLUGIN) { toast('请先在上方选择一个浏览器', true); return false; }
322
+ return true;
323
+ }
324
+
325
+ async function runEvaluate() {
326
+ const expr = document.getElementById('jsExpression').value || 'document.title';
327
+ closeModal('evaluateModal');
328
+ runCdp('evaluate', { expression: expr });
329
+ }
330
+
331
+ async function runNewTab() {
332
+ const url = document.getElementById('newtabUrl').value || 'about:blank';
333
+ closeModal('newtabModal');
334
+ runCdp('newtab', { url });
335
+ }
336
+
337
+ async function runCdp(action, extra = {}) {
338
+ document.getElementById('cdpResult').innerHTML = '<div class="cdp-result">执行中... (' + action + ')</div>';
339
+ const { ok, data } = await api('cdp/' + action, 'POST', { pluginId: SELECTED_PLUGIN, action, ...extra });
340
+ if (!ok) { document.getElementById('cdpResult').innerHTML = '<div class="cdp-result">❌ ' + (data.error || '失败') + '</div>'; return; }
341
+ if (data.error) { document.getElementById('cdpResult').innerHTML = '<div class="cdp-result">❌ ' + data.error + '</div>'; return; }
342
+ if (data.screenshot) {
343
+ document.getElementById('cdpResult').innerHTML = '<img class="screenshot-preview" src="data:image/png;base64,' + data.screenshot + '">';
344
+ } else if (data.result !== undefined) {
345
+ const display = typeof data.result === 'object' ? JSON.stringify(data.result, null, 2) : String(data.result);
346
+ document.getElementById('cdpResult').innerHTML = '<div class="cdp-result">✅ ' + display + '</div>';
347
+ } else if (data.targetId) {
348
+ document.getElementById('cdpResult').innerHTML = '<div class="cdp-result">✅ 已创建标签页: ' + data.targetId + '</div>';
349
+ loadBrowsers(); // 刷新 target 数
350
+ }
351
+ }
352
+
353
+ // ===== Tab 管理 =====
354
+ async function cdpListTabs() {
355
+ if (!checkSelected()) return;
356
+ document.getElementById('tabList').innerHTML = '<div class="muted" style="font-size:13px;">⏳ 加载中...</div>';
357
+ const { data } = await api('cdp/listtabs', 'POST', { pluginId: SELECTED_PLUGIN, action: 'listtabs' });
358
+ if (data.error) { document.getElementById('tabList').innerHTML = '<div class="muted">❌ ' + data.error + '</div>'; return; }
359
+ const tabs = data.tabs || [];
360
+ if (!tabs.length) { document.getElementById('tabList').innerHTML = '<div class="muted">没有标签页</div>'; return; }
361
+ let html = '<table style="font-size:12px;"><tr><th>标题</th><th>URL</th><th>操作</th></tr>';
362
+ tabs.forEach(t => {
363
+ const title = (t.title || '(无标题)').slice(0, 30);
364
+ const url = (t.url || '').slice(0, 40);
365
+ html += `<tr>
366
+ <td>${title}</td>
367
+ <td class="mono muted">${url}</td>
368
+ <td>
369
+ <button class="btn btn-copy" onclick="cdpSwitchTab('${t.targetId}')" title="切换到此标签">切换</button>
370
+ <button class="btn btn-danger" onclick="cdpCloseTab('${t.targetId}')" title="关闭此标签">关闭</button>
371
+ </td>
372
+ </tr>`;
373
+ });
374
+ document.getElementById('tabList').innerHTML = html + '</table>';
375
+ }
376
+
377
+ async function cdpCloseTab(targetId) {
378
+ if (!confirm('确定关闭这个标签页?')) return;
379
+ const { data } = await api('cdp/closetab', 'POST', { pluginId: SELECTED_PLUGIN, action: 'closetab', targetId });
380
+ toast(data.closed ? '已关闭' : '关闭失败: ' + (data.error || ''), !data.closed);
381
+ cdpListTabs(); // 刷新列表
382
+ }
383
+
384
+ async function cdpSwitchTab(targetId) {
385
+ const { data } = await api('cdp/switchtab', 'POST', { pluginId: SELECTED_PLUGIN, action: 'switchtab', targetId });
386
+ toast(data.switched ? '已切换' : '切换失败: ' + (data.error || ''), !data.switched);
387
+ }
388
+
389
+ async function cdpCloseBrowser() {
390
+ if (!confirm('⚠️ 确定关闭整个浏览器?这将断开扩展连接,所有标签页都会关闭。')) return;
391
+ if (!checkSelected()) return;
392
+ const { data } = await api('cdp/closebrowser', 'POST', { pluginId: SELECTED_PLUGIN, action: 'closebrowser' });
393
+ toast(data.closed ? '浏览器已关闭' : '关闭失败: ' + (data.error || ''), !data.closed);
394
+ setTimeout(() => refreshAll(), 2000);
395
+ }
396
+
397
+ // ===== 快捷操作 =====
398
+ async function quickOpen(url) {
399
+ if (!checkSelected()) return;
400
+ showResult('⏳ 正在打开 ' + url + ' ...');
401
+ // 用 navigate 方式(先拿到 page 的 sessionId,再 Page.navigate)
402
+ const { ok, data } = await api('cdp/evaluate', 'POST', {
403
+ pluginId: SELECTED_PLUGIN, action: 'evaluate',
404
+ expression: `window.location.href = '${url}'`
405
+ });
406
+ if (data.error) { showResult('❌ ' + data.error); return; }
407
+ showResult('✅ 已打开 ' + url + ',等待加载...');
408
+ setTimeout(() => cdpScreenshot(), 3000); // 3 秒后自动截图
409
+ }
410
+
411
+ async function quickInfo(field) {
412
+ if (!checkSelected()) return;
413
+ const expr = field === 'url' ? 'window.location.href' : 'document.title';
414
+ const { data } = await api('cdp/evaluate', 'POST', { pluginId: SELECTED_PLUGIN, action: 'evaluate', expression: expr });
415
+ if (data.error) { showResult('❌ ' + data.error); return; }
416
+ showResult('📋 ' + (field === 'url' ? 'URL' : 'Title') + ': ' + data.result);
417
+ }
418
+
419
+ async function quickRefresh() {
420
+ if (!checkSelected()) return;
421
+ const { data } = await api('cdp/evaluate', 'POST', { pluginId: SELECTED_PLUGIN, action: 'evaluate', expression: 'window.location.reload(); "refreshing"' });
422
+ showResult(data.error ? '❌ ' + data.error : '🔄 页面刷新中...');
423
+ setTimeout(() => cdpScreenshot(), 3000);
424
+ }
425
+
426
+ // ===== 一键演示循环 =====
427
+ async function runDemo() {
428
+ if (!checkSelected()) return;
429
+ const steps = [
430
+ { label: '🎬 开始演示', fn: () => showResult('🎬 演示开始!即将打开百度搜索"天气"\n\n') },
431
+ { label: '🔍 打开百度', fn: () => quickOpenSilent('https://www.baidu.com') },
432
+ { label: '⌨️ 输入"天气"', fn: () => demoEval('document.querySelector("#kw").value = "天气"; document.querySelector("#kw").dispatchEvent(new Event("input")); "已输入"') },
433
+ { label: '🔘 点击搜索', fn: () => demoEval('document.querySelector("#su").click(); "已点击搜索"') },
434
+ { label: '⏳ 等待结果加载', fn: () => sleep(3000) },
435
+ { label: '📋 搜索结果标题', fn: () => demoEval('document.title') },
436
+ { label: '📸 截图', fn: () => demoScreenshot() },
437
+ { label: '✅ 演示完成', fn: () => showResult(prevResult + '\n\n✅ 演示完成!这就是远程控制浏览器的完整链路。') },
438
+ ];
439
+ let prevResult = '';
440
+ showResult('🎬 演示开始...\n');
441
+ for (const step of steps) {
442
+ appendResult('\n' + step.label + '...');
443
+ try {
444
+ const r = await step.fn();
445
+ if (r) { prevResult = r; appendResult(' → ' + r); }
446
+ } catch (e) { appendResult(' ❌ ' + e.message); }
447
+ }
448
+ }
449
+
450
+ async function quickOpenSilent(url) {
451
+ await api('cdp/evaluate', 'POST', { pluginId: SELECTED_PLUGIN, action: 'evaluate', expression: `window.location.href='${url}'` });
452
+ await sleep(2000);
453
+ return '已加载';
454
+ }
455
+ async function demoEval(expr) {
456
+ const { data } = await api('cdp/evaluate', 'POST', { pluginId: SELECTED_PLUGIN, action: 'evaluate', expression: expr });
457
+ return data.error ? ('失败:' + data.error) : data.result;
458
+ }
459
+ async function demoScreenshot() {
460
+ const { data } = await api('cdp/screenshot', 'POST', { pluginId: SELECTED_PLUGIN, action: 'screenshot' });
461
+ if (data.screenshot) {
462
+ document.getElementById('cdpResult').innerHTML += '<br><img class="screenshot-preview" src="data:image/png;base64,' + data.screenshot + '">';
463
+ return '已截图';
464
+ }
465
+ return '截图失败';
466
+ }
467
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
468
+
469
+ function showResult(text) { document.getElementById('cdpResult').innerHTML = '<div class="cdp-result">' + text + '</div>'; }
470
+ function appendResult(text) {
471
+ const el = document.getElementById('cdpResult');
472
+ const current = el.querySelector('.cdp-result');
473
+ if (current) current.textContent += text;
474
+ }
475
+ function refreshAll() { loadBrowsers(); loadKeys(); }
476
+ function closeModal(id) { document.getElementById(id).classList.remove('show'); }
477
+ function copyText(text) { navigator.clipboard.writeText(text).then(() => toast('已复制')); }
478
+ function toast(msg, isError) { const t = document.getElementById('toast'); t.textContent = msg; t.style.background = isError ? '#ef4444' : '#1a1a2e'; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 2000); }
479
+ </script>
480
+ </body>
481
+ </html>
@@ -24,7 +24,9 @@ class PortPoolManager {
24
24
  this.idCounter = 0; // 全局唯一 id 计数器 // 现有 proxy 的引用(拿 plugin 连接)
25
25
  this.createServers = []; // [http.Server] 每个 create 端口一个
26
26
  this.createWss = []; // [WebSocket.Server] 每个 create 端口一个
27
- this.portSessions = []; // [PortSession] 每个 create 端口一个
27
+ this.portSessions = []; // [PortSession] 每个 create 端口一个(端口池端口用)
28
+ this.keySessions = new Map(); // apiKey → PortSession(主端口按 key 隔离,一 key 一 session)
29
+ this._keyToPortIndex = new Map(); // apiKey → portIndex(key 到端口池端口的映射)
28
30
  this.takeoverServer = null;
29
31
  this.targetToPort = new Map(); // targetId → portIndex(事件路由用)
30
32
  this.sessionToPort = new Map(); // CDP sessionId → portIndex
@@ -75,6 +77,34 @@ class PortPoolManager {
75
77
  console.log(`[CREATE PORT ${portIndex}] Main port ${port} (reuses main server)`);
76
78
  }
77
79
 
80
+ /**
81
+ * 按 key 获取/分配独立的端口池端口 session(一 key 一 session,完全隔离)
82
+ * 主端口(9221)的 /client 连接按 key 路由到端口池端口(9231+)的 session。
83
+ * 复用端口池端口的全部隔离逻辑(targetIds/事件路由/分组),不动 handlePluginMessage。
84
+ *
85
+ * @returns PortSession 或 null(无可用端口时)
86
+ */
87
+ _getKeySession(apiKey) {
88
+ if (!this._keyToPortIndex) this._keyToPortIndex = new Map();
89
+ // 已分配过 → 直接返回
90
+ if (this._keyToPortIndex.has(apiKey)) {
91
+ return this.portSessions[this._keyToPortIndex.get(apiKey)];
92
+ }
93
+ // 找一个未分配的端口池端口(portIndex >= 1)
94
+ const usedIndexes = new Set(this._keyToPortIndex.values());
95
+ for (let i = 1; i < this.portSessions.length; i++) {
96
+ if (this.portSessions[i] && !usedIndexes.has(i)) {
97
+ this._keyToPortIndex.set(apiKey, i);
98
+ this.portSessions[i].apiKey = apiKey; // 标记这个 session 属于哪个 key
99
+ console.log(`[KEY SESSION] key=${apiKey.slice(0, 16)}... → portIndex=${i} (port ${this.portSessions[i].port})`);
100
+ return this.portSessions[i];
101
+ }
102
+ }
103
+ // 端口池满(所有端口都已分配)→ 复用主端口 session(降级,不隔离)
104
+ console.warn(`[KEY SESSION] port pool exhausted, key=${apiKey.slice(0, 16)}... falling back to shared main session`);
105
+ return null;
106
+ }
107
+
78
108
  _startCreatePort(portIndex, port) {
79
109
  const session = new PortPoolManager.PortSession(portIndex, port);
80
110
  this.portSessions[portIndex] = session;
@@ -196,8 +226,6 @@ class PortPoolManager {
196
226
  * Client WebSocket 连接处理
197
227
  */
198
228
  _handleClientConnect(ws, req, session) {
199
- session.clients.add(ws);
200
-
201
229
  // 鉴权:从 URL 提取 key,找到对应的 plugin(一 key 一浏览器)
202
230
  let apiKey = null;
203
231
  try {
@@ -216,6 +244,19 @@ class PortPoolManager {
216
244
 
217
245
  ws.apiKey = apiKey; // 记录到 ws,后续命令转发时带上
218
246
 
247
+ // 主端口(portIndex === 0):按 key 路由到独立的端口池端口 session
248
+ // 每个 key 分配一个端口池端口(portSessions[1..N]),实现完全隔离
249
+ // - targetIds 独立(listtabs 只看自己的)
250
+ // - 事件路由用现有 targetToPort(按 portIndex,不动 handlePluginMessage)
251
+ // - 分组名按 key(阶段1已实现)
252
+ // 无 key 或端口池满时,fallback 到主端口共享 session(兼容)
253
+ if (session.portIndex === 0 && apiKey) {
254
+ const keySession = this._getKeySession(apiKey);
255
+ if (keySession) session = keySession;
256
+ }
257
+
258
+ session.clients.add(ws);
259
+
219
260
  const pluginWs = this.mainProxy.getPluginConnection(apiKey);
220
261
  if (!pluginWs) {
221
262
  ws.close(1011, apiKey ? 'No browser connected for this key' : 'No extension connected');
@@ -239,12 +280,15 @@ class PortPoolManager {
239
280
 
240
281
  // 通知扩展有 client 连接(带端口号作为分组标识)
241
282
  // clientId 固定为 pool_{port},让扩展为每个端口建一个独立分组
283
+ // __groupName 带 key 名称,让扩展用 key 名称命名 Chrome 分组(一眼看出是谁的浏览器)
242
284
  const poolClientId = `pool_${session.port}`;
285
+ pluginWs._lastPoolClientId = poolClientId;
243
286
  pluginWs.send(JSON.stringify({
244
287
  type: 'client-connected',
245
288
  clientId: poolClientId,
246
289
  __mode: 'create',
247
- __connectionTag: String(session.port)
290
+ __connectionTag: String(session.port),
291
+ __groupName: pluginWs.apiKeyName || null
248
292
  }));
249
293
 
250
294
  // 合成输入命令需要 ensureVisible(和 forward.js 的逻辑一致)
@@ -24,8 +24,10 @@ let portPool = null;
24
24
  const { logCDP, logEvent, clearLog, logStatus, logConnectionEvent, flushAllLogs, logDisconnect } = require('./modules/logger');
25
25
 
26
26
  let validateApiKey = null;
27
+ let saasAuth = null; // 完整 SaaS auth 模块(createApiKey/listApiKeys/revokeApiKey)
27
28
  try {
28
- validateApiKey = require('./saas/auth').validateApiKey;
29
+ saasAuth = require('./saas/auth');
30
+ validateApiKey = saasAuth.validateApiKey;
29
31
  var HAS_SAAS = true;
30
32
  } catch (e) {
31
33
  var HAS_SAAS = false;
@@ -318,6 +320,250 @@ function resolvePluginFromUrl(url) {
318
320
  return pluginConnections.values().next().value || null;
319
321
  }
320
322
 
323
+ // ===== 管理控制台 API =====
324
+ // 鉴权:ADMIN_TOKEN 环境变量(Bearer token 或 query ?token=)
325
+ // 未设 ADMIN_TOKEN 时只允许 localhost(开发友好)
326
+ function checkAdminAuth(req, url) {
327
+ const token = process.env.ADMIN_TOKEN;
328
+ if (!token) {
329
+ // 未设 token:只允许 localhost
330
+ const ip = req.socket.remoteAddress;
331
+ return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
332
+ }
333
+ // 设了 token:校验 Bearer 或 query
334
+ const authHeader = req.headers.authorization || '';
335
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
336
+ const queryToken = url.searchParams.get('token');
337
+ return bearer === token || queryToken === token;
338
+ }
339
+
340
+ function adminJson(res, code, data) {
341
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
342
+ res.end(JSON.stringify(data));
343
+ }
344
+
345
+ async function readBody(req) {
346
+ return new Promise(resolve => {
347
+ let d = ''; req.on('data', c => d += c);
348
+ req.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); } });
349
+ });
350
+ }
351
+
352
+ async function handleAdminApi(req, res, url) {
353
+ const path = url.pathname.replace('/admin/api/', '');
354
+ const method = req.method;
355
+
356
+ if (!checkAdminAuth(req, url)) {
357
+ return adminJson(res, 401, { error: 'Unauthorized. Set ADMIN_TOKEN env or access from localhost.' });
358
+ }
359
+
360
+ try {
361
+ // 浏览器列表
362
+ if (path === 'browsers' && method === 'GET') {
363
+ const browsers = [];
364
+ for (const pluginWs of pluginConnections) {
365
+ if (pluginWs.readyState !== WebSocket.OPEN) continue;
366
+ const ns = getNamespace(pluginWs);
367
+ browsers.push({
368
+ pluginId: pluginWs.pluginId,
369
+ pluginName: pluginWs.pluginName || 'My Browser',
370
+ userId: pluginWs.userId || null,
371
+ apiKeyId: pluginWs.apiKeyId || null,
372
+ browserName: ns.cachedBrowserVersion?.Browser || 'Unknown',
373
+ userAgent: ns.cachedBrowserVersion?.['User-Agent'] || '',
374
+ targets: ns.cachedTargets?.length || 0,
375
+ connected: true,
376
+ connectedAt: pluginWs.connectedAt,
377
+ extVersion: pluginWs.extVersion || 'unknown'
378
+ });
379
+ }
380
+ return adminJson(res, 200, browsers);
381
+ }
382
+
383
+ // key 管理(需要 SaaS)
384
+ if (path === 'keys' && method === 'GET') {
385
+ if (!saasAuth) return adminJson(res, 503, { error: 'SaaS not available' });
386
+ return adminJson(res, 200, saasAuth.listApiKeys('builtin-admin'));
387
+ }
388
+ if (path === 'keys' && method === 'POST') {
389
+ if (!saasAuth) return adminJson(res, 503, { error: 'SaaS not available' });
390
+ const body = await readBody(req);
391
+ const name = body.name || ('browser-' + Date.now().toString(36));
392
+ const result = saasAuth.createApiKey('builtin-admin', name);
393
+ const port = PORT;
394
+ return adminJson(res, 200, {
395
+ ...result,
396
+ pluginUrl: `ws://localhost:${port}/plugin?key=${result.key}`,
397
+ clientUrl: `ws://localhost:${port}/client?key=${result.key}`
398
+ });
399
+ }
400
+ if (path.startsWith('keys/') && method === 'DELETE') {
401
+ if (!saasAuth) return adminJson(res, 503, { error: 'SaaS not available' });
402
+ const keyId = path.split('/')[1];
403
+ const result = saasAuth.revokeApiKey(keyId, 'builtin-admin');
404
+ return adminJson(res, result.changes > 0 ? 200 : 404, { ok: result.changes > 0, changes: result.changes });
405
+ }
406
+
407
+ // CDP 快捷操作(通过 pluginId 直接连已在线浏览器)
408
+ if (path.startsWith('cdp/') && method === 'POST') {
409
+ const body = await readBody(req);
410
+ if (!body.pluginId) return adminJson(res, 400, { error: 'Missing pluginId' });
411
+ const result = await execCdpViaPlugin(body.pluginId, body);
412
+ return adminJson(res, result.error ? 500 : 200, result);
413
+ }
414
+
415
+ return adminJson(res, 404, { error: 'Not found: ' + path });
416
+ } catch (e) {
417
+ return adminJson(res, 500, { error: e.message });
418
+ }
419
+ }
420
+
421
+ // 通过 pluginId 走端口池 /client 路径执行 CDP 命令(复用完整分组/session 路由逻辑)
422
+ async function execCdpViaPlugin(pluginId, params) {
423
+ const action = params.action || 'evaluate';
424
+ // 找到 pluginId 对应的 plugin 连接
425
+ let pluginWs = null;
426
+ for (const ws of pluginConnections) {
427
+ if (ws.readyState === WebSocket.OPEN && ws.pluginId === pluginId) { pluginWs = ws; break; }
428
+ }
429
+ if (!pluginWs) return { error: 'Browser not found or offline' };
430
+
431
+ // closebrowser 特殊处理:不走 /client(会被 Browser.close 断开),
432
+ // 直接用 pluginWs 发 client-disconnected + closeTarget
433
+ if (action === 'closebrowser') {
434
+ // 从 apiKey 找到对应的 key session 的端口,拿到正确的 clientId
435
+ let clientId = `pool_${PORT}`; // fallback
436
+ if (pluginWs.apiKey && portPool && portPool._keyToPortIndex) {
437
+ const portIdx = portPool._keyToPortIndex.get(pluginWs.apiKey);
438
+ if (portIdx !== undefined && portPool.portSessions[portIdx]) {
439
+ clientId = `pool_${portPool.portSessions[portIdx].port}`;
440
+ }
441
+ }
442
+ // 发 client-disconnected 让扩展关分组 + 关 tab
443
+ try {
444
+ pluginWs.send(JSON.stringify({ type: 'client-disconnected', clientId }));
445
+ } catch (e) {}
446
+ // 清理 key session(让下次连接重新分配)
447
+ if (pluginWs.apiKey && portPool) {
448
+ portPool.keySessions.delete(pluginWs.apiKey);
449
+ if (portPool._keyToPortIndex) portPool._keyToPortIndex.delete(pluginWs.apiKey);
450
+ }
451
+ return { closed: true };
452
+ }
453
+
454
+ const apiKey = pluginWs.apiKey;
455
+ if (!apiKey) return { error: 'Browser has no API key (not connected via /plugin?key=)' };
456
+
457
+ // 内部连 /client?key=xxx,走端口池完整路径(建组 + session 路由 + 分组)
458
+ const port = PORT;
459
+ return new Promise((resolve) => {
460
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/client?key=${apiKey}`);
461
+ const pending = new Map(); let id = 1; let sessionId = null;
462
+ const timeout = setTimeout(() => { try { ws.close(); } catch {}; resolve({ error: 'Timeout' }); }, 15000);
463
+
464
+ function cdp(method, p = {}, sid) {
465
+ return new Promise((res, rej) => {
466
+ const i = id++; pending.set(i, { resolve: res, reject: rej });
467
+ const o = { id: i, method, params: p };
468
+ if (sid) o.sessionId = sid; else if (sessionId) o.sessionId = sessionId;
469
+ ws.send(JSON.stringify(o));
470
+ setTimeout(() => { if (pending.has(i)) { pending.delete(i); rej(new Error('CDP timeout: ' + method)); } }, 12000);
471
+ });
472
+ }
473
+
474
+ function finish(result) { clearTimeout(timeout); try { ws.close(); } catch {}; resolve(result); }
475
+
476
+ ws.on('message', async (data) => {
477
+ const m = JSON.parse(data.toString());
478
+ if (m.id && pending.has(m.id)) {
479
+ const { resolve: r, reject: rj } = pending.get(m.id); pending.delete(m.id);
480
+ m.error ? rj(new Error(JSON.stringify(m.error))) : r(m.result);
481
+ return;
482
+ }
483
+ // attachedToTarget 事件 → 记 sessionId
484
+ if (m.method === 'Target.attachedToTarget' && m.params?.sessionId && !sessionId) {
485
+ sessionId = m.params.sessionId;
486
+ try {
487
+ if (action === 'evaluate') {
488
+ await cdp('Runtime.enable', {}, sessionId);
489
+ const ev = await cdp('Runtime.evaluate', { expression: params.expression || 'document.title', returnByValue: true }, sessionId);
490
+ finish({ result: ev.result.value });
491
+ } else if (action === 'screenshot') {
492
+ await cdp('Page.enable', {}, sessionId);
493
+ const shot = await cdp('Page.captureScreenshot', { format: 'png' }, sessionId);
494
+ finish({ screenshot: shot.data });
495
+ }
496
+ } catch (e) { finish({ error: e.message }); }
497
+ }
498
+ });
499
+
500
+ ws.on('open', async () => {
501
+ try {
502
+ // listtabs:走 /client 路径,getTargets 被端口池按 key 自动过滤
503
+ if (action === 'listtabs') {
504
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
505
+ const tg = await cdp('Target.getTargets');
506
+ const tabs = (tg?.targetInfos || [])
507
+ .filter(t => t.type === 'page')
508
+ .map(t => ({ targetId: t.targetId, url: t.url, title: t.title || '', attached: t.attached }));
509
+ finish({ tabs });
510
+ }
511
+ // closetab:关闭指定 targetId
512
+ else if (action === 'closetab') {
513
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
514
+ await cdp('Target.closeTarget', { targetId: params.targetId });
515
+ finish({ closed: true, targetId: params.targetId });
516
+ }
517
+ // switchtab:切换到指定 targetId(bringToFront)
518
+ else if (action === 'switchtab') {
519
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
520
+ const at = await cdp('Target.attachToTarget', { targetId: params.targetId, flatten: true });
521
+ if (at.sessionId) {
522
+ await cdp('Page.enable', {}, at.sessionId);
523
+ await cdp('Page.bringToFront', {}, at.sessionId);
524
+ }
525
+ finish({ switched: true, targetId: params.targetId });
526
+ }
527
+ else if (action === 'newtab') {
528
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
529
+ const created = await cdp('Target.createTarget', { url: params.url || 'about:blank' });
530
+ finish({ targetId: created.targetId });
531
+ } else {
532
+ // evaluate/screenshot:直接 getTargets + attach(不等 setAutoAttach 事件,避免超时)
533
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
534
+ const tg = await cdp('Target.getTargets');
535
+ let page = (tg.targetInfos || []).find(t => t.type === 'page');
536
+ if (!page) {
537
+ const created = await cdp('Target.createTarget', { url: 'about:blank' });
538
+ page = { targetId: created.targetId };
539
+ }
540
+ // attach 后等 attachedToTarget 事件触发 evaluate/screenshot
541
+ // 如果事件没来,1 秒后用手动方式(attach 响应里的 sessionId)
542
+ const at = await cdp('Target.attachToTarget', { targetId: page.targetId, flatten: true });
543
+ // 如果事件路径没处理,手动执行
544
+ setTimeout(async () => {
545
+ if (sessionId || !at.sessionId) return;
546
+ sessionId = at.sessionId;
547
+ try {
548
+ if (action === 'evaluate') {
549
+ await cdp('Runtime.enable', {}, sessionId);
550
+ const ev = await cdp('Runtime.evaluate', { expression: params.expression || 'document.title', returnByValue: true }, sessionId);
551
+ finish({ result: ev.result.value });
552
+ } else if (action === 'screenshot') {
553
+ await cdp('Page.enable', {}, sessionId);
554
+ const shot = await cdp('Page.captureScreenshot', { format: 'png' }, sessionId);
555
+ finish({ screenshot: shot.data });
556
+ }
557
+ } catch (e) { finish({ error: e.message }); }
558
+ }, 1000);
559
+ }
560
+ } catch (e) { finish({ error: e.message }); }
561
+ });
562
+
563
+ ws.on('error', e => { clearTimeout(timeout); resolve({ error: e.message }); });
564
+ });
565
+ }
566
+
321
567
  async function handleHttpRequest(req, res) {
322
568
  const url = new URL(req.url, `http://localhost:${PORT}`);
323
569
 
@@ -333,6 +579,21 @@ async function handleHttpRequest(req, res) {
333
579
  return;
334
580
  }
335
581
 
582
+ // 管理控制台:/admin 返回页面,/admin/api/* 返回 JSON
583
+ if (url.pathname === '/admin' || url.pathname === '/admin/') {
584
+ try {
585
+ const html = fs.readFileSync(path.join(__dirname, 'admin-console.html'), 'utf8');
586
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
587
+ res.end(html);
588
+ } catch (e) {
589
+ res.writeHead(500); res.end('Admin console not found: ' + e.message);
590
+ }
591
+ return;
592
+ }
593
+ if (url.pathname.startsWith('/admin/api/')) {
594
+ return handleAdminApi(req, res, url);
595
+ }
596
+
336
597
  if (url.pathname === '/json/browsers' || url.pathname === '/json/browsers/') {
337
598
  const browsers = [];
338
599
  for (const pluginWs of pluginConnections) {
@@ -756,6 +1017,7 @@ function handlePluginConnection(ws, clientInfo, request) {
756
1017
  ws.userId = keyInfo.userId;
757
1018
  ws.apiKeyId = keyInfo.keyId;
758
1019
  ws.apiKey = apiKey; // 记录 key 字符串,供 client 连接时反查 plugin
1020
+ ws.apiKeyName = keyInfo.keyName; // key 名称,用作 Chrome 分组名
759
1021
  logConnectionEvent('PLUGIN_AUTHED', `userId=${keyInfo.userId} keyName=${keyInfo.keyName}`);
760
1022
  } else {
761
1023
  logConnectionEvent('PLUGIN_AUTH_FAIL', 'Invalid API key');