@teapotz/electron-mcp 0.1.1
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 +288 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +48 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +81 -0
- package/dist/protocol/responses.d.ts +4 -0
- package/dist/protocol/responses.js +22 -0
- package/dist/session/SessionController.d.ts +19 -0
- package/dist/session/SessionController.js +145 -0
- package/dist/session/types.d.ts +6 -0
- package/dist/session/types.js +1 -0
- package/dist/tools/connection.tools.d.ts +2 -0
- package/dist/tools/connection.tools.js +106 -0
- package/dist/tools/evaluate.tools.d.ts +3 -0
- package/dist/tools/evaluate.tools.js +73 -0
- package/dist/tools/inspection.tools.d.ts +2 -0
- package/dist/tools/inspection.tools.js +120 -0
- package/dist/tools/interaction.tools.d.ts +2 -0
- package/dist/tools/interaction.tools.js +233 -0
- package/dist/tools/mouse.tools.d.ts +2 -0
- package/dist/tools/mouse.tools.js +105 -0
- package/dist/tools/registry.d.ts +10 -0
- package/dist/tools/registry.js +40 -0
- package/dist/tools/scroll.tools.d.ts +2 -0
- package/dist/tools/scroll.tools.js +84 -0
- package/dist/validation/schemas.d.ts +86 -0
- package/dist/validation/schemas.js +84 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# Electron MCP
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server for testing Electron applications using Playwright. Enables AI models like Claude to interact with and test your Electron apps.
|
|
4
|
+
|
|
5
|
+
## 🚀 Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Run directly with npx
|
|
9
|
+
npx @teapotz/electron-mcp
|
|
10
|
+
|
|
11
|
+
# Or install globally
|
|
12
|
+
npm install -g @teapotz/electron-mcp
|
|
13
|
+
electron-mcp
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- **Two Connection Modes**
|
|
19
|
+
- **CDP Mode**: Connect to a running Electron app via Chrome DevTools Protocol
|
|
20
|
+
- **Launch Mode**: Launch a fresh Electron app instance for testing
|
|
21
|
+
- **Full Playwright API**: screenshot, click, fill, type, hover, press, wait, evaluate, and more
|
|
22
|
+
- **Accessibility Snapshots**: Get the accessibility tree for element discovery
|
|
23
|
+
- **Main Process Access**: Execute code in Electron's main process (launch mode only)
|
|
24
|
+
|
|
25
|
+
## How It Works
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
User <--> AI Model (Claude) <--> MCP Protocol <--> electron-mcp <--> Electron App
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
1. **User**: "Click the login button and fill in the email field"
|
|
32
|
+
2. **AI Model**: Determines which MCP tools to use
|
|
33
|
+
3. **MCP Protocol**: Standardized communication
|
|
34
|
+
4. **electron-mcp**: Executes Playwright commands on the Electron app
|
|
35
|
+
5. **Electron App**: Actions are performed in the actual application
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
### Claude Desktop / MCP Clients
|
|
40
|
+
|
|
41
|
+
Add to your MCP configuration file:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"electron-test": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["@teapotz/electron-mcp"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### OpenCode
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcp": {
|
|
59
|
+
"electron-test": {
|
|
60
|
+
"type": "local",
|
|
61
|
+
"command": ["npx", "@teapotz/electron-mcp"],
|
|
62
|
+
"enabled": true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Connection Modes
|
|
69
|
+
|
|
70
|
+
### CDP Mode (Recommended for Development)
|
|
71
|
+
|
|
72
|
+
Connect to an already running Electron app with remote debugging enabled:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Start your Electron app with debugging port
|
|
76
|
+
electron your-app --remote-debugging-port=9222
|
|
77
|
+
|
|
78
|
+
# Or with electron-vite
|
|
79
|
+
electron-vite dev -- --remote-debugging-port=9222
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Then use the `connect` tool:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
connect({ port: 9222 })
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Advantages:**
|
|
89
|
+
|
|
90
|
+
- Works with your existing dev workflow
|
|
91
|
+
- App state preserved between tests
|
|
92
|
+
- Hot reload still works
|
|
93
|
+
|
|
94
|
+
### Launch Mode
|
|
95
|
+
|
|
96
|
+
Launch a fresh Electron app instance:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
launch({ appPath: "./out/main/index.js" })
|
|
100
|
+
|
|
101
|
+
# With headless mode for CI
|
|
102
|
+
launch({ appPath: "./out/main/index.js", headless: true })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Advantages:**
|
|
106
|
+
|
|
107
|
+
- Clean state for each test
|
|
108
|
+
- Access to main process via `evaluateMain`
|
|
109
|
+
- Can pass custom environment variables
|
|
110
|
+
- Supports headless mode for CI/automation
|
|
111
|
+
|
|
112
|
+
## Headless Mode (CI/Automation)
|
|
113
|
+
|
|
114
|
+
### Launch Mode
|
|
115
|
+
|
|
116
|
+
Pass `headless: true` to run without a visible window:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
launch({ appPath: "./out/main/index.js", headless: true })
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### CDP Mode
|
|
123
|
+
|
|
124
|
+
Start your Electron app with headless flags before connecting:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Option 1: Electron headless flag (Electron 28+)
|
|
128
|
+
electron your-app --headless=new --remote-debugging-port=9222
|
|
129
|
+
|
|
130
|
+
# Option 2: xvfb (Linux) - virtual framebuffer
|
|
131
|
+
xvfb-run electron your-app --remote-debugging-port=9222
|
|
132
|
+
|
|
133
|
+
# Option 3: xvfb with specific display (CI environments)
|
|
134
|
+
Xvfb :99 -screen 0 1920x1080x24 &
|
|
135
|
+
DISPLAY=:99 electron your-app --remote-debugging-port=9222
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Then connect normally:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
connect({ port: 9222 })
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Available Tools
|
|
145
|
+
|
|
146
|
+
### Connection
|
|
147
|
+
|
|
148
|
+
| Tool | Description |
|
|
149
|
+
| ------------ | --------------------------------------- |
|
|
150
|
+
| `connect` | Connect to running app via CDP |
|
|
151
|
+
| `disconnect` | Disconnect from CDP (app keeps running) |
|
|
152
|
+
| `launch` | Launch new Electron app instance |
|
|
153
|
+
| `close` | Close launched app |
|
|
154
|
+
|
|
155
|
+
### Interaction
|
|
156
|
+
|
|
157
|
+
| Tool | Description |
|
|
158
|
+
| -------------- | ----------------------------------- |
|
|
159
|
+
| `click` | Click an element |
|
|
160
|
+
| `dblclick` | Double-click an element |
|
|
161
|
+
| `fill` | Fill text into input (clears first) |
|
|
162
|
+
| `type` | Type text character by character |
|
|
163
|
+
| `hover` | Hover over an element |
|
|
164
|
+
| `press` | Press keyboard key |
|
|
165
|
+
| `drag` | Drag and drop |
|
|
166
|
+
| `selectOption` | Select from dropdown |
|
|
167
|
+
|
|
168
|
+
### Scroll
|
|
169
|
+
|
|
170
|
+
| Tool | Description |
|
|
171
|
+
| ----------------- | ------------------------------------ |
|
|
172
|
+
| `scroll` | Scroll using mouse wheel (deltaX/Y) |
|
|
173
|
+
| `scrollTo` | Scroll to absolute position |
|
|
174
|
+
| `scrollIntoView` | Scroll element into view |
|
|
175
|
+
|
|
176
|
+
### Mouse
|
|
177
|
+
|
|
178
|
+
| Tool | Description |
|
|
179
|
+
| -------------- | ---------------------------------------- |
|
|
180
|
+
| `mouseMove` | Move mouse cursor to coordinates |
|
|
181
|
+
| `mouseDown` | Press mouse button down |
|
|
182
|
+
| `mouseUp` | Release mouse button |
|
|
183
|
+
| `mouseClick` | Click at specific coordinates |
|
|
184
|
+
|
|
185
|
+
### Inspection
|
|
186
|
+
|
|
187
|
+
| Tool | Description |
|
|
188
|
+
| -------------- | -------------------------------------- |
|
|
189
|
+
| `screenshot` | Take screenshot (returns base64 image) |
|
|
190
|
+
| `snapshot` | Get accessibility tree |
|
|
191
|
+
| `getText` | Get element text content |
|
|
192
|
+
| `getAttribute` | Get element attribute |
|
|
193
|
+
| `isVisible` | Check if element is visible |
|
|
194
|
+
| `count` | Count matching elements |
|
|
195
|
+
|
|
196
|
+
### Advanced
|
|
197
|
+
|
|
198
|
+
| Tool | Description |
|
|
199
|
+
| -------------- | ------------------------------------------- |
|
|
200
|
+
| `wait` | Wait for element state |
|
|
201
|
+
| `evaluate` | Run JS in renderer process |
|
|
202
|
+
| `evaluateMain` | Run code in main process (launch mode only) |
|
|
203
|
+
|
|
204
|
+
## Selectors
|
|
205
|
+
|
|
206
|
+
Supports all Playwright selectors:
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
# CSS selectors
|
|
210
|
+
[data-testid="submit-btn"]
|
|
211
|
+
.my-class
|
|
212
|
+
#my-id
|
|
213
|
+
|
|
214
|
+
# Text selectors
|
|
215
|
+
text=Submit
|
|
216
|
+
text="Exact Match"
|
|
217
|
+
|
|
218
|
+
# Role selectors
|
|
219
|
+
role=button[name="Submit"]
|
|
220
|
+
|
|
221
|
+
# Combining
|
|
222
|
+
.form >> text=Submit
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Usage Examples
|
|
226
|
+
|
|
227
|
+
### Basic Test Flow
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
1. connect({ port: 9222 })
|
|
231
|
+
2. snapshot() // See the page structure
|
|
232
|
+
3. click('[data-testid="login-btn"]')
|
|
233
|
+
4. fill('[data-testid="email"]', 'test@example.com')
|
|
234
|
+
5. fill('[data-testid="password"]', 'password123')
|
|
235
|
+
6. click('text=Sign In')
|
|
236
|
+
7. wait({ selector: '[data-testid="dashboard"]' })
|
|
237
|
+
8. screenshot()
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Main Process Access (Launch Mode)
|
|
241
|
+
|
|
242
|
+
```javascript
|
|
243
|
+
// Get app version
|
|
244
|
+
evaluateMain({
|
|
245
|
+
script: "({ app }) => app.getVersion()",
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Show dialog
|
|
249
|
+
evaluateMain({
|
|
250
|
+
script: "({ dialog }) => dialog.showMessageBox({ message: 'Hello!' })",
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### With AI Assistant
|
|
255
|
+
|
|
256
|
+
You can ask Claude or other AI assistants to test your Electron app:
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
Connect to my Electron app running on port 9222 and:
|
|
260
|
+
1. Take a screenshot of the current state
|
|
261
|
+
2. Click the "Settings" button in the sidebar
|
|
262
|
+
3. Change the theme to dark mode
|
|
263
|
+
4. Verify the theme changed by checking the background color
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Tips for Testable Electron Apps
|
|
267
|
+
|
|
268
|
+
1. **Add `data-testid` attributes** to important elements
|
|
269
|
+
2. **Enable remote debugging** in development: `--remote-debugging-port=9222`
|
|
270
|
+
3. **Use semantic HTML** for better accessibility snapshots
|
|
271
|
+
4. **Keep selectors stable** - prefer `data-testid` over classes
|
|
272
|
+
|
|
273
|
+
## Development
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
# Clone repository
|
|
277
|
+
git clone https://github.com/teapotz/electron-mcp.git
|
|
278
|
+
cd electron-mcp
|
|
279
|
+
|
|
280
|
+
# Install dependencies
|
|
281
|
+
npm install
|
|
282
|
+
|
|
283
|
+
# Build
|
|
284
|
+
npm run build
|
|
285
|
+
|
|
286
|
+
# Run locally
|
|
287
|
+
node dist/index.js
|
|
288
|
+
```
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class NotConnectedError extends Error {
|
|
2
|
+
constructor(message?: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class NotLaunchedError extends Error {
|
|
5
|
+
constructor(message?: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function classifyPlaywrightError(error: unknown): {
|
|
8
|
+
message: string;
|
|
9
|
+
category: string;
|
|
10
|
+
remediation: string;
|
|
11
|
+
};
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export class NotConnectedError extends Error {
|
|
2
|
+
constructor(message = "Not connected. Call connect or launch first.") {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "NotConnectedError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class NotLaunchedError extends Error {
|
|
8
|
+
constructor(message = "Not connected. Call launch first.") {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "NotLaunchedError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function classifyPlaywrightError(error) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
if (/timeout/i.test(message)) {
|
|
16
|
+
return {
|
|
17
|
+
message,
|
|
18
|
+
category: "Timeout",
|
|
19
|
+
remediation: "Increase timeout or check selector",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (/no node found|no element/i.test(message)) {
|
|
23
|
+
return {
|
|
24
|
+
message,
|
|
25
|
+
category: "ElementNotFound",
|
|
26
|
+
remediation: "Verify selector with snapshot()",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (/target closed|target page/i.test(message)) {
|
|
30
|
+
return {
|
|
31
|
+
message,
|
|
32
|
+
category: "TargetClosed",
|
|
33
|
+
remediation: "Reconnect to the application",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (/protocol error/i.test(message)) {
|
|
37
|
+
return {
|
|
38
|
+
message,
|
|
39
|
+
category: "ProtocolError",
|
|
40
|
+
remediation: "Check that the application is still running",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
message,
|
|
45
|
+
category: "Unknown",
|
|
46
|
+
remediation: "Check the error message for details",
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { SessionController } from "./session/SessionController.js";
|
|
10
|
+
import { buildRegistry, dispatch } from "./tools/registry.js";
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const { name: PKG_NAME, version: PKG_VERSION } = require("../package.json");
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Server factory (exported for testing)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
export function createServer() {
|
|
17
|
+
const session = new SessionController();
|
|
18
|
+
const registry = buildRegistry();
|
|
19
|
+
const server = new Server({ name: PKG_NAME, version: PKG_VERSION }, { capabilities: { tools: {} } });
|
|
20
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
21
|
+
tools: [...registry.values()].map((spec) => spec.definition),
|
|
22
|
+
}));
|
|
23
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
24
|
+
const { name, arguments: args } = request.params;
|
|
25
|
+
return dispatch(registry, session, name, args);
|
|
26
|
+
});
|
|
27
|
+
return { server, session };
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Main entry point (only runs when executed directly, not when imported)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
33
|
+
const isMainModule = (() => {
|
|
34
|
+
if (typeof process === "undefined" || !process.argv[1])
|
|
35
|
+
return false;
|
|
36
|
+
try {
|
|
37
|
+
// npm/npx binaries are often symlinks; compare real paths first.
|
|
38
|
+
return realpathSync(process.argv[1]) === realpathSync(modulePath);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return path.resolve(process.argv[1]) === path.resolve(modulePath);
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
if (isMainModule) {
|
|
45
|
+
const flag = process.argv[2];
|
|
46
|
+
if (flag === "--version" || flag === "-v") {
|
|
47
|
+
console.error(`${PKG_NAME} ${PKG_VERSION}`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
if (flag === "--help" || flag === "-h") {
|
|
51
|
+
console.error(`${PKG_NAME} ${PKG_VERSION}
|
|
52
|
+
Electron UI automation via Playwright, exposed as an MCP server.
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
electron-mcp Start the MCP server (communicates over stdio)
|
|
56
|
+
electron-mcp --help Show this help message
|
|
57
|
+
electron-mcp --version Show version
|
|
58
|
+
|
|
59
|
+
Environment variables:
|
|
60
|
+
ELECTRON_MCP_TIMEOUT_MS Default timeout for Playwright operations (default: 30000)
|
|
61
|
+
|
|
62
|
+
Connection modes:
|
|
63
|
+
connect Attach to a running Electron app via CDP (--remote-debugging-port)
|
|
64
|
+
launch Launch a fresh Electron instance with full main-process access
|
|
65
|
+
|
|
66
|
+
Documentation: https://github.com/teapotznet/electron-mcp`);
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
const { server, session } = createServer();
|
|
70
|
+
let shuttingDown = false;
|
|
71
|
+
async function cleanup() {
|
|
72
|
+
if (shuttingDown)
|
|
73
|
+
return;
|
|
74
|
+
shuttingDown = true;
|
|
75
|
+
await session.cleanup();
|
|
76
|
+
}
|
|
77
|
+
process.on("SIGTERM", () => void cleanup().finally(() => process.exit(143)));
|
|
78
|
+
process.on("SIGINT", () => void cleanup().finally(() => process.exit(130)));
|
|
79
|
+
const transport = new StdioServerTransport();
|
|
80
|
+
server.connect(transport).catch(console.error);
|
|
81
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function toolOk(text) {
|
|
2
|
+
return {
|
|
3
|
+
content: [{ type: "text", text }],
|
|
4
|
+
};
|
|
5
|
+
}
|
|
6
|
+
export function toolError(text) {
|
|
7
|
+
return {
|
|
8
|
+
content: [{ type: "text", text }],
|
|
9
|
+
isError: true,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function toolImage(base64) {
|
|
13
|
+
return {
|
|
14
|
+
content: [
|
|
15
|
+
{
|
|
16
|
+
type: "image",
|
|
17
|
+
data: base64,
|
|
18
|
+
mimeType: "image/png",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ElectronApplication, type Page } from "playwright";
|
|
2
|
+
import type { ConnectionMode, LaunchOptions } from "./types.js";
|
|
3
|
+
export declare class SessionController {
|
|
4
|
+
private electronApp;
|
|
5
|
+
private cdpBrowser;
|
|
6
|
+
private page;
|
|
7
|
+
private mode;
|
|
8
|
+
private mutex;
|
|
9
|
+
requirePage(): Page;
|
|
10
|
+
requireElectronApp(): ElectronApplication;
|
|
11
|
+
get connectionMode(): ConnectionMode;
|
|
12
|
+
get isConnected(): boolean;
|
|
13
|
+
private withMutex;
|
|
14
|
+
connectCdp(port: number): Promise<string>;
|
|
15
|
+
launch(opts: LaunchOptions): Promise<string>;
|
|
16
|
+
disconnect(): Promise<void>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
cleanup(): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { _electron, chromium, } from "playwright";
|
|
2
|
+
import { NotConnectedError, NotLaunchedError } from "../errors.js";
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = Number(process.env.ELECTRON_MCP_TIMEOUT_MS ?? 30_000);
|
|
4
|
+
export class SessionController {
|
|
5
|
+
electronApp = null;
|
|
6
|
+
cdpBrowser = null;
|
|
7
|
+
page = null;
|
|
8
|
+
mode = null;
|
|
9
|
+
mutex = Promise.resolve();
|
|
10
|
+
// ----- Guards -----
|
|
11
|
+
requirePage() {
|
|
12
|
+
if (!this.page)
|
|
13
|
+
throw new NotConnectedError();
|
|
14
|
+
return this.page;
|
|
15
|
+
}
|
|
16
|
+
requireElectronApp() {
|
|
17
|
+
if (!this.electronApp)
|
|
18
|
+
throw new NotLaunchedError();
|
|
19
|
+
return this.electronApp;
|
|
20
|
+
}
|
|
21
|
+
get connectionMode() {
|
|
22
|
+
return this.mode;
|
|
23
|
+
}
|
|
24
|
+
get isConnected() {
|
|
25
|
+
return this.page !== null;
|
|
26
|
+
}
|
|
27
|
+
// ----- Mutex for state-mutating operations -----
|
|
28
|
+
withMutex(fn) {
|
|
29
|
+
const result = this.mutex.then(fn);
|
|
30
|
+
this.mutex = result.then(() => { }, () => { });
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
// ----- Connection lifecycle -----
|
|
34
|
+
async connectCdp(port) {
|
|
35
|
+
return this.withMutex(async () => {
|
|
36
|
+
if (this.page) {
|
|
37
|
+
throw new Error("Already connected. Disconnect first.");
|
|
38
|
+
}
|
|
39
|
+
const browser = await chromium.connectOverCDP(`http://localhost:${port}`);
|
|
40
|
+
try {
|
|
41
|
+
browser.on("disconnected", () => {
|
|
42
|
+
this.cdpBrowser = null;
|
|
43
|
+
this.page = null;
|
|
44
|
+
this.mode = null;
|
|
45
|
+
});
|
|
46
|
+
const contexts = browser.contexts();
|
|
47
|
+
if (contexts.length === 0) {
|
|
48
|
+
throw new Error("No browser contexts found.");
|
|
49
|
+
}
|
|
50
|
+
const pages = contexts[0].pages();
|
|
51
|
+
if (pages.length === 0) {
|
|
52
|
+
throw new Error("No pages found.");
|
|
53
|
+
}
|
|
54
|
+
this.cdpBrowser = browser;
|
|
55
|
+
this.page = pages[0];
|
|
56
|
+
this.page.setDefaultTimeout(DEFAULT_TIMEOUT_MS);
|
|
57
|
+
this.page.setDefaultNavigationTimeout(DEFAULT_TIMEOUT_MS);
|
|
58
|
+
this.mode = "cdp";
|
|
59
|
+
return await this.page.title();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
await browser.close().catch(() => { });
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async launch(opts) {
|
|
68
|
+
return this.withMutex(async () => {
|
|
69
|
+
if (this.page) {
|
|
70
|
+
throw new Error("Already connected. Disconnect/close first.");
|
|
71
|
+
}
|
|
72
|
+
const launchArgs = [];
|
|
73
|
+
if (process.platform === "linux") {
|
|
74
|
+
const ozone = process.env.ELECTRON_OZONE_PLATFORM ??
|
|
75
|
+
(process.env.WAYLAND_DISPLAY ? "wayland" : "x11");
|
|
76
|
+
launchArgs.push(`--ozone-platform=${ozone}`);
|
|
77
|
+
}
|
|
78
|
+
if (opts.headless) {
|
|
79
|
+
launchArgs.push("--headless=new", "--disable-gpu");
|
|
80
|
+
}
|
|
81
|
+
launchArgs.push(opts.appPath);
|
|
82
|
+
const app = await _electron.launch({
|
|
83
|
+
args: launchArgs,
|
|
84
|
+
env: { ...process.env, ...opts.env, TEST_MODE: "true" },
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const page = await app.firstWindow();
|
|
88
|
+
page.setDefaultTimeout(DEFAULT_TIMEOUT_MS);
|
|
89
|
+
page.setDefaultNavigationTimeout(DEFAULT_TIMEOUT_MS);
|
|
90
|
+
await page.waitForLoadState("domcontentloaded");
|
|
91
|
+
this.electronApp = app;
|
|
92
|
+
this.page = page;
|
|
93
|
+
this.mode = "electron";
|
|
94
|
+
return await page.title();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await app.close().catch(() => { });
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async disconnect() {
|
|
103
|
+
return this.withMutex(async () => {
|
|
104
|
+
if (this.cdpBrowser) {
|
|
105
|
+
await this.cdpBrowser.close();
|
|
106
|
+
this.cdpBrowser = null;
|
|
107
|
+
this.page = null;
|
|
108
|
+
this.mode = null;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async close() {
|
|
113
|
+
return this.withMutex(async () => {
|
|
114
|
+
if (this.mode === "cdp") {
|
|
115
|
+
throw new Error("Cannot close CDP connection. Use disconnect instead (app keeps running).");
|
|
116
|
+
}
|
|
117
|
+
if (this.electronApp) {
|
|
118
|
+
await this.electronApp.close();
|
|
119
|
+
this.electronApp = null;
|
|
120
|
+
this.page = null;
|
|
121
|
+
this.mode = null;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async cleanup() {
|
|
126
|
+
try {
|
|
127
|
+
if (this.electronApp)
|
|
128
|
+
await this.electronApp.close();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* best-effort */
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
if (this.cdpBrowser)
|
|
135
|
+
await this.cdpBrowser.close();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* best-effort */
|
|
139
|
+
}
|
|
140
|
+
this.electronApp = null;
|
|
141
|
+
this.cdpBrowser = null;
|
|
142
|
+
this.page = null;
|
|
143
|
+
this.mode = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|