safari-mcp 2.0.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/LICENSE +21 -0
- package/README.md +356 -0
- package/index.js +1061 -0
- package/package.json +46 -0
- package/safari.js +2246 -0
- package/social-preview.png +0 -0
package/safari.js
ADDED
|
@@ -0,0 +1,2246 @@
|
|
|
1
|
+
// Safari automation layer via AppleScript/JXA
|
|
2
|
+
// כל הפקודות רצות ברקע - בלי להקפיץ חלון Safari לקדמה
|
|
3
|
+
// Persistent osascript process for fast execution (~5ms vs ~80ms per call)
|
|
4
|
+
|
|
5
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { readFile, writeFile, unlink } from "node:fs/promises";
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
// ========== ACTIVE TAB TRACKING ==========
|
|
14
|
+
// Instead of visually switching tabs (which interrupts the user),
|
|
15
|
+
// we track which tab index we're "working on" and pass it to runJS.
|
|
16
|
+
// This way Safari stays on whatever tab the user is viewing.
|
|
17
|
+
let _activeTabIndex = null; // null = use front document (default)
|
|
18
|
+
|
|
19
|
+
export function getActiveTabIndex() { return _activeTabIndex; }
|
|
20
|
+
export function setActiveTabIndex(idx) { _activeTabIndex = idx; }
|
|
21
|
+
|
|
22
|
+
// ========== PERSISTENT OSASCRIPT PROCESS ==========
|
|
23
|
+
// Instead of spawning osascript for each command (~80ms overhead),
|
|
24
|
+
// we keep one alive and send commands via stdin (~5ms per command)
|
|
25
|
+
|
|
26
|
+
let _osaProc = null;
|
|
27
|
+
|
|
28
|
+
function getOsaProcess() {
|
|
29
|
+
if (_osaProc && !_osaProc.killed) return _osaProc;
|
|
30
|
+
|
|
31
|
+
_osaProc = spawn("osascript", ["-i"], {
|
|
32
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
+
env: { ...process.env, LANG: "en_US.UTF-8" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
_osaProc.on("exit", () => { _osaProc = null; });
|
|
37
|
+
_osaProc.on("error", () => { _osaProc = null; });
|
|
38
|
+
_osaReady = true;
|
|
39
|
+
|
|
40
|
+
return _osaProc;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// withoutStealingFocus: just run the function.
|
|
44
|
+
// All Safari MCP operations use `tell application "Safari"` (not `activate`)
|
|
45
|
+
// and `do JavaScript` — neither steals focus. No restore needed.
|
|
46
|
+
async function withoutStealingFocus(fn) {
|
|
47
|
+
// Save the frontmost app BEFORE touching Safari
|
|
48
|
+
let prevApp = null;
|
|
49
|
+
try {
|
|
50
|
+
prevApp = await osascriptFast(
|
|
51
|
+
'tell application "System Events" to return name of first application process whose frontmost is true'
|
|
52
|
+
);
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
|
|
55
|
+
const result = await fn();
|
|
56
|
+
|
|
57
|
+
// If Safari stole focus, give it back to the previous app
|
|
58
|
+
if (prevApp && prevApp !== "Safari") {
|
|
59
|
+
try {
|
|
60
|
+
await osascriptFast(
|
|
61
|
+
`tell application "${prevApp}" to activate`
|
|
62
|
+
);
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Helper: get title + URL in a single call (instead of 2)
|
|
70
|
+
async function getTitleAndURL() {
|
|
71
|
+
const result = await runJS(
|
|
72
|
+
"JSON.stringify({title:document.title,url:location.href})"
|
|
73
|
+
);
|
|
74
|
+
try { return JSON.parse(result); } catch { return { title: result, url: "" }; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Run AppleScript — uses execFile (safe, isolated, for complex scripts)
|
|
78
|
+
async function osascript(script, { timeout = 10000 } = {}) {
|
|
79
|
+
try {
|
|
80
|
+
const { stdout } = await execFileAsync("osascript", ["-e", script], {
|
|
81
|
+
timeout,
|
|
82
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
83
|
+
});
|
|
84
|
+
return stdout.trim();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new Error(`AppleScript error: ${err.stderr || err.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Fast osascript via persistent process — for simple tell commands
|
|
91
|
+
async function osascriptFast(script, { timeout = 10000 } = {}) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const proc = getOsaProcess();
|
|
94
|
+
if (!proc || proc.killed) {
|
|
95
|
+
// Fallback to regular osascript if persistent process is dead
|
|
96
|
+
return osascript(script, { timeout }).then(resolve, reject);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let output = "";
|
|
100
|
+
let timer = setTimeout(() => {
|
|
101
|
+
cleanup();
|
|
102
|
+
// Fallback on timeout
|
|
103
|
+
osascript(script, { timeout }).then(resolve, reject);
|
|
104
|
+
}, timeout);
|
|
105
|
+
|
|
106
|
+
function onData(chunk) {
|
|
107
|
+
output += chunk.toString();
|
|
108
|
+
// osascript -i returns result then a new prompt
|
|
109
|
+
if (output.includes("\r\n")) {
|
|
110
|
+
cleanup();
|
|
111
|
+
// Remove prompt artifacts
|
|
112
|
+
const result = output.split("\r\n")[0].trim();
|
|
113
|
+
resolve(result);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onError(chunk) {
|
|
118
|
+
const err = chunk.toString().trim();
|
|
119
|
+
if (err && !err.startsWith(">>")) {
|
|
120
|
+
cleanup();
|
|
121
|
+
reject(new Error(`AppleScript error: ${err}`));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cleanup() {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
proc.stdout.removeListener("data", onData);
|
|
128
|
+
proc.stderr.removeListener("data", onError);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
proc.stdout.on("data", onData);
|
|
132
|
+
proc.stderr.on("data", onError);
|
|
133
|
+
|
|
134
|
+
// Send the script as a single line
|
|
135
|
+
proc.stdin.write(script.replace(/\n/g, "\r") + "\n");
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Run JavaScript in Safari — fastest path, no focus stealing
|
|
140
|
+
// Uses osascriptFast (persistent process, ~5ms) for short scripts,
|
|
141
|
+
// falls back to osascript (~80ms) for long scripts that exceed stdin limits
|
|
142
|
+
async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
|
|
143
|
+
const escaped = js
|
|
144
|
+
.replace(/\\/g, "\\\\")
|
|
145
|
+
.replace(/"/g, '\\"')
|
|
146
|
+
.replace(/\n/g, "\\n")
|
|
147
|
+
.replace(/\r/g, "\\r")
|
|
148
|
+
.replace(/\t/g, "\\t");
|
|
149
|
+
// Use explicit tabIndex > tracked activeTabIndex > front document
|
|
150
|
+
const idx = tabIndex || _activeTabIndex;
|
|
151
|
+
const target = idx
|
|
152
|
+
? `tab ${idx} of front window`
|
|
153
|
+
: "front document";
|
|
154
|
+
const script = `tell application "Safari" to do JavaScript "${escaped}" in ${target}`;
|
|
155
|
+
// Use fast path for scripts under 50KB (persistent process handles these well)
|
|
156
|
+
if (script.length < 50000) {
|
|
157
|
+
return osascriptFast(script, { timeout });
|
|
158
|
+
}
|
|
159
|
+
return osascript(script, { timeout });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Run large JavaScript via temp file — bypasses osascript arg length limit (~260KB)
|
|
163
|
+
// Used for operations that embed file data (upload, paste image)
|
|
164
|
+
async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
|
|
165
|
+
const idx = tabIndex || _activeTabIndex;
|
|
166
|
+
const target = idx
|
|
167
|
+
? `tab ${idx} of front window`
|
|
168
|
+
: "front document";
|
|
169
|
+
// Write AppleScript to temp file — the JS is embedded inside the AppleScript
|
|
170
|
+
const escaped = js
|
|
171
|
+
.replace(/\\/g, "\\\\")
|
|
172
|
+
.replace(/"/g, '\\"')
|
|
173
|
+
.replace(/\n/g, "\\n")
|
|
174
|
+
.replace(/\r/g, "\\r")
|
|
175
|
+
.replace(/\t/g, "\\t");
|
|
176
|
+
const appleScript = `tell application "Safari" to do JavaScript "${escaped}" in ${target}`;
|
|
177
|
+
const tmpFile = join(tmpdir(), `safari-mcp-${Date.now()}.scpt`);
|
|
178
|
+
await writeFile(tmpFile, appleScript, "utf8");
|
|
179
|
+
try {
|
|
180
|
+
const { stdout } = await execFileAsync("osascript", [tmpFile], {
|
|
181
|
+
timeout,
|
|
182
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
183
|
+
});
|
|
184
|
+
return stdout.trim();
|
|
185
|
+
} finally {
|
|
186
|
+
unlink(tmpFile).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ========== NAVIGATION ==========
|
|
191
|
+
|
|
192
|
+
export async function navigate(url) {
|
|
193
|
+
// No withoutStealingFocus needed — set URL + do JavaScript don't steal focus
|
|
194
|
+
// Safari 17+ auto-upgrades HTTP→HTTPS. If user gives http://, try https:// first.
|
|
195
|
+
// If user gives no protocol, default to https://
|
|
196
|
+
let targetUrl = url;
|
|
197
|
+
if (!/^https?:\/\//i.test(targetUrl)) {
|
|
198
|
+
targetUrl = "https://" + targetUrl;
|
|
199
|
+
}
|
|
200
|
+
const safeUrl = targetUrl.replace(/"/g, '\\"');
|
|
201
|
+
const navTarget = _activeTabIndex
|
|
202
|
+
? `tab ${_activeTabIndex} of front window`
|
|
203
|
+
: "front document";
|
|
204
|
+
await osascriptFast(
|
|
205
|
+
`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`
|
|
206
|
+
);
|
|
207
|
+
// Single JS call: poll readyState inside JS, then return title+URL — 1 call instead of 40
|
|
208
|
+
const result = await runJS(
|
|
209
|
+
`(async function(){
|
|
210
|
+
for(var i=0;i<80;i++){
|
|
211
|
+
if(document.readyState==='complete')break;
|
|
212
|
+
await new Promise(function(r){setTimeout(r,i<10?200:500)});
|
|
213
|
+
}
|
|
214
|
+
// Check if page failed to load (Safari HTTPS-only block shows error page)
|
|
215
|
+
var failed = document.title.includes('cannot open') || document.title.includes('אין אפשרות') ||
|
|
216
|
+
document.body?.innerText?.includes('not secure') || document.body?.innerText?.includes('אינו מאובטח');
|
|
217
|
+
return JSON.stringify({title:document.title,url:location.href,blocked:failed});
|
|
218
|
+
})()`
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// If HTTPS failed and original was HTTP, try original HTTP URL
|
|
222
|
+
try {
|
|
223
|
+
const parsed = JSON.parse(result);
|
|
224
|
+
if (parsed.blocked && url.startsWith("http://")) {
|
|
225
|
+
// Try the original HTTP URL — Safari may still block it, but at least we tried
|
|
226
|
+
const httpUrl = url.replace(/"/g, '\\"');
|
|
227
|
+
await osascriptFast(
|
|
228
|
+
`tell application "Safari" to set URL of front document to "${httpUrl}"`
|
|
229
|
+
);
|
|
230
|
+
const retry = await runJS(
|
|
231
|
+
`(async function(){
|
|
232
|
+
for(var i=0;i<40;i++){
|
|
233
|
+
if(document.readyState==='complete')break;
|
|
234
|
+
await new Promise(function(r){setTimeout(r,300)});
|
|
235
|
+
}
|
|
236
|
+
return JSON.stringify({title:document.title,url:location.href});
|
|
237
|
+
})()`
|
|
238
|
+
);
|
|
239
|
+
return retry;
|
|
240
|
+
}
|
|
241
|
+
} catch (_) {}
|
|
242
|
+
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function goBack() {
|
|
247
|
+
// Single call: go back + wait + return title+URL
|
|
248
|
+
return runJS(
|
|
249
|
+
`(async function(){history.back();await new Promise(function(r){setTimeout(r,500)});return JSON.stringify({title:document.title,url:location.href});})()`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function goForward() {
|
|
254
|
+
return runJS(
|
|
255
|
+
`(async function(){history.forward();await new Promise(function(r){setTimeout(r,500)});return JSON.stringify({title:document.title,url:location.href});})()`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function reload(hardReload = false) {
|
|
260
|
+
const reload = hardReload ? "location.reload(true)" : "location.reload()";
|
|
261
|
+
await runJS(reload);
|
|
262
|
+
// Must wait separately since page reloads and JS context is lost
|
|
263
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
264
|
+
return runJS("JSON.stringify({title:document.title,url:location.href})");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ========== PAGE INFO ==========
|
|
268
|
+
|
|
269
|
+
export async function getTitle() {
|
|
270
|
+
return osascript(
|
|
271
|
+
'tell application "Safari" to return name of front document'
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function getURL() {
|
|
276
|
+
return osascript(
|
|
277
|
+
'tell application "Safari" to return URL of front document'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function readPage({ selector, maxLength = 50000 } = {}) {
|
|
282
|
+
if (selector) {
|
|
283
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
284
|
+
return runJS(
|
|
285
|
+
`(function(){
|
|
286
|
+
var el = document.querySelector('${sel}');
|
|
287
|
+
if (!el) return 'Element not found: ${sel}';
|
|
288
|
+
if (el.value !== undefined && el.value !== '') return el.value.substring(0,${Number(maxLength)});
|
|
289
|
+
return (el.innerText || el.textContent || '').substring(0,${Number(maxLength)});
|
|
290
|
+
})()`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return runJS(
|
|
294
|
+
`JSON.stringify({title:document.title,url:location.href,text:document.body.innerText.substring(0,${Number(maxLength)})})`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function getPageSource({ maxLength = 200000 } = {}) {
|
|
299
|
+
return runJS(`document.documentElement.outerHTML.substring(0,${Number(maxLength)})`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ========== CLICK ==========
|
|
303
|
+
|
|
304
|
+
export async function click({ selector, text, x, y, ref }) {
|
|
305
|
+
// Ref-based click (from takeSnapshot) — fastest and most reliable
|
|
306
|
+
if (ref) {
|
|
307
|
+
selector = refSelector(ref);
|
|
308
|
+
}
|
|
309
|
+
if (selector) {
|
|
310
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
311
|
+
return runJS(
|
|
312
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({block:'center'});el.click();return 'Clicked: '+el.tagName+(el.textContent?(' "'+el.textContent.trim().substring(0,50)+'"'):'');})()`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (text) {
|
|
316
|
+
const safeText = text.replace(/'/g, "\\'");
|
|
317
|
+
return runJS(
|
|
318
|
+
`(function(){
|
|
319
|
+
var safeText = '${safeText}';
|
|
320
|
+
// First: search interactive elements (buttons, links, roles)
|
|
321
|
+
var interactive = [...document.querySelectorAll('a,button,input[type=submit],input[type=button],[role=button],[role=link],[role=tab],[role=menuitem],[onclick],label')];
|
|
322
|
+
var el = interactive.find(function(e){ return e.textContent.trim().includes(safeText); });
|
|
323
|
+
// Second: search ALL visible elements if not found
|
|
324
|
+
if (!el) {
|
|
325
|
+
var all = [...document.querySelectorAll('*')].filter(function(e) {
|
|
326
|
+
var r = e.getBoundingClientRect();
|
|
327
|
+
return r.width > 0 && r.height > 0 && e.textContent.trim().includes(safeText);
|
|
328
|
+
});
|
|
329
|
+
// Prefer the deepest (most specific) match
|
|
330
|
+
el = all.sort(function(a, b) { return a.textContent.length - b.textContent.length; })[0];
|
|
331
|
+
}
|
|
332
|
+
if (!el) return 'Element not found with text: ${safeText}';
|
|
333
|
+
el.scrollIntoView({block:'center'});
|
|
334
|
+
el.click();
|
|
335
|
+
return 'Clicked: ' + el.tagName + ' "' + el.textContent.trim().substring(0, 50) + '"';
|
|
336
|
+
})()`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (x !== undefined && y !== undefined) {
|
|
340
|
+
return runJS(
|
|
341
|
+
`(function(){var el=document.elementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${Number(x)},${Number(y)})';el.click();return 'Clicked: '+el.tagName+' at (${Number(x)},${Number(y)})';})()`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
throw new Error("click requires selector, text, or x/y coordinates");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function doubleClick({ selector, x, y, ref }) {
|
|
348
|
+
if (ref) selector = refSelector(ref);
|
|
349
|
+
if (selector) {
|
|
350
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
351
|
+
return runJS(
|
|
352
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true,cancelable:true}));return 'Double-clicked: '+el.tagName;})()`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (x !== undefined && y !== undefined) {
|
|
356
|
+
return runJS(
|
|
357
|
+
`(function(){var el=document.elementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${x},${y})';el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true,cancelable:true}));return 'Double-clicked: '+el.tagName+' at (${x},${y})';})()`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
throw new Error("doubleClick requires selector or x/y coordinates");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function rightClick({ selector, x, y }) {
|
|
364
|
+
if (selector) {
|
|
365
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
366
|
+
return runJS(
|
|
367
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,button:2}));return 'Right-clicked: '+el.tagName;})()`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (x !== undefined && y !== undefined) {
|
|
371
|
+
return runJS(
|
|
372
|
+
`(function(){var el=document.elementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${x},${y})';el.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,button:2}));return 'Right-clicked: '+el.tagName+' at (${x},${y})';})()`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
throw new Error("rightClick requires selector or x/y coordinates");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ========== FORM INPUT ==========
|
|
379
|
+
|
|
380
|
+
export async function fill({ selector, value, ref }) {
|
|
381
|
+
if (ref) selector = refSelector(ref);
|
|
382
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
383
|
+
// Proper escaping order: backslashes first, then quotes
|
|
384
|
+
const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
385
|
+
return runJS(
|
|
386
|
+
`(function(){
|
|
387
|
+
var el = document.querySelector('${sel}');
|
|
388
|
+
if (!el) return 'Element not found: ${sel}';
|
|
389
|
+
el.focus();
|
|
390
|
+
// For contenteditable elements (Rich text editors like Medium, ProseMirror)
|
|
391
|
+
if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
|
|
392
|
+
el.textContent = '';
|
|
393
|
+
document.execCommand('insertText', false, '${val}');
|
|
394
|
+
return 'Filled contenteditable with "' + el.textContent.substring(0, 50) + '"';
|
|
395
|
+
}
|
|
396
|
+
// For regular inputs/textareas (React-compatible via native setter)
|
|
397
|
+
var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') ||
|
|
398
|
+
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
|
|
399
|
+
if (nativeSetter && nativeSetter.set) {
|
|
400
|
+
nativeSetter.set.call(el, '${val}');
|
|
401
|
+
} else {
|
|
402
|
+
el.value = '${val}';
|
|
403
|
+
}
|
|
404
|
+
el.dispatchEvent(new Event('input', {bubbles: true}));
|
|
405
|
+
el.dispatchEvent(new Event('change', {bubbles: true}));
|
|
406
|
+
return 'Filled: ' + el.tagName + '[' + (el.type || '') + '] with "' + el.value.substring(0, 50) + '"';
|
|
407
|
+
})()`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export async function clearField({ selector }) {
|
|
412
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
413
|
+
return runJS(
|
|
414
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';el.value='';el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));return 'Cleared';})()`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function selectOption({ selector, value }) {
|
|
419
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
420
|
+
const val = value.replace(/'/g, "\\'");
|
|
421
|
+
return runJS(
|
|
422
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';el.value='${val}';el.dispatchEvent(new Event('change',{bubbles:true}));return 'Selected: '+el.value;})()`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function fillForm({ fields }) {
|
|
427
|
+
// Single JS call for ALL fields (instead of N separate osascript calls)
|
|
428
|
+
const fieldsJSON = JSON.stringify(fields.map(f => ({
|
|
429
|
+
s: f.selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'"),
|
|
430
|
+
v: f.value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n"),
|
|
431
|
+
})));
|
|
432
|
+
return runJS(
|
|
433
|
+
`(function(){
|
|
434
|
+
var fields = ${fieldsJSON};
|
|
435
|
+
var results = [];
|
|
436
|
+
fields.forEach(function(f) {
|
|
437
|
+
var el = document.querySelector(f.s);
|
|
438
|
+
if (!el) { results.push('Not found: ' + f.s); return; }
|
|
439
|
+
el.focus();
|
|
440
|
+
if (el.isContentEditable) {
|
|
441
|
+
el.textContent = '';
|
|
442
|
+
document.execCommand('insertText', false, f.v);
|
|
443
|
+
} else {
|
|
444
|
+
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') ||
|
|
445
|
+
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
|
|
446
|
+
if (setter && setter.set) setter.set.call(el, f.v);
|
|
447
|
+
else el.value = f.v;
|
|
448
|
+
el.dispatchEvent(new Event('input', {bubbles: true}));
|
|
449
|
+
el.dispatchEvent(new Event('change', {bubbles: true}));
|
|
450
|
+
}
|
|
451
|
+
results.push('Filled: ' + el.tagName + ' with "' + f.v.substring(0, 30) + '"');
|
|
452
|
+
});
|
|
453
|
+
return results.join('\\n');
|
|
454
|
+
})()`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ========== KEYBOARD ==========
|
|
459
|
+
|
|
460
|
+
// JS key names for KeyboardEvent
|
|
461
|
+
const jsKeyMap = {
|
|
462
|
+
enter: "Enter", return: "Enter", tab: "Tab", escape: "Escape", space: " ",
|
|
463
|
+
delete: "Backspace", backspace: "Backspace", up: "ArrowUp", down: "ArrowDown",
|
|
464
|
+
left: "ArrowLeft", right: "ArrowRight", home: "Home", end: "End",
|
|
465
|
+
pageup: "PageUp", pagedown: "PageDown",
|
|
466
|
+
f1: "F1", f2: "F2", f3: "F3", f4: "F4", f5: "F5", f6: "F6",
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// System Events key codes — only used for paste_image, upload_file, save_pdf
|
|
470
|
+
// (functions that truly require OS-level UI interaction)
|
|
471
|
+
|
|
472
|
+
export async function pressKey({ key, modifiers = [] }) {
|
|
473
|
+
const hasCmdOrCtrl = modifiers.some((m) => ["cmd", "ctrl"].includes(m.toLowerCase()));
|
|
474
|
+
const hasShift = modifiers.some((m) => m.toLowerCase() === "shift");
|
|
475
|
+
const k = key.toLowerCase();
|
|
476
|
+
|
|
477
|
+
// Try to handle EVERYTHING via JavaScript — no System Events, no focus stealing
|
|
478
|
+
if (hasCmdOrCtrl) {
|
|
479
|
+
// Map Cmd/Ctrl shortcuts to JS execCommand equivalents
|
|
480
|
+
const jsShortcuts = {
|
|
481
|
+
a: "document.execCommand('selectAll')",
|
|
482
|
+
c: `(function(){
|
|
483
|
+
var sel = window.getSelection();
|
|
484
|
+
if (sel.toString()) { navigator.clipboard.writeText(sel.toString()).catch(function(){}); }
|
|
485
|
+
return 'Copied';
|
|
486
|
+
})()`,
|
|
487
|
+
x: "document.execCommand('cut')",
|
|
488
|
+
z: hasShift ? "document.execCommand('redo')" : "document.execCommand('undo')",
|
|
489
|
+
b: "document.execCommand('bold')",
|
|
490
|
+
i: "document.execCommand('italic')",
|
|
491
|
+
u: "document.execCommand('underline')",
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
if (jsShortcuts[k]) {
|
|
495
|
+
await runJS(jsShortcuts[k]);
|
|
496
|
+
return `Pressed: ${modifiers.join("+")}+${key} (via JS)`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Cmd+V (paste) — the ONE shortcut that truly needs System Events
|
|
500
|
+
// because JS can't access system clipboard for paste without user gesture
|
|
501
|
+
if (k === "v") {
|
|
502
|
+
// Use Safari's Edit menu instead of keystroke — more reliable, targets Safari directly
|
|
503
|
+
await osascript(
|
|
504
|
+
`tell application "System Events"
|
|
505
|
+
tell process "Safari"
|
|
506
|
+
click menu item "Paste" of menu "Edit" of menu bar 1
|
|
507
|
+
end tell
|
|
508
|
+
end tell`
|
|
509
|
+
);
|
|
510
|
+
return `Pressed: ${modifiers.join("+")}+v (via menu)`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Other Cmd shortcuts — dispatch JS KeyboardEvent
|
|
514
|
+
await runJS(
|
|
515
|
+
`(function(){
|
|
516
|
+
var el = document.activeElement || document.body;
|
|
517
|
+
var e = new KeyboardEvent('keydown', {key:'${k}',code:'Key${k.toUpperCase()}',metaKey:true,ctrlKey:false,bubbles:true,cancelable:true});
|
|
518
|
+
el.dispatchEvent(e);
|
|
519
|
+
el.dispatchEvent(new KeyboardEvent('keyup', {key:'${k}',metaKey:true,bubbles:true}));
|
|
520
|
+
return 'Pressed';
|
|
521
|
+
})()`
|
|
522
|
+
);
|
|
523
|
+
return `Pressed: ${modifiers.join("+")}+${key}`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Non-modifier keys: pure JavaScript (no System Events)
|
|
527
|
+
const jsKey = jsKeyMap[k] || key;
|
|
528
|
+
const safeKey = jsKey.replace(/'/g, "\\'");
|
|
529
|
+
const shiftKey = hasShift;
|
|
530
|
+
const altKey = modifiers.some((m) => m.toLowerCase() === "alt");
|
|
531
|
+
|
|
532
|
+
await runJS(
|
|
533
|
+
`(function(){
|
|
534
|
+
var el = document.activeElement || document.body;
|
|
535
|
+
var opts = {key:'${safeKey}',code:'Key${safeKey.length === 1 ? safeKey.toUpperCase() : safeKey}',bubbles:true,cancelable:true,shiftKey:${shiftKey},altKey:${altKey}};
|
|
536
|
+
var down = new KeyboardEvent('keydown', opts);
|
|
537
|
+
var prevented = !el.dispatchEvent(down);
|
|
538
|
+
if (!prevented) {
|
|
539
|
+
if ('${safeKey}' === 'Enter') {
|
|
540
|
+
if (el.tagName === 'INPUT') { el.form && el.form.dispatchEvent(new Event('submit',{bubbles:true})); }
|
|
541
|
+
else { document.execCommand('insertLineBreak'); }
|
|
542
|
+
} else if ('${safeKey}' === 'Tab') {
|
|
543
|
+
var focusable = [...document.querySelectorAll('input,textarea,select,button,a,[tabindex]')].filter(function(e){return e.tabIndex>=0;});
|
|
544
|
+
var idx = focusable.indexOf(el);
|
|
545
|
+
var next = ${shiftKey} ? focusable[idx-1] : focusable[idx+1];
|
|
546
|
+
if (next) next.focus();
|
|
547
|
+
} else if ('${safeKey}' === 'Backspace') {
|
|
548
|
+
document.execCommand('delete');
|
|
549
|
+
} else if ('${safeKey}' === 'Escape') {
|
|
550
|
+
el.blur();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
el.dispatchEvent(new KeyboardEvent('keyup', opts));
|
|
554
|
+
return 'OK';
|
|
555
|
+
})()`
|
|
556
|
+
);
|
|
557
|
+
return `Pressed: ${modifiers.length ? modifiers.join("+") + "+" : ""}${key}`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function typeText({ text, selector, ref }) {
|
|
561
|
+
if (ref) selector = refSelector(ref);
|
|
562
|
+
if (selector) {
|
|
563
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
564
|
+
await runJS(`document.querySelector('${sel}')?.focus()`);
|
|
565
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Use JavaScript insertText — works in inputs, textareas, and contenteditable
|
|
569
|
+
// No System Events = no keyboard conflict with user
|
|
570
|
+
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n");
|
|
571
|
+
const result = await runJS(
|
|
572
|
+
`(function(){
|
|
573
|
+
var el = document.activeElement;
|
|
574
|
+
if (!el) return 'No focused element';
|
|
575
|
+
|
|
576
|
+
// Method 1: execCommand insertText (works in contenteditable, inputs, textareas)
|
|
577
|
+
var ok = document.execCommand('insertText', false, '${safeText}');
|
|
578
|
+
if (ok) return 'Typed ' + ${text.length} + ' chars via insertText';
|
|
579
|
+
|
|
580
|
+
// Method 2: InputEvent (for modern editors like ProseMirror, Draft.js, Slate)
|
|
581
|
+
try {
|
|
582
|
+
el.dispatchEvent(new InputEvent('beforeinput', {inputType:'insertText', data:'${safeText}', bubbles:true, cancelable:true}));
|
|
583
|
+
el.dispatchEvent(new InputEvent('input', {inputType:'insertText', data:'${safeText}', bubbles:true}));
|
|
584
|
+
return 'Typed ' + ${text.length} + ' chars via InputEvent';
|
|
585
|
+
} catch(e) {}
|
|
586
|
+
|
|
587
|
+
// Method 3: Direct value set (for plain inputs/textareas)
|
|
588
|
+
if ('value' in el) {
|
|
589
|
+
var start = el.selectionStart || 0;
|
|
590
|
+
el.value = el.value.substring(0, start) + '${safeText}' + el.value.substring(el.selectionEnd || start);
|
|
591
|
+
el.selectionStart = el.selectionEnd = start + ${text.length};
|
|
592
|
+
el.dispatchEvent(new Event('input', {bubbles:true}));
|
|
593
|
+
el.dispatchEvent(new Event('change', {bubbles:true}));
|
|
594
|
+
return 'Typed ' + ${text.length} + ' chars via value set';
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return 'Could not type — no compatible input method';
|
|
598
|
+
})()`
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
return result;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ========== SCREENSHOT ==========
|
|
605
|
+
|
|
606
|
+
export async function screenshot({ fullPage = false } = {}) {
|
|
607
|
+
const tmpFile = join(tmpdir(), `safari-screenshot-${Date.now()}.png`);
|
|
608
|
+
try {
|
|
609
|
+
// If we're working on a non-current tab, screencapture won't work
|
|
610
|
+
// (it captures the visually active tab). Use JS method directly.
|
|
611
|
+
const isBackgroundTab = _activeTabIndex != null;
|
|
612
|
+
|
|
613
|
+
// Check if our tab is the current one
|
|
614
|
+
let skipScreencapture = isBackgroundTab;
|
|
615
|
+
if (isBackgroundTab) {
|
|
616
|
+
try {
|
|
617
|
+
const currentIdx = await osascriptFast(
|
|
618
|
+
`tell application "Safari" to return index of current tab of front window`
|
|
619
|
+
);
|
|
620
|
+
if (Number(currentIdx) === _activeTabIndex) skipScreencapture = false;
|
|
621
|
+
} catch (_) {}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Try screencapture first (needs Screen Recording permission)
|
|
625
|
+
const windowId = !skipScreencapture ? await osascript(
|
|
626
|
+
'tell application "Safari" to return id of window 1'
|
|
627
|
+
).catch(() => null) : null;
|
|
628
|
+
|
|
629
|
+
if (windowId) {
|
|
630
|
+
try {
|
|
631
|
+
if (fullPage) {
|
|
632
|
+
const bounds = await osascript(
|
|
633
|
+
'tell application "Safari" to return bounds of front window'
|
|
634
|
+
);
|
|
635
|
+
const height = await runJS("document.documentElement.scrollHeight");
|
|
636
|
+
const width = await runJS("document.documentElement.scrollWidth");
|
|
637
|
+
await osascript(
|
|
638
|
+
`tell application "Safari" to set bounds of front window to {0, 0, ${Number(width)}, ${Math.min(Number(height) + 100, 5000)}}`
|
|
639
|
+
);
|
|
640
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
641
|
+
await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
642
|
+
await osascript(
|
|
643
|
+
`tell application "Safari" to set bounds of front window to {${bounds}}`
|
|
644
|
+
);
|
|
645
|
+
} else {
|
|
646
|
+
await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
647
|
+
}
|
|
648
|
+
const data = await readFile(tmpFile);
|
|
649
|
+
await unlink(tmpFile).catch(() => {});
|
|
650
|
+
if (data.length > 100) return data.toString("base64");
|
|
651
|
+
} catch (_) {
|
|
652
|
+
// screencapture failed, fall through to JS method
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Fallback: JS-based screenshot via canvas (no permissions needed)
|
|
657
|
+
const dataUrl = await runJS(
|
|
658
|
+
`(async function(){` +
|
|
659
|
+
`var c=document.createElement('canvas');var ctx=c.getContext('2d');` +
|
|
660
|
+
`c.width=window.innerWidth;c.height=${fullPage ? 'document.documentElement.scrollHeight' : 'window.innerHeight'};` +
|
|
661
|
+
`var svg='<svg xmlns="http://www.w3.org/2000/svg" width="'+c.width+'" height="'+c.height+'">' +` +
|
|
662
|
+
`'<foreignObject width="100%" height="100%">' +` +
|
|
663
|
+
`'<div xmlns="http://www.w3.org/1999/xhtml">' + document.documentElement.outerHTML + '</div>' +` +
|
|
664
|
+
`'</foreignObject></svg>';` +
|
|
665
|
+
`var blob=new Blob([svg],{type:'image/svg+xml'});` +
|
|
666
|
+
`var url=URL.createObjectURL(blob);` +
|
|
667
|
+
`var img=new Image();` +
|
|
668
|
+
`return new Promise(function(resolve){` +
|
|
669
|
+
`img.onload=function(){ctx.drawImage(img,0,0);resolve(c.toDataURL('image/png').split(',')[1]);};` +
|
|
670
|
+
`img.onerror=function(){resolve('FALLBACK_TEXT')};` +
|
|
671
|
+
`img.src=url;});})()`,
|
|
672
|
+
{ timeout: 30000 }
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
if (dataUrl && dataUrl !== "FALLBACK_TEXT") {
|
|
676
|
+
return dataUrl;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Final fallback: return page text description
|
|
680
|
+
throw new Error("Screenshot unavailable. Grant Screen Recording permission to VS Code/Terminal in System Settings → Privacy & Security → Screen Recording, then restart.");
|
|
681
|
+
} finally {
|
|
682
|
+
await unlink(tmpFile).catch(() => {});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ========== ELEMENT SCREENSHOT ==========
|
|
687
|
+
|
|
688
|
+
export async function screenshotElement({ selector }) {
|
|
689
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
690
|
+
// Use html2canvas-like approach: capture element via SVG foreignObject
|
|
691
|
+
const result = await runJS(
|
|
692
|
+
`(async function(){
|
|
693
|
+
var el = document.querySelector('${sel}');
|
|
694
|
+
if (!el) return 'Element not found: ${sel}';
|
|
695
|
+
var rect = el.getBoundingClientRect();
|
|
696
|
+
if (rect.width === 0 || rect.height === 0) return 'Element has no dimensions';
|
|
697
|
+
|
|
698
|
+
// Scroll element into view
|
|
699
|
+
el.scrollIntoView({block:'center'});
|
|
700
|
+
await new Promise(r => setTimeout(r, 100));
|
|
701
|
+
rect = el.getBoundingClientRect();
|
|
702
|
+
|
|
703
|
+
// Use canvas + drawImage from window screenshot approach
|
|
704
|
+
var c = document.createElement('canvas');
|
|
705
|
+
c.width = Math.ceil(rect.width * window.devicePixelRatio);
|
|
706
|
+
c.height = Math.ceil(rect.height * window.devicePixelRatio);
|
|
707
|
+
var ctx = c.getContext('2d');
|
|
708
|
+
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
|
709
|
+
|
|
710
|
+
// Clone element to avoid cross-origin issues
|
|
711
|
+
var clone = el.cloneNode(true);
|
|
712
|
+
var styles = window.getComputedStyle(el);
|
|
713
|
+
var wrapper = document.createElement('div');
|
|
714
|
+
wrapper.style.cssText = 'position:absolute;left:-99999px;top:0;width:'+rect.width+'px;height:'+rect.height+'px;overflow:hidden;background:'+styles.backgroundColor;
|
|
715
|
+
wrapper.appendChild(clone);
|
|
716
|
+
document.body.appendChild(wrapper);
|
|
717
|
+
|
|
718
|
+
// Serialize to SVG foreignObject
|
|
719
|
+
var html = new XMLSerializer().serializeToString(wrapper);
|
|
720
|
+
document.body.removeChild(wrapper);
|
|
721
|
+
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="'+rect.width+'" height="'+rect.height+'">' +
|
|
722
|
+
'<foreignObject width="100%" height="100%">' + html + '</foreignObject></svg>';
|
|
723
|
+
var blob = new Blob([svg], {type:'image/svg+xml;charset=utf-8'});
|
|
724
|
+
var url = URL.createObjectURL(blob);
|
|
725
|
+
var img = new Image();
|
|
726
|
+
return new Promise(function(resolve){
|
|
727
|
+
img.onload = function(){
|
|
728
|
+
ctx.drawImage(img, 0, 0, rect.width, rect.height);
|
|
729
|
+
URL.revokeObjectURL(url);
|
|
730
|
+
resolve(c.toDataURL('image/png').split(',')[1]);
|
|
731
|
+
};
|
|
732
|
+
img.onerror = function(){ resolve('SVG_RENDER_FAILED'); };
|
|
733
|
+
img.src = url;
|
|
734
|
+
});
|
|
735
|
+
})()`,
|
|
736
|
+
{ timeout: 15000 }
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
if (result === "SVG_RENDER_FAILED" || result.startsWith("Element")) {
|
|
740
|
+
// Fallback: use screencapture + crop
|
|
741
|
+
const tmpFile = join(tmpdir(), `safari-el-${Date.now()}.png`);
|
|
742
|
+
try {
|
|
743
|
+
const windowId = await osascript('tell application "Safari" to return id of window 1').catch(() => null);
|
|
744
|
+
if (!windowId) throw new Error("Cannot get Safari window ID");
|
|
745
|
+
|
|
746
|
+
// Get element bounds relative to screen
|
|
747
|
+
const bounds = await runJS(
|
|
748
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return '';var r=el.getBoundingClientRect();return JSON.stringify({x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)});})()`
|
|
749
|
+
);
|
|
750
|
+
if (!bounds) throw new Error(result);
|
|
751
|
+
|
|
752
|
+
// Full window screenshot then crop with sips
|
|
753
|
+
await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
754
|
+
const { x, y, w, h } = JSON.parse(bounds);
|
|
755
|
+
// Use sips to crop (macOS built-in)
|
|
756
|
+
const toolbarHeight = 52; // Safari toolbar approximate height
|
|
757
|
+
const cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
|
|
758
|
+
await execFileAsync("sips", [
|
|
759
|
+
"-c", String(h), String(w),
|
|
760
|
+
"--cropOffset", String(y + toolbarHeight), String(x),
|
|
761
|
+
tmpFile, "--out", cropFile
|
|
762
|
+
]);
|
|
763
|
+
const data = await readFile(cropFile);
|
|
764
|
+
await unlink(tmpFile).catch(() => {});
|
|
765
|
+
await unlink(cropFile).catch(() => {});
|
|
766
|
+
if (data.length > 100) return data.toString("base64");
|
|
767
|
+
} catch (e) {
|
|
768
|
+
await unlink(tmpFile).catch(() => {});
|
|
769
|
+
throw new Error(`Element screenshot failed: ${e.message}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return result;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ========== SCROLL ==========
|
|
777
|
+
|
|
778
|
+
export async function scroll({ direction = "down", amount = 500 }) {
|
|
779
|
+
const y = direction === "up" ? -Number(amount) : Number(amount);
|
|
780
|
+
// Single call: scroll + return position
|
|
781
|
+
return runJS(
|
|
782
|
+
`(function(){window.scrollBy(0,${y});return 'Scrolled ${direction} ${amount}px. Position: '+JSON.stringify({x:window.scrollX,y:window.scrollY,height:document.documentElement.scrollHeight});})()`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export async function scrollTo({ x = 0, y = 0 }) {
|
|
787
|
+
return runJS(`(function(){window.scrollTo(${Number(x)},${Number(y)});return 'Scrolled to (${x},${y})';})()`);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ========== TAB MANAGEMENT ==========
|
|
791
|
+
|
|
792
|
+
export async function listTabs() {
|
|
793
|
+
const result = await osascript(
|
|
794
|
+
`tell application "Safari"
|
|
795
|
+
set output to ""
|
|
796
|
+
set tabIndex to 1
|
|
797
|
+
repeat with t in every tab of front window
|
|
798
|
+
if tabIndex > 1 then set output to output & linefeed
|
|
799
|
+
set output to output & (tabIndex as text) & (ASCII character 9) & name of t & (ASCII character 9) & URL of t
|
|
800
|
+
set tabIndex to tabIndex + 1
|
|
801
|
+
end repeat
|
|
802
|
+
return output
|
|
803
|
+
end tell`
|
|
804
|
+
);
|
|
805
|
+
if (!result.trim()) return JSON.stringify([]);
|
|
806
|
+
const tabs = result.split("\n").map((line) => {
|
|
807
|
+
const parts = line.split("\t");
|
|
808
|
+
return { index: parseInt(parts[0]), title: parts[1] || "", url: parts[2] || "" };
|
|
809
|
+
});
|
|
810
|
+
return JSON.stringify(tabs, null, 2);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export async function newTab(url = "") {
|
|
814
|
+
return withoutStealingFocus(async () => {
|
|
815
|
+
const safeUrl = url ? url.replace(/"/g, '\\"') : "";
|
|
816
|
+
try {
|
|
817
|
+
// Save user's current tab, create new tab, restore user's tab — all in ONE osascript call
|
|
818
|
+
// This minimizes the visual "flash" to nearly zero
|
|
819
|
+
if (url) {
|
|
820
|
+
await osascript(
|
|
821
|
+
`tell application "Safari"
|
|
822
|
+
tell front window
|
|
823
|
+
set userTab to current tab
|
|
824
|
+
make new tab with properties {URL:"${safeUrl}"}
|
|
825
|
+
set current tab to userTab
|
|
826
|
+
end tell
|
|
827
|
+
end tell`
|
|
828
|
+
);
|
|
829
|
+
} else {
|
|
830
|
+
await osascript(
|
|
831
|
+
`tell application "Safari"
|
|
832
|
+
tell front window
|
|
833
|
+
set userTab to current tab
|
|
834
|
+
make new tab
|
|
835
|
+
set current tab to userTab
|
|
836
|
+
end tell
|
|
837
|
+
end tell`
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
} catch {
|
|
841
|
+
// No window exists — create one
|
|
842
|
+
if (url) {
|
|
843
|
+
await osascript(`tell application "Safari" to make new document with properties {URL:"${safeUrl}"}`);
|
|
844
|
+
} else {
|
|
845
|
+
await osascript('tell application "Safari" to make new document');
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Get the index of the newly created tab (always the last one)
|
|
850
|
+
const tabCount = await osascriptFast(
|
|
851
|
+
'tell application "Safari" to return count of tabs of front window'
|
|
852
|
+
);
|
|
853
|
+
const newIndex = Number(tabCount);
|
|
854
|
+
_activeTabIndex = newIndex; // Track this as our working tab
|
|
855
|
+
|
|
856
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
857
|
+
// Get info from the new tab by index (without switching visually)
|
|
858
|
+
return runJS(`JSON.stringify({title:document.title,url:location.href,tabIndex:${newIndex}})`, { tabIndex: newIndex });
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export async function closeTab() {
|
|
863
|
+
if (_activeTabIndex) {
|
|
864
|
+
await osascript(
|
|
865
|
+
`tell application "Safari" to close tab ${_activeTabIndex} of front window`
|
|
866
|
+
);
|
|
867
|
+
_activeTabIndex = null;
|
|
868
|
+
} else {
|
|
869
|
+
await osascript(
|
|
870
|
+
`tell application "Safari" to close current tab of front window`
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
return "Tab closed";
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
export async function switchTab(index) {
|
|
877
|
+
// NO visual tab switch — just update internal tracking.
|
|
878
|
+
// All subsequent runJS calls will target this tab by index.
|
|
879
|
+
// The user's visible tab stays unchanged.
|
|
880
|
+
const idx = Number(index);
|
|
881
|
+
_activeTabIndex = idx;
|
|
882
|
+
// Get title+URL from the target tab without switching visually
|
|
883
|
+
const result = await runJS(
|
|
884
|
+
`JSON.stringify({title:document.title,url:location.href})`,
|
|
885
|
+
{ tabIndex: idx }
|
|
886
|
+
);
|
|
887
|
+
return result;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ========== WAIT ==========
|
|
891
|
+
|
|
892
|
+
export async function waitFor({ selector, text, timeout = 10000 }) {
|
|
893
|
+
// Single JS call with internal polling loop — 1 call instead of 20+
|
|
894
|
+
const safeSelector = selector ? selector.replace(/'/g, "\\'") : "";
|
|
895
|
+
const safeText = text ? text.replace(/'/g, "\\'") : "";
|
|
896
|
+
const result = await runJS(
|
|
897
|
+
`(async function(){
|
|
898
|
+
var deadline = Date.now() + ${Number(timeout)};
|
|
899
|
+
while (Date.now() < deadline) {
|
|
900
|
+
${safeSelector ? `if (document.querySelector('${safeSelector}')) return 'Found: ${safeSelector}';` : ""}
|
|
901
|
+
${safeText ? `if (document.body && document.body.innerText.includes('${safeText}')) return 'Found text: ${safeText}';` : ""}
|
|
902
|
+
await new Promise(function(r){setTimeout(r, 200)});
|
|
903
|
+
}
|
|
904
|
+
return 'TIMEOUT';
|
|
905
|
+
})()`,
|
|
906
|
+
{ timeout: timeout + 2000 }
|
|
907
|
+
);
|
|
908
|
+
if (result === "TIMEOUT") {
|
|
909
|
+
throw new Error(`Timeout waiting for ${selector || text} (${timeout}ms)`);
|
|
910
|
+
}
|
|
911
|
+
return result;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ========== EVALUATE ==========
|
|
915
|
+
|
|
916
|
+
export async function evaluate({ script }) {
|
|
917
|
+
return runJS(script);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ========== ELEMENT INFO ==========
|
|
921
|
+
|
|
922
|
+
export async function getElementInfo({ selector }) {
|
|
923
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
924
|
+
return runJS(
|
|
925
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';var r=el.getBoundingClientRect();return JSON.stringify({tag:el.tagName,text:el.textContent.trim().substring(0,200),href:el.href||'',value:el.value||'',visible:r.width>0&&r.height>0,rect:{x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)},attrs:Object.fromEntries([...el.attributes].map(function(a){return[a.name,a.value.substring(0,100)]}))})})()`
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
export async function querySelectorAll({ selector, limit = 20 }) {
|
|
930
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
931
|
+
return runJS(
|
|
932
|
+
`JSON.stringify([...document.querySelectorAll('${sel}')].slice(0,${Number(limit)}).map(function(el,i){return{index:i,tag:el.tagName,text:el.textContent.trim().substring(0,100),href:el.href||undefined,value:el.value||undefined}}))`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ========== HOVER ==========
|
|
937
|
+
|
|
938
|
+
export async function hover({ selector, x, y, ref }) {
|
|
939
|
+
if (ref) selector = refSelector(ref);
|
|
940
|
+
if (selector) {
|
|
941
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
942
|
+
return runJS(
|
|
943
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));el.dispatchEvent(new MouseEvent('mouseenter',{bubbles:true}));return 'Hovered: '+el.tagName;})()`
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
if (x !== undefined && y !== undefined) {
|
|
947
|
+
return runJS(
|
|
948
|
+
`(function(){var el=document.elementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element';el.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));el.dispatchEvent(new MouseEvent('mouseenter',{bubbles:true}));return 'Hovered: '+el.tagName+' at (${Number(x)},${Number(y)})';})()`
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
throw new Error("hover requires selector or x/y coordinates");
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ========== DIALOG HANDLING ==========
|
|
955
|
+
|
|
956
|
+
export async function handleDialog({ action = "accept", text }) {
|
|
957
|
+
if (text !== undefined) {
|
|
958
|
+
const safeText = text.replace(/'/g, "\\'");
|
|
959
|
+
await runJS(
|
|
960
|
+
`window.__mcp_dialog_response='${safeText}';window.__origPrompt=window.prompt;window.prompt=function(){var r=window.__mcp_dialog_response;window.prompt=window.__origPrompt;return r;}`
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
if (action === "accept") {
|
|
964
|
+
await runJS(
|
|
965
|
+
"window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return true;};window.__origAlert=window.alert;window.alert=function(){window.alert=window.__origAlert;};"
|
|
966
|
+
);
|
|
967
|
+
} else {
|
|
968
|
+
await runJS(
|
|
969
|
+
"window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
return `Dialog handler set: ${action}${text ? ' with "' + text + '"' : ""}`;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ========== WINDOW ==========
|
|
976
|
+
|
|
977
|
+
export async function resizeWindow({ width, height }) {
|
|
978
|
+
await osascript(
|
|
979
|
+
`tell application "Safari" to set bounds of front window to {0, 0, ${Number(width)}, ${Number(height)}}`
|
|
980
|
+
);
|
|
981
|
+
return `Resized to ${width}x${height}`;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ========== COOKIES & STORAGE ==========
|
|
985
|
+
|
|
986
|
+
export async function getCookies() {
|
|
987
|
+
return runJS("document.cookie");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export async function getLocalStorage({ key }) {
|
|
991
|
+
if (key) {
|
|
992
|
+
const safeKey = key.replace(/'/g, "\\'");
|
|
993
|
+
return runJS(`localStorage.getItem('${safeKey}')`);
|
|
994
|
+
}
|
|
995
|
+
return runJS(
|
|
996
|
+
"JSON.stringify(Object.fromEntries(Object.keys(localStorage).map(function(k){return[k,localStorage.getItem(k).substring(0,200)]})))"
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ========== NETWORK (via Performance API) ==========
|
|
1001
|
+
|
|
1002
|
+
export async function getNetworkRequests({ limit = 50 } = {}) {
|
|
1003
|
+
return runJS(
|
|
1004
|
+
`JSON.stringify(performance.getEntriesByType('resource').slice(-${Number(limit)}).map(function(r){return{name:r.name,type:r.initiatorType,duration:Math.round(r.duration),size:r.transferSize||0}}))`
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// ========== DRAG ==========
|
|
1009
|
+
|
|
1010
|
+
export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, targetX, targetY }) {
|
|
1011
|
+
if (sourceSelector && targetSelector) {
|
|
1012
|
+
const srcSel = sourceSelector.replace(/'/g, "\\'");
|
|
1013
|
+
const tgtSel = targetSelector.replace(/'/g, "\\'");
|
|
1014
|
+
return runJS(
|
|
1015
|
+
`(function(){` +
|
|
1016
|
+
`var src=document.querySelector('${srcSel}');var tgt=document.querySelector('${tgtSel}');` +
|
|
1017
|
+
`if(!src)return 'Source not found: ${srcSel}';if(!tgt)return 'Target not found: ${tgtSel}';` +
|
|
1018
|
+
`var sr=src.getBoundingClientRect();var tr=tgt.getBoundingClientRect();` +
|
|
1019
|
+
`var sx=sr.x+sr.width/2,sy=sr.y+sr.height/2,tx=tr.x+tr.width/2,ty=tr.y+tr.height/2;` +
|
|
1020
|
+
`src.dispatchEvent(new MouseEvent('mousedown',{clientX:sx,clientY:sy,bubbles:true}));` +
|
|
1021
|
+
`src.dispatchEvent(new MouseEvent('mousemove',{clientX:sx,clientY:sy,bubbles:true}));` +
|
|
1022
|
+
`tgt.dispatchEvent(new MouseEvent('mousemove',{clientX:tx,clientY:ty,bubbles:true}));` +
|
|
1023
|
+
`tgt.dispatchEvent(new MouseEvent('mouseup',{clientX:tx,clientY:ty,bubbles:true}));` +
|
|
1024
|
+
`tgt.dispatchEvent(new DragEvent('drop',{bubbles:true}));` +
|
|
1025
|
+
`return 'Dragged from '+src.tagName+' to '+tgt.tagName;})()`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
if (sourceX !== undefined && sourceY !== undefined && targetX !== undefined && targetY !== undefined) {
|
|
1029
|
+
return runJS(
|
|
1030
|
+
`(function(){` +
|
|
1031
|
+
`var src=document.elementFromPoint(${Number(sourceX)},${Number(sourceY)});` +
|
|
1032
|
+
`if(!src)return 'No element at source';` +
|
|
1033
|
+
`src.dispatchEvent(new MouseEvent('mousedown',{clientX:${Number(sourceX)},clientY:${Number(sourceY)},bubbles:true}));` +
|
|
1034
|
+
`src.dispatchEvent(new MouseEvent('mousemove',{clientX:${Number(sourceX)},clientY:${Number(sourceY)},bubbles:true}));` +
|
|
1035
|
+
`document.elementFromPoint(${Number(targetX)},${Number(targetY)})?.dispatchEvent(new MouseEvent('mousemove',{clientX:${Number(targetX)},clientY:${Number(targetY)},bubbles:true}));` +
|
|
1036
|
+
`document.elementFromPoint(${Number(targetX)},${Number(targetY)})?.dispatchEvent(new MouseEvent('mouseup',{clientX:${Number(targetX)},clientY:${Number(targetY)},bubbles:true}));` +
|
|
1037
|
+
`return 'Dragged from (${Number(sourceX)},${Number(sourceY)}) to (${Number(targetX)},${Number(targetY)})';})()`
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
throw new Error("drag requires sourceSelector+targetSelector or sourceX/Y+targetX/Y");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ========== UPLOAD FILE ==========
|
|
1044
|
+
|
|
1045
|
+
export async function uploadFile({ selector, filePath }) {
|
|
1046
|
+
// Read file in Node.js, send as base64 to Safari JS, create File + DataTransfer
|
|
1047
|
+
// NO file dialog, NO System Events, NO focus stealing
|
|
1048
|
+
|
|
1049
|
+
// Safety: close any open file dialog first (in case Claude clicked the input before calling this)
|
|
1050
|
+
await osascript(
|
|
1051
|
+
`tell application "System Events"
|
|
1052
|
+
tell process "Safari"
|
|
1053
|
+
if exists sheet 1 of window 1 then
|
|
1054
|
+
click button "ביטול" of sheet 1 of window 1
|
|
1055
|
+
end if
|
|
1056
|
+
end tell
|
|
1057
|
+
end tell`
|
|
1058
|
+
).catch(() => {}); // Ignore if no dialog open
|
|
1059
|
+
|
|
1060
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
1061
|
+
const { basename, extname } = await import("node:path");
|
|
1062
|
+
const fileName = basename(filePath);
|
|
1063
|
+
const ext = extname(filePath).toLowerCase().replace(".", "");
|
|
1064
|
+
|
|
1065
|
+
// Read file as base64
|
|
1066
|
+
const fileData = await readFile(filePath);
|
|
1067
|
+
const base64 = fileData.toString("base64");
|
|
1068
|
+
|
|
1069
|
+
// Determine MIME type
|
|
1070
|
+
const mimeMap = {
|
|
1071
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
|
|
1072
|
+
webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf",
|
|
1073
|
+
mp4: "video/mp4", mp3: "audio/mpeg", txt: "text/plain", csv: "text/csv",
|
|
1074
|
+
json: "application/json", zip: "application/zip", doc: "application/msword",
|
|
1075
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1076
|
+
xls: "application/vnd.ms-excel",
|
|
1077
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1078
|
+
};
|
|
1079
|
+
const mime = mimeMap[ext] || "application/octet-stream";
|
|
1080
|
+
const safeName = fileName.replace(/'/g, "\\'");
|
|
1081
|
+
|
|
1082
|
+
// Send to Safari via runJSLarge (handles files >260KB via temp file)
|
|
1083
|
+
const result = await runJSLarge(
|
|
1084
|
+
`(function(){
|
|
1085
|
+
var el = document.querySelector('${sel}');
|
|
1086
|
+
if (!el) return 'Element not found: ${sel}';
|
|
1087
|
+
|
|
1088
|
+
// Decode base64 to binary
|
|
1089
|
+
var b64 = '${base64}';
|
|
1090
|
+
var binary = atob(b64);
|
|
1091
|
+
var bytes = new Uint8Array(binary.length);
|
|
1092
|
+
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
1093
|
+
|
|
1094
|
+
var file = new File([bytes], '${safeName}', { type: '${mime}' });
|
|
1095
|
+
var dt = new DataTransfer();
|
|
1096
|
+
dt.items.add(file);
|
|
1097
|
+
el.files = dt.files;
|
|
1098
|
+
|
|
1099
|
+
// Trigger events for frameworks
|
|
1100
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1101
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1102
|
+
return 'Uploaded: ${safeName} (' + Math.round(bytes.length / 1024) + ' KB)';
|
|
1103
|
+
})()`,
|
|
1104
|
+
{ timeout: 30000 }
|
|
1105
|
+
);
|
|
1106
|
+
|
|
1107
|
+
return result;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ========== PASTE IMAGE FROM FILE ==========
|
|
1111
|
+
|
|
1112
|
+
export async function pasteImageFromFile({ filePath }) {
|
|
1113
|
+
// Paste image via JS ClipboardEvent — NO clipboard touch, NO System Events, NO focus steal
|
|
1114
|
+
const { extname } = await import("node:path");
|
|
1115
|
+
const ext = extname(filePath).toLowerCase().replace(".", "");
|
|
1116
|
+
const mimeMap = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
|
|
1117
|
+
const mime = mimeMap[ext] || "image/png";
|
|
1118
|
+
|
|
1119
|
+
// Read image as base64
|
|
1120
|
+
const fileData = await readFile(filePath);
|
|
1121
|
+
const base64 = fileData.toString("base64");
|
|
1122
|
+
const fileName = filePath.split("/").pop().replace(/'/g, "\\'");
|
|
1123
|
+
|
|
1124
|
+
// Use runJSLarge — images are often >260KB as base64
|
|
1125
|
+
const result = await runJSLarge(
|
|
1126
|
+
`(function(){
|
|
1127
|
+
var el = document.activeElement;
|
|
1128
|
+
if (!el) return 'No focused element';
|
|
1129
|
+
|
|
1130
|
+
// Decode base64 to blob
|
|
1131
|
+
var b64 = '${base64}';
|
|
1132
|
+
var binary = atob(b64);
|
|
1133
|
+
var bytes = new Uint8Array(binary.length);
|
|
1134
|
+
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
1135
|
+
var blob = new Blob([bytes], { type: '${mime}' });
|
|
1136
|
+
var file = new File([blob], '${fileName}', { type: '${mime}' });
|
|
1137
|
+
|
|
1138
|
+
// Method 1: Synthetic paste event with DataTransfer (works on Medium, dev.to, etc.)
|
|
1139
|
+
var dt = new DataTransfer();
|
|
1140
|
+
dt.items.add(file);
|
|
1141
|
+
var pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
|
|
1142
|
+
var handled = el.dispatchEvent(pasteEvent);
|
|
1143
|
+
|
|
1144
|
+
// Method 2: If paste didn't work, try drop event (works on drag-drop zones)
|
|
1145
|
+
if (!handled || !document.querySelector('img[src^="blob:"],img[src^="data:"]')) {
|
|
1146
|
+
var dropDt = new DataTransfer();
|
|
1147
|
+
dropDt.items.add(file);
|
|
1148
|
+
var dropEvent = new DragEvent('drop', { dataTransfer: dropDt, bubbles: true, cancelable: true });
|
|
1149
|
+
el.dispatchEvent(new DragEvent('dragenter', { dataTransfer: dropDt, bubbles: true }));
|
|
1150
|
+
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dropDt, bubbles: true }));
|
|
1151
|
+
el.dispatchEvent(dropEvent);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return 'Pasted image: ${fileName} (' + Math.round(bytes.length / 1024) + ' KB)';
|
|
1155
|
+
})()`,
|
|
1156
|
+
{ timeout: 30000 }
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
return result;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// ========== EMULATE (VIEWPORT) ==========
|
|
1163
|
+
|
|
1164
|
+
export async function emulate({ device, width, height, userAgent, scale = 1 }) {
|
|
1165
|
+
const devices = {
|
|
1166
|
+
"iphone-14": { width: 390, height: 844, ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" },
|
|
1167
|
+
"iphone-14-pro-max": { width: 430, height: 932, ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" },
|
|
1168
|
+
"ipad": { width: 820, height: 1180, ua: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" },
|
|
1169
|
+
"ipad-pro": { width: 1024, height: 1366, ua: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" },
|
|
1170
|
+
"pixel-7": { width: 412, height: 915, ua: "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" },
|
|
1171
|
+
"galaxy-s24": { width: 412, height: 915, ua: "Mozilla/5.0 (Linux; Android 14; SM-S921B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" },
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
const d = device ? devices[device.toLowerCase()] : null;
|
|
1175
|
+
const w = d ? d.width : (width || 375);
|
|
1176
|
+
const h = d ? d.height : (height || 812);
|
|
1177
|
+
const ua = d ? d.ua : (userAgent || "");
|
|
1178
|
+
|
|
1179
|
+
// Resize Safari window to match device
|
|
1180
|
+
await osascript(
|
|
1181
|
+
`tell application "Safari" to set bounds of front window to {0, 0, ${w}, ${h + 100}}`
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
// Override viewport meta and user agent if specified
|
|
1185
|
+
if (ua) {
|
|
1186
|
+
await runJS(
|
|
1187
|
+
`Object.defineProperty(navigator,'userAgent',{get:function(){return '${ua.replace(/'/g, "\\'")}'},configurable:true})`
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Set viewport meta tag
|
|
1192
|
+
await runJS(
|
|
1193
|
+
`(function(){var m=document.querySelector('meta[name=viewport]');if(!m){m=document.createElement('meta');m.name='viewport';document.head.appendChild(m);}m.content='width=${w},initial-scale=${scale}';})()`
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
// Reload to apply changes
|
|
1197
|
+
// Reload and wait via inline JS polling (not fixed sleep)
|
|
1198
|
+
await runJS(
|
|
1199
|
+
`(async function(){location.reload();await new Promise(function(r){setTimeout(r,300)});for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}return 'done';})()`
|
|
1200
|
+
);
|
|
1201
|
+
|
|
1202
|
+
return JSON.stringify({
|
|
1203
|
+
device: device || "custom",
|
|
1204
|
+
width: w,
|
|
1205
|
+
height: h,
|
|
1206
|
+
userAgent: ua ? ua.substring(0, 60) + "..." : "(default)",
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
export async function resetEmulation() {
|
|
1211
|
+
// Reset user agent
|
|
1212
|
+
await runJS(
|
|
1213
|
+
"delete Object.getOwnPropertyDescriptor(Navigator.prototype,'userAgent')||true"
|
|
1214
|
+
);
|
|
1215
|
+
// Maximize window
|
|
1216
|
+
await osascript(
|
|
1217
|
+
`tell application "Safari" to set bounds of front window to {0, 0, 1440, 900}`
|
|
1218
|
+
);
|
|
1219
|
+
await runJS(
|
|
1220
|
+
`(async function(){location.reload();await new Promise(function(r){setTimeout(r,300)});for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}return 'done';})()`
|
|
1221
|
+
);
|
|
1222
|
+
return "Emulation reset to desktop";
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ========== CONSOLE CAPTURE ==========
|
|
1226
|
+
|
|
1227
|
+
export async function startConsoleCapture() {
|
|
1228
|
+
await runJS(
|
|
1229
|
+
"if(!window.__mcp_console){window.__mcp_console=[];var orig={log:console.log,warn:console.warn,error:console.error,info:console.info};['log','warn','error','info'].forEach(function(level){console[level]=function(){window.__mcp_console.push({level:level,message:[].slice.call(arguments).map(String).join(' '),time:Date.now()});orig[level].apply(console,arguments);};});window.addEventListener('error',function(e){window.__mcp_console.push({level:'error',message:e.message,time:Date.now()});});}"
|
|
1230
|
+
);
|
|
1231
|
+
return "Console capture started";
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
export async function getConsoleMessages() {
|
|
1235
|
+
return runJS("JSON.stringify(window.__mcp_console||[])");
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
export async function clearConsoleCapture() {
|
|
1239
|
+
return runJS("window.__mcp_console=[]; 'Console cleared'");
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// ========== PDF SAVE ==========
|
|
1243
|
+
|
|
1244
|
+
export async function savePDF({ path: pdfPath }) {
|
|
1245
|
+
const safePath = pdfPath.replace(/"/g, '\\"');
|
|
1246
|
+
// Use window.print() API + AppleScript to save as PDF
|
|
1247
|
+
// Strategy: use osascript to print to PDF directly via CUPS (no UI at all)
|
|
1248
|
+
try {
|
|
1249
|
+
// Method 1: Use Safari's native "Export as PDF" via AppleScript menu + clipboard paste for path
|
|
1250
|
+
await osascript(
|
|
1251
|
+
`tell application "Safari" to activate
|
|
1252
|
+
delay 0.3
|
|
1253
|
+
tell application "System Events"
|
|
1254
|
+
tell process "Safari"
|
|
1255
|
+
click menu item "Export as PDF…" of menu "File" of menu bar 1
|
|
1256
|
+
delay 1
|
|
1257
|
+
-- Use clipboard to paste path (immune to keyboard layout)
|
|
1258
|
+
set the clipboard to "${safePath}"
|
|
1259
|
+
delay 0.1
|
|
1260
|
+
keystroke "g" using {command down, shift down}
|
|
1261
|
+
delay 0.8
|
|
1262
|
+
keystroke "a" using {command down}
|
|
1263
|
+
delay 0.1
|
|
1264
|
+
keystroke "v" using {command down}
|
|
1265
|
+
delay 0.3
|
|
1266
|
+
key code 36
|
|
1267
|
+
delay 1
|
|
1268
|
+
key code 36
|
|
1269
|
+
end tell
|
|
1270
|
+
end tell`,
|
|
1271
|
+
{ timeout: 20000 }
|
|
1272
|
+
);
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
throw new Error(`PDF save failed: ${err.message}. Try granting Accessibility permission to Terminal/VS Code.`);
|
|
1275
|
+
}
|
|
1276
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1277
|
+
return `PDF saved to: ${pdfPath}`;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// ========== SNAPSHOT — ref-based interaction (like Chrome DevTools MCP) ==========
|
|
1281
|
+
// Assigns numeric refs to interactive/visible elements so Claude can say "click ref 5"
|
|
1282
|
+
// instead of guessing CSS selectors. Much faster, no hallucination risk.
|
|
1283
|
+
|
|
1284
|
+
let _snapshotGen = 0;
|
|
1285
|
+
|
|
1286
|
+
export async function takeSnapshot({ selector } = {}) {
|
|
1287
|
+
const gen = _snapshotGen++;
|
|
1288
|
+
const root = selector ? `document.querySelector('${selector.replace(/'/g, "\\'")}')` : "document.body";
|
|
1289
|
+
|
|
1290
|
+
const result = await runJS(
|
|
1291
|
+
`(function(){
|
|
1292
|
+
var gen = ${gen};
|
|
1293
|
+
var id = 0;
|
|
1294
|
+
var lines = [];
|
|
1295
|
+
// Clear old refs
|
|
1296
|
+
document.querySelectorAll('[data-mcp-ref]').forEach(function(el){ el.removeAttribute('data-mcp-ref'); });
|
|
1297
|
+
|
|
1298
|
+
function getRole(el) {
|
|
1299
|
+
var role = el.getAttribute('role');
|
|
1300
|
+
if (role) return role;
|
|
1301
|
+
var tag = el.tagName.toLowerCase();
|
|
1302
|
+
var map = {
|
|
1303
|
+
a:'link', button:'button', input:'textbox', textarea:'textbox',
|
|
1304
|
+
select:'combobox', img:'img', h1:'heading', h2:'heading', h3:'heading',
|
|
1305
|
+
h4:'heading', h5:'heading', h6:'heading', nav:'navigation', main:'main',
|
|
1306
|
+
header:'banner', footer:'contentinfo', form:'form', table:'table',
|
|
1307
|
+
tr:'row', th:'columnheader', td:'cell', ul:'list', ol:'list', li:'listitem',
|
|
1308
|
+
dialog:'dialog', details:'group', summary:'button', label:'label',
|
|
1309
|
+
iframe:'document', video:'video', audio:'audio', canvas:'canvas',
|
|
1310
|
+
progress:'progressbar', meter:'meter'
|
|
1311
|
+
};
|
|
1312
|
+
if (tag === 'input') {
|
|
1313
|
+
var type = (el.type || 'text').toLowerCase();
|
|
1314
|
+
if (type === 'checkbox') return 'checkbox';
|
|
1315
|
+
if (type === 'radio') return 'radio';
|
|
1316
|
+
if (type === 'submit' || type === 'button') return 'button';
|
|
1317
|
+
if (type === 'file') return 'file';
|
|
1318
|
+
if (type === 'range') return 'slider';
|
|
1319
|
+
return 'textbox';
|
|
1320
|
+
}
|
|
1321
|
+
return map[tag] || null;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function getName(el) {
|
|
1325
|
+
var ariaLabel = el.getAttribute('aria-label');
|
|
1326
|
+
if (ariaLabel) return ariaLabel;
|
|
1327
|
+
var ariaLabelledBy = el.getAttribute('aria-labelledby');
|
|
1328
|
+
if (ariaLabelledBy) {
|
|
1329
|
+
var ref = document.getElementById(ariaLabelledBy);
|
|
1330
|
+
if (ref) return ref.textContent.trim().substring(0,80);
|
|
1331
|
+
}
|
|
1332
|
+
if (el.tagName === 'IMG') return el.alt || '';
|
|
1333
|
+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') {
|
|
1334
|
+
var label = el.closest('label') || (el.id && document.querySelector('label[for=\"'+el.id+'\"]'));
|
|
1335
|
+
if (label) return label.textContent.trim().substring(0,80);
|
|
1336
|
+
if (el.placeholder) return el.placeholder;
|
|
1337
|
+
if (el.name) return el.name;
|
|
1338
|
+
}
|
|
1339
|
+
if (el.title) return el.title;
|
|
1340
|
+
// For links/buttons, use text content
|
|
1341
|
+
if (['A','BUTTON','LABEL','SUMMARY'].includes(el.tagName)) {
|
|
1342
|
+
return el.textContent.trim().substring(0,80);
|
|
1343
|
+
}
|
|
1344
|
+
return '';
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function isInteractive(el) {
|
|
1348
|
+
var tag = el.tagName;
|
|
1349
|
+
if (['A','BUTTON','INPUT','TEXTAREA','SELECT','SUMMARY','DETAILS'].includes(tag)) return true;
|
|
1350
|
+
if (el.getAttribute('role')) return true;
|
|
1351
|
+
if (el.getAttribute('tabindex') !== null) return true;
|
|
1352
|
+
if (el.onclick || el.getAttribute('onclick')) return true;
|
|
1353
|
+
if (el.isContentEditable) return true;
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function isVisible(el) {
|
|
1358
|
+
if (!el.offsetParent && el.tagName !== 'BODY' && el.tagName !== 'HTML') return false;
|
|
1359
|
+
var r = el.getBoundingClientRect();
|
|
1360
|
+
return r.width > 0 && r.height > 0;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function walk(el, depth) {
|
|
1364
|
+
if (depth > 8) return;
|
|
1365
|
+
if (!isVisible(el)) return;
|
|
1366
|
+
|
|
1367
|
+
var role = getRole(el);
|
|
1368
|
+
var interactive = isInteractive(el);
|
|
1369
|
+
var isHeading = /^H[1-6]$/.test(el.tagName);
|
|
1370
|
+
var isText = !role && el.children.length === 0 && el.textContent.trim().length > 0 && el.textContent.trim().length < 200;
|
|
1371
|
+
|
|
1372
|
+
// Include: interactive elements, headings, images, text nodes with content
|
|
1373
|
+
if (role || interactive || isHeading || isText) {
|
|
1374
|
+
var ref = gen + '_' + (id++);
|
|
1375
|
+
el.setAttribute('data-mcp-ref', ref);
|
|
1376
|
+
var indent = ' '.repeat(depth);
|
|
1377
|
+
var line = indent + 'ref=' + ref + ' ';
|
|
1378
|
+
|
|
1379
|
+
if (role) line += role;
|
|
1380
|
+
else if (isText) line += 'text';
|
|
1381
|
+
else line += el.tagName.toLowerCase();
|
|
1382
|
+
|
|
1383
|
+
var name = getName(el);
|
|
1384
|
+
if (name) line += ' "' + name.replace(/"/g, "'") + '"';
|
|
1385
|
+
|
|
1386
|
+
// Value for inputs
|
|
1387
|
+
if (el.value !== undefined && el.value !== '' && el.tagName !== 'BUTTON') {
|
|
1388
|
+
line += ' value="' + String(el.value).substring(0,50).replace(/"/g, "'") + '"';
|
|
1389
|
+
}
|
|
1390
|
+
// Checked state
|
|
1391
|
+
if (el.checked) line += ' checked';
|
|
1392
|
+
// Disabled
|
|
1393
|
+
if (el.disabled) line += ' disabled';
|
|
1394
|
+
// Required
|
|
1395
|
+
if (el.required) line += ' required';
|
|
1396
|
+
// Selected (option)
|
|
1397
|
+
if (el.selected) line += ' selected';
|
|
1398
|
+
// Expanded (details, aria-expanded)
|
|
1399
|
+
if (el.open !== undefined) line += el.open ? ' expanded' : ' collapsed';
|
|
1400
|
+
if (el.getAttribute('aria-expanded') === 'true') line += ' expanded';
|
|
1401
|
+
if (el.getAttribute('aria-expanded') === 'false') line += ' collapsed';
|
|
1402
|
+
// Heading level
|
|
1403
|
+
if (isHeading) line += ' level=' + el.tagName[1];
|
|
1404
|
+
// Focusable
|
|
1405
|
+
if (el.tabIndex >= 0) line += ' focusable';
|
|
1406
|
+
// Link href
|
|
1407
|
+
if (el.tagName === 'A' && el.href) line += ' href="' + el.href.substring(0,100) + '"';
|
|
1408
|
+
// Content editable
|
|
1409
|
+
if (el.isContentEditable && el.getAttribute('contenteditable') !== 'inherit') line += ' editable';
|
|
1410
|
+
|
|
1411
|
+
lines.push(line);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Recurse into children
|
|
1415
|
+
for (var i = 0; i < el.children.length; i++) {
|
|
1416
|
+
walk(el.children[i], depth + (role ? 1 : 0));
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
var root = ${root};
|
|
1421
|
+
if (!root) return 'Element not found';
|
|
1422
|
+
walk(root, 0);
|
|
1423
|
+
return lines.join('\\n');
|
|
1424
|
+
})()`
|
|
1425
|
+
);
|
|
1426
|
+
|
|
1427
|
+
return result;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Click/fill/type by ref — resolves data-mcp-ref attribute
|
|
1431
|
+
export function refSelector(ref) {
|
|
1432
|
+
return `[data-mcp-ref="${ref}"]`;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// ========== RUN SCRIPT (multi-step automation in one call) ==========
|
|
1436
|
+
|
|
1437
|
+
// Execute multiple safari.js operations in a single tool call
|
|
1438
|
+
// Avoids round-trip overhead of calling tools one by one
|
|
1439
|
+
// script is a JSON array of steps: [{action: "navigate", args: {url: "..."}}, {action: "click", args: {selector: "..."}}, ...]
|
|
1440
|
+
export async function runScript({ steps }) {
|
|
1441
|
+
const results = [];
|
|
1442
|
+
for (const step of steps) {
|
|
1443
|
+
const { action, args = {} } = step;
|
|
1444
|
+
try {
|
|
1445
|
+
// Map action names to safari.js functions
|
|
1446
|
+
const actions = {
|
|
1447
|
+
navigate, click, doubleClick, rightClick, fill, clearField, typeText,
|
|
1448
|
+
pressKey, scroll, scrollTo, scrollToElement, readPage, getPageSource,
|
|
1449
|
+
screenshot, screenshotElement, evaluate, waitFor, waitForTime, hover,
|
|
1450
|
+
selectOption, fillForm, fillAndSubmit, navigateAndRead, clickAndWait,
|
|
1451
|
+
goBack, goForward, reload, newTab, closeTab, switchTab, listTabs,
|
|
1452
|
+
getLocalStorage, setLocalStorage, deleteLocalStorage,
|
|
1453
|
+
getSessionStorage, setSessionStorage, deleteSessionStorage,
|
|
1454
|
+
getCookies, setCookie, deleteCookies, getElementInfo, querySelectorAll,
|
|
1455
|
+
extractTables, extractMeta, extractImages, extractLinks,
|
|
1456
|
+
analyzePage, detectForms, getAccessibilityTree, getPerformanceMetrics,
|
|
1457
|
+
};
|
|
1458
|
+
const fn = actions[action];
|
|
1459
|
+
if (!fn) {
|
|
1460
|
+
results.push({ action, error: `Unknown action: ${action}` });
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
const result = await fn(args);
|
|
1464
|
+
results.push({ action, result: typeof result === "string" ? result.substring(0, 2000) : result });
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
results.push({ action, error: err.message });
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return JSON.stringify(results);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// ========== ACCESSIBILITY SNAPSHOT ==========
|
|
1473
|
+
|
|
1474
|
+
export async function getAccessibilityTree({ selector, maxDepth = 5 }) {
|
|
1475
|
+
const sel = selector ? `'${selector.replace(/'/g, "\\'")}'` : "null";
|
|
1476
|
+
return runJS(
|
|
1477
|
+
`(function(){
|
|
1478
|
+
function buildTree(el, depth) {
|
|
1479
|
+
if (!el || depth > ${Number(maxDepth)}) return null;
|
|
1480
|
+
var role = el.getAttribute('role') || el.tagName.toLowerCase();
|
|
1481
|
+
var ariaLabel = el.getAttribute('aria-label') || '';
|
|
1482
|
+
var ariaDescribedBy = el.getAttribute('aria-describedby') || '';
|
|
1483
|
+
var ariaExpanded = el.getAttribute('aria-expanded');
|
|
1484
|
+
var ariaChecked = el.getAttribute('aria-checked');
|
|
1485
|
+
var ariaSelected = el.getAttribute('aria-selected');
|
|
1486
|
+
var ariaDisabled = el.getAttribute('aria-disabled');
|
|
1487
|
+
var ariaHidden = el.getAttribute('aria-hidden');
|
|
1488
|
+
var tabIndex = el.tabIndex;
|
|
1489
|
+
var text = '';
|
|
1490
|
+
if (el.childNodes.length === 1 && el.childNodes[0].nodeType === 3) {
|
|
1491
|
+
text = el.childNodes[0].textContent.trim().substring(0, 100);
|
|
1492
|
+
}
|
|
1493
|
+
var node = { role: role };
|
|
1494
|
+
if (ariaLabel) node.name = ariaLabel;
|
|
1495
|
+
if (text) node.text = text;
|
|
1496
|
+
if (el.id) node.id = el.id;
|
|
1497
|
+
if (ariaExpanded !== null) node.expanded = ariaExpanded;
|
|
1498
|
+
if (ariaChecked !== null) node.checked = ariaChecked;
|
|
1499
|
+
if (ariaSelected !== null) node.selected = ariaSelected;
|
|
1500
|
+
if (ariaDisabled !== null) node.disabled = ariaDisabled;
|
|
1501
|
+
if (ariaHidden === 'true') node.hidden = true;
|
|
1502
|
+
if (tabIndex >= 0) node.focusable = true;
|
|
1503
|
+
if (el.tagName === 'A' && el.href) node.href = el.href;
|
|
1504
|
+
if (el.tagName === 'IMG') { node.alt = el.alt || '(missing)'; node.src = el.src; }
|
|
1505
|
+
if (['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
|
|
1506
|
+
node.type = el.type || el.tagName.toLowerCase();
|
|
1507
|
+
node.value = (el.value || '').substring(0, 100);
|
|
1508
|
+
if (el.required) node.required = true;
|
|
1509
|
+
if (el.placeholder) node.placeholder = el.placeholder;
|
|
1510
|
+
}
|
|
1511
|
+
var children = [];
|
|
1512
|
+
for (var i = 0; i < el.children.length; i++) {
|
|
1513
|
+
if (el.children[i].getAttribute('aria-hidden') === 'true') continue;
|
|
1514
|
+
var child = buildTree(el.children[i], depth + 1);
|
|
1515
|
+
if (child) children.push(child);
|
|
1516
|
+
}
|
|
1517
|
+
if (children.length > 0) node.children = children;
|
|
1518
|
+
return node;
|
|
1519
|
+
}
|
|
1520
|
+
var root = ${sel} ? document.querySelector(${sel}) : document.body;
|
|
1521
|
+
if (!root) return JSON.stringify({ error: 'Element not found' });
|
|
1522
|
+
return JSON.stringify(buildTree(root, 0));
|
|
1523
|
+
})()`,
|
|
1524
|
+
{ timeout: 30000 }
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// ========== COOKIE CRUD ==========
|
|
1529
|
+
|
|
1530
|
+
export async function setCookie({ name, value, domain, path: cookiePath, expires, secure, sameSite, httpOnly }) {
|
|
1531
|
+
const safeName = name.replace(/'/g, "\\'");
|
|
1532
|
+
const safeValue = value.replace(/'/g, "\\'");
|
|
1533
|
+
let cookie = `${safeName}=${safeValue}`;
|
|
1534
|
+
if (cookiePath) cookie += `; path=${cookiePath}`;
|
|
1535
|
+
if (domain) cookie += `; domain=${domain}`;
|
|
1536
|
+
if (expires) cookie += `; expires=${expires}`;
|
|
1537
|
+
if (secure) cookie += '; secure';
|
|
1538
|
+
if (sameSite) cookie += `; samesite=${sameSite}`;
|
|
1539
|
+
return runJS(`document.cookie='${cookie.replace(/'/g, "\\'")}'; 'Cookie set: ${safeName}'`);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
export async function deleteCookies({ name, all }) {
|
|
1543
|
+
if (all) {
|
|
1544
|
+
return runJS(
|
|
1545
|
+
`(function(){var cookies=document.cookie.split(';');var count=0;cookies.forEach(function(c){var name=c.split('=')[0].trim();document.cookie=name+'=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/';count++;});return 'Deleted '+count+' cookies';})()`
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
if (name) {
|
|
1549
|
+
const safeName = name.replace(/'/g, "\\'");
|
|
1550
|
+
return runJS(
|
|
1551
|
+
`document.cookie='${safeName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; 'Deleted cookie: ${safeName}'`
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
throw new Error("deleteCookies requires name or all:true");
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// ========== SESSION STORAGE ==========
|
|
1558
|
+
|
|
1559
|
+
export async function getSessionStorage({ key }) {
|
|
1560
|
+
if (key) {
|
|
1561
|
+
const safeKey = key.replace(/'/g, "\\'");
|
|
1562
|
+
return runJS(`sessionStorage.getItem('${safeKey}')`);
|
|
1563
|
+
}
|
|
1564
|
+
return runJS(
|
|
1565
|
+
"JSON.stringify(Object.fromEntries(Object.keys(sessionStorage).map(function(k){return[k,sessionStorage.getItem(k).substring(0,200)]})))"
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
export async function setSessionStorage({ key, value }) {
|
|
1570
|
+
const safeKey = key.replace(/'/g, "\\'");
|
|
1571
|
+
const safeValue = value.replace(/'/g, "\\'");
|
|
1572
|
+
return runJS(`sessionStorage.setItem('${safeKey}','${safeValue}'); 'Set sessionStorage: ${safeKey}'`);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
export async function setLocalStorage({ key, value }) {
|
|
1576
|
+
const safeKey = key.replace(/'/g, "\\'");
|
|
1577
|
+
const safeValue = value.replace(/'/g, "\\'");
|
|
1578
|
+
return runJS(`localStorage.setItem('${safeKey}','${safeValue}'); 'Set localStorage: ${safeKey}'`);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
export async function deleteLocalStorage({ key }) {
|
|
1582
|
+
if (key) {
|
|
1583
|
+
const safeKey = key.replace(/'/g, "\\'");
|
|
1584
|
+
return runJS(`localStorage.removeItem('${safeKey}'); 'Deleted localStorage: ${safeKey}'`);
|
|
1585
|
+
}
|
|
1586
|
+
return runJS("var n=localStorage.length; localStorage.clear(); 'Cleared localStorage: '+n+' items'");
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
export async function deleteSessionStorage({ key }) {
|
|
1590
|
+
if (key) {
|
|
1591
|
+
const safeKey = key.replace(/'/g, "\\'");
|
|
1592
|
+
return runJS(`sessionStorage.removeItem('${safeKey}'); 'Deleted sessionStorage: ${safeKey}'`);
|
|
1593
|
+
}
|
|
1594
|
+
return runJS("var n=sessionStorage.length; sessionStorage.clear(); 'Cleared sessionStorage: '+n+' items'");
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Export all storage state (cookies + localStorage + sessionStorage) as JSON
|
|
1598
|
+
export async function exportStorageState() {
|
|
1599
|
+
return runJS(
|
|
1600
|
+
`JSON.stringify({
|
|
1601
|
+
url: location.href,
|
|
1602
|
+
cookies: document.cookie,
|
|
1603
|
+
localStorage: Object.fromEntries(Object.keys(localStorage).map(function(k){return[k,localStorage.getItem(k)]})),
|
|
1604
|
+
sessionStorage: Object.fromEntries(Object.keys(sessionStorage).map(function(k){return[k,sessionStorage.getItem(k)]}))
|
|
1605
|
+
})`
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Import storage state from JSON
|
|
1610
|
+
export async function importStorageState({ state }) {
|
|
1611
|
+
const parsed = typeof state === "string" ? JSON.parse(state) : state;
|
|
1612
|
+
const cmds = [];
|
|
1613
|
+
if (parsed.cookies) cmds.push(`document.cookie='${parsed.cookies.replace(/'/g, "\\'")}'`);
|
|
1614
|
+
if (parsed.localStorage) {
|
|
1615
|
+
for (const [k, v] of Object.entries(parsed.localStorage)) {
|
|
1616
|
+
cmds.push(`localStorage.setItem('${k.replace(/'/g, "\\'")}','${String(v).replace(/'/g, "\\'")}')`);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (parsed.sessionStorage) {
|
|
1620
|
+
for (const [k, v] of Object.entries(parsed.sessionStorage)) {
|
|
1621
|
+
cmds.push(`sessionStorage.setItem('${k.replace(/'/g, "\\'")}','${String(v).replace(/'/g, "\\'")}')`);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
return runJS(cmds.join(";") + "; 'Imported ' + " + cmds.length + " + ' items'");
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// ========== CLIPBOARD ==========
|
|
1628
|
+
|
|
1629
|
+
export async function clipboardRead() {
|
|
1630
|
+
try {
|
|
1631
|
+
const text = await execFileAsync("pbpaste", []);
|
|
1632
|
+
return text.stdout;
|
|
1633
|
+
} catch {
|
|
1634
|
+
return "(clipboard empty or contains non-text data)";
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
export async function clipboardWrite({ text, restore = true }) {
|
|
1639
|
+
// Save current clipboard
|
|
1640
|
+
let oldClipboard = null;
|
|
1641
|
+
if (restore) {
|
|
1642
|
+
try {
|
|
1643
|
+
const { stdout } = await execFileAsync("pbpaste", []);
|
|
1644
|
+
oldClipboard = stdout;
|
|
1645
|
+
} catch {}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const safeText = text.replace(/"/g, '\\"');
|
|
1649
|
+
await execFileAsync("bash", ["-c", `echo -n "${safeText}" | pbcopy`]);
|
|
1650
|
+
|
|
1651
|
+
// Schedule clipboard restore after 5 seconds (gives time to paste)
|
|
1652
|
+
if (restore && oldClipboard !== null) {
|
|
1653
|
+
setTimeout(async () => {
|
|
1654
|
+
try {
|
|
1655
|
+
const safeOld = oldClipboard.replace(/"/g, '\\"');
|
|
1656
|
+
await execFileAsync("bash", ["-c", `echo -n "${safeOld}" | pbcopy`]);
|
|
1657
|
+
} catch {}
|
|
1658
|
+
}, 5000);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
return `Copied ${text.length} chars to clipboard${restore ? " (will restore in 5s)" : ""}`;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// ========== NETWORK MOCKING ==========
|
|
1665
|
+
|
|
1666
|
+
// Intercept fetch/XHR requests matching a URL pattern and return mock responses
|
|
1667
|
+
export async function mockNetworkRoute({ urlPattern, response }) {
|
|
1668
|
+
const safePattern = urlPattern.replace(/'/g, "\\'").replace(/\\/g, "\\\\");
|
|
1669
|
+
const safeBody = (response.body || "").replace(/'/g, "\\'").replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
|
|
1670
|
+
const status = response.status || 200;
|
|
1671
|
+
const contentType = response.contentType || "application/json";
|
|
1672
|
+
|
|
1673
|
+
return runJS(
|
|
1674
|
+
`(function(){
|
|
1675
|
+
if (!window.__mcp_mocks) window.__mcp_mocks = [];
|
|
1676
|
+
window.__mcp_mocks.push({pattern: '${safePattern}', status: ${status}, body: '${safeBody}', contentType: '${contentType}'});
|
|
1677
|
+
|
|
1678
|
+
// Patch fetch (once)
|
|
1679
|
+
if (!window.__mcp_fetch_patched) {
|
|
1680
|
+
window.__mcp_fetch_patched = true;
|
|
1681
|
+
var origFetch = window.fetch;
|
|
1682
|
+
window.fetch = function(url, opts) {
|
|
1683
|
+
var reqUrl = typeof url === 'string' ? url : url.url;
|
|
1684
|
+
var mock = window.__mcp_mocks.find(function(m) {
|
|
1685
|
+
return reqUrl.includes(m.pattern) || new RegExp(m.pattern).test(reqUrl);
|
|
1686
|
+
});
|
|
1687
|
+
if (mock) {
|
|
1688
|
+
return Promise.resolve(new Response(mock.body, {
|
|
1689
|
+
status: mock.status,
|
|
1690
|
+
headers: {'Content-Type': mock.contentType}
|
|
1691
|
+
}));
|
|
1692
|
+
}
|
|
1693
|
+
return origFetch.apply(this, arguments);
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
// Patch XHR
|
|
1697
|
+
var origOpen = XMLHttpRequest.prototype.open;
|
|
1698
|
+
var origSend = XMLHttpRequest.prototype.send;
|
|
1699
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
1700
|
+
this.__mcp_url = url;
|
|
1701
|
+
this.__mcp_method = method;
|
|
1702
|
+
return origOpen.apply(this, arguments);
|
|
1703
|
+
};
|
|
1704
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
1705
|
+
var mock = (window.__mcp_mocks || []).find(function(m) {
|
|
1706
|
+
return this.__mcp_url && (this.__mcp_url.includes(m.pattern) || new RegExp(m.pattern).test(this.__mcp_url));
|
|
1707
|
+
}.bind(this));
|
|
1708
|
+
if (mock) {
|
|
1709
|
+
Object.defineProperty(this, 'status', {get: function(){return mock.status;}});
|
|
1710
|
+
Object.defineProperty(this, 'responseText', {get: function(){return mock.body;}});
|
|
1711
|
+
Object.defineProperty(this, 'response', {get: function(){return mock.body;}});
|
|
1712
|
+
Object.defineProperty(this, 'readyState', {get: function(){return 4;}});
|
|
1713
|
+
this.dispatchEvent(new Event('readystatechange'));
|
|
1714
|
+
this.dispatchEvent(new Event('load'));
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
return origSend.apply(this, arguments);
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
return 'Mock added: ' + '${safePattern}' + ' → ' + ${status} + ' (' + window.__mcp_mocks.length + ' total mocks)';
|
|
1721
|
+
})()`
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Remove all network mocks
|
|
1726
|
+
export async function clearNetworkMocks() {
|
|
1727
|
+
return runJS(
|
|
1728
|
+
"window.__mcp_mocks=[]; 'All network mocks cleared'"
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// ========== WAIT FOR TIME ==========
|
|
1733
|
+
|
|
1734
|
+
export async function waitForTime({ ms }) {
|
|
1735
|
+
await new Promise((r) => setTimeout(r, Number(ms)));
|
|
1736
|
+
return `Waited ${ms}ms`;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// ========== NETWORK CAPTURE (Detailed) ==========
|
|
1740
|
+
|
|
1741
|
+
export async function startNetworkCapture() {
|
|
1742
|
+
await runJS(
|
|
1743
|
+
`if(!window.__mcp_network){window.__mcp_network=[];
|
|
1744
|
+
var origFetch=window.fetch;
|
|
1745
|
+
window.fetch=function(){var url=arguments[0];var opts=arguments[1]||{};var start=Date.now();
|
|
1746
|
+
return origFetch.apply(this,arguments).then(function(resp){
|
|
1747
|
+
var entry={url:typeof url==='string'?url:url.url,method:opts.method||'GET',status:resp.status,statusText:resp.statusText,
|
|
1748
|
+
type:'fetch',duration:Date.now()-start,headers:Object.fromEntries([...resp.headers.entries()].slice(0,20)),time:new Date().toISOString()};
|
|
1749
|
+
window.__mcp_network.push(entry);return resp;
|
|
1750
|
+
}).catch(function(err){
|
|
1751
|
+
window.__mcp_network.push({url:typeof url==='string'?url:url.url,method:opts.method||'GET',error:err.message,type:'fetch',time:new Date().toISOString()});
|
|
1752
|
+
throw err;
|
|
1753
|
+
});
|
|
1754
|
+
};
|
|
1755
|
+
var origXHR=XMLHttpRequest.prototype.open;
|
|
1756
|
+
XMLHttpRequest.prototype.open=function(method,url){
|
|
1757
|
+
this.__mcp_method=method;this.__mcp_url=url;this.__mcp_start=Date.now();
|
|
1758
|
+
this.addEventListener('load',function(){
|
|
1759
|
+
window.__mcp_network.push({url:this.__mcp_url,method:this.__mcp_method,status:this.status,statusText:this.statusText,
|
|
1760
|
+
type:'xhr',duration:Date.now()-this.__mcp_start,responseSize:this.responseText.length,time:new Date().toISOString()});
|
|
1761
|
+
});
|
|
1762
|
+
this.addEventListener('error',function(){
|
|
1763
|
+
window.__mcp_network.push({url:this.__mcp_url,method:this.__mcp_method,error:'Network error',type:'xhr',time:new Date().toISOString()});
|
|
1764
|
+
});
|
|
1765
|
+
return origXHR.apply(this,arguments);
|
|
1766
|
+
};}`
|
|
1767
|
+
);
|
|
1768
|
+
return "Network capture started (fetch + XHR interception)";
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
export async function clearNetworkCapture() {
|
|
1772
|
+
return runJS("window.__mcp_network=[]; 'Network capture cleared'");
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
export async function getNetworkDetails({ limit = 50, filter } = {}) {
|
|
1776
|
+
const filterStr = filter ? `.filter(function(r){return r.url.includes('${filter.replace(/'/g, "\\'")}')})` : "";
|
|
1777
|
+
return runJS(
|
|
1778
|
+
`JSON.stringify((window.__mcp_network||[])${filterStr}.slice(-${Number(limit)}))`
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// ========== PERFORMANCE METRICS ==========
|
|
1783
|
+
|
|
1784
|
+
export async function getPerformanceMetrics() {
|
|
1785
|
+
return runJS(
|
|
1786
|
+
`(function(){
|
|
1787
|
+
var nav = performance.getEntriesByType('navigation')[0] || {};
|
|
1788
|
+
var paint = performance.getEntriesByType('paint');
|
|
1789
|
+
var fcp = paint.find(function(p){return p.name==='first-contentful-paint'});
|
|
1790
|
+
var lcp = null;
|
|
1791
|
+
try {
|
|
1792
|
+
var entries = performance.getEntriesByType('largest-contentful-paint');
|
|
1793
|
+
if (entries.length) lcp = entries[entries.length - 1];
|
|
1794
|
+
} catch(e) {}
|
|
1795
|
+
var cls = 0;
|
|
1796
|
+
try {
|
|
1797
|
+
var entries = performance.getEntriesByType('layout-shift');
|
|
1798
|
+
entries.forEach(function(e){ if (!e.hadRecentInput) cls += e.value; });
|
|
1799
|
+
} catch(e) {}
|
|
1800
|
+
var resources = performance.getEntriesByType('resource');
|
|
1801
|
+
var totalTransfer = resources.reduce(function(sum, r){ return sum + (r.transferSize || 0); }, 0);
|
|
1802
|
+
return JSON.stringify({
|
|
1803
|
+
navigation: {
|
|
1804
|
+
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
|
|
1805
|
+
tcp: Math.round(nav.connectEnd - nav.connectStart),
|
|
1806
|
+
ttfb: Math.round(nav.responseStart - nav.requestStart),
|
|
1807
|
+
download: Math.round(nav.responseEnd - nav.responseStart),
|
|
1808
|
+
domInteractive: Math.round(nav.domInteractive),
|
|
1809
|
+
domComplete: Math.round(nav.domComplete),
|
|
1810
|
+
loadEvent: Math.round(nav.loadEventEnd),
|
|
1811
|
+
},
|
|
1812
|
+
webVitals: {
|
|
1813
|
+
fcp: fcp ? Math.round(fcp.startTime) : null,
|
|
1814
|
+
lcp: lcp ? Math.round(lcp.startTime) : null,
|
|
1815
|
+
cls: Math.round(cls * 1000) / 1000,
|
|
1816
|
+
},
|
|
1817
|
+
resources: {
|
|
1818
|
+
total: resources.length,
|
|
1819
|
+
totalTransferKB: Math.round(totalTransfer / 1024),
|
|
1820
|
+
byType: resources.reduce(function(acc, r) {
|
|
1821
|
+
var type = r.initiatorType || 'other';
|
|
1822
|
+
if (!acc[type]) acc[type] = { count: 0, sizeKB: 0 };
|
|
1823
|
+
acc[type].count++;
|
|
1824
|
+
acc[type].sizeKB += Math.round((r.transferSize || 0) / 1024);
|
|
1825
|
+
return acc;
|
|
1826
|
+
}, {}),
|
|
1827
|
+
},
|
|
1828
|
+
memory: window.performance.memory ? {
|
|
1829
|
+
usedMB: Math.round(performance.memory.usedJSHeapSize / 1048576),
|
|
1830
|
+
totalMB: Math.round(performance.memory.totalJSHeapSize / 1048576),
|
|
1831
|
+
limitMB: Math.round(performance.memory.jsHeapSizeLimit / 1048576),
|
|
1832
|
+
} : null,
|
|
1833
|
+
});
|
|
1834
|
+
})()`
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// ========== NETWORK THROTTLING ==========
|
|
1839
|
+
|
|
1840
|
+
export async function throttleNetwork({ profile, latency, downloadKbps, uploadKbps }) {
|
|
1841
|
+
const profiles = {
|
|
1842
|
+
"slow-3g": { latency: 2000, download: 50, upload: 50 },
|
|
1843
|
+
"fast-3g": { latency: 560, download: 150, upload: 75 },
|
|
1844
|
+
"4g": { latency: 170, download: 400, upload: 150 },
|
|
1845
|
+
offline: { latency: 0, download: 0, upload: 0 },
|
|
1846
|
+
};
|
|
1847
|
+
const p = profile ? profiles[profile.toLowerCase()] : null;
|
|
1848
|
+
const lat = p ? p.latency : (latency || 0);
|
|
1849
|
+
const dl = p ? p.download : (downloadKbps || 0);
|
|
1850
|
+
|
|
1851
|
+
if (profile === "offline") {
|
|
1852
|
+
await runJS(
|
|
1853
|
+
`window.__mcp_throttle={active:true,profile:'offline'};
|
|
1854
|
+
var origFetch=window.__mcp_origFetch||window.fetch;
|
|
1855
|
+
window.__mcp_origFetch=origFetch;
|
|
1856
|
+
window.fetch=function(){return Promise.reject(new TypeError('Network request failed (simulated offline)'));};`
|
|
1857
|
+
);
|
|
1858
|
+
return "Network throttled: offline";
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
if (lat > 0) {
|
|
1862
|
+
await runJS(
|
|
1863
|
+
`window.__mcp_throttle={active:true,profile:'${profile || "custom"}',latency:${lat},downloadKbps:${dl}};
|
|
1864
|
+
var origFetch=window.__mcp_origFetch||window.fetch;
|
|
1865
|
+
window.__mcp_origFetch=origFetch;
|
|
1866
|
+
window.fetch=function(){var args=arguments;return new Promise(function(resolve){
|
|
1867
|
+
setTimeout(function(){resolve(origFetch.apply(window,args));},${lat});
|
|
1868
|
+
});};`
|
|
1869
|
+
);
|
|
1870
|
+
return JSON.stringify({ profile: profile || "custom", latency: lat, downloadKbps: dl });
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// Reset
|
|
1874
|
+
await runJS(
|
|
1875
|
+
`if(window.__mcp_origFetch){window.fetch=window.__mcp_origFetch;delete window.__mcp_origFetch;}
|
|
1876
|
+
delete window.__mcp_throttle; 'Throttle removed'`
|
|
1877
|
+
);
|
|
1878
|
+
return "Network throttle removed";
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// ========== CONSOLE FILTER ==========
|
|
1882
|
+
|
|
1883
|
+
export async function getConsoleByLevel({ level }) {
|
|
1884
|
+
const safeLevel = level.replace(/'/g, "\\'");
|
|
1885
|
+
return runJS(
|
|
1886
|
+
`JSON.stringify((window.__mcp_console||[]).filter(function(m){return m.level==='${safeLevel}'}))`
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// ========== DATA EXTRACTION ==========
|
|
1891
|
+
|
|
1892
|
+
export async function extractTables({ selector, limit = 10 }) {
|
|
1893
|
+
const sel = selector ? `'${selector.replace(/'/g, "\\'")}'` : "'table'";
|
|
1894
|
+
return runJS(
|
|
1895
|
+
`(function(){
|
|
1896
|
+
var tables = [...document.querySelectorAll(${sel})].slice(0, ${Number(limit)});
|
|
1897
|
+
return JSON.stringify(tables.map(function(table, ti) {
|
|
1898
|
+
var headers = [...table.querySelectorAll('thead th, thead td, tr:first-child th')].map(function(th){ return th.textContent.trim(); });
|
|
1899
|
+
var rows = [...table.querySelectorAll('tbody tr, tr')].slice(headers.length ? 0 : 1).map(function(tr) {
|
|
1900
|
+
return [...tr.querySelectorAll('td, th')].map(function(td){ return td.textContent.trim().substring(0, 200); });
|
|
1901
|
+
});
|
|
1902
|
+
return { index: ti, headers: headers, rows: rows.slice(0, 100), rowCount: rows.length };
|
|
1903
|
+
}));
|
|
1904
|
+
})()`
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
export async function extractMeta() {
|
|
1909
|
+
return runJS(
|
|
1910
|
+
`(function(){
|
|
1911
|
+
var meta = {};
|
|
1912
|
+
meta.title = document.title;
|
|
1913
|
+
meta.description = (document.querySelector('meta[name="description"]') || {}).content || '';
|
|
1914
|
+
meta.canonical = (document.querySelector('link[rel="canonical"]') || {}).href || '';
|
|
1915
|
+
meta.robots = (document.querySelector('meta[name="robots"]') || {}).content || '';
|
|
1916
|
+
meta.viewport = (document.querySelector('meta[name="viewport"]') || {}).content || '';
|
|
1917
|
+
meta.charset = (document.querySelector('meta[charset]') || {}).getAttribute('charset') || document.characterSet;
|
|
1918
|
+
meta.language = document.documentElement.lang || '';
|
|
1919
|
+
meta.og = {};
|
|
1920
|
+
document.querySelectorAll('meta[property^="og:"]').forEach(function(m) {
|
|
1921
|
+
meta.og[m.getAttribute('property').replace('og:','')] = m.content;
|
|
1922
|
+
});
|
|
1923
|
+
meta.twitter = {};
|
|
1924
|
+
document.querySelectorAll('meta[name^="twitter:"]').forEach(function(m) {
|
|
1925
|
+
meta.twitter[m.getAttribute('name').replace('twitter:','')] = m.content;
|
|
1926
|
+
});
|
|
1927
|
+
meta.jsonLd = [...document.querySelectorAll('script[type="application/ld+json"]')].map(function(s) {
|
|
1928
|
+
try { return JSON.parse(s.textContent); } catch(e) { return null; }
|
|
1929
|
+
}).filter(Boolean);
|
|
1930
|
+
meta.alternateLanguages = [...document.querySelectorAll('link[rel="alternate"][hreflang]')].map(function(l) {
|
|
1931
|
+
return { lang: l.hreflang, href: l.href };
|
|
1932
|
+
});
|
|
1933
|
+
meta.feeds = [...document.querySelectorAll('link[type="application/rss+xml"], link[type="application/atom+xml"]')].map(function(l) {
|
|
1934
|
+
return { title: l.title, href: l.href, type: l.type };
|
|
1935
|
+
});
|
|
1936
|
+
return JSON.stringify(meta);
|
|
1937
|
+
})()`
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
export async function extractImages({ limit = 50 }) {
|
|
1942
|
+
return runJS(
|
|
1943
|
+
`JSON.stringify([...document.querySelectorAll('img')].slice(0,${Number(limit)}).map(function(img){
|
|
1944
|
+
var r = img.getBoundingClientRect();
|
|
1945
|
+
return {
|
|
1946
|
+
src: img.src, alt: img.alt || '(missing)', width: img.naturalWidth, height: img.naturalHeight,
|
|
1947
|
+
displayWidth: Math.round(r.width), displayHeight: Math.round(r.height),
|
|
1948
|
+
loading: img.loading || 'eager', srcset: img.srcset || '',
|
|
1949
|
+
inViewport: r.top < window.innerHeight && r.bottom > 0,
|
|
1950
|
+
decoded: img.complete,
|
|
1951
|
+
};
|
|
1952
|
+
}))`
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
export async function extractLinks({ limit = 100, filter }) {
|
|
1957
|
+
const filterStr = filter
|
|
1958
|
+
? `.filter(function(a){return a.href.includes('${filter.replace(/'/g, "\\'")}')||a.textContent.includes('${filter.replace(/'/g, "\\'")}')})`
|
|
1959
|
+
: "";
|
|
1960
|
+
return runJS(
|
|
1961
|
+
`JSON.stringify([...document.querySelectorAll('a[href]')]${filterStr}.slice(0,${Number(limit)}).map(function(a){
|
|
1962
|
+
return {
|
|
1963
|
+
href: a.href, text: a.textContent.trim().substring(0,100),
|
|
1964
|
+
rel: a.rel || '', target: a.target || '',
|
|
1965
|
+
isExternal: a.hostname !== location.hostname,
|
|
1966
|
+
isNofollow: a.rel.includes('nofollow'),
|
|
1967
|
+
};
|
|
1968
|
+
}))`
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// ========== GEOLOCATION OVERRIDE ==========
|
|
1973
|
+
|
|
1974
|
+
export async function overrideGeolocation({ latitude, longitude, accuracy = 100 }) {
|
|
1975
|
+
return runJS(
|
|
1976
|
+
`navigator.geolocation.getCurrentPosition = function(success) {
|
|
1977
|
+
success({ coords: { latitude: ${Number(latitude)}, longitude: ${Number(longitude)}, accuracy: ${Number(accuracy)}, altitude: null, altitudeAccuracy: null, heading: null, speed: null }, timestamp: Date.now() });
|
|
1978
|
+
};
|
|
1979
|
+
navigator.geolocation.watchPosition = function(success) {
|
|
1980
|
+
success({ coords: { latitude: ${Number(latitude)}, longitude: ${Number(longitude)}, accuracy: ${Number(accuracy)}, altitude: null, altitudeAccuracy: null, heading: null, speed: null }, timestamp: Date.now() });
|
|
1981
|
+
return 1;
|
|
1982
|
+
};
|
|
1983
|
+
'Geolocation set to: ${Number(latitude)}, ${Number(longitude)}'`
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// ========== COMPUTED STYLES ==========
|
|
1988
|
+
|
|
1989
|
+
export async function getComputedStyles({ selector, properties }) {
|
|
1990
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
1991
|
+
const propsFilter = properties
|
|
1992
|
+
? `.filter(function(p){return [${properties.map((p) => `'${p}'`).join(",")}].includes(p)})`
|
|
1993
|
+
: "";
|
|
1994
|
+
return runJS(
|
|
1995
|
+
`(function(){
|
|
1996
|
+
var el = document.querySelector('${sel}');
|
|
1997
|
+
if (!el) return JSON.stringify({ error: 'Element not found' });
|
|
1998
|
+
var styles = window.getComputedStyle(el);
|
|
1999
|
+
var result = {};
|
|
2000
|
+
var props = [...styles]${propsFilter};
|
|
2001
|
+
props.forEach(function(p) { result[p] = styles.getPropertyValue(p); });
|
|
2002
|
+
return JSON.stringify(result);
|
|
2003
|
+
})()`
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// ========== INDEXEDDB ==========
|
|
2008
|
+
|
|
2009
|
+
export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
|
|
2010
|
+
const safeDb = dbName.replace(/'/g, "\\'");
|
|
2011
|
+
const safeStore = storeName.replace(/'/g, "\\'");
|
|
2012
|
+
return runJS(
|
|
2013
|
+
`(async function(){
|
|
2014
|
+
return new Promise(function(resolve, reject) {
|
|
2015
|
+
var request = indexedDB.open('${safeDb}');
|
|
2016
|
+
request.onerror = function() { resolve(JSON.stringify({ error: 'Cannot open database: ${safeDb}' })); };
|
|
2017
|
+
request.onsuccess = function(e) {
|
|
2018
|
+
var db = e.target.result;
|
|
2019
|
+
if (!db.objectStoreNames.contains('${safeStore}')) {
|
|
2020
|
+
resolve(JSON.stringify({ error: 'Store not found: ${safeStore}', stores: [...db.objectStoreNames] }));
|
|
2021
|
+
db.close(); return;
|
|
2022
|
+
}
|
|
2023
|
+
var tx = db.transaction('${safeStore}', 'readonly');
|
|
2024
|
+
var store = tx.objectStore('${safeStore}');
|
|
2025
|
+
var results = [];
|
|
2026
|
+
var cursor = store.openCursor();
|
|
2027
|
+
cursor.onsuccess = function(e) {
|
|
2028
|
+
var c = e.target.result;
|
|
2029
|
+
if (c && results.length < ${Number(limit)}) { results.push({ key: c.key, value: c.value }); c.continue(); }
|
|
2030
|
+
else { resolve(JSON.stringify({ database: '${safeDb}', store: '${safeStore}', count: results.length, records: results })); db.close(); }
|
|
2031
|
+
};
|
|
2032
|
+
cursor.onerror = function() { resolve(JSON.stringify({ error: 'Cursor error' })); db.close(); };
|
|
2033
|
+
};
|
|
2034
|
+
});
|
|
2035
|
+
})()`,
|
|
2036
|
+
{ timeout: 15000 }
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
export async function listIndexedDBs() {
|
|
2041
|
+
return runJS(
|
|
2042
|
+
`(async function(){
|
|
2043
|
+
try {
|
|
2044
|
+
var dbs = await indexedDB.databases();
|
|
2045
|
+
return JSON.stringify(dbs.map(function(db){ return { name: db.name, version: db.version }; }));
|
|
2046
|
+
} catch(e) {
|
|
2047
|
+
return JSON.stringify({ error: 'indexedDB.databases() not supported, try getIndexedDB with a known db name' });
|
|
2048
|
+
}
|
|
2049
|
+
})()`
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// ========== CSS COVERAGE ==========
|
|
2054
|
+
|
|
2055
|
+
export async function getCSSCoverage() {
|
|
2056
|
+
return runJS(
|
|
2057
|
+
`(function(){
|
|
2058
|
+
var results = [];
|
|
2059
|
+
for (var i = 0; i < document.styleSheets.length; i++) {
|
|
2060
|
+
try {
|
|
2061
|
+
var sheet = document.styleSheets[i];
|
|
2062
|
+
var rules = sheet.cssRules || sheet.rules;
|
|
2063
|
+
var total = rules.length;
|
|
2064
|
+
var used = 0;
|
|
2065
|
+
var unused = [];
|
|
2066
|
+
for (var j = 0; j < rules.length; j++) {
|
|
2067
|
+
var rule = rules[j];
|
|
2068
|
+
if (rule.selectorText) {
|
|
2069
|
+
try {
|
|
2070
|
+
if (document.querySelector(rule.selectorText)) { used++; }
|
|
2071
|
+
else { unused.push(rule.selectorText); }
|
|
2072
|
+
} catch(e) { used++; }
|
|
2073
|
+
} else { used++; }
|
|
2074
|
+
}
|
|
2075
|
+
results.push({
|
|
2076
|
+
href: sheet.href || '(inline)',
|
|
2077
|
+
totalRules: total,
|
|
2078
|
+
usedRules: used,
|
|
2079
|
+
unusedRules: total - used,
|
|
2080
|
+
coveragePercent: total > 0 ? Math.round(used / total * 100) : 100,
|
|
2081
|
+
unusedSelectors: unused.slice(0, 20),
|
|
2082
|
+
});
|
|
2083
|
+
} catch(e) {
|
|
2084
|
+
results.push({ href: sheet.href || '(inline)', error: 'CORS blocked' });
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
return JSON.stringify(results);
|
|
2088
|
+
})()`
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// ========== FORM AUTO-DETECT ==========
|
|
2093
|
+
|
|
2094
|
+
export async function detectForms() {
|
|
2095
|
+
return runJS(
|
|
2096
|
+
`(function(){
|
|
2097
|
+
var forms = [...document.querySelectorAll('form')];
|
|
2098
|
+
if (forms.length === 0) {
|
|
2099
|
+
var inputs = document.querySelectorAll('input, textarea, select');
|
|
2100
|
+
if (inputs.length > 0) {
|
|
2101
|
+
return JSON.stringify([{
|
|
2102
|
+
index: 0, action: '(no form tag)', method: '', fields: [...inputs].slice(0, 30).map(function(el) {
|
|
2103
|
+
return { tag: el.tagName, type: el.type || '', name: el.name || '', id: el.id || '',
|
|
2104
|
+
placeholder: el.placeholder || '', required: el.required, value: (el.value || '').substring(0, 50),
|
|
2105
|
+
selector: el.id ? '#' + el.id : (el.name ? '[name="' + el.name + '"]' : el.tagName.toLowerCase() + '[type="' + el.type + '"]') };
|
|
2106
|
+
})
|
|
2107
|
+
}]);
|
|
2108
|
+
}
|
|
2109
|
+
return JSON.stringify([]);
|
|
2110
|
+
}
|
|
2111
|
+
return JSON.stringify(forms.map(function(form, i) {
|
|
2112
|
+
var fields = [...form.querySelectorAll('input, textarea, select')].map(function(el) {
|
|
2113
|
+
return { tag: el.tagName, type: el.type || '', name: el.name || '', id: el.id || '',
|
|
2114
|
+
placeholder: el.placeholder || '', required: el.required, value: (el.value || '').substring(0, 50),
|
|
2115
|
+
selector: el.id ? '#' + el.id : (el.name ? '[name="' + el.name + '"]' : el.tagName.toLowerCase()) };
|
|
2116
|
+
});
|
|
2117
|
+
return { index: i, action: form.action || '', method: form.method || 'GET', id: form.id || '',
|
|
2118
|
+
fieldCount: fields.length, fields: fields.slice(0, 30),
|
|
2119
|
+
hasSubmit: !!form.querySelector('[type="submit"], button:not([type])') };
|
|
2120
|
+
}));
|
|
2121
|
+
})()`
|
|
2122
|
+
);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// ========== SCROLL TO ELEMENT ==========
|
|
2126
|
+
|
|
2127
|
+
export async function scrollToElement({ selector, block = "center" }) {
|
|
2128
|
+
const sel = selector.replace(/'/g, "\\'");
|
|
2129
|
+
return runJS(
|
|
2130
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({behavior:'smooth',block:'${block}'});var r=el.getBoundingClientRect();return 'Scrolled to: '+el.tagName+' at y='+Math.round(r.y);})()`
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// ========== COMBO TOOLS (multi-step operations in a single call) ==========
|
|
2135
|
+
|
|
2136
|
+
// Navigate + wait + read — the most common 3-step workflow
|
|
2137
|
+
export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
2138
|
+
let targetUrl = url;
|
|
2139
|
+
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
|
|
2140
|
+
const safeUrl = targetUrl.replace(/"/g, '\\"');
|
|
2141
|
+
const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of front window` : "front document";
|
|
2142
|
+
await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
|
|
2143
|
+
// Single JS call: poll readyState + return page data — 1 call instead of 30+
|
|
2144
|
+
return runJS(
|
|
2145
|
+
`(async function(){
|
|
2146
|
+
for(var i=0;i<60;i++){
|
|
2147
|
+
if(document.readyState==='complete')break;
|
|
2148
|
+
await new Promise(function(r){setTimeout(r,i<10?200:500)});
|
|
2149
|
+
}
|
|
2150
|
+
return JSON.stringify({title:document.title,url:location.href,text:document.body.innerText.substring(0,${Number(maxLength)})});
|
|
2151
|
+
})()`,
|
|
2152
|
+
{ timeout: 30000 }
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// Click + wait for navigation or element — common after clicking a link/button
|
|
2157
|
+
export async function clickAndWait({ selector, text, waitFor: waitSelector, timeout = 10000 }) {
|
|
2158
|
+
const safeSel = selector ? selector.replace(/'/g, "\\'") : "";
|
|
2159
|
+
const safeText = text ? text.replace(/'/g, "\\'") : "";
|
|
2160
|
+
const safeWait = waitSelector ? waitSelector.replace(/'/g, "\\'") : "";
|
|
2161
|
+
// Single JS call: click + wait — 1 call instead of 20+
|
|
2162
|
+
return runJS(
|
|
2163
|
+
`(async function(){
|
|
2164
|
+
// Find and click
|
|
2165
|
+
var el;
|
|
2166
|
+
${safeSel ? `el = document.querySelector('${safeSel}');` : ""}
|
|
2167
|
+
${safeText && !safeSel ? `
|
|
2168
|
+
el = [...document.querySelectorAll('a,button,[role=button],label,[onclick]')].find(function(e){return e.textContent.trim().includes('${safeText}');});
|
|
2169
|
+
if(!el) el = [...document.querySelectorAll('*')].filter(function(e){var r=e.getBoundingClientRect();return r.width>0&&r.height>0&&e.textContent.trim().includes('${safeText}');}).sort(function(a,b){return a.textContent.length-b.textContent.length;})[0];
|
|
2170
|
+
` : ""}
|
|
2171
|
+
if(!el) return JSON.stringify({error:'Element not found'});
|
|
2172
|
+
el.scrollIntoView({block:'center'});
|
|
2173
|
+
el.click();
|
|
2174
|
+
// Wait
|
|
2175
|
+
var deadline = Date.now() + ${Number(timeout)};
|
|
2176
|
+
${safeWait ? `
|
|
2177
|
+
while(Date.now()<deadline){
|
|
2178
|
+
if(document.querySelector('${safeWait}'))break;
|
|
2179
|
+
await new Promise(function(r){setTimeout(r,200)});
|
|
2180
|
+
}
|
|
2181
|
+
` : `
|
|
2182
|
+
await new Promise(function(r){setTimeout(r,300)});
|
|
2183
|
+
while(Date.now()<deadline){
|
|
2184
|
+
if(document.readyState==='complete')break;
|
|
2185
|
+
await new Promise(function(r){setTimeout(r,200)});
|
|
2186
|
+
}
|
|
2187
|
+
`}
|
|
2188
|
+
return JSON.stringify({title:document.title,url:location.href,clicked:el.tagName+' "'+el.textContent.trim().substring(0,50)+'"'});
|
|
2189
|
+
})()`,
|
|
2190
|
+
{ timeout: timeout + 5000 }
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// Fill form + submit — common for login, search, etc.
|
|
2195
|
+
export async function fillAndSubmit({ fields, submitSelector }) {
|
|
2196
|
+
await fillForm({ fields });
|
|
2197
|
+
if (submitSelector) {
|
|
2198
|
+
const sel = submitSelector.replace(/'/g, "\\'");
|
|
2199
|
+
await runJS(
|
|
2200
|
+
`(function(){var el=document.querySelector('${sel}');if(el)el.click();})()`
|
|
2201
|
+
);
|
|
2202
|
+
} else {
|
|
2203
|
+
// Auto-find and click submit button
|
|
2204
|
+
await runJS(
|
|
2205
|
+
`(function(){var btn=document.querySelector('[type=submit],button:not([type])');if(btn)btn.click();})()`
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
// Wait for navigation/reload via inline JS (not fixed sleep)
|
|
2209
|
+
return runJS(
|
|
2210
|
+
`(async function(){
|
|
2211
|
+
await new Promise(function(r){setTimeout(r,300)});
|
|
2212
|
+
for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}
|
|
2213
|
+
return JSON.stringify({title:document.title,url:location.href});
|
|
2214
|
+
})()`,
|
|
2215
|
+
{ timeout: 15000 }
|
|
2216
|
+
);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// Full page analysis — extracts everything in ONE call
|
|
2220
|
+
export async function analyzePage() {
|
|
2221
|
+
return runJS(
|
|
2222
|
+
`(function(){
|
|
2223
|
+
var result = {};
|
|
2224
|
+
result.title = document.title;
|
|
2225
|
+
result.url = location.href;
|
|
2226
|
+
result.meta = {};
|
|
2227
|
+
result.meta.description = (document.querySelector('meta[name="description"]')||{}).content||'';
|
|
2228
|
+
result.meta.canonical = (document.querySelector('link[rel="canonical"]')||{}).href||'';
|
|
2229
|
+
result.meta.robots = (document.querySelector('meta[name="robots"]')||{}).content||'';
|
|
2230
|
+
result.meta.og = {};
|
|
2231
|
+
document.querySelectorAll('meta[property^="og:"]').forEach(function(m){result.meta.og[m.getAttribute('property').replace('og:','')]=m.content;});
|
|
2232
|
+
result.headings = {};
|
|
2233
|
+
for(var i=1;i<=3;i++){result.headings['h'+i]=[...document.querySelectorAll('h'+i)].map(function(h){return h.textContent.trim().substring(0,100);});}
|
|
2234
|
+
result.links = {internal:0,external:0,nofollow:0};
|
|
2235
|
+
document.querySelectorAll('a[href]').forEach(function(a){
|
|
2236
|
+
if(a.hostname===location.hostname)result.links.internal++;
|
|
2237
|
+
else result.links.external++;
|
|
2238
|
+
if(a.rel&&a.rel.includes('nofollow'))result.links.nofollow++;
|
|
2239
|
+
});
|
|
2240
|
+
result.images = {total:document.querySelectorAll('img').length,withoutAlt:[...document.querySelectorAll('img:not([alt]),img[alt=""]')].length};
|
|
2241
|
+
result.forms = document.querySelectorAll('form').length;
|
|
2242
|
+
result.text = document.body.innerText.substring(0,5000);
|
|
2243
|
+
return JSON.stringify(result);
|
|
2244
|
+
})()`
|
|
2245
|
+
);
|
|
2246
|
+
}
|