surf-cli 2.0.0 → 2.1.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 +10 -6
- package/native/cli.cjs +21 -13
- package/native/config.cjs +3 -0
- package/native/host-helpers.cjs +12 -2
- package/native/host.cjs +3 -1
- package/native/stream-viewer.html +415 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -170,16 +170,20 @@ surf scroll.bottom # Scroll to bottom
|
|
|
170
170
|
|
|
171
171
|
### Screenshots
|
|
172
172
|
|
|
173
|
-
Screenshots
|
|
173
|
+
Screenshots auto-save to `/tmp` by default (optimized for AI agents):
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
|
-
surf screenshot
|
|
177
|
-
surf screenshot --
|
|
178
|
-
surf screenshot --
|
|
179
|
-
surf screenshot --
|
|
180
|
-
surf
|
|
176
|
+
surf screenshot # Auto-saves to /tmp/surf-snap-*.png
|
|
177
|
+
surf screenshot --output /tmp/shot.png # Save to specific path
|
|
178
|
+
surf screenshot --full --output /tmp/hd.png # Full resolution (skip resize)
|
|
179
|
+
surf screenshot --annotate # With element labels
|
|
180
|
+
surf screenshot --fullpage # Entire page
|
|
181
|
+
surf screenshot --no-save # Return base64 + ID only (no file)
|
|
182
|
+
surf snap # Alias for screenshot
|
|
181
183
|
```
|
|
182
184
|
|
|
185
|
+
To disable auto-save globally, set `autoSaveScreenshots: false` in `surf.json`.
|
|
186
|
+
|
|
183
187
|
Actions like `click`, `type`, and `scroll` automatically capture a screenshot after execution - no extra command needed.
|
|
184
188
|
|
|
185
189
|
### Tabs
|
package/native/cli.cjs
CHANGED
|
@@ -234,7 +234,7 @@ const TOOLS = {
|
|
|
234
234
|
examples: [{ cmd: "forward", desc: "Browser forward" }]
|
|
235
235
|
},
|
|
236
236
|
"screenshot": {
|
|
237
|
-
desc: "Capture screenshot (auto-
|
|
237
|
+
desc: "Capture screenshot (auto-saves to /tmp by default)",
|
|
238
238
|
args: [],
|
|
239
239
|
opts: {
|
|
240
240
|
output: "Save to file",
|
|
@@ -243,14 +243,15 @@ const TOOLS = {
|
|
|
243
243
|
fullpage: "Capture full page",
|
|
244
244
|
"max-height": "Max height for fullpage (default: 4000)",
|
|
245
245
|
full: "Skip resize, save at full resolution",
|
|
246
|
-
"max-size": "Max dimension in px (default: 1200)"
|
|
246
|
+
"max-size": "Max dimension in px (default: 1200)",
|
|
247
|
+
"no-save": "Don't auto-save, return base64 + ID (saves context)"
|
|
247
248
|
},
|
|
248
249
|
examples: [
|
|
249
|
-
{ cmd: "screenshot
|
|
250
|
-
{ cmd: "screenshot --
|
|
251
|
-
{ cmd: "screenshot --
|
|
252
|
-
{ cmd: "screenshot --annotate
|
|
253
|
-
{ cmd: "snap", desc: "
|
|
250
|
+
{ cmd: "screenshot", desc: "Auto-save to /tmp (default)" },
|
|
251
|
+
{ cmd: "screenshot --output /tmp/shot.png", desc: "Save to specific file" },
|
|
252
|
+
{ cmd: "screenshot --no-save", desc: "Return base64 without saving" },
|
|
253
|
+
{ cmd: "screenshot --annotate", desc: "With element labels" },
|
|
254
|
+
{ cmd: "snap", desc: "Alias for screenshot" },
|
|
254
255
|
]
|
|
255
256
|
},
|
|
256
257
|
"snap": { desc: "Alias for screenshot (auto-saves to /tmp)", args: [], alias: "screenshot" },
|
|
@@ -1672,7 +1673,7 @@ if (args.includes("--script")) {
|
|
|
1672
1673
|
return;
|
|
1673
1674
|
}
|
|
1674
1675
|
|
|
1675
|
-
const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl"];
|
|
1676
|
+
const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl", "no-save"];
|
|
1676
1677
|
|
|
1677
1678
|
const AUTO_SCREENSHOT_TOOLS = ["click", "type", "key", "smart_type", "form.fill", "form_input", "drag", "hover", "scroll", "scroll.top", "scroll.bottom", "scroll.to", "dialog.accept", "dialog.dismiss", "js", "eval"];
|
|
1678
1679
|
|
|
@@ -1727,10 +1728,14 @@ if (REMOVED_COMMANDS[tool]) {
|
|
|
1727
1728
|
process.exit(1);
|
|
1728
1729
|
}
|
|
1729
1730
|
|
|
1730
|
-
const wasSnap = tool === "snap";
|
|
1731
1731
|
tool = ALIASES[tool] || tool;
|
|
1732
1732
|
|
|
1733
|
-
|
|
1733
|
+
// Auto-save screenshots to temp file when no --output specified
|
|
1734
|
+
// This ensures agents always get a usable file path, not just an in-memory ID
|
|
1735
|
+
// Can be disabled with --no-save flag or autoSaveScreenshots: false in surf.json
|
|
1736
|
+
const config = loadConfig();
|
|
1737
|
+
const autoSaveEnabled = config.autoSaveScreenshots !== false && !options["no-save"];
|
|
1738
|
+
if (tool === "screenshot" && !options.output && !options.savePath && autoSaveEnabled) {
|
|
1734
1739
|
options.savePath = `/tmp/surf-snap-${Date.now()}.png`;
|
|
1735
1740
|
}
|
|
1736
1741
|
|
|
@@ -1887,7 +1892,7 @@ if (!noScreenshot && AUTO_SCREENSHOT_TOOLS.includes(tool)) {
|
|
|
1887
1892
|
const outputPath = toolArgs.output;
|
|
1888
1893
|
delete toolArgs.output;
|
|
1889
1894
|
|
|
1890
|
-
if (
|
|
1895
|
+
if (tool === "screenshot" && outputPath) {
|
|
1891
1896
|
if (typeof outputPath !== "string") {
|
|
1892
1897
|
console.error("Error: --output requires a file path");
|
|
1893
1898
|
process.exit(1);
|
|
@@ -2208,7 +2213,7 @@ async function handleResponse(response) {
|
|
|
2208
2213
|
process.exit(0);
|
|
2209
2214
|
}
|
|
2210
2215
|
|
|
2211
|
-
if (
|
|
2216
|
+
if (tool === "screenshot" && data?.base64 && (outputPath || toolArgs.savePath)) {
|
|
2212
2217
|
const saveTo = outputPath || toolArgs.savePath;
|
|
2213
2218
|
fs.writeFileSync(saveTo, Buffer.from(data.base64, "base64"));
|
|
2214
2219
|
|
|
@@ -2227,8 +2232,11 @@ async function handleResponse(response) {
|
|
|
2227
2232
|
} else {
|
|
2228
2233
|
console.log(`Saved to ${saveTo} (${origWidth}x${origHeight})`);
|
|
2229
2234
|
}
|
|
2230
|
-
} else if (
|
|
2235
|
+
} else if (tool === "screenshot" && data?.message) {
|
|
2231
2236
|
console.log(data.message);
|
|
2237
|
+
if (data.screenshotId) {
|
|
2238
|
+
console.log(`[Screenshot ID: ${data.screenshotId}]`);
|
|
2239
|
+
}
|
|
2232
2240
|
} else if (tool === "tab.list") {
|
|
2233
2241
|
const tabs = data?.tabs || data || [];
|
|
2234
2242
|
if (Array.isArray(tabs)) {
|
package/native/config.cjs
CHANGED
|
@@ -8,6 +8,9 @@ let cachedConfig = null;
|
|
|
8
8
|
let cachedConfigPath = null;
|
|
9
9
|
|
|
10
10
|
const STARTER_CONFIG = {
|
|
11
|
+
// Set to false to disable auto-saving screenshots to /tmp
|
|
12
|
+
// When disabled, screenshots return base64 + ID instead of file path
|
|
13
|
+
autoSaveScreenshots: true,
|
|
11
14
|
routes: {
|
|
12
15
|
main: ["http://localhost:3000"]
|
|
13
16
|
},
|
package/native/host-helpers.cjs
CHANGED
|
@@ -84,7 +84,17 @@ function formatToolContent(result, log = () => {}) {
|
|
|
84
84
|
return text(result.output);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
// Screenshot saved to file (has path but no base64)
|
|
88
|
+
if (result.path && result.message && !result.base64) {
|
|
89
|
+
let msg = result.message;
|
|
90
|
+
if (result.screenshotId) {
|
|
91
|
+
msg += `\n[Screenshot ID: ${result.screenshotId} - use with upload_image]`;
|
|
92
|
+
}
|
|
93
|
+
return text(msg);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Screenshot with inline base64 (MCP flow or no savePath)
|
|
97
|
+
if (result.screenshotId && result.base64) {
|
|
88
98
|
const dims = result.width && result.height
|
|
89
99
|
? `${result.width}x${result.height}`
|
|
90
100
|
: "unknown dimensions";
|
|
@@ -489,7 +499,7 @@ function mapToolToMessage(tool, args, tabId) {
|
|
|
489
499
|
case "screenshot":
|
|
490
500
|
return {
|
|
491
501
|
type: "EXECUTE_SCREENSHOT",
|
|
492
|
-
savePath: a.savePath,
|
|
502
|
+
savePath: a.savePath || a.output, // Accept both savePath (CLI) and output (MCP)
|
|
493
503
|
annotate: a.annotate || false,
|
|
494
504
|
fullpage: a.fullpage || false,
|
|
495
505
|
maxHeight: a["max-height"] || 4000,
|
package/native/host.cjs
CHANGED
|
@@ -996,7 +996,9 @@ function processInput() {
|
|
|
996
996
|
}
|
|
997
997
|
}
|
|
998
998
|
sendToolResponse(socket, originalId, {
|
|
999
|
-
message: `Saved to ${savePath} (${finalDims})
|
|
999
|
+
message: `Saved to ${savePath} (${finalDims})`,
|
|
1000
|
+
path: savePath,
|
|
1001
|
+
screenshotId: msg.screenshotId, // Preserve for upload_image workflow
|
|
1000
1002
|
}, null);
|
|
1001
1003
|
} catch (e) {
|
|
1002
1004
|
sendToolResponse(socket, originalId, null, `Failed to save: ${e.message}`);
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Surf Stream Viewer</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
16
|
+
background: #1a1a1a;
|
|
17
|
+
color: #e0e0e0;
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
height: 100vh;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#status {
|
|
25
|
+
background: #2d2d2d;
|
|
26
|
+
padding: 12px 20px;
|
|
27
|
+
border-bottom: 1px solid #404040;
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: 20px;
|
|
31
|
+
font-size: 14px;
|
|
32
|
+
flex-shrink: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#status.connected {
|
|
36
|
+
border-bottom-color: #4caf50;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#status.disconnected {
|
|
40
|
+
border-bottom-color: #f44336;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.status-indicator {
|
|
44
|
+
width: 10px;
|
|
45
|
+
height: 10px;
|
|
46
|
+
border-radius: 50%;
|
|
47
|
+
background: #666;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.status-indicator.connected {
|
|
51
|
+
background: #4caf50;
|
|
52
|
+
box-shadow: 0 0 8px #4caf50;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.status-indicator.disconnected {
|
|
56
|
+
background: #f44336;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#viewport-container {
|
|
60
|
+
flex: 1;
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
overflow: auto;
|
|
65
|
+
background: #0a0a0a;
|
|
66
|
+
position: relative;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#viewport {
|
|
70
|
+
display: block;
|
|
71
|
+
max-width: 100%;
|
|
72
|
+
max-height: 100%;
|
|
73
|
+
cursor: crosshair;
|
|
74
|
+
image-rendering: crisp-edges;
|
|
75
|
+
image-rendering: pixelated;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#viewport:not(.ready) {
|
|
79
|
+
opacity: 0.5;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#loading {
|
|
83
|
+
position: absolute;
|
|
84
|
+
top: 50%;
|
|
85
|
+
left: 50%;
|
|
86
|
+
transform: translate(-50%, -50%);
|
|
87
|
+
text-align: center;
|
|
88
|
+
color: #888;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.spinner {
|
|
92
|
+
border: 3px solid #333;
|
|
93
|
+
border-top: 3px solid #4caf50;
|
|
94
|
+
border-radius: 50%;
|
|
95
|
+
width: 40px;
|
|
96
|
+
height: 40px;
|
|
97
|
+
animation: spin 1s linear infinite;
|
|
98
|
+
margin: 0 auto 10px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@keyframes spin {
|
|
102
|
+
0% { transform: rotate(0deg); }
|
|
103
|
+
100% { transform: rotate(360deg); }
|
|
104
|
+
}
|
|
105
|
+
</style>
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
<div id="status" class="disconnected">
|
|
109
|
+
<div class="status-indicator disconnected"></div>
|
|
110
|
+
<span id="status-text">Connecting...</span>
|
|
111
|
+
<span id="viewport-info" style="margin-left: auto;"></span>
|
|
112
|
+
</div>
|
|
113
|
+
<div id="viewport-container">
|
|
114
|
+
<div id="loading">
|
|
115
|
+
<div class="spinner"></div>
|
|
116
|
+
<div>Waiting for stream...</div>
|
|
117
|
+
</div>
|
|
118
|
+
<canvas id="viewport"></canvas>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<script>
|
|
122
|
+
const canvas = document.getElementById('viewport');
|
|
123
|
+
const ctx = canvas.getContext('2d');
|
|
124
|
+
const statusEl = document.getElementById('status');
|
|
125
|
+
const statusText = document.getElementById('status-text');
|
|
126
|
+
const viewportInfo = document.getElementById('viewport-info');
|
|
127
|
+
const loadingEl = document.getElementById('loading');
|
|
128
|
+
|
|
129
|
+
let ws = null;
|
|
130
|
+
let frameCount = 0;
|
|
131
|
+
let lastFrameTime = Date.now();
|
|
132
|
+
let fps = 0;
|
|
133
|
+
let isMouseDown = false;
|
|
134
|
+
let activeButton = 'left';
|
|
135
|
+
let activeTouches = new Map();
|
|
136
|
+
|
|
137
|
+
function connect() {
|
|
138
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
139
|
+
const wsUrl = `${protocol}//${window.location.host}`;
|
|
140
|
+
|
|
141
|
+
ws = new WebSocket(wsUrl);
|
|
142
|
+
|
|
143
|
+
ws.onopen = () => {
|
|
144
|
+
statusEl.className = 'connected';
|
|
145
|
+
statusEl.querySelector('.status-indicator').className = 'status-indicator connected';
|
|
146
|
+
statusText.textContent = 'Connected';
|
|
147
|
+
loadingEl.style.display = 'none';
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
ws.onmessage = (event) => {
|
|
151
|
+
try {
|
|
152
|
+
const message = JSON.parse(event.data);
|
|
153
|
+
|
|
154
|
+
if (message.type === 'frame') {
|
|
155
|
+
drawFrame(message);
|
|
156
|
+
} else if (message.type === 'status') {
|
|
157
|
+
updateStatus(message);
|
|
158
|
+
} else if (message.type === 'error') {
|
|
159
|
+
statusText.textContent = `Error: ${message.message}`;
|
|
160
|
+
statusEl.className = 'disconnected';
|
|
161
|
+
statusEl.querySelector('.status-indicator').className = 'status-indicator disconnected';
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.error('Error parsing message:', e);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
ws.onerror = (error) => {
|
|
169
|
+
console.error('WebSocket error:', error);
|
|
170
|
+
statusText.textContent = 'Connection error';
|
|
171
|
+
statusEl.className = 'disconnected';
|
|
172
|
+
statusEl.querySelector('.status-indicator').className = 'status-indicator disconnected';
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
ws.onclose = () => {
|
|
176
|
+
statusText.textContent = 'Disconnected';
|
|
177
|
+
statusEl.className = 'disconnected';
|
|
178
|
+
statusEl.querySelector('.status-indicator').className = 'status-indicator disconnected';
|
|
179
|
+
loadingEl.style.display = 'block';
|
|
180
|
+
|
|
181
|
+
setTimeout(connect, 2000);
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function drawFrame(message) {
|
|
186
|
+
const img = new Image();
|
|
187
|
+
img.onload = () => {
|
|
188
|
+
canvas.width = img.width;
|
|
189
|
+
canvas.height = img.height;
|
|
190
|
+
ctx.drawImage(img, 0, 0);
|
|
191
|
+
canvas.classList.add('ready');
|
|
192
|
+
|
|
193
|
+
frameCount++;
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
if (now - lastFrameTime >= 1000) {
|
|
196
|
+
fps = frameCount;
|
|
197
|
+
frameCount = 0;
|
|
198
|
+
lastFrameTime = now;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
updateViewportInfo(img.width, img.height, fps);
|
|
202
|
+
};
|
|
203
|
+
const mimeType = message.data.startsWith('iVBORw0K') ? 'image/png' : 'image/jpeg';
|
|
204
|
+
img.src = `data:${mimeType};base64,${message.data}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function updateStatus(message) {
|
|
208
|
+
if (message.viewportWidth && message.viewportHeight) {
|
|
209
|
+
updateViewportInfo(message.viewportWidth, message.viewportHeight, fps);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function updateViewportInfo(width, height, fps) {
|
|
214
|
+
viewportInfo.textContent = `${width}×${height} | ${fps} fps`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function getCanvasCoordinates(e) {
|
|
218
|
+
const rect = canvas.getBoundingClientRect();
|
|
219
|
+
const scaleX = canvas.width / rect.width;
|
|
220
|
+
const scaleY = canvas.height / rect.height;
|
|
221
|
+
|
|
222
|
+
const x = Math.round((e.clientX - rect.left) * scaleX);
|
|
223
|
+
const y = Math.round((e.clientY - rect.top) * scaleY);
|
|
224
|
+
|
|
225
|
+
// Check if click is within canvas bounds
|
|
226
|
+
if (x < 0 || x > canvas.width || y < 0 || y > canvas.height) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { x, y };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getModifiers(e) {
|
|
234
|
+
return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sendMouseEvent(eventType, e, button = 'left') {
|
|
238
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
239
|
+
|
|
240
|
+
const coords = getCanvasCoordinates(e);
|
|
241
|
+
if (!coords) return; // Click outside canvas bounds
|
|
242
|
+
|
|
243
|
+
ws.send(JSON.stringify({
|
|
244
|
+
type: 'input_mouse',
|
|
245
|
+
eventType,
|
|
246
|
+
x: coords.x,
|
|
247
|
+
y: coords.y,
|
|
248
|
+
button,
|
|
249
|
+
clickCount: 1,
|
|
250
|
+
modifiers: getModifiers(e),
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
isMouseDown = true;
|
|
257
|
+
activeButton = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left';
|
|
258
|
+
// Send mouseMoved first to ensure element has hover state
|
|
259
|
+
sendMouseEvent('mouseMoved', e, 'none');
|
|
260
|
+
sendMouseEvent('mousePressed', e, activeButton);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
canvas.addEventListener('mouseup', (e) => {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left';
|
|
266
|
+
sendMouseEvent('mouseReleased', e, button);
|
|
267
|
+
isMouseDown = false;
|
|
268
|
+
activeButton = 'left';
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
if (isMouseDown) {
|
|
274
|
+
sendMouseEvent('mouseMoved', e, activeButton);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
canvas.addEventListener('mouseleave', (e) => {
|
|
279
|
+
if (isMouseDown) {
|
|
280
|
+
sendMouseEvent('mouseReleased', e, activeButton);
|
|
281
|
+
isMouseDown = false;
|
|
282
|
+
activeButton = 'left';
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
canvas.addEventListener('contextmenu', (e) => {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
canvas.addEventListener('wheel', (e) => {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
293
|
+
|
|
294
|
+
const coords = getCanvasCoordinates(e);
|
|
295
|
+
let deltaX = e.deltaX;
|
|
296
|
+
let deltaY = e.deltaY;
|
|
297
|
+
if (e.deltaMode === 1) {
|
|
298
|
+
deltaX *= 40;
|
|
299
|
+
deltaY *= 40;
|
|
300
|
+
} else if (e.deltaMode === 2) {
|
|
301
|
+
deltaX *= window.innerHeight;
|
|
302
|
+
deltaY *= window.innerHeight;
|
|
303
|
+
}
|
|
304
|
+
ws.send(JSON.stringify({
|
|
305
|
+
type: 'input_mouse',
|
|
306
|
+
eventType: 'mouseWheel',
|
|
307
|
+
x: coords.x,
|
|
308
|
+
y: coords.y,
|
|
309
|
+
deltaX,
|
|
310
|
+
deltaY,
|
|
311
|
+
modifiers: getModifiers(e),
|
|
312
|
+
}));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
function getTouchCoordinates(touch) {
|
|
316
|
+
const rect = canvas.getBoundingClientRect();
|
|
317
|
+
const scaleX = canvas.width / rect.width;
|
|
318
|
+
const scaleY = canvas.height / rect.height;
|
|
319
|
+
return {
|
|
320
|
+
x: Math.round((touch.clientX - rect.left) * scaleX),
|
|
321
|
+
y: Math.round((touch.clientY - rect.top) * scaleY),
|
|
322
|
+
id: touch.identifier,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function sendTouchEvent(eventType, touches) {
|
|
327
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
328
|
+
ws.send(JSON.stringify({
|
|
329
|
+
type: 'input_touch',
|
|
330
|
+
eventType,
|
|
331
|
+
touchPoints: touches,
|
|
332
|
+
modifiers: 0,
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
canvas.addEventListener('touchstart', (e) => {
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
for (const touch of e.changedTouches) {
|
|
339
|
+
const coords = getTouchCoordinates(touch);
|
|
340
|
+
activeTouches.set(touch.identifier, coords);
|
|
341
|
+
}
|
|
342
|
+
sendTouchEvent('touchStart', Array.from(activeTouches.values()));
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
canvas.addEventListener('touchend', (e) => {
|
|
346
|
+
e.preventDefault();
|
|
347
|
+
for (const touch of e.changedTouches) {
|
|
348
|
+
activeTouches.delete(touch.identifier);
|
|
349
|
+
}
|
|
350
|
+
sendTouchEvent('touchEnd', Array.from(activeTouches.values()));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
canvas.addEventListener('touchmove', (e) => {
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
for (const touch of e.changedTouches) {
|
|
356
|
+
const coords = getTouchCoordinates(touch);
|
|
357
|
+
activeTouches.set(touch.identifier, coords);
|
|
358
|
+
}
|
|
359
|
+
sendTouchEvent('touchMove', Array.from(activeTouches.values()));
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
canvas.addEventListener('touchcancel', (e) => {
|
|
363
|
+
e.preventDefault();
|
|
364
|
+
for (const touch of e.changedTouches) {
|
|
365
|
+
activeTouches.delete(touch.identifier);
|
|
366
|
+
}
|
|
367
|
+
sendTouchEvent('touchCancel', Array.from(activeTouches.values()));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
document.addEventListener('keydown', (e) => {
|
|
371
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
372
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
373
|
+
|
|
374
|
+
e.preventDefault();
|
|
375
|
+
const modifiers = (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
376
|
+
|
|
377
|
+
// Send keyDown event
|
|
378
|
+
ws.send(JSON.stringify({
|
|
379
|
+
type: 'input_keyboard',
|
|
380
|
+
eventType: 'keyDown',
|
|
381
|
+
key: e.key,
|
|
382
|
+
code: e.code,
|
|
383
|
+
modifiers,
|
|
384
|
+
}));
|
|
385
|
+
|
|
386
|
+
// For printable characters, also send a char event
|
|
387
|
+
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
|
388
|
+
ws.send(JSON.stringify({
|
|
389
|
+
type: 'input_keyboard',
|
|
390
|
+
eventType: 'char',
|
|
391
|
+
text: e.key,
|
|
392
|
+
modifiers,
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
document.addEventListener('keyup', (e) => {
|
|
398
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
399
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
400
|
+
|
|
401
|
+
e.preventDefault();
|
|
402
|
+
|
|
403
|
+
ws.send(JSON.stringify({
|
|
404
|
+
type: 'input_keyboard',
|
|
405
|
+
eventType: 'keyUp',
|
|
406
|
+
key: e.key,
|
|
407
|
+
code: e.code,
|
|
408
|
+
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0),
|
|
409
|
+
}));
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
connect();
|
|
413
|
+
</script>
|
|
414
|
+
</body>
|
|
415
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "surf-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chrome",
|
|
@@ -49,14 +49,14 @@
|
|
|
49
49
|
"uninstall:native": "node scripts/uninstall-native-host.cjs"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@google/generative-ai": "^0.
|
|
52
|
+
"@google/generative-ai": "^0.24.1",
|
|
53
53
|
"@modelcontextprotocol/sdk": "^1.7.0",
|
|
54
54
|
"buffer": "^6.0.3",
|
|
55
55
|
"crypto-browserify": "^3.12.1",
|
|
56
56
|
"events": "^3.3.0",
|
|
57
57
|
"stream-browserify": "^3.0.0",
|
|
58
58
|
"vite-plugin-node-polyfills": "^0.24.0",
|
|
59
|
-
"zod": "^3.
|
|
59
|
+
"zod": "^4.3.5"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@biomejs/biome": "^2.3.11",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@vitest/coverage-v8": "^4.0.16",
|
|
65
65
|
"@vitest/ui": "^4.0.16",
|
|
66
66
|
"typescript": "^5.7.2",
|
|
67
|
-
"vite": "^
|
|
67
|
+
"vite": "^7.3.1",
|
|
68
68
|
"vitest": "^4.0.16"
|
|
69
69
|
}
|
|
70
70
|
}
|