ghost-bridge 0.5.1 → 0.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.
- package/README.md +121 -153
- package/dist/server.js +42 -34
- package/extension/background.js +336 -119
- package/extension/manifest.json +1 -1
- package/extension/offscreen.js +33 -25
- package/extension/popup.html +9 -0
- package/extension/popup.js +16 -5
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,183 +1,151 @@
|
|
|
1
1
|
# 👻 Ghost Bridge
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/ghost-bridge)
|
|
4
|
+
[](https://www.npmjs.com/package/ghost-bridge)
|
|
4
5
|
[](https://opensource.org/licenses/MIT)
|
|
5
6
|
|
|
6
|
-
>
|
|
7
|
+
> Zero-restart Chrome bridge for MCP clients. Let AI inspect, debug, and operate the browser session you are already using.
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
## Why
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
Most browser-capable AI tools start a separate browser. Ghost Bridge connects AI to your existing Chrome session instead, so it can work with the page state you already have: logged-in accounts, reproduced bugs, in-progress flows, network failures, and real UI state.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
- 🔍 **No-Sourcemap Debugging** — Slice code fragments, perform string searches, and analyze coverage to pinpoint bugs straight in minified production code.
|
|
14
|
-
- 🌐 **Deep Network Analysis** — Comprehensive capture of requests/responses with multi-dimensional filtering and response body inspection.
|
|
15
|
-
- 📸 **Visual & Structural Perception** — Full-page or clipped high-fidelity screenshots paired with structural data extraction (titles, links, forms, buttons).
|
|
16
|
-
- 🎯 **DOM Physical Manipulation** — Empowers AI to click, type, and form-submit with CDP-level physical simulation. Fully compatible with complex SPAs (React/Vue/Angular/Svelte).
|
|
17
|
-
- 📊 **Performance Diagnostics** — Get granular engine metrics: JS Heap, Layout recalculations, Web Vitals (TTFB/FCP/LCP), and resource loading speeds.
|
|
18
|
-
- 🔄 **Multi-Client Mastery** — Built-in singleton manager automatically coordinates multiple MCP clients sharing a single Chrome transport.
|
|
13
|
+
## What It Does
|
|
19
14
|
|
|
20
|
-
|
|
15
|
+
- Attach to Chrome without `--remote-debugging-port`
|
|
16
|
+
- Inspect page structure, text, screenshots, errors, and network traffic
|
|
17
|
+
- Search and extract script sources, even in production bundles
|
|
18
|
+
- Click, type, scroll, and submit forms on the current page
|
|
19
|
+
- Share one Chrome transport across multiple MCP clients
|
|
21
20
|
|
|
22
|
-
##
|
|
21
|
+
## What's New in 0.6.0
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
- `inspect_page` now collects structured page data and interactive elements in one browser-side snapshot, reducing duplicate DOM scans and cutting one round-trip from the hot path
|
|
24
|
+
- `capture_screenshot` now defaults to JPEG for better transfer efficiency: visible viewport screenshots default to `quality: 80`, and `fullPage` screenshots default to `quality: 70`
|
|
25
|
+
- Use `format: "png"` when you need high-fidelity text rendering, 1px lines, icon edges, transparency, or pixel-level UI inspection
|
|
26
|
+
- Attachment and request cleanup paths are more robust under concurrent usage and multi-client reconnect scenarios
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
### 1. Install
|
|
25
31
|
|
|
26
32
|
```bash
|
|
27
|
-
# Install globally
|
|
28
33
|
npm install -g ghost-bridge
|
|
29
|
-
|
|
30
|
-
# Auto-configure MCP (Claude Code, Codex, Cursor, Antigravity) and prepare the extension directory
|
|
31
34
|
ghost-bridge init
|
|
32
35
|
```
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
> - Claude Code: `~/.claude/settings.json` or legacy `~/.claude.json`
|
|
36
|
-
> - Codex: `~/.codex/config.toml`
|
|
37
|
-
> - Cursor: `~/.cursor/mcp.json`
|
|
38
|
-
> - Antigravity: `~/.gemini/antigravity/mcp.json`
|
|
39
|
-
>
|
|
40
|
-
> **Note on other MCP clients (Windsurf, Roo, others):**
|
|
41
|
-
> `ghost-bridge init` attempts to auto-configure supported clients. If your client isn't auto-detected, add one of the following snippets to the appropriate MCP configuration file:
|
|
42
|
-
>
|
|
43
|
-
> JSON-style MCP configs (`mcp.json`, Claude JSON config, Cursor):
|
|
44
|
-
> ```json
|
|
45
|
-
> {
|
|
46
|
-
> "mcpServers": {
|
|
47
|
-
> "ghost-bridge": {
|
|
48
|
-
> "command": "/absolute/path/to/node",
|
|
49
|
-
> "args": ["/absolute/path/to/global/node_modules/ghost-bridge/dist/server.js"]
|
|
50
|
-
> }
|
|
51
|
-
> }
|
|
52
|
-
> }
|
|
53
|
-
> ```
|
|
54
|
-
>
|
|
55
|
-
> Codex TOML config (`~/.codex/config.toml`):
|
|
56
|
-
> ```toml
|
|
57
|
-
> [mcp_servers.ghost-bridge]
|
|
58
|
-
> type = "stdio"
|
|
59
|
-
> command = "/absolute/path/to/node"
|
|
60
|
-
> args = ["/absolute/path/to/global/node_modules/ghost-bridge/dist/server.js"]
|
|
61
|
-
> ```
|
|
62
|
-
|
|
63
|
-
### 2. Load the Chrome Extension
|
|
64
|
-
|
|
65
|
-
1. Open Chrome and navigate to `chrome://extensions`
|
|
66
|
-
2. Toggle **Developer mode** in the top right corner.
|
|
67
|
-
3. Click **Load unpacked**
|
|
68
|
-
4. Select the directory: `~/.ghost-bridge/extension`
|
|
69
|
-
|
|
70
|
-
> 💡 *Tip: Run `ghost-bridge extension --open` to reveal the directory directly.*
|
|
71
|
-
|
|
72
|
-
### 3. Connect & Command
|
|
73
|
-
|
|
74
|
-
1. Click the **Ghost Bridge** ghost icon in your browser toolbar.
|
|
75
|
-
2. Click **Connect** and wait for the status to turn to ✅ **ON**.
|
|
76
|
-
3. Open Claude Desktop or your Claude CLI. All tools are now primed and ready!
|
|
77
|
-
|
|
78
|
-
**Prompting tips:**
|
|
79
|
-
- You usually do not need to say "please use ghost-bridge". Direct requests like `分析当前页面`、`看看这个网站的 DOM 结构`、`帮我检查这个页面为什么布局错了`、`帮我点击登录按钮并提交表单` should be enough.
|
|
80
|
-
- `inspect_page` is the default entry tool for generic page analysis.
|
|
81
|
-
- For visual/UI issues, the model should prefer `capture_screenshot`.
|
|
82
|
-
- For text/DOM extraction, the model should prefer `get_page_content`.
|
|
83
|
-
- For page actions, the model should start with `get_interactive_snapshot` and then call `dispatch_action`.
|
|
84
|
-
|
|
85
|
-
---
|
|
86
|
-
|
|
87
|
-
## 🛠️ Tool Arsenal
|
|
88
|
-
|
|
89
|
-
### 🔍 Core Debugging
|
|
90
|
-
|
|
91
|
-
| Tool | Capability |
|
|
92
|
-
|------|------------|
|
|
93
|
-
| `inspect_page` | Best default entry for page analysis. Returns metadata, structured content, and an interactive-elements overview. |
|
|
94
|
-
| `get_server_info` | Retrieves server instance status, WebSocket ports, and client roles. |
|
|
95
|
-
| `get_last_error` | Aggregates recent exceptions, console errors, and failed network requests with mapped locators. |
|
|
96
|
-
| `get_script_source` | Pulls raw script definitions. Supports URL-fragment filtering, specific line targeting, and beautification. |
|
|
97
|
-
| `coverage_snapshot` | Triggers a quick coverage trace (1.5s default) to identify the most active scripts on the page. |
|
|
98
|
-
| `find_by_string` | Scans page script sources for keywords, returning a 200-character context window. |
|
|
99
|
-
| `symbolic_hints` | Gathers context clues: Resource lists, Global Variable keys, LocalStorage schema, and UA strings. |
|
|
100
|
-
| `eval_script` | Executes raw JavaScript expressions in the page context. *(Use with caution)* |
|
|
101
|
-
|
|
102
|
-
### 🌍 Network Intelligence
|
|
103
|
-
|
|
104
|
-
| Tool | Capability |
|
|
105
|
-
|------|------------|
|
|
106
|
-
| `list_network_requests` | Lists captured network traffic. Supports filtering by URL, Method, Status Code, or Resource Type. |
|
|
107
|
-
| `get_network_detail` | Dives deep into a specific request's Headers, Timing, and optional Response Body extraction. |
|
|
108
|
-
| `clear_network_requests` | Wipes the current network capture buffer. |
|
|
109
|
-
|
|
110
|
-
### 📸 Page Perception
|
|
111
|
-
|
|
112
|
-
| Tool | Capability |
|
|
113
|
-
|------|------------|
|
|
114
|
-
| `capture_screenshot` | Captures the viewport or emulates full-page scrolling screenshots. |
|
|
115
|
-
| `get_page_content` | Extracts raw text, sanitized HTML, or structured actionable data representations. |
|
|
116
|
-
|
|
117
|
-
### 🎯 Interactive Automation (DOM)
|
|
118
|
-
|
|
119
|
-
| Tool | Capability |
|
|
120
|
-
|------|------------|
|
|
121
|
-
| `get_interactive_snapshot` | Scans for visible interactive elements, returning a concise map `[e1, e2...]`. Pierces open Shadow DOMs. |
|
|
122
|
-
| `dispatch_action` | Dispatches physical UI actions (click, fill, press, hover) against targeted element references (e.g., `e1`). |
|
|
123
|
-
|
|
124
|
-
**Example Agent Workflow:**
|
|
125
|
-
1. AI: `get_interactive_snapshot` ➝ `[{ref:"e1", tag:"input", placeholder:"Search..."}, {ref:"e2", tag:"button", text:"Login"}]`
|
|
126
|
-
2. AI: `dispatch_action({ref: "e1", action: "fill", value: "hello"})`
|
|
127
|
-
3. AI: `dispatch_action({ref: "e2", action: "click"})`
|
|
128
|
-
4. AI: `capture_screenshot` to verify state changes.
|
|
129
|
-
|
|
130
|
-
### 📊 Performance Profiling
|
|
131
|
-
|
|
132
|
-
| Tool | Capability |
|
|
133
|
-
|------|------------|
|
|
134
|
-
| `perf_metrics` | Collects layered performance data (Engine Metrics, Web Vitals, and Resource Load Summaries). |
|
|
135
|
-
|
|
136
|
-
---
|
|
137
|
-
|
|
138
|
-
## ⚙️ Configuration
|
|
139
|
-
|
|
140
|
-
| Setting | Default | Description |
|
|
141
|
-
|---------|---------|-------------|
|
|
142
|
-
| **Base Port** | `33333` | WS port. Auto-increments if occupied. |
|
|
143
|
-
| **Token** | *Monthly UUID* | Local WS auth token, auto-rotates on the 1st of every month. |
|
|
144
|
-
| **Auto Detach** | `false` | Keeps debugger attached to actively buffer invisible exceptions and network calls. |
|
|
145
|
-
|
|
146
|
-
**Environment Variables:**
|
|
147
|
-
- `GHOST_BRIDGE_PORT` — Override base port.
|
|
148
|
-
- `GHOST_BRIDGE_TOKEN` — Override connection token.
|
|
37
|
+
`ghost-bridge init` currently writes config for:
|
|
149
38
|
|
|
150
|
-
|
|
39
|
+
- Claude Code: `~/.claude/settings.json` or `~/.claude.json`
|
|
40
|
+
- Codex: `~/.codex/config.toml`
|
|
41
|
+
- Cursor: `~/.cursor/mcp.json`
|
|
42
|
+
- Antigravity: `~/.gemini/antigravity/mcp.json`
|
|
151
43
|
|
|
152
|
-
|
|
44
|
+
If your MCP client is not auto-detected, add one of these manually.
|
|
153
45
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
46
|
+
JSON config:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"ghost-bridge": {
|
|
52
|
+
"command": "/absolute/path/to/node",
|
|
53
|
+
"args": ["/absolute/path/to/global/node_modules/ghost-bridge/dist/server.js"]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Codex TOML:
|
|
60
|
+
|
|
61
|
+
```toml
|
|
62
|
+
[mcp_servers.ghost-bridge]
|
|
63
|
+
type = "stdio"
|
|
64
|
+
command = "/absolute/path/to/node"
|
|
65
|
+
args = ["/absolute/path/to/global/node_modules/ghost-bridge/dist/server.js"]
|
|
159
66
|
```
|
|
160
67
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
68
|
+
### 2. Load the Extension
|
|
69
|
+
|
|
70
|
+
1. Open `chrome://extensions`
|
|
71
|
+
2. Enable Developer mode
|
|
72
|
+
3. Click `Load unpacked`
|
|
73
|
+
4. Select `~/.ghost-bridge/extension`
|
|
164
74
|
|
|
165
|
-
|
|
75
|
+
You can also run:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
ghost-bridge extension --open
|
|
79
|
+
```
|
|
166
80
|
|
|
167
|
-
|
|
81
|
+
### 3. Connect
|
|
168
82
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
83
|
+
1. Click the Ghost Bridge extension icon
|
|
84
|
+
2. Click `Connect`
|
|
85
|
+
3. Wait until the status becomes `ON`
|
|
86
|
+
4. Open your MCP client and start working on the current page
|
|
173
87
|
|
|
174
|
-
|
|
88
|
+
Typical prompts:
|
|
89
|
+
|
|
90
|
+
- `Analyze the current page`
|
|
91
|
+
- `Check why this layout is broken`
|
|
92
|
+
- `Inspect the DOM structure`
|
|
93
|
+
- `Click the login button and submit the form`
|
|
94
|
+
|
|
95
|
+
## Tools
|
|
96
|
+
|
|
97
|
+
| Tool | Purpose |
|
|
98
|
+
|------|---------|
|
|
99
|
+
| `inspect_page` | Default entry point for page analysis |
|
|
100
|
+
| `capture_screenshot` | Visual inspection and UI debugging |
|
|
101
|
+
| `get_page_content` | Text, HTML, and structured DOM extraction |
|
|
102
|
+
| `get_interactive_snapshot` | Find clickable and editable elements |
|
|
103
|
+
| `dispatch_action` | Click, fill, press, scroll, hover, select |
|
|
104
|
+
| `list_network_requests` | Inspect captured network traffic |
|
|
105
|
+
| `get_network_detail` | Read one request in detail |
|
|
106
|
+
| `get_last_error` | Inspect recent errors and exceptions |
|
|
107
|
+
| `get_script_source` | Extract page scripts |
|
|
108
|
+
| `find_by_string` | Search within bundled script content |
|
|
109
|
+
| `coverage_snapshot` | Identify active scripts quickly |
|
|
110
|
+
| `perf_metrics` | Collect Web Vitals and engine metrics |
|
|
111
|
+
|
|
112
|
+
Recommended flow:
|
|
113
|
+
|
|
114
|
+
1. Start with `inspect_page`
|
|
115
|
+
2. Use `capture_screenshot` for visual issues
|
|
116
|
+
Default is optimized for transfer with JPEG; switch to `png` for pixel-level checks
|
|
117
|
+
3. Use `get_page_content` for DOM or text extraction
|
|
118
|
+
4. Use `get_interactive_snapshot` before `dispatch_action`
|
|
119
|
+
|
|
120
|
+
## Configuration
|
|
121
|
+
|
|
122
|
+
| Setting | Default | Notes |
|
|
123
|
+
|---------|---------|-------|
|
|
124
|
+
| Port | `33333` | Set `GHOST_BRIDGE_PORT` to override |
|
|
125
|
+
| Token | Monthly UUID | Set `GHOST_BRIDGE_TOKEN` to override |
|
|
126
|
+
| Auto detach | `false` | Keeps debugger attached for ongoing capture |
|
|
127
|
+
|
|
128
|
+
## Architecture
|
|
129
|
+
|
|
130
|
+
```mermaid
|
|
131
|
+
flowchart LR
|
|
132
|
+
A["AI Client<br/>Claude / Codex / Cursor"]
|
|
133
|
+
B["Ghost Bridge MCP Server<br/>server.js"]
|
|
134
|
+
C["Chrome Extension<br/>background.js"]
|
|
135
|
+
D["Browser Tab<br/>Target Context"]
|
|
136
|
+
|
|
137
|
+
A <-->|"stdio"| B
|
|
138
|
+
B <-->|"WebSocket"| C
|
|
139
|
+
C <-->|"CDP"| D
|
|
140
|
+
```
|
|
175
141
|
|
|
176
|
-
##
|
|
142
|
+
## Limitations
|
|
177
143
|
|
|
178
|
-
|
|
179
|
-
|
|
144
|
+
- Chrome DevTools on the target tab can conflict with `chrome.debugger.attach`
|
|
145
|
+
- MV3 background lifecycle can still cause reconnect scenarios after long idle periods
|
|
146
|
+
- Very large minified bundles may be truncated during beautify or extraction
|
|
147
|
+
- Deep cross-origin iframe cases are not fully covered yet
|
|
180
148
|
|
|
181
|
-
##
|
|
149
|
+
## License
|
|
182
150
|
|
|
183
|
-
|
|
151
|
+
[MIT](LICENSE)
|
package/dist/server.js
CHANGED
|
@@ -28536,7 +28536,6 @@ var GHOST_BRIDGE_VERSION = packageJson.version;
|
|
|
28536
28536
|
|
|
28537
28537
|
// src/server.js
|
|
28538
28538
|
var BASE_PORT = Number(process.env.GHOST_BRIDGE_PORT || 33333);
|
|
28539
|
-
var MAX_PORT_RETRIES = 10;
|
|
28540
28539
|
function getMonthlyToken() {
|
|
28541
28540
|
const now = /* @__PURE__ */ new Date();
|
|
28542
28541
|
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
|
@@ -28567,6 +28566,10 @@ function getExistingService() {
|
|
|
28567
28566
|
if (!fs2.existsSync(PORT_INFO_FILE)) return null;
|
|
28568
28567
|
const info = JSON.parse(fs2.readFileSync(PORT_INFO_FILE, "utf-8"));
|
|
28569
28568
|
if (!info.pid || !info.port) return null;
|
|
28569
|
+
if (info.port !== BASE_PORT) {
|
|
28570
|
+
fs2.unlinkSync(PORT_INFO_FILE);
|
|
28571
|
+
return null;
|
|
28572
|
+
}
|
|
28570
28573
|
if (!isProcessRunning(info.pid)) {
|
|
28571
28574
|
log(`\u65E7\u670D\u52A1 PID ${info.pid} \u5DF2\u4E0D\u5B58\u5728\uFF0C\u6E05\u7406\u65E7\u4FE1\u606F`);
|
|
28572
28575
|
fs2.unlinkSync(PORT_INFO_FILE);
|
|
@@ -28618,22 +28621,14 @@ function isPortAvailable(port) {
|
|
|
28618
28621
|
});
|
|
28619
28622
|
}
|
|
28620
28623
|
async function startWebSocketServer() {
|
|
28621
|
-
|
|
28622
|
-
|
|
28623
|
-
|
|
28624
|
-
if (available) {
|
|
28625
|
-
actualPort = port;
|
|
28626
|
-
const wss2 = new import_websocket_server.default({ port });
|
|
28627
|
-
if (port !== BASE_PORT) {
|
|
28628
|
-
log(`\u26A0\uFE0F \u7AEF\u53E3 ${BASE_PORT} \u88AB\u5360\u7528\uFF0C\u5DF2\u5207\u6362\u5230\u7AEF\u53E3 ${port}`);
|
|
28629
|
-
}
|
|
28630
|
-
log(`\u{1F680} WebSocket \u670D\u52A1\u5DF2\u542F\u52A8\uFF0C\u7AEF\u53E3 ${port}${WS_TOKEN ? "\uFF08\u542F\u7528 token \u6821\u9A8C\uFF09" : ""}`);
|
|
28631
|
-
return wss2;
|
|
28632
|
-
} else {
|
|
28633
|
-
log(`\u7AEF\u53E3 ${port} \u88AB\u5360\u7528\uFF0C\u5C1D\u8BD5\u4E0B\u4E00\u4E2A...`);
|
|
28634
|
-
}
|
|
28624
|
+
const available = await isPortAvailable(BASE_PORT);
|
|
28625
|
+
if (!available) {
|
|
28626
|
+
throw new Error(`\u56FA\u5B9A\u7AEF\u53E3 ${BASE_PORT} \u4E0D\u53EF\u7528\uFF0C\u8BF7\u91CA\u653E\u8BE5\u7AEF\u53E3\u6216\u901A\u8FC7 GHOST_BRIDGE_PORT \u6307\u5B9A\u5176\u4ED6\u7AEF\u53E3`);
|
|
28635
28627
|
}
|
|
28636
|
-
|
|
28628
|
+
actualPort = BASE_PORT;
|
|
28629
|
+
const wss2 = new import_websocket_server.default({ port: BASE_PORT });
|
|
28630
|
+
log(`\u{1F680} WebSocket \u670D\u52A1\u5DF2\u542F\u52A8\uFF0C\u7AEF\u53E3 ${BASE_PORT}${WS_TOKEN ? "\uFF08\u542F\u7528 token \u6821\u9A8C\uFF09" : ""}`);
|
|
28631
|
+
return wss2;
|
|
28637
28632
|
}
|
|
28638
28633
|
async function initWebSocketService() {
|
|
28639
28634
|
const existing = getExistingService();
|
|
@@ -28653,6 +28648,16 @@ async function initWebSocketService() {
|
|
|
28653
28648
|
}
|
|
28654
28649
|
}
|
|
28655
28650
|
}
|
|
28651
|
+
if (!await isPortAvailable(BASE_PORT)) {
|
|
28652
|
+
const valid = await verifyExistingService(BASE_PORT);
|
|
28653
|
+
if (valid) {
|
|
28654
|
+
actualPort = BASE_PORT;
|
|
28655
|
+
isMainInstance = false;
|
|
28656
|
+
log(`\u2705 \u590D\u7528\u56FA\u5B9A\u7AEF\u53E3\u4E0A\u7684\u73B0\u6709\u670D\u52A1\uFF0C\u7AEF\u53E3 ${actualPort}`);
|
|
28657
|
+
return null;
|
|
28658
|
+
}
|
|
28659
|
+
throw new Error(`\u56FA\u5B9A\u7AEF\u53E3 ${BASE_PORT} \u5DF2\u88AB\u5176\u4ED6\u8FDB\u7A0B\u5360\u7528\uFF0C\u8BF7\u91CA\u653E\u8BE5\u7AEF\u53E3\u6216\u901A\u8FC7 GHOST_BRIDGE_PORT \u6307\u5B9A\u5176\u4ED6\u7AEF\u53E3`);
|
|
28660
|
+
}
|
|
28656
28661
|
const wss2 = await startWebSocketServer();
|
|
28657
28662
|
isMainInstance = true;
|
|
28658
28663
|
fs2.writeFileSync(
|
|
@@ -28808,9 +28813,18 @@ function connectToMainInstance() {
|
|
|
28808
28813
|
});
|
|
28809
28814
|
}
|
|
28810
28815
|
function failAllPending(message) {
|
|
28811
|
-
pendingRequests.forEach((
|
|
28812
|
-
|
|
28813
|
-
|
|
28816
|
+
pendingRequests.forEach((pending, id) => {
|
|
28817
|
+
if (pending.reject) {
|
|
28818
|
+
clearTimeout(pending.timer);
|
|
28819
|
+
pending.reject(new Error(message));
|
|
28820
|
+
} else if (pending.source) {
|
|
28821
|
+
try {
|
|
28822
|
+
if (pending.source.readyState === import_websocket.default.OPEN) {
|
|
28823
|
+
pending.source.send(JSON.stringify({ id, error: message }));
|
|
28824
|
+
}
|
|
28825
|
+
} catch {
|
|
28826
|
+
}
|
|
28827
|
+
}
|
|
28814
28828
|
});
|
|
28815
28829
|
pendingRequests.clear();
|
|
28816
28830
|
}
|
|
@@ -29063,18 +29077,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
29063
29077
|
},
|
|
29064
29078
|
{
|
|
29065
29079
|
name: "capture_screenshot",
|
|
29066
|
-
description: "\u3010\u63A8\u8350\u7528\u4E8E\u89C6\u89C9\u5206\u6790\u3011\u622A\u53D6\u5F53\u524D\u9875\u9762\u7684\u622A\u56FE\uFF0C\u8FD4\u56DE base64 \u56FE\u7247\u3002\u9002\u7528\u4E8E\uFF1A1) \u67E5\u770B\u9875\u9762\u5B9E\u9645\u89C6\u89C9\u6548\u679C 2) \u6392\u67E5 UI/\u6837\u5F0F/\u5E03\u5C40/\u989C\u8272\u95EE\u9898 3) \u9A8C\u8BC1\u9875\u9762\u6E32\u67D3 4) \u5206\u6790\u5143\u7D20\u4F4D\u7F6E\u548C\u95F4\u8DDD 5) \u67E5\u770B\u56FE\u7247/\u56FE\u6807\u7B49\u89C6\u89C9\u5185\u5BB9\u3002\u5F53\u7528\u6237\u8BF4\u201C\u770B\u770B\u8FD9\u4E2A\u9875\u9762\u957F\u4EC0\u4E48\u6837\u201D\u201C\u5E2E\u6211\u5206\u6790\u754C\u9762/\u5E03\u5C40/\u6837\u5F0F\u201D\u65F6\uFF0C\u5E94\u4F18\u5148\u4F7F\u7528\u6B64\u5DE5\u5177\uFF0C\u65E0\u9700\u7528\u6237\u663E\u5F0F\u63D0\u5230 ghost-bridge\u3002\u5F53\u9700\u8981\u770B\u5230\u9875\u9762\u300C\u957F\u4EC0\u4E48\u6837\u300D\u65F6\u4F7F\u7528\u6B64\u5DE5\u5177\u3002\u5982\u4EC5\u9700\u6587\u672C/\u94FE\u63A5\u7B49\u4FE1\u606F\uFF0C\u5EFA\u8BAE\u4F7F\u7528\u66F4\u5FEB\u7684 get_page_content\u3002",
|
|
29080
|
+
description: "\u3010\u63A8\u8350\u7528\u4E8E\u89C6\u89C9\u5206\u6790\u3011\u622A\u53D6\u5F53\u524D\u9875\u9762\u7684\u622A\u56FE\uFF0C\u8FD4\u56DE base64 \u56FE\u7247\u3002\u9002\u7528\u4E8E\uFF1A1) \u67E5\u770B\u9875\u9762\u5B9E\u9645\u89C6\u89C9\u6548\u679C 2) \u6392\u67E5 UI/\u6837\u5F0F/\u5E03\u5C40/\u989C\u8272\u95EE\u9898 3) \u9A8C\u8BC1\u9875\u9762\u6E32\u67D3 4) \u5206\u6790\u5143\u7D20\u4F4D\u7F6E\u548C\u95F4\u8DDD 5) \u67E5\u770B\u56FE\u7247/\u56FE\u6807\u7B49\u89C6\u89C9\u5185\u5BB9\u3002\u5F53\u7528\u6237\u8BF4\u201C\u770B\u770B\u8FD9\u4E2A\u9875\u9762\u957F\u4EC0\u4E48\u6837\u201D\u201C\u5E2E\u6211\u5206\u6790\u754C\u9762/\u5E03\u5C40/\u6837\u5F0F\u201D\u65F6\uFF0C\u5E94\u4F18\u5148\u4F7F\u7528\u6B64\u5DE5\u5177\uFF0C\u65E0\u9700\u7528\u6237\u663E\u5F0F\u63D0\u5230 ghost-bridge\u3002\u5F53\u9700\u8981\u770B\u5230\u9875\u9762\u300C\u957F\u4EC0\u4E48\u6837\u300D\u65F6\u4F7F\u7528\u6B64\u5DE5\u5177\u3002\u9ED8\u8BA4\u4F18\u5148\u4F7F\u7528\u66F4\u7701\u4F20\u8F93\u7684 JPEG\uFF1A\u666E\u901A\u622A\u56FE\u9ED8\u8BA4 quality 80\uFF0C\u5B8C\u6574\u957F\u622A\u56FE\u9ED8\u8BA4 quality 70\u3002\u5F53\u9700\u8981\u68C0\u67E5\u6587\u5B57\u6E05\u6670\u5EA6\u30011px \u7EC6\u7EBF\u3001\u56FE\u6807\u8FB9\u7F18\u3001\u900F\u660E\u80CC\u666F\u6216\u50CF\u7D20\u7EA7\u7EC6\u8282\u65F6\uFF0C\u5E94\u4F18\u5148\u4F7F\u7528 PNG\u3002\u5982\u4EC5\u9700\u6587\u672C/\u94FE\u63A5\u7B49\u4FE1\u606F\uFF0C\u5EFA\u8BAE\u4F7F\u7528\u66F4\u5FEB\u7684 get_page_content\u3002",
|
|
29067
29081
|
inputSchema: {
|
|
29068
29082
|
type: "object",
|
|
29069
29083
|
properties: {
|
|
29070
29084
|
format: {
|
|
29071
29085
|
type: "string",
|
|
29072
29086
|
enum: ["png", "jpeg"],
|
|
29073
|
-
description: "\u56FE\u7247\u683C\u5F0F\
|
|
29087
|
+
description: "\u56FE\u7247\u683C\u5F0F\u3002\u9ED8\u8BA4\u4F7F\u7528 jpeg\uFF1B\u9700\u8981\u9AD8\u4FDD\u771F\u6587\u5B57\u3001\u7EC6\u7EBF\u3001\u900F\u660E\u80CC\u666F\u65F6\u7528 png"
|
|
29074
29088
|
},
|
|
29075
29089
|
quality: {
|
|
29076
29090
|
type: "number",
|
|
29077
|
-
description: "JPEG \u8D28\u91CF (0-100)\uFF0C\u4EC5\u5F53 format \u4E3A jpeg \u65F6\u6709\u6548\uFF0C\
|
|
29091
|
+
description: "JPEG \u8D28\u91CF (0-100)\uFF0C\u4EC5\u5F53 format \u4E3A jpeg \u65F6\u6709\u6548\u3002\u9ED8\u8BA4\u666E\u901A\u622A\u56FE 80\uFF0C\u5B8C\u6574\u957F\u622A\u56FE 70"
|
|
29078
29092
|
},
|
|
29079
29093
|
fullPage: {
|
|
29080
29094
|
type: "boolean",
|
|
@@ -29187,20 +29201,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
29187
29201
|
try {
|
|
29188
29202
|
if (name === "inspect_page") {
|
|
29189
29203
|
const { selector, includeInteractive = true, maxElements = 30 } = args;
|
|
29190
|
-
const
|
|
29191
|
-
mode: "structured",
|
|
29204
|
+
const snapshot = await askChrome("inspectPageSnapshot", {
|
|
29192
29205
|
selector,
|
|
29193
|
-
|
|
29194
|
-
|
|
29206
|
+
includeInteractive,
|
|
29207
|
+
maxElements
|
|
29195
29208
|
});
|
|
29196
|
-
|
|
29197
|
-
|
|
29198
|
-
interactive = await askChrome("getInteractiveSnapshot", {
|
|
29199
|
-
selector,
|
|
29200
|
-
includeText: true,
|
|
29201
|
-
maxElements
|
|
29202
|
-
});
|
|
29203
|
-
}
|
|
29209
|
+
const page = snapshot?.page;
|
|
29210
|
+
const interactive = snapshot?.interactive ?? null;
|
|
29204
29211
|
const links = page?.counts?.links;
|
|
29205
29212
|
const buttons = page?.counts?.buttons;
|
|
29206
29213
|
const forms = page?.counts?.forms;
|
|
@@ -29352,6 +29359,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
29352
29359
|
}
|
|
29353
29360
|
const metadata = {
|
|
29354
29361
|
format: res.format,
|
|
29362
|
+
...res.quality !== void 0 ? { quality: res.quality } : {},
|
|
29355
29363
|
fullPage: res.fullPage,
|
|
29356
29364
|
width: res.width,
|
|
29357
29365
|
height: res.height,
|
package/extension/background.js
CHANGED
|
@@ -7,7 +7,6 @@ function getMonthlyToken() {
|
|
|
7
7
|
|
|
8
8
|
const CONFIG = {
|
|
9
9
|
basePort: 33333,
|
|
10
|
-
maxPortRetries: 10,
|
|
11
10
|
token: getMonthlyToken(),
|
|
12
11
|
autoDetach: false,
|
|
13
12
|
maxErrors: 100,
|
|
@@ -23,7 +22,7 @@ let lastErrors = []
|
|
|
23
22
|
let lastErrorLocation = null
|
|
24
23
|
let requestMap = new Map()
|
|
25
24
|
let networkRequests = []
|
|
26
|
-
let state = { enabled: false, connected: false, port: null, currentPort: null,
|
|
25
|
+
let state = { enabled: false, connected: false, port: null, currentPort: null, connectionStatus: 'disconnected', connectionError: '' }
|
|
27
26
|
|
|
28
27
|
// 待处理的请求(等待 offscreen 响应)
|
|
29
28
|
const pendingRequests = new Map()
|
|
@@ -399,46 +398,57 @@ function compactStack(stackTrace) {
|
|
|
399
398
|
|
|
400
399
|
// ========== Debugger 操作 ==========
|
|
401
400
|
|
|
401
|
+
// attach 互斥锁:防止并发调用 ensureAttached 导致重复 attach / 状态竞态
|
|
402
|
+
let _attachLock = Promise.resolve()
|
|
403
|
+
|
|
402
404
|
async function ensureAttached() {
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
405
|
+
let _release
|
|
406
|
+
const _prev = _attachLock
|
|
407
|
+
_attachLock = new Promise(r => _release = r)
|
|
408
|
+
await _prev
|
|
409
|
+
try {
|
|
410
|
+
if (!state.enabled) throw new Error("扩展已暂停,点击图标开启后再试")
|
|
411
|
+
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
|
|
412
|
+
if (!tab) throw new Error("没有激活的标签页")
|
|
413
|
+
if (attachedTabId !== tab.id) {
|
|
414
|
+
if (attachedTabId) {
|
|
415
|
+
try { await chrome.debugger.detach({ tabId: attachedTabId }) } catch (e) {}
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
await chrome.debugger.attach({ tabId: tab.id }, "1.3")
|
|
416
419
|
setBadgeState("on")
|
|
417
|
-
}
|
|
418
|
-
|
|
420
|
+
} catch (e) {
|
|
421
|
+
attachedTabId = null
|
|
422
|
+
if (state.connected) {
|
|
423
|
+
setBadgeState("on")
|
|
424
|
+
} else {
|
|
425
|
+
setBadgeState("att")
|
|
426
|
+
}
|
|
427
|
+
throw e
|
|
419
428
|
}
|
|
420
|
-
|
|
429
|
+
attachedTabId = tab.id
|
|
430
|
+
scriptMap = new Map()
|
|
431
|
+
scriptSourceCache = new Map()
|
|
432
|
+
networkRequests = []
|
|
433
|
+
requestMap = new Map()
|
|
434
|
+
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Runtime.enable")
|
|
435
|
+
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Log.enable")
|
|
436
|
+
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Console.enable").catch(() => {})
|
|
437
|
+
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Debugger.enable")
|
|
438
|
+
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Profiler.enable")
|
|
439
|
+
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Network.enable").catch(() => {})
|
|
440
|
+
|
|
441
|
+
// Enable auto-attach to sub-targets (iframes, workers) for comprehensive capture
|
|
442
|
+
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Target.setAutoAttach", {
|
|
443
|
+
autoAttach: true,
|
|
444
|
+
waitForDebuggerOnStart: false,
|
|
445
|
+
flatten: true,
|
|
446
|
+
}).catch(() => {})
|
|
421
447
|
}
|
|
422
|
-
attachedTabId
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
networkRequests = []
|
|
426
|
-
requestMap = new Map()
|
|
427
|
-
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Runtime.enable")
|
|
428
|
-
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Log.enable")
|
|
429
|
-
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Console.enable").catch(() => {})
|
|
430
|
-
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Debugger.enable")
|
|
431
|
-
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Profiler.enable")
|
|
432
|
-
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Network.enable").catch(() => {})
|
|
433
|
-
|
|
434
|
-
// Enable auto-attach to sub-targets (iframes, workers) for comprehensive capture
|
|
435
|
-
await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Target.setAutoAttach", {
|
|
436
|
-
autoAttach: true,
|
|
437
|
-
waitForDebuggerOnStart: false,
|
|
438
|
-
flatten: true,
|
|
439
|
-
}).catch(() => {})
|
|
448
|
+
return { tabId: attachedTabId }
|
|
449
|
+
} finally {
|
|
450
|
+
_release()
|
|
440
451
|
}
|
|
441
|
-
return { tabId: attachedTabId }
|
|
442
452
|
}
|
|
443
453
|
|
|
444
454
|
async function maybeDetach(force = false) {
|
|
@@ -880,13 +890,17 @@ function roundMs(seconds) {
|
|
|
880
890
|
|
|
881
891
|
async function handleCaptureScreenshot(params = {}) {
|
|
882
892
|
const target = await ensureAttached()
|
|
883
|
-
const { format
|
|
893
|
+
const { format: requestedFormat, quality: requestedQuality, fullPage = false, clip } = params
|
|
894
|
+
const format = requestedFormat || 'jpeg'
|
|
895
|
+
const quality = format === 'jpeg'
|
|
896
|
+
? (requestedQuality ?? (fullPage ? 70 : 80))
|
|
897
|
+
: undefined
|
|
884
898
|
|
|
885
899
|
await chrome.debugger.sendCommand(target, 'Page.enable')
|
|
886
900
|
|
|
887
901
|
let captureParams = {
|
|
888
902
|
format,
|
|
889
|
-
...(
|
|
903
|
+
...(format === 'jpeg' ? { quality } : {}),
|
|
890
904
|
}
|
|
891
905
|
|
|
892
906
|
if (clip) {
|
|
@@ -928,7 +942,7 @@ async function handleCaptureScreenshot(params = {}) {
|
|
|
928
942
|
}
|
|
929
943
|
await chrome.debugger.sendCommand(target, 'Emulation.clearDeviceMetricsOverride').catch(() => {})
|
|
930
944
|
return {
|
|
931
|
-
imageData: data, format, fullPage: true, width: maxWidth, height: maxHeight,
|
|
945
|
+
imageData: data, format, quality, fullPage: true, width: maxWidth, height: maxHeight,
|
|
932
946
|
note: pageSize.height > maxHeight ? `页面高度 ${pageSize.height}px 超过限制,已截取前 ${maxHeight}px` : undefined,
|
|
933
947
|
}
|
|
934
948
|
} catch (e) {
|
|
@@ -944,7 +958,207 @@ async function handleCaptureScreenshot(params = {}) {
|
|
|
944
958
|
returnByValue: true,
|
|
945
959
|
})
|
|
946
960
|
|
|
947
|
-
return {
|
|
961
|
+
return {
|
|
962
|
+
imageData: data,
|
|
963
|
+
format,
|
|
964
|
+
quality,
|
|
965
|
+
fullPage: false,
|
|
966
|
+
width: sizeResult?.value?.width,
|
|
967
|
+
height: sizeResult?.value?.height
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async function handleInspectPageSnapshot(params = {}) {
|
|
972
|
+
const target = await ensureAttached()
|
|
973
|
+
const { selector, includeInteractive = true, maxElements = 30 } = params
|
|
974
|
+
|
|
975
|
+
const selectorStr = selector ? JSON.stringify(selector) : 'null'
|
|
976
|
+
|
|
977
|
+
const expression = `(function() {
|
|
978
|
+
try {
|
|
979
|
+
if (document.readyState === 'loading') {
|
|
980
|
+
return { error: '页面尚未加载完成,请稍后重试', readyState: document.readyState };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const includeInteractive = ${includeInteractive};
|
|
984
|
+
const maxEls = ${maxElements};
|
|
985
|
+
const selector = ${selectorStr};
|
|
986
|
+
const result = {};
|
|
987
|
+
let targetElement = document.body;
|
|
988
|
+
|
|
989
|
+
if (selector) {
|
|
990
|
+
try {
|
|
991
|
+
targetElement = document.querySelector(selector);
|
|
992
|
+
if (!targetElement) {
|
|
993
|
+
return { error: '选择器未匹配到任何元素', selector: selector, suggestion: '请检查选择器是否正确' };
|
|
994
|
+
}
|
|
995
|
+
result.selector = selector;
|
|
996
|
+
result.matchedTag = targetElement.tagName.toLowerCase();
|
|
997
|
+
} catch (e) {
|
|
998
|
+
return { error: '无效的 CSS 选择器: ' + e.message, selector: selector };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
result.metadata = {
|
|
1003
|
+
title: document.title || '',
|
|
1004
|
+
url: window.location.href,
|
|
1005
|
+
description: document.querySelector('meta[name="description"]')?.content || '',
|
|
1006
|
+
keywords: document.querySelector('meta[name="keywords"]')?.content || '',
|
|
1007
|
+
charset: document.characterSet,
|
|
1008
|
+
language: document.documentElement.lang || '',
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
const structured = {};
|
|
1012
|
+
const headings = targetElement.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
|
1013
|
+
structured.headings = Array.from(headings).slice(0, 50).map(h => ({
|
|
1014
|
+
level: parseInt(h.tagName[1]),
|
|
1015
|
+
text: h.innerText.trim().slice(0, 200)
|
|
1016
|
+
}));
|
|
1017
|
+
const links = targetElement.querySelectorAll('a[href]');
|
|
1018
|
+
structured.links = Array.from(links).slice(0, 100).map(a => ({
|
|
1019
|
+
text: (a.innerText || '').trim().slice(0, 100),
|
|
1020
|
+
href: a.href
|
|
1021
|
+
})).filter(l => l.href && !l.href.startsWith('javascript:'));
|
|
1022
|
+
const buttons = targetElement.querySelectorAll('button, input[type="button"], input[type="submit"], [role="button"]');
|
|
1023
|
+
structured.buttons = Array.from(buttons).slice(0, 50).map(b => ({
|
|
1024
|
+
text: (b.innerText || b.value || b.getAttribute('aria-label') || '').trim().slice(0, 100),
|
|
1025
|
+
type: b.type || 'button',
|
|
1026
|
+
disabled: b.disabled || false
|
|
1027
|
+
}));
|
|
1028
|
+
const forms = targetElement.querySelectorAll('form');
|
|
1029
|
+
structured.forms = Array.from(forms).slice(0, 20).map(f => {
|
|
1030
|
+
const fields = Array.from(f.querySelectorAll('input, select, textarea')).slice(0, 30);
|
|
1031
|
+
return {
|
|
1032
|
+
action: f.action || '',
|
|
1033
|
+
method: (f.method || 'GET').toUpperCase(),
|
|
1034
|
+
fieldCount: fields.length,
|
|
1035
|
+
fields: fields.map(field => ({
|
|
1036
|
+
tag: field.tagName.toLowerCase(),
|
|
1037
|
+
type: field.type || '',
|
|
1038
|
+
name: field.name || '',
|
|
1039
|
+
placeholder: field.placeholder || '',
|
|
1040
|
+
required: field.required || false
|
|
1041
|
+
}))
|
|
1042
|
+
};
|
|
1043
|
+
});
|
|
1044
|
+
const images = targetElement.querySelectorAll('img');
|
|
1045
|
+
structured.images = Array.from(images).slice(0, 50).map(img => ({
|
|
1046
|
+
alt: img.alt || '',
|
|
1047
|
+
src: img.src ? img.src.slice(0, 200) : ''
|
|
1048
|
+
})).filter(img => img.src);
|
|
1049
|
+
const tables = targetElement.querySelectorAll('table');
|
|
1050
|
+
structured.tables = Array.from(tables).slice(0, 10).map(table => {
|
|
1051
|
+
const headers = Array.from(table.querySelectorAll('th')).map(th => th.innerText.trim().slice(0, 50));
|
|
1052
|
+
const rows = table.querySelectorAll('tr');
|
|
1053
|
+
return { headers: headers.slice(0, 20), rowCount: rows.length };
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
result.page = {
|
|
1057
|
+
metadata: result.metadata,
|
|
1058
|
+
...(result.selector ? { selector: result.selector, matchedTag: result.matchedTag } : {}),
|
|
1059
|
+
structured,
|
|
1060
|
+
counts: {
|
|
1061
|
+
headings: structured.headings.length,
|
|
1062
|
+
links: structured.links.length,
|
|
1063
|
+
buttons: structured.buttons.length,
|
|
1064
|
+
forms: structured.forms.length,
|
|
1065
|
+
images: structured.images.length,
|
|
1066
|
+
tables: structured.tables.length
|
|
1067
|
+
},
|
|
1068
|
+
mode: 'structured'
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
if (!includeInteractive) {
|
|
1072
|
+
result.interactive = null;
|
|
1073
|
+
return result;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
let refCounter = 0;
|
|
1077
|
+
const elements = [];
|
|
1078
|
+
const INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="radio"],[role="switch"],[role="combobox"],[tabindex]:not([tabindex="-1"]),[contenteditable="true"],[onclick]';
|
|
1079
|
+
|
|
1080
|
+
function isVisible(el) {
|
|
1081
|
+
const style = window.getComputedStyle(el);
|
|
1082
|
+
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return null;
|
|
1083
|
+
if (!el.offsetParent && el.tagName !== 'HTML' && el.tagName !== 'BODY' &&
|
|
1084
|
+
style.position !== 'fixed' && style.position !== 'sticky') return null;
|
|
1085
|
+
const rect = el.getBoundingClientRect();
|
|
1086
|
+
if (rect.width === 0 && rect.height === 0) return null;
|
|
1087
|
+
return rect;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function buildEntry(el, rect) {
|
|
1091
|
+
refCounter++;
|
|
1092
|
+
const ref = 'e' + refCounter;
|
|
1093
|
+
el.setAttribute('data-ghost-ref', ref);
|
|
1094
|
+
const tag = el.tagName.toLowerCase();
|
|
1095
|
+
const entry = { ref, tag, cx: Math.round(rect.left + rect.width / 2), cy: Math.round(rect.top + rect.height / 2) };
|
|
1096
|
+
if (el.type) entry.type = el.type;
|
|
1097
|
+
if (el.name) entry.name = el.name;
|
|
1098
|
+
if (el.getAttribute('role')) entry.role = el.getAttribute('role');
|
|
1099
|
+
if (el.placeholder) entry.placeholder = el.placeholder.slice(0, 80);
|
|
1100
|
+
if (el.value && tag !== 'textarea') entry.value = el.value.slice(0, 80);
|
|
1101
|
+
if (tag === 'a') entry.href = (el.href || '').slice(0, 150);
|
|
1102
|
+
if (tag === 'select') {
|
|
1103
|
+
entry.options = Array.from(el.options).slice(0, 10).map(o => ({
|
|
1104
|
+
value: o.value, text: o.text.slice(0, 50), selected: o.selected
|
|
1105
|
+
}));
|
|
1106
|
+
}
|
|
1107
|
+
const text = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
|
|
1108
|
+
if (text && text.length <= 100) entry.text = text;
|
|
1109
|
+
else if (text) entry.text = text.slice(0, 97) + '...';
|
|
1110
|
+
if (el.disabled) entry.disabled = true;
|
|
1111
|
+
return entry;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function scanRoot(root) {
|
|
1115
|
+
const candidates = root.querySelectorAll(INTERACTIVE_SELECTOR);
|
|
1116
|
+
for (let i = 0; i < candidates.length && elements.length < maxEls; i++) {
|
|
1117
|
+
const rect = isVisible(candidates[i]);
|
|
1118
|
+
if (rect) elements.push(buildEntry(candidates[i], rect));
|
|
1119
|
+
}
|
|
1120
|
+
if (elements.length < maxEls) {
|
|
1121
|
+
const all = root.querySelectorAll('*');
|
|
1122
|
+
for (let i = 0; i < all.length && elements.length < maxEls; i++) {
|
|
1123
|
+
const el = all[i];
|
|
1124
|
+
if (el.shadowRoot) scanRoot(el.shadowRoot);
|
|
1125
|
+
if (el.onclick && !el.hasAttribute('data-ghost-ref')) {
|
|
1126
|
+
const rect = isVisible(el);
|
|
1127
|
+
if (rect) elements.push(buildEntry(el, rect));
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
document.querySelectorAll('[data-ghost-ref]').forEach(el => el.removeAttribute('data-ghost-ref'));
|
|
1134
|
+
scanRoot(targetElement);
|
|
1135
|
+
|
|
1136
|
+
result.interactive = {
|
|
1137
|
+
url: window.location.href,
|
|
1138
|
+
title: document.title,
|
|
1139
|
+
elementCount: elements.length,
|
|
1140
|
+
viewport: {
|
|
1141
|
+
width: window.innerWidth,
|
|
1142
|
+
height: window.innerHeight,
|
|
1143
|
+
scrollX: Math.round(window.scrollX),
|
|
1144
|
+
scrollY: Math.round(window.scrollY),
|
|
1145
|
+
},
|
|
1146
|
+
elements
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
return result;
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
return { error: e.message };
|
|
1152
|
+
}
|
|
1153
|
+
})()`
|
|
1154
|
+
|
|
1155
|
+
const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
|
|
1156
|
+
expression,
|
|
1157
|
+
returnByValue: true,
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
if (result?.value?.error) throw new Error(result.value.error)
|
|
1161
|
+
return result?.value
|
|
948
1162
|
}
|
|
949
1163
|
|
|
950
1164
|
async function handleGetPageContent(params = {}) {
|
|
@@ -1062,78 +1276,65 @@ async function handleGetInteractiveSnapshot(params = {}) {
|
|
|
1062
1276
|
let refCounter = 0;
|
|
1063
1277
|
const elements = [];
|
|
1064
1278
|
|
|
1065
|
-
|
|
1279
|
+
const maxEls = ${maxElements};
|
|
1280
|
+
// 候选集选择器——用浏览器原生选择器引擎代替全树 JS 递归
|
|
1281
|
+
const INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="radio"],[role="switch"],[role="combobox"],[tabindex]:not([tabindex="-1"]),[contenteditable="true"],[onclick]';
|
|
1282
|
+
|
|
1283
|
+
// 可见性检测(单次 getComputedStyle,返回 rect 复用)
|
|
1066
1284
|
function isVisible(el) {
|
|
1067
|
-
if (!el.offsetParent && el.tagName !== 'HTML' && el.tagName !== 'BODY' &&
|
|
1068
|
-
window.getComputedStyle(el).position !== 'fixed' &&
|
|
1069
|
-
window.getComputedStyle(el).position !== 'sticky') return false;
|
|
1070
1285
|
const style = window.getComputedStyle(el);
|
|
1071
|
-
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return
|
|
1286
|
+
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return null;
|
|
1287
|
+
if (!el.offsetParent && el.tagName !== 'HTML' && el.tagName !== 'BODY' &&
|
|
1288
|
+
style.position !== 'fixed' && style.position !== 'sticky') return null;
|
|
1072
1289
|
const rect = el.getBoundingClientRect();
|
|
1073
|
-
if (rect.width === 0 && rect.height === 0) return
|
|
1074
|
-
return
|
|
1290
|
+
if (rect.width === 0 && rect.height === 0) return null;
|
|
1291
|
+
return rect;
|
|
1075
1292
|
}
|
|
1076
1293
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1294
|
+
function buildEntry(el, rect) {
|
|
1295
|
+
refCounter++;
|
|
1296
|
+
const ref = 'e' + refCounter;
|
|
1297
|
+
el.setAttribute('data-ghost-ref', ref);
|
|
1079
1298
|
const tag = el.tagName.toLowerCase();
|
|
1080
|
-
|
|
1081
|
-
if (el.
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1299
|
+
const entry = { ref, tag, cx: Math.round(rect.left + rect.width / 2), cy: Math.round(rect.top + rect.height / 2) };
|
|
1300
|
+
if (el.type) entry.type = el.type;
|
|
1301
|
+
if (el.name) entry.name = el.name;
|
|
1302
|
+
if (el.getAttribute('role')) entry.role = el.getAttribute('role');
|
|
1303
|
+
if (${includeText}) {
|
|
1304
|
+
if (el.placeholder) entry.placeholder = el.placeholder.slice(0, 80);
|
|
1305
|
+
if (el.value && tag !== 'textarea') entry.value = el.value.slice(0, 80);
|
|
1306
|
+
if (tag === 'a') entry.href = (el.href || '').slice(0, 150);
|
|
1307
|
+
if (tag === 'select') {
|
|
1308
|
+
entry.options = Array.from(el.options).slice(0, 10).map(o => ({
|
|
1309
|
+
value: o.value, text: o.text.slice(0, 50), selected: o.selected
|
|
1310
|
+
}));
|
|
1311
|
+
}
|
|
1312
|
+
const text = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
|
|
1313
|
+
if (text && text.length <= 100) entry.text = text;
|
|
1314
|
+
else if (text) entry.text = text.slice(0, 97) + '...';
|
|
1315
|
+
}
|
|
1316
|
+
if (el.disabled) entry.disabled = true;
|
|
1317
|
+
return entry;
|
|
1089
1318
|
}
|
|
1090
1319
|
|
|
1091
|
-
//
|
|
1092
|
-
function
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if (elements.
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
cy: Math.round(rect.top + rect.height / 2),
|
|
1109
|
-
};
|
|
1110
|
-
// 类型信息
|
|
1111
|
-
if (el.type) entry.type = el.type;
|
|
1112
|
-
if (el.name) entry.name = el.name;
|
|
1113
|
-
if (el.getAttribute('role')) entry.role = el.getAttribute('role');
|
|
1114
|
-
// 文本信息
|
|
1115
|
-
if (${includeText}) {
|
|
1116
|
-
if (el.placeholder) entry.placeholder = el.placeholder.slice(0, 80);
|
|
1117
|
-
if (el.value && tag !== 'textarea') entry.value = el.value.slice(0, 80);
|
|
1118
|
-
if (tag === 'a') entry.href = (el.href || '').slice(0, 150);
|
|
1119
|
-
if (tag === 'select') {
|
|
1120
|
-
entry.options = Array.from(el.options).slice(0, 10).map(o => ({
|
|
1121
|
-
value: o.value, text: o.text.slice(0, 50), selected: o.selected
|
|
1122
|
-
}));
|
|
1123
|
-
}
|
|
1124
|
-
const text = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
|
|
1125
|
-
if (text && text.length <= 100) entry.text = text;
|
|
1126
|
-
else if (text) entry.text = text.slice(0, 97) + '...';
|
|
1320
|
+
// 候选集扫描(含 Shadow DOM 穿透)
|
|
1321
|
+
function scanRoot(root) {
|
|
1322
|
+
const candidates = root.querySelectorAll(INTERACTIVE_SELECTOR);
|
|
1323
|
+
for (let i = 0; i < candidates.length && elements.length < maxEls; i++) {
|
|
1324
|
+
const rect = isVisible(candidates[i]);
|
|
1325
|
+
if (rect) elements.push(buildEntry(candidates[i], rect));
|
|
1326
|
+
}
|
|
1327
|
+
// 穿透 Shadow DOM + 兜底检测 el.onclick = fn 形式的 JS 属性绑定
|
|
1328
|
+
if (elements.length < maxEls) {
|
|
1329
|
+
const all = root.querySelectorAll('*');
|
|
1330
|
+
for (let i = 0; i < all.length && elements.length < maxEls; i++) {
|
|
1331
|
+
const el = all[i];
|
|
1332
|
+
if (el.shadowRoot) scanRoot(el.shadowRoot);
|
|
1333
|
+
// CSS 选择器只能匹配 [onclick] 属性,这里兜住 el.onclick = fn 的情况
|
|
1334
|
+
if (el.onclick && !el.hasAttribute('data-ghost-ref')) {
|
|
1335
|
+
const rect = isVisible(el);
|
|
1336
|
+
if (rect) elements.push(buildEntry(el, rect));
|
|
1127
1337
|
}
|
|
1128
|
-
// disabled 状态
|
|
1129
|
-
if (el.disabled) entry.disabled = true;
|
|
1130
|
-
elements.push(entry);
|
|
1131
|
-
}
|
|
1132
|
-
// 递归子节点
|
|
1133
|
-
walkDOM(el, root);
|
|
1134
|
-
// 穿透 Shadow DOM
|
|
1135
|
-
if (el.shadowRoot) {
|
|
1136
|
-
walkDOM(el.shadowRoot, root);
|
|
1137
1338
|
}
|
|
1138
1339
|
}
|
|
1139
1340
|
}
|
|
@@ -1141,7 +1342,6 @@ async function handleGetInteractiveSnapshot(params = {}) {
|
|
|
1141
1342
|
// 清理旧的 ref 标记
|
|
1142
1343
|
document.querySelectorAll('[data-ghost-ref]').forEach(el => el.removeAttribute('data-ghost-ref'));
|
|
1143
1344
|
|
|
1144
|
-
// 确定扫描根节点
|
|
1145
1345
|
let rootEl = document.body;
|
|
1146
1346
|
const sel = ${selectorStr};
|
|
1147
1347
|
if (sel) {
|
|
@@ -1149,7 +1349,7 @@ async function handleGetInteractiveSnapshot(params = {}) {
|
|
|
1149
1349
|
if (!rootEl) return { error: '选择器未匹配到任何元素', selector: sel };
|
|
1150
1350
|
}
|
|
1151
1351
|
|
|
1152
|
-
|
|
1352
|
+
scanRoot(rootEl);
|
|
1153
1353
|
|
|
1154
1354
|
return {
|
|
1155
1355
|
url: window.location.href,
|
|
@@ -1369,6 +1569,7 @@ async function handleCommand(message) {
|
|
|
1369
1569
|
else if (command === "clearNetworkRequests") result = await handleClearNetworkRequests()
|
|
1370
1570
|
else if (command === "perfMetrics") result = await handlePerfMetrics(params)
|
|
1371
1571
|
else if (command === "captureScreenshot") result = await handleCaptureScreenshot(params)
|
|
1572
|
+
else if (command === "inspectPageSnapshot") result = await handleInspectPageSnapshot(params)
|
|
1372
1573
|
else if (command === "getPageContent") result = await handleGetPageContent(params)
|
|
1373
1574
|
else if (command === "getInteractiveSnapshot") result = await handleGetInteractiveSnapshot(params)
|
|
1374
1575
|
else if (command === "dispatchAction") result = await handleDispatchAction(params)
|
|
@@ -1396,10 +1597,8 @@ function broadcastStatus() {
|
|
|
1396
1597
|
status = 'disconnected'
|
|
1397
1598
|
} else if (state.connected) {
|
|
1398
1599
|
status = 'connected'
|
|
1399
|
-
} else if (state.scanRound >= 4) {
|
|
1400
|
-
status = 'not_found'
|
|
1401
1600
|
} else {
|
|
1402
|
-
status = '
|
|
1601
|
+
status = state.connectionStatus || 'connecting'
|
|
1403
1602
|
}
|
|
1404
1603
|
|
|
1405
1604
|
let tabUrl = ''
|
|
@@ -1425,7 +1624,7 @@ function broadcastStatus() {
|
|
|
1425
1624
|
port: state.port,
|
|
1426
1625
|
currentPort: state.currentPort,
|
|
1427
1626
|
basePort: CONFIG.basePort,
|
|
1428
|
-
|
|
1627
|
+
connectionError: state.connectionError,
|
|
1429
1628
|
errorCount: actualErrors.length,
|
|
1430
1629
|
recentErrors: actualErrors.slice(0, 5),
|
|
1431
1630
|
tabTitle,
|
|
@@ -1474,18 +1673,32 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1474
1673
|
if (message.status === 'connected') {
|
|
1475
1674
|
state.connected = true
|
|
1476
1675
|
state.port = message.port
|
|
1676
|
+
state.currentPort = message.port
|
|
1677
|
+
state.connectionStatus = 'connected'
|
|
1678
|
+
state.connectionError = ''
|
|
1477
1679
|
setBadgeState('on')
|
|
1478
1680
|
log(`✅ 已连接到 ghost-bridge 服务 (端口 ${message.port})`)
|
|
1479
1681
|
ensureAttached().catch((e) => log(`attach 失败:${e.message}`))
|
|
1480
1682
|
} else if (message.status === 'disconnected') {
|
|
1481
1683
|
state.connected = false
|
|
1482
1684
|
state.port = null
|
|
1685
|
+
state.connectionStatus = 'connecting'
|
|
1686
|
+
state.connectionError = ''
|
|
1483
1687
|
if (state.enabled) setBadgeState('connecting')
|
|
1484
|
-
} else if (message.status === '
|
|
1688
|
+
} else if (message.status === 'connecting') {
|
|
1485
1689
|
state.currentPort = message.currentPort
|
|
1690
|
+
state.connectionStatus = 'connecting'
|
|
1691
|
+
state.connectionError = ''
|
|
1486
1692
|
setBadgeState('connecting')
|
|
1693
|
+
} else if (message.status === 'error') {
|
|
1694
|
+
state.currentPort = message.currentPort
|
|
1695
|
+
state.connectionStatus = 'error'
|
|
1696
|
+
state.connectionError = message.errorMessage || ''
|
|
1697
|
+
setBadgeState('err')
|
|
1487
1698
|
} else if (message.status === 'not_found') {
|
|
1488
|
-
state.
|
|
1699
|
+
state.currentPort = message.currentPort
|
|
1700
|
+
state.connectionStatus = 'not_found'
|
|
1701
|
+
state.connectionError = ''
|
|
1489
1702
|
setBadgeState('connecting')
|
|
1490
1703
|
}
|
|
1491
1704
|
broadcastStatus() // 状态变化时主动推送
|
|
@@ -1516,10 +1729,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1516
1729
|
status = 'disconnected'
|
|
1517
1730
|
} else if (state.connected) {
|
|
1518
1731
|
status = 'connected'
|
|
1519
|
-
} else if (state.scanRound >= 4) {
|
|
1520
|
-
status = 'not_found'
|
|
1521
1732
|
} else {
|
|
1522
|
-
status = '
|
|
1733
|
+
status = state.connectionStatus || 'connecting'
|
|
1523
1734
|
}
|
|
1524
1735
|
|
|
1525
1736
|
let tabUrl = ''
|
|
@@ -1544,7 +1755,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1544
1755
|
port: state.port,
|
|
1545
1756
|
currentPort: state.currentPort,
|
|
1546
1757
|
basePort: CONFIG.basePort,
|
|
1547
|
-
|
|
1758
|
+
connectionError: state.connectionError,
|
|
1548
1759
|
errorCount: actualErrors.length,
|
|
1549
1760
|
recentErrors: actualErrors.slice(0, 5),
|
|
1550
1761
|
tabTitle,
|
|
@@ -1561,7 +1772,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1561
1772
|
chrome.storage.local.set({ basePort: message.port })
|
|
1562
1773
|
}
|
|
1563
1774
|
state.enabled = true
|
|
1564
|
-
state.
|
|
1775
|
+
state.connected = false
|
|
1776
|
+
state.port = null
|
|
1777
|
+
state.currentPort = CONFIG.basePort
|
|
1778
|
+
state.connectionStatus = 'connecting'
|
|
1779
|
+
state.connectionError = ''
|
|
1565
1780
|
setBadgeState('connecting')
|
|
1566
1781
|
|
|
1567
1782
|
// 启动 offscreen 并开始连接
|
|
@@ -1570,7 +1785,6 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1570
1785
|
type: 'connect',
|
|
1571
1786
|
basePort: CONFIG.basePort,
|
|
1572
1787
|
token: CONFIG.token,
|
|
1573
|
-
maxPortRetries: CONFIG.maxPortRetries,
|
|
1574
1788
|
}).catch(() => {})
|
|
1575
1789
|
})
|
|
1576
1790
|
|
|
@@ -1582,7 +1796,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
1582
1796
|
if (message.type === 'disconnect') {
|
|
1583
1797
|
state.enabled = false
|
|
1584
1798
|
state.connected = false
|
|
1585
|
-
state.
|
|
1799
|
+
state.port = null
|
|
1800
|
+
state.currentPort = null
|
|
1801
|
+
state.connectionStatus = 'disconnected'
|
|
1802
|
+
state.connectionError = ''
|
|
1586
1803
|
setBadgeState('off')
|
|
1587
1804
|
detachAllTargets().catch(() => {})
|
|
1588
1805
|
|
package/extension/manifest.json
CHANGED
package/extension/offscreen.js
CHANGED
|
@@ -7,7 +7,6 @@ let manualDisconnect = false // 用户主动断开标志,防止 onclose 触
|
|
|
7
7
|
let config = {
|
|
8
8
|
basePort: 33333,
|
|
9
9
|
token: '',
|
|
10
|
-
maxPortRetries: 10,
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
function log(msg) {
|
|
@@ -23,29 +22,17 @@ function getMonthlyToken() {
|
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
// 连接到服务器
|
|
26
|
-
function connect(
|
|
25
|
+
function connect() {
|
|
27
26
|
// 如果已手动断开,不再尝试连接
|
|
28
27
|
if (manualDisconnect) return
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
log(`扫描完毕,未找到服务,2秒后重试...`)
|
|
32
|
-
reconnectTimer = setTimeout(() => connect(0, true), 2000)
|
|
33
|
-
chrome.runtime.sendMessage({ type: 'status', status: 'not_found' }).catch(() => {})
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const port = config.basePort + portIndex
|
|
29
|
+
const port = config.basePort
|
|
38
30
|
const url = new URL(`ws://localhost:${port}`)
|
|
39
31
|
url.searchParams.set('token', config.token)
|
|
40
|
-
|
|
41
|
-
if (portIndex === 0 && isNewRound) {
|
|
42
|
-
log(`开始扫描端口 ${config.basePort}-${config.basePort + config.maxPortRetries - 1}`)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
log(`尝试连接端口 ${port}...`)
|
|
32
|
+
log(`尝试连接固定端口 ${port}...`)
|
|
46
33
|
chrome.runtime.sendMessage({
|
|
47
34
|
type: 'status',
|
|
48
|
-
status: '
|
|
35
|
+
status: 'connecting',
|
|
49
36
|
currentPort: port,
|
|
50
37
|
}).catch(() => {})
|
|
51
38
|
|
|
@@ -59,8 +46,11 @@ function connect(portIndex = 0, isNewRound = false) {
|
|
|
59
46
|
}, 2000) // 增加到 2 秒
|
|
60
47
|
|
|
61
48
|
let identityVerified = false
|
|
49
|
+
let socketOpened = false
|
|
50
|
+
let terminalErrorMessage = ''
|
|
62
51
|
|
|
63
52
|
ws.onopen = () => {
|
|
53
|
+
socketOpened = true
|
|
64
54
|
clearTimeout(connectionTimeout)
|
|
65
55
|
log(`WebSocket 已连接端口 ${port},等待身份验证...`)
|
|
66
56
|
}
|
|
@@ -84,9 +74,15 @@ function connect(portIndex = 0, isNewRound = false) {
|
|
|
84
74
|
port: port,
|
|
85
75
|
}).catch(() => {})
|
|
86
76
|
} else {
|
|
87
|
-
|
|
77
|
+
terminalErrorMessage = `Port ${port} is occupied by a non-matching service, or the token does not match.`
|
|
78
|
+
log('身份验证失败,将在固定端口上重试...')
|
|
79
|
+
chrome.runtime.sendMessage({
|
|
80
|
+
type: 'status',
|
|
81
|
+
status: 'error',
|
|
82
|
+
currentPort: port,
|
|
83
|
+
errorMessage: terminalErrorMessage,
|
|
84
|
+
}).catch(() => {})
|
|
88
85
|
ws.close()
|
|
89
|
-
setTimeout(() => connect(portIndex + 1), 50)
|
|
90
86
|
}
|
|
91
87
|
return
|
|
92
88
|
}
|
|
@@ -107,15 +103,28 @@ function connect(portIndex = 0, isNewRound = false) {
|
|
|
107
103
|
if (manualDisconnect) return
|
|
108
104
|
|
|
109
105
|
if (!identityVerified) {
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
if (terminalErrorMessage || socketOpened) {
|
|
107
|
+
const errorMessage = terminalErrorMessage || `Port ${port} is occupied or responding with a non-ghost-bridge protocol.`
|
|
108
|
+
log(`${errorMessage} 2秒后重试...`)
|
|
109
|
+
chrome.runtime.sendMessage({
|
|
110
|
+
type: 'status',
|
|
111
|
+
status: 'error',
|
|
112
|
+
currentPort: port,
|
|
113
|
+
errorMessage,
|
|
114
|
+
}).catch(() => {})
|
|
115
|
+
reconnectTimer = setTimeout(() => connect(), 2000)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
log(`固定端口 ${port} 未发现可用服务,2秒后重试...`)
|
|
119
|
+
chrome.runtime.sendMessage({ type: 'status', status: 'not_found', currentPort: port }).catch(() => {})
|
|
120
|
+
reconnectTimer = setTimeout(() => connect(), 2000)
|
|
112
121
|
return
|
|
113
122
|
}
|
|
114
123
|
|
|
115
124
|
// 连接断开,重试
|
|
116
|
-
log(
|
|
125
|
+
log(`端口 ${port} 连接断开,尝试重连...`)
|
|
117
126
|
chrome.runtime.sendMessage({ type: 'status', status: 'disconnected' }).catch(() => {})
|
|
118
|
-
reconnectTimer = setTimeout(() => connect(
|
|
127
|
+
reconnectTimer = setTimeout(() => connect(), 1000)
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
ws.onerror = () => {
|
|
@@ -151,10 +160,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
151
160
|
if (message.type === 'connect') {
|
|
152
161
|
config.basePort = message.basePort || 33333
|
|
153
162
|
config.token = message.token || getMonthlyToken()
|
|
154
|
-
config.maxPortRetries = message.maxPortRetries || 10
|
|
155
163
|
disconnect()
|
|
156
164
|
manualDisconnect = false // 用户重新连接,清除断开标志
|
|
157
|
-
connect(
|
|
165
|
+
connect()
|
|
158
166
|
sendResponse({ ok: true })
|
|
159
167
|
return true
|
|
160
168
|
}
|
package/extension/popup.html
CHANGED
|
@@ -121,6 +121,15 @@
|
|
|
121
121
|
}
|
|
122
122
|
body.connected-state .bg-ripple-2 { animation-delay: 1.25s; }
|
|
123
123
|
|
|
124
|
+
.ghost-error .ghost-body { fill: #ef4444; }
|
|
125
|
+
.ghost-error .ghost-eyes-normal { opacity: 1; }
|
|
126
|
+
|
|
127
|
+
body.error-state .bg-ripple {
|
|
128
|
+
stroke: #ef4444;
|
|
129
|
+
animation: bg-ripple-scan 1.8s infinite cubic-bezier(0, 0, 0.2, 1);
|
|
130
|
+
}
|
|
131
|
+
body.error-state .bg-ripple-2 { animation-delay: 0.9s; }
|
|
132
|
+
|
|
124
133
|
@keyframes bg-ripple-scan {
|
|
125
134
|
0% { r: 10%; opacity: 0.8; stroke-width: 3; }
|
|
126
135
|
100% { r: 80%; opacity: 0; stroke-width: 0; }
|
package/extension/popup.js
CHANGED
|
@@ -53,7 +53,7 @@ const STATUS_MAP = {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function renderUI(state) {
|
|
56
|
-
const { status, port,
|
|
56
|
+
const { status, port, enabled, currentPort, basePort, connectionError, errorCount, recentErrors, tabTitle } = state
|
|
57
57
|
const config = STATUS_MAP[status] || STATUS_MAP.disconnected
|
|
58
58
|
|
|
59
59
|
// Update classes for color & animations
|
|
@@ -66,6 +66,9 @@ function renderUI(state) {
|
|
|
66
66
|
} else if (config.statusClass === 'connecting') {
|
|
67
67
|
headerGhost.className = 'ghost-wrapper ghost-connecting'
|
|
68
68
|
document.body.className = 'connecting-state'
|
|
69
|
+
} else if (config.statusClass === 'error') {
|
|
70
|
+
headerGhost.className = 'ghost-wrapper ghost-error'
|
|
71
|
+
document.body.className = 'error-state'
|
|
69
72
|
} else {
|
|
70
73
|
headerGhost.className = 'ghost-wrapper ghost-disconnected'
|
|
71
74
|
document.body.className = 'disconnected-state'
|
|
@@ -117,8 +120,7 @@ function renderUI(state) {
|
|
|
117
120
|
|
|
118
121
|
detailContainer.classList.remove('collapsed')
|
|
119
122
|
} else if ((status === 'connecting' || status === 'verifying' || status === 'scanning') && currentPort) {
|
|
120
|
-
|
|
121
|
-
portVal.textContent = `Scanning: ${currentPort}${roundText}`
|
|
123
|
+
portVal.textContent = `Connecting: ${currentPort}`
|
|
122
124
|
portVal.className = 'detail-value highlight'
|
|
123
125
|
tabRow.classList.add('hidden')
|
|
124
126
|
errorRow.classList.add('hidden')
|
|
@@ -131,6 +133,12 @@ function renderUI(state) {
|
|
|
131
133
|
tabRow.classList.add('hidden')
|
|
132
134
|
errorRow.classList.add('hidden')
|
|
133
135
|
detailContainer.classList.remove('collapsed')
|
|
136
|
+
} else if (status === 'error') {
|
|
137
|
+
portVal.textContent = `Port ${currentPort || basePort || '-'} blocked`
|
|
138
|
+
portVal.className = 'detail-value warning'
|
|
139
|
+
tabRow.classList.add('hidden')
|
|
140
|
+
errorRow.classList.add('hidden')
|
|
141
|
+
detailContainer.classList.remove('collapsed')
|
|
134
142
|
} else {
|
|
135
143
|
detailContainer.classList.add('collapsed')
|
|
136
144
|
}
|
|
@@ -145,8 +153,11 @@ function renderUI(state) {
|
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
// Scan info text
|
|
148
|
-
if (
|
|
149
|
-
scanInfo.textContent =
|
|
156
|
+
if (status === 'error' && connectionError) {
|
|
157
|
+
scanInfo.textContent = connectionError
|
|
158
|
+
scanInfo.classList.remove('collapsed')
|
|
159
|
+
} else if (status === 'not_found' && basePort) {
|
|
160
|
+
scanInfo.textContent = `Port ${basePort} unavailable. Is your MCP server running on this port?`
|
|
150
161
|
scanInfo.classList.remove('collapsed')
|
|
151
162
|
} else {
|
|
152
163
|
scanInfo.classList.add('collapsed')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghost-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Ghost Bridge: Zero-restart Chrome debugger bridge for Claude MCP. Includes CLI for easy setup.",
|
|
@@ -48,5 +48,8 @@
|
|
|
48
48
|
"fs-extra": "^11.1.1",
|
|
49
49
|
"js-beautify": "^1.14.11",
|
|
50
50
|
"ws": "^8.14.2"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"ghost-bridge": "^0.5.2"
|
|
51
54
|
}
|
|
52
55
|
}
|