safari-mcp 2.3.1 → 2.5.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/extension/background.js +3 -1
- package/index.js +8 -4
- package/mcp-helpers.js +2 -0
- package/package.json +1 -1
- package/safari-helper +0 -0
- package/safari-helper.swift +92 -1
- package/safari.js +125 -297
package/extension/background.js
CHANGED
|
@@ -242,6 +242,7 @@ async function handleCommand(type, payload) {
|
|
|
242
242
|
await browser.tabs.goBack(tabId);
|
|
243
243
|
await waitForTabSettled(tabId, 3000);
|
|
244
244
|
const updated = await browser.tabs.get(tabId);
|
|
245
|
+
_setSessionTab(sessionId, updated.id, updated.url);
|
|
245
246
|
return { title: updated.title, url: updated.url };
|
|
246
247
|
}
|
|
247
248
|
|
|
@@ -249,6 +250,7 @@ async function handleCommand(type, payload) {
|
|
|
249
250
|
await browser.tabs.goForward(tabId);
|
|
250
251
|
await waitForTabSettled(tabId, 3000);
|
|
251
252
|
const updated = await browser.tabs.get(tabId);
|
|
253
|
+
_setSessionTab(sessionId, updated.id, updated.url);
|
|
252
254
|
return { title: updated.title, url: updated.url };
|
|
253
255
|
}
|
|
254
256
|
|
|
@@ -524,7 +526,7 @@ async function handleCommand(type, payload) {
|
|
|
524
526
|
|
|
525
527
|
// --- Visibility check ---
|
|
526
528
|
const cs = window.getComputedStyle(el);
|
|
527
|
-
if (cs.display === "none" || cs.visibility === "hidden" || cs.
|
|
529
|
+
if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse" || parseFloat(cs.opacity) === 0) {
|
|
528
530
|
return "Element not visible (display/visibility/opacity)";
|
|
529
531
|
}
|
|
530
532
|
|
package/index.js
CHANGED
|
@@ -17,6 +17,8 @@ import { readFileSync } from "node:fs";
|
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
|
|
20
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap on POST body — prevents DoS
|
|
21
|
+
|
|
20
22
|
// ========== SINGLETON: kill stale instances from previous sessions ==========
|
|
21
23
|
// NOTE: Claude Code VSCode may start 2 instances simultaneously (~40ms apart).
|
|
22
24
|
// Only kill instances running >10 seconds (truly stale), not fresh siblings.
|
|
@@ -132,8 +134,11 @@ function _startMemoryMonitor() {
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
// Cleanup on exit
|
|
137
|
+
let _cleaningUp = false;
|
|
135
138
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
136
139
|
process.on(sig, async () => {
|
|
140
|
+
if (_cleaningUp) return; // Prevent double-exit on rapid signal repeat
|
|
141
|
+
_cleaningUp = true;
|
|
137
142
|
await _cleanupTabs();
|
|
138
143
|
process.exit(0);
|
|
139
144
|
});
|
|
@@ -257,7 +262,6 @@ try {
|
|
|
257
262
|
}
|
|
258
263
|
|
|
259
264
|
// POST /result — extension sends command result
|
|
260
|
-
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap — prevents DoS from large payloads
|
|
261
265
|
if (req.method === "POST" && req.url === "/result") {
|
|
262
266
|
let body = "";
|
|
263
267
|
req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
@@ -309,9 +313,9 @@ try {
|
|
|
309
313
|
const safeNonce = String(nonce).replace(/[^0-9]/g, ''); // nonce is numeric only
|
|
310
314
|
const safeProfile = (expectedProfile || "").replace(/[^\p{L}\p{N}\s\-_]/gu, ''); // whitelist: letters, numbers, spaces, hyphens, underscores
|
|
311
315
|
// Check via AppleScript — look for the nonce in the profile window
|
|
312
|
-
const { execFile } = await import("node:child_process");
|
|
313
|
-
const { promisify } = await import("node:util");
|
|
314
|
-
const execFileAsync =
|
|
316
|
+
const { execFile: execFileCb } = await import("node:child_process");
|
|
317
|
+
const { promisify: pfy } = await import("node:util");
|
|
318
|
+
const execFileAsync = pfy(execFileCb);
|
|
315
319
|
const script = `tell application "Safari"
|
|
316
320
|
repeat with w in every window
|
|
317
321
|
repeat with t in every tab of w
|
package/mcp-helpers.js
CHANGED
|
@@ -75,6 +75,8 @@ if (window.__mcpVersion !== 5) {
|
|
|
75
75
|
if (!window.mcpIsVisible(el)) return false;
|
|
76
76
|
if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false;
|
|
77
77
|
if (el.matches && el.matches('input[type="hidden"]')) return false;
|
|
78
|
+
var cs = window.getComputedStyle(el);
|
|
79
|
+
if (cs && cs.pointerEvents === 'none') return false;
|
|
78
80
|
return true;
|
|
79
81
|
};
|
|
80
82
|
window.mcpPickActionable = function(el) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari-helper
CHANGED
|
Binary file
|
package/safari-helper.swift
CHANGED
|
@@ -128,6 +128,57 @@ func performNativeClick(x: Double, y: Double, doubleClick: Bool = false, windowI
|
|
|
128
128
|
return ["result": "clicked at (\(Int(x)),\(Int(y)))\(doubleClick ? " (double)" : "")\(targetInfo)"]
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// ========== CGEvent Native Keyboard ==========
|
|
132
|
+
// Sends keyboard events (keystrokes, shortcuts) targeted to a specific window
|
|
133
|
+
// WITHOUT activating Safari or stealing focus. Same principle as native click.
|
|
134
|
+
|
|
135
|
+
func performNativeKeyboard(keyCode: UInt16, flags: CGEventFlags = [], windowId: Int64 = 0) -> [String: Any] {
|
|
136
|
+
// Get Safari PID
|
|
137
|
+
var safariPID: pid_t = 0
|
|
138
|
+
if windowId > 0 {
|
|
139
|
+
let ws = NSWorkspace.shared
|
|
140
|
+
for app in ws.runningApplications {
|
|
141
|
+
if app.bundleIdentifier == "com.apple.Safari" {
|
|
142
|
+
safariPID = app.processIdentifier
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if safariPID == 0 {
|
|
147
|
+
return ["error": "Safari process not found"]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let kWindowField = CGEventField(rawValue: 91)!
|
|
152
|
+
let kWindowHandlerField = CGEventField(rawValue: 92)!
|
|
153
|
+
|
|
154
|
+
guard let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true) else {
|
|
155
|
+
return ["error": "Failed to create key down event"]
|
|
156
|
+
}
|
|
157
|
+
guard let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) else {
|
|
158
|
+
return ["error": "Failed to create key up event"]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
keyDown.flags = flags
|
|
162
|
+
keyUp.flags = flags
|
|
163
|
+
|
|
164
|
+
if windowId > 0 {
|
|
165
|
+
keyDown.setIntegerValueField(kWindowField, value: windowId)
|
|
166
|
+
keyDown.setIntegerValueField(kWindowHandlerField, value: windowId)
|
|
167
|
+
keyUp.setIntegerValueField(kWindowField, value: windowId)
|
|
168
|
+
keyUp.setIntegerValueField(kWindowHandlerField, value: windowId)
|
|
169
|
+
keyDown.postToPid(safariPID)
|
|
170
|
+
usleep(30_000)
|
|
171
|
+
keyUp.postToPid(safariPID)
|
|
172
|
+
} else {
|
|
173
|
+
keyDown.post(tap: .cghidEventTap)
|
|
174
|
+
usleep(30_000)
|
|
175
|
+
keyUp.post(tap: .cghidEventTap)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let targetInfo = windowId > 0 ? " (window \(windowId), background)" : ""
|
|
179
|
+
return ["result": "key \(keyCode) pressed\(targetInfo)"]
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
// ========== Response Helper ==========
|
|
132
183
|
|
|
133
184
|
func respond(_ obj: [String: Any]) {
|
|
@@ -166,6 +217,24 @@ if args.count >= 4 && args[1] == "--click" {
|
|
|
166
217
|
exit(result["error"] != nil ? 1 : 0)
|
|
167
218
|
}
|
|
168
219
|
|
|
220
|
+
// ========== CLI Mode: --paste --window WID ==========
|
|
221
|
+
// Convenience: Cmd+V via CGEvent (no activate needed)
|
|
222
|
+
if args.count >= 2 && args[1] == "--paste" {
|
|
223
|
+
var windowId: Int64 = 0
|
|
224
|
+
var i = 2
|
|
225
|
+
while i < args.count {
|
|
226
|
+
if args[i] == "--window" && i + 1 < args.count {
|
|
227
|
+
windowId = Int64(args[i + 1]) ?? 0
|
|
228
|
+
i += 1
|
|
229
|
+
}
|
|
230
|
+
i += 1
|
|
231
|
+
}
|
|
232
|
+
// keyCode 9 = V key
|
|
233
|
+
let result = performNativeKeyboard(keyCode: 9, flags: .maskCommand, windowId: windowId)
|
|
234
|
+
respond(result)
|
|
235
|
+
exit(result["error"] != nil ? 1 : 0)
|
|
236
|
+
}
|
|
237
|
+
|
|
169
238
|
// ========== Daemon Mode: JSON lines on stdin ==========
|
|
170
239
|
|
|
171
240
|
while let line = readLine(strippingNewline: true) {
|
|
@@ -187,9 +256,31 @@ while let line = readLine(strippingNewline: true) {
|
|
|
187
256
|
continue
|
|
188
257
|
}
|
|
189
258
|
|
|
259
|
+
// Handle CGEvent keyboard command
|
|
260
|
+
// {"keyboard": {"keyCode": 9, "flags": ["cmd"], "windowId": 4127}}
|
|
261
|
+
// keyCode 9 = V, flags: cmd/shift/alt/ctrl
|
|
262
|
+
if let kbData = json["keyboard"] as? [String: Any],
|
|
263
|
+
let keyCode = kbData["keyCode"] as? Int {
|
|
264
|
+
let windowId = Int64((kbData["windowId"] as? Int) ?? 0)
|
|
265
|
+
var flags: CGEventFlags = []
|
|
266
|
+
if let flagNames = kbData["flags"] as? [String] {
|
|
267
|
+
for f in flagNames {
|
|
268
|
+
switch f.lowercased() {
|
|
269
|
+
case "cmd": flags.insert(.maskCommand)
|
|
270
|
+
case "shift": flags.insert(.maskShift)
|
|
271
|
+
case "alt", "option": flags.insert(.maskAlternate)
|
|
272
|
+
case "ctrl", "control": flags.insert(.maskControl)
|
|
273
|
+
default: break
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
respond(performNativeKeyboard(keyCode: UInt16(keyCode), flags: flags, windowId: windowId))
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
|
|
190
281
|
// Handle AppleScript command
|
|
191
282
|
guard let script = json["script"] as? String else {
|
|
192
|
-
respond(["error": "invalid input — expected 'script' or '
|
|
283
|
+
respond(["error": "invalid input — expected 'script', 'click', or 'keyboard'"])
|
|
193
284
|
continue
|
|
194
285
|
}
|
|
195
286
|
|
package/safari.js
CHANGED
|
@@ -386,7 +386,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
386
386
|
function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
|
|
387
387
|
return new Promise((resolve, reject) => {
|
|
388
388
|
if (!_helperProc) startHelper();
|
|
389
|
-
if (!_helperProc || !_helperProc.stdin) {
|
|
389
|
+
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
390
390
|
reject(new Error("safari-helper not available for native click"));
|
|
391
391
|
return;
|
|
392
392
|
}
|
|
@@ -420,6 +420,44 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
|
|
|
420
420
|
});
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
// Sends a CGEvent keyboard command to the Swift helper daemon.
|
|
424
|
+
// No focus stealing — sends key events directly to the target window via PID.
|
|
425
|
+
function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000) {
|
|
426
|
+
return new Promise((resolve, reject) => {
|
|
427
|
+
if (!_helperProc) startHelper();
|
|
428
|
+
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
429
|
+
reject(new Error("safari-helper not available for native keyboard"));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
let resolved = false;
|
|
433
|
+
const timer = setTimeout(() => {
|
|
434
|
+
if (resolved) return;
|
|
435
|
+
resolved = true;
|
|
436
|
+
const idx = _helperQueue.indexOf(cb);
|
|
437
|
+
if (idx >= 0) _helperQueue.splice(idx, 1);
|
|
438
|
+
reject(new Error("native keyboard timeout"));
|
|
439
|
+
}, timeout);
|
|
440
|
+
|
|
441
|
+
function cb(line) {
|
|
442
|
+
if (resolved) return;
|
|
443
|
+
resolved = true;
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
try {
|
|
446
|
+
const parsed = JSON.parse(line);
|
|
447
|
+
if (parsed.error) reject(new Error(parsed.error));
|
|
448
|
+
else resolve(parsed.result ?? "");
|
|
449
|
+
} catch {
|
|
450
|
+
resolve(line);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
_helperQueue.push(cb);
|
|
455
|
+
const cmd = { keyboard: { keyCode, flags } };
|
|
456
|
+
if (windowId) cmd.keyboard.windowId = windowId;
|
|
457
|
+
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
423
461
|
// Get Safari window bounds, toolbar height, and window ID for coordinate calculation
|
|
424
462
|
async function _getSafariWindowGeometry() {
|
|
425
463
|
await refreshTargetWindow();
|
|
@@ -696,301 +734,8 @@ export async function getPageSource({ maxLength = 200000 } = {}) {
|
|
|
696
734
|
// Loaded from mcp-helpers.js at startup — enables syntax highlighting, linting, and easier maintenance
|
|
697
735
|
const INJECT_MCP_HELPERS = readFileSync(join(__dirname, 'mcp-helpers.js'), 'utf8');
|
|
698
736
|
|
|
699
|
-
// NOTE:
|
|
700
|
-
|
|
701
|
-
const _LEGACY_INLINE_START = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
|
|
702
|
-
window.__mcpRefs=window.__mcpRefs||{};
|
|
703
|
-
window.__mcpCachedRoots=null;
|
|
704
|
-
window.__mcpRootsDirty=true;
|
|
705
|
-
if(!window.__mcpRootsObserver){
|
|
706
|
-
window.__mcpRootsObserver=new MutationObserver(function(){window.__mcpRootsDirty=true;});
|
|
707
|
-
window.__mcpRootsObserver.observe(document.documentElement,{childList:true,subtree:true});
|
|
708
|
-
}
|
|
709
|
-
window.mcpCollectRoots=function(){
|
|
710
|
-
if(!window.__mcpRootsDirty&&window.__mcpCachedRoots)return window.__mcpCachedRoots;
|
|
711
|
-
var roots=[];
|
|
712
|
-
function collect(root){
|
|
713
|
-
roots.push(root);
|
|
714
|
-
var all=root.querySelectorAll('*');
|
|
715
|
-
for(var i=0;i<all.length;i++){
|
|
716
|
-
if(all[i].shadowRoot)collect(all[i].shadowRoot);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
collect(document);
|
|
720
|
-
var iframes=document.querySelectorAll('iframe');
|
|
721
|
-
for(var i=0;i<iframes.length;i++){
|
|
722
|
-
try{var doc=iframes[i].contentDocument;if(doc)collect(doc);}catch(e){}
|
|
723
|
-
}
|
|
724
|
-
window.__mcpCachedRoots=roots;
|
|
725
|
-
window.__mcpRootsDirty=false;
|
|
726
|
-
return roots;
|
|
727
|
-
};
|
|
728
|
-
window.mcpQuerySelectorDeep=function(selector){
|
|
729
|
-
try{
|
|
730
|
-
var direct=document.querySelector(selector);
|
|
731
|
-
if(direct)return direct;
|
|
732
|
-
}catch(e){return null}
|
|
733
|
-
var roots=window.mcpCollectRoots();
|
|
734
|
-
for(var i=1;i<roots.length;i++){
|
|
735
|
-
try{
|
|
736
|
-
var found=roots[i].querySelector(selector);
|
|
737
|
-
if(found)return found;
|
|
738
|
-
}catch(e){}
|
|
739
|
-
}
|
|
740
|
-
var iframes=document.querySelectorAll('iframe');
|
|
741
|
-
for(var i=0;i<iframes.length;i++){
|
|
742
|
-
try{
|
|
743
|
-
var doc=iframes[i].contentDocument;
|
|
744
|
-
if(doc){var found=doc.querySelector(selector);if(found)return found;}
|
|
745
|
-
}catch(e){}
|
|
746
|
-
}
|
|
747
|
-
return null;
|
|
748
|
-
};
|
|
749
|
-
window.mcpElementFromPoint=function(x,y){
|
|
750
|
-
var el=document.elementFromPoint(x,y);
|
|
751
|
-
while(el&&el.shadowRoot){
|
|
752
|
-
try{
|
|
753
|
-
if(typeof el.shadowRoot.elementFromPoint!=='function')break;
|
|
754
|
-
var inner=el.shadowRoot.elementFromPoint(x,y);
|
|
755
|
-
if(!inner||inner===el)break;
|
|
756
|
-
el=inner;
|
|
757
|
-
}catch(e){break}
|
|
758
|
-
}
|
|
759
|
-
return el;
|
|
760
|
-
};
|
|
761
|
-
window.mcpIsVisible=function(el){
|
|
762
|
-
if(!el||el.nodeType!==1||!el.isConnected)return false;
|
|
763
|
-
var cs=window.getComputedStyle(el);
|
|
764
|
-
if(!cs||cs.display==='none'||cs.visibility==='hidden'||cs.visibility==='collapse'||cs.pointerEvents==='none'||cs.opacity==='0')return false;
|
|
765
|
-
var r=el.getBoundingClientRect();
|
|
766
|
-
return r.width>0&&r.height>0;
|
|
767
|
-
};
|
|
768
|
-
window.mcpIsActionable=function(el){
|
|
769
|
-
if(!window.mcpIsVisible(el))return false;
|
|
770
|
-
if(el.disabled||el.getAttribute('aria-disabled')==='true')return false;
|
|
771
|
-
if(el.matches&&el.matches('input[type="hidden"]'))return false;
|
|
772
|
-
return true;
|
|
773
|
-
};
|
|
774
|
-
window.mcpPickActionable=function(el){
|
|
775
|
-
var node=el&&el.nodeType===1?el:(el&&el.parentElement)||null;
|
|
776
|
-
while(node){
|
|
777
|
-
if(window.mcpIsActionable(node)&&node.matches&&node.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,option,[role],[onclick],[tabindex],[contenteditable=""],[contenteditable="true"]'))return node;
|
|
778
|
-
node=node.parentElement;
|
|
779
|
-
}
|
|
780
|
-
return el&&el.nodeType===1?el:null;
|
|
781
|
-
};
|
|
782
|
-
window.mcpFindByAttr=function(attr,value,selector){
|
|
783
|
-
if(!value)return null;
|
|
784
|
-
var roots=window.mcpCollectRoots();
|
|
785
|
-
var sel=selector||('['+attr+']');
|
|
786
|
-
for(var i=0;i<roots.length;i++){
|
|
787
|
-
var els=roots[i].querySelectorAll(sel);
|
|
788
|
-
for(var j=0;j<els.length;j++){
|
|
789
|
-
var current=attr==='href'?els[j].href:els[j].getAttribute(attr);
|
|
790
|
-
if(current===value&&window.mcpIsVisible(els[j]))return els[j];
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
return null;
|
|
794
|
-
};
|
|
795
|
-
window.mcpNormalizeText=function(value){
|
|
796
|
-
return String(value||'').replace(/\\s+/g,' ').trim();
|
|
797
|
-
};
|
|
798
|
-
window.mcpResolveTarget=function(el){
|
|
799
|
-
var base=window.mcpPickActionable(el)||el;
|
|
800
|
-
if(!base)return null;
|
|
801
|
-
try{base.scrollIntoView({block:'center',inline:'center'});}catch(e){}
|
|
802
|
-
var r=base.getBoundingClientRect();
|
|
803
|
-
var dx=Math.min(12,Math.max(4,r.width/4));
|
|
804
|
-
var dy=Math.min(12,Math.max(4,r.height/4));
|
|
805
|
-
var pts=[
|
|
806
|
-
[r.left+r.width/2,r.top+r.height/2],
|
|
807
|
-
[r.left+dx,r.top+r.height/2],
|
|
808
|
-
[r.right-dx,r.top+r.height/2],
|
|
809
|
-
[r.left+r.width/2,r.top+dy],
|
|
810
|
-
[r.left+r.width/2,r.bottom-dy]
|
|
811
|
-
];
|
|
812
|
-
for(var i=0;i<pts.length;i++){
|
|
813
|
-
var x=pts[i][0],y=pts[i][1];
|
|
814
|
-
if(x<0||y<0||x>window.innerWidth||y>window.innerHeight)continue;
|
|
815
|
-
var hit=window.mcpElementFromPoint(x,y);
|
|
816
|
-
if(!hit)continue;
|
|
817
|
-
if(base===hit||base.contains(hit)||hit.contains(base))return window.mcpPickActionable(hit)||hit;
|
|
818
|
-
// Overlay pattern: transparent button/link overlays a text label (e.g. dropdown items).
|
|
819
|
-
// If the hit is an interactive element sharing a nearby common ancestor, prefer it.
|
|
820
|
-
var hitAction=window.mcpPickActionable(hit);
|
|
821
|
-
if(hitAction&&hitAction!==base&&hitAction.matches&&hitAction.matches('button,a[href],[role="button"],[role="option"],[role="menuitem"]')){
|
|
822
|
-
var p=base.parentElement;
|
|
823
|
-
for(var k=0;k<4&&p;k++){
|
|
824
|
-
if(p.contains(hitAction))return hitAction;
|
|
825
|
-
p=p.parentElement;
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
return base;
|
|
830
|
-
};
|
|
831
|
-
window.mcpClick=function(el){
|
|
832
|
-
var target=window.mcpResolveTarget(el);
|
|
833
|
-
if(!window.mcpIsActionable(target))return false;
|
|
834
|
-
var r=target.getBoundingClientRect();
|
|
835
|
-
var x=r.left+r.width/2,y=r.top+r.height/2;
|
|
836
|
-
var beforeUrl=location.href;
|
|
837
|
-
var anchor=target.closest?target.closest('a[href]'):null;
|
|
838
|
-
var href=anchor&&anchor.href&&!anchor.href.startsWith('javascript:')?anchor.href:'';
|
|
839
|
-
var s={bubbles:true,cancelable:true,composed:true,view:window,clientX:x,clientY:y,button:0,detail:1};
|
|
840
|
-
var p={...s,pointerId:1,pointerType:'mouse',isPrimary:true,width:1,height:1,pressure:0.5};
|
|
841
|
-
target.dispatchEvent(new PointerEvent('pointerover',{...p,buttons:0}));
|
|
842
|
-
target.dispatchEvent(new MouseEvent('mouseover',{...s,buttons:0}));
|
|
843
|
-
target.dispatchEvent(new PointerEvent('pointerenter',{...p,buttons:0}));
|
|
844
|
-
target.dispatchEvent(new MouseEvent('mouseenter',{...s,buttons:0}));
|
|
845
|
-
target.dispatchEvent(new PointerEvent('pointermove',{...p,buttons:0}));
|
|
846
|
-
target.dispatchEvent(new MouseEvent('mousemove',{...s,buttons:0}));
|
|
847
|
-
target.dispatchEvent(new PointerEvent('pointerdown',{...p,buttons:1}));
|
|
848
|
-
target.dispatchEvent(new MouseEvent('mousedown',{...s,buttons:1}));
|
|
849
|
-
if(target.focus){try{target.focus({preventScroll:true});}catch(e){try{target.focus();}catch(_){}}}
|
|
850
|
-
target.dispatchEvent(new PointerEvent('pointerup',{...p,buttons:0,pressure:0}));
|
|
851
|
-
target.dispatchEvent(new MouseEvent('mouseup',{...s,buttons:0}));
|
|
852
|
-
try{
|
|
853
|
-
if(typeof target.click==='function'){
|
|
854
|
-
target.click();
|
|
855
|
-
if(href&&href!==beforeUrl){
|
|
856
|
-
location.href=href;
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
}catch(e){}
|
|
860
|
-
target.dispatchEvent(new MouseEvent('click',{...s,buttons:0}));
|
|
861
|
-
if(href&&href!==beforeUrl){
|
|
862
|
-
location.href=href;
|
|
863
|
-
}
|
|
864
|
-
var form=target.closest?target.closest('form'):null;
|
|
865
|
-
if(form&&(target.type==='submit'||(target.tagName==='BUTTON'&&target.type!=='button'&&target.type!=='reset'))){
|
|
866
|
-
try{form.requestSubmit?form.requestSubmit(target.type==='submit'?target:undefined):form.submit();}catch(e){}
|
|
867
|
-
}
|
|
868
|
-
return true;
|
|
869
|
-
};
|
|
870
|
-
window.mcpReactClick=function(el){
|
|
871
|
-
var startNode=window.mcpResolveTarget(el)||el;
|
|
872
|
-
if(!startNode)return false;
|
|
873
|
-
function makeSynth(targetNode, type){
|
|
874
|
-
var r=targetNode.getBoundingClientRect();
|
|
875
|
-
return{type:type||'click',target:targetNode,currentTarget:targetNode,
|
|
876
|
-
clientX:r.left+r.width/2,clientY:r.top+r.height/2,pageX:r.left+r.width/2+window.scrollX,pageY:r.top+r.height/2+window.scrollY,
|
|
877
|
-
preventDefault:function(){},stopPropagation:function(){},nativeEvent:new MouseEvent(type||'click'),persist:function(){},bubbles:true,cancelable:true};
|
|
878
|
-
}
|
|
879
|
-
// For React radio/checkbox inputs, onChange is the primary handler (not onClick)
|
|
880
|
-
var isToggle=startNode.tagName==='INPUT'&&(startNode.type==='radio'||startNode.type==='checkbox');
|
|
881
|
-
function tryOnChange(propsObj,targetNode){
|
|
882
|
-
if(!isToggle||!propsObj.onChange)return false;
|
|
883
|
-
var newChecked=startNode.type==='radio'?true:!startNode.checked;
|
|
884
|
-
propsObj.onChange({target:{value:startNode.value,checked:newChecked,type:startNode.type,name:startNode.name,id:startNode.id,tagName:'INPUT'},
|
|
885
|
-
currentTarget:targetNode,preventDefault:function(){},stopPropagation:function(){},nativeEvent:new Event('change'),persist:function(){},bubbles:true,cancelable:true,type:'change'});
|
|
886
|
-
return true;
|
|
887
|
-
}
|
|
888
|
-
var node=startNode;
|
|
889
|
-
for(var depth=0;depth<15&&node;depth++){
|
|
890
|
-
var pk=Object.keys(node).find(function(k){return k.startsWith('__reactProps$')});
|
|
891
|
-
if(pk&&node[pk]){
|
|
892
|
-
var props=node[pk];
|
|
893
|
-
if(tryOnChange(props,node))return true;
|
|
894
|
-
if(props.onClick){props.onClick(makeSynth(node,'click'));return true}
|
|
895
|
-
if(props.onMouseDown){props.onMouseDown(makeSynth(node,'mousedown'));return true}
|
|
896
|
-
if(props.onPointerDown){props.onPointerDown(makeSynth(node,'pointerdown'));return true}
|
|
897
|
-
}
|
|
898
|
-
var fk=Object.keys(node).find(function(k){return k.startsWith('__reactFiber$')||k.startsWith('__reactInternalInstance$')});
|
|
899
|
-
if(fk){
|
|
900
|
-
var f=node[fk];
|
|
901
|
-
while(f){
|
|
902
|
-
if(f.memoizedProps){
|
|
903
|
-
if(tryOnChange(f.memoizedProps,node))return true;
|
|
904
|
-
if(f.memoizedProps.onClick){f.memoizedProps.onClick(makeSynth(node,'click'));return true}
|
|
905
|
-
if(f.memoizedProps.onMouseDown){f.memoizedProps.onMouseDown(makeSynth(node,'mousedown'));return true}
|
|
906
|
-
if(f.memoizedProps.onPointerDown){f.memoizedProps.onPointerDown(makeSynth(node,'pointerdown'));return true}
|
|
907
|
-
}
|
|
908
|
-
f=f.return;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
node=node.parentElement;
|
|
912
|
-
}
|
|
913
|
-
return false;
|
|
914
|
-
};
|
|
915
|
-
window.mcpClickWithReact=function(el){
|
|
916
|
-
var target=window.mcpResolveTarget(el)||el;
|
|
917
|
-
var reactFired=false;
|
|
918
|
-
try{reactFired=window.mcpReactClick(target);}catch(e){}
|
|
919
|
-
var anchor=target&&target.closest?target.closest('a[href]'):null;
|
|
920
|
-
if(!reactFired||anchor)window.mcpClick(target);
|
|
921
|
-
return target;
|
|
922
|
-
};
|
|
923
|
-
window.mcpFindRef=function(ref){
|
|
924
|
-
var el=window.mcpQuerySelectorDeep('[data-mcp-ref="'+ref+'"]');
|
|
925
|
-
if(el)return el;
|
|
926
|
-
if(!window.__mcpRefs||!window.__mcpRefs[ref])return null;
|
|
927
|
-
var m=window.__mcpRefs[ref];
|
|
928
|
-
if(m.id){el=window.mcpFindByAttr('id',m.id);if(el)return el}
|
|
929
|
-
if(m.testid){el=window.mcpFindByAttr('data-testid',m.testid);if(el)return el}
|
|
930
|
-
if(m.nameAttr){el=window.mcpFindByAttr('name',m.nameAttr);if(el)return el}
|
|
931
|
-
if(m.href){el=window.mcpFindByAttr('href',m.href,'a[href]');if(el)return el}
|
|
932
|
-
if(m.al){el=window.mcpFindByAttr('aria-label',m.al);if(el)return el}
|
|
933
|
-
if(m.ph){el=window.mcpFindByAttr('placeholder',m.ph);if(el)return el}
|
|
934
|
-
if(m.text){
|
|
935
|
-
el=window.mcpFindText(m.text,true)||window.mcpFindText(m.text,false);
|
|
936
|
-
if(el)return el;
|
|
937
|
-
}
|
|
938
|
-
if(m.cx!==undefined&&m.cy!==undefined){
|
|
939
|
-
try{
|
|
940
|
-
window.scrollTo(window.scrollX,Math.max(0,m.cy-window.innerHeight/2));
|
|
941
|
-
}catch(e){}
|
|
942
|
-
el=window.mcpElementFromPoint(m.cx-window.scrollX,m.cy-window.scrollY);
|
|
943
|
-
if(el)return window.mcpPickActionable(el)||el;
|
|
944
|
-
}
|
|
945
|
-
return null;
|
|
946
|
-
};
|
|
947
|
-
window.mcpFindText=function(text,exact){
|
|
948
|
-
var needle=window.mcpNormalizeText(text);
|
|
949
|
-
var best=null,bestScore=Infinity;
|
|
950
|
-
function consider(node){
|
|
951
|
-
var target=window.mcpPickActionable(node)||node;
|
|
952
|
-
if(!window.mcpIsVisible(target))return;
|
|
953
|
-
var r=target.getBoundingClientRect();
|
|
954
|
-
var area=r.width*r.height;
|
|
955
|
-
var interactive=target.matches&&target.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,[role=button],[role=link],[role=tab],[onclick],[tabindex]');
|
|
956
|
-
var score=area+(interactive?0:1000000);
|
|
957
|
-
if(score<bestScore){best=target;bestScore=score}
|
|
958
|
-
}
|
|
959
|
-
var roots=window.mcpCollectRoots();
|
|
960
|
-
for(var i=0;i<roots.length;i++){
|
|
961
|
-
var attrEls=roots[i].querySelectorAll('[aria-label],[placeholder],[title],[data-testid],[alt]');
|
|
962
|
-
for(var j=0;j<attrEls.length;j++){
|
|
963
|
-
var a=attrEls[j];
|
|
964
|
-
var vals=[a.getAttribute('aria-label'),a.getAttribute('placeholder'),a.getAttribute('title'),a.getAttribute('data-testid'),a.getAttribute('alt')];
|
|
965
|
-
for(var k=0;k<vals.length;k++){
|
|
966
|
-
var val=vals[k];
|
|
967
|
-
if(!val)continue;
|
|
968
|
-
var normalized=window.mcpNormalizeText(val);
|
|
969
|
-
if(exact?normalized===needle:normalized.includes(needle)){consider(a);break}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
var tw=document.createTreeWalker(roots[i],NodeFilter.SHOW_TEXT,null);
|
|
973
|
-
while(tw.nextNode()){
|
|
974
|
-
var n=tw.currentNode;
|
|
975
|
-
var t=window.mcpNormalizeText(n.textContent);
|
|
976
|
-
if(!t)continue;
|
|
977
|
-
if(exact?(t!==needle):!t.includes(needle))continue;
|
|
978
|
-
if(n.parentElement)consider(n.parentElement);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
if(!best){
|
|
982
|
-
var allEls=document.querySelectorAll('*');
|
|
983
|
-
for(var i=0;i<allEls.length;i++){
|
|
984
|
-
var el=allEls[i];
|
|
985
|
-
var it=window.mcpNormalizeText(el.innerText);
|
|
986
|
-
if(!it)continue;
|
|
987
|
-
if(exact?(it!==needle):!it.includes(needle))continue;
|
|
988
|
-
consider(el);
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
return best;
|
|
992
|
-
};
|
|
993
|
-
}`;
|
|
737
|
+
// NOTE: ~300 lines of legacy inline helpers were here — now in mcp-helpers.js (deleted in v2.4.0)
|
|
738
|
+
|
|
994
739
|
|
|
995
740
|
// Precomputed escaped helper string — avoids re-escaping ~4KB on every injection call
|
|
996
741
|
const _HELPERS_ESCAPED = INJECT_MCP_HELPERS
|
|
@@ -1219,6 +964,7 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
|
|
|
1219
964
|
|
|
1220
965
|
export async function fill({ selector, value, ref }) {
|
|
1221
966
|
if (ref) selector = refSelector(ref);
|
|
967
|
+
if (!selector) throw new Error("fill requires selector or ref");
|
|
1222
968
|
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1223
969
|
// Proper escaping order: backslashes first, then quotes
|
|
1224
970
|
const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
@@ -1362,6 +1108,19 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
1362
1108
|
|
|
1363
1109
|
// Cmd+V (paste) — read clipboard via AppleScript (no activate!), inject via JS
|
|
1364
1110
|
if (k === "v") {
|
|
1111
|
+
// Cross-origin iframe: JS can't paste into it, use CGEvent Cmd+V (no focus steal)
|
|
1112
|
+
const activeTag = await runJS(`document.activeElement ? document.activeElement.tagName : ''`);
|
|
1113
|
+
if (activeTag === 'IFRAME') {
|
|
1114
|
+
try {
|
|
1115
|
+
const geo = await _getSafariWindowGeometry();
|
|
1116
|
+
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
|
|
1117
|
+
} catch {
|
|
1118
|
+
await _helperNativeKeyboard(9, ["cmd"], 0);
|
|
1119
|
+
}
|
|
1120
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1121
|
+
return `Pressed: ${modifiers.join("+")}+v (CGEvent Cmd+V into iframe, no focus steal)`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1365
1124
|
const clipText = await osascript(`the clipboard as text`).catch(() => "");
|
|
1366
1125
|
if (clipText) {
|
|
1367
1126
|
const escaped = clipText.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
@@ -1445,6 +1204,61 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
1445
1204
|
return `Pressed: ${modifiers.length ? modifiers.join("+") + "+" : ""}${key}`;
|
|
1446
1205
|
}
|
|
1447
1206
|
|
|
1207
|
+
// Native typing via clipboard paste — for cross-origin iframes (Intercom, Zendesk, etc.)
|
|
1208
|
+
// JS can't access content inside cross-origin iframes, so we use OS-level CGEvent:
|
|
1209
|
+
// 1. Save current clipboard
|
|
1210
|
+
// 2. Set clipboard to our text
|
|
1211
|
+
// 3. Cmd+V via CGEvent keyboard (targeted to window — NO focus steal, NO activate)
|
|
1212
|
+
// 4. Restore clipboard
|
|
1213
|
+
// Requires nativeClick to have focused the input inside the iframe first.
|
|
1214
|
+
async function _nativeTypeViaClipboard(text) {
|
|
1215
|
+
// Save current clipboard
|
|
1216
|
+
let savedClipboard = null;
|
|
1217
|
+
try {
|
|
1218
|
+
const { stdout } = await execFileAsync("pbpaste", []);
|
|
1219
|
+
savedClipboard = stdout;
|
|
1220
|
+
} catch {}
|
|
1221
|
+
|
|
1222
|
+
// Set clipboard to our text via pipe (safe from shell injection)
|
|
1223
|
+
await new Promise((resolve, reject) => {
|
|
1224
|
+
const proc = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
1225
|
+
proc.stdin.write(text);
|
|
1226
|
+
proc.stdin.end();
|
|
1227
|
+
proc.on("close", resolve);
|
|
1228
|
+
proc.on("error", reject);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
// Paste via CGEvent Cmd+V targeted to Safari window — NO activate, NO focus steal
|
|
1232
|
+
try {
|
|
1233
|
+
const geo = await _getSafariWindowGeometry();
|
|
1234
|
+
// keyCode 9 = V key, flags: ["cmd"]
|
|
1235
|
+
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
|
|
1236
|
+
} catch {
|
|
1237
|
+
// Fallback: try without windowId (global)
|
|
1238
|
+
await _helperNativeKeyboard(9, ["cmd"], 0);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Wait for paste to settle
|
|
1242
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1243
|
+
|
|
1244
|
+
// Restore clipboard after delay
|
|
1245
|
+
if (savedClipboard !== null) {
|
|
1246
|
+
setTimeout(async () => {
|
|
1247
|
+
try {
|
|
1248
|
+
await new Promise((resolve, reject) => {
|
|
1249
|
+
const proc = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
1250
|
+
proc.stdin.write(savedClipboard);
|
|
1251
|
+
proc.stdin.end();
|
|
1252
|
+
proc.on("close", resolve);
|
|
1253
|
+
proc.on("error", reject);
|
|
1254
|
+
});
|
|
1255
|
+
} catch {}
|
|
1256
|
+
}, 3000);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
return `Typed ${text.length} chars (native paste into iframe)`;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1448
1262
|
export async function typeText({ text, selector, ref }) {
|
|
1449
1263
|
if (ref) selector = refSelector(ref);
|
|
1450
1264
|
if (selector) {
|
|
@@ -1454,6 +1268,13 @@ export async function typeText({ text, selector, ref }) {
|
|
|
1454
1268
|
await new Promise((r) => setTimeout(r, 30));
|
|
1455
1269
|
}
|
|
1456
1270
|
|
|
1271
|
+
// Cross-origin iframe detection: JS can't access content inside cross-origin iframes.
|
|
1272
|
+
// When activeElement is an IFRAME, use native clipboard paste via System Events.
|
|
1273
|
+
const activeTag = await runJS(`document.activeElement ? document.activeElement.tagName : ''`);
|
|
1274
|
+
if (activeTag === 'IFRAME') {
|
|
1275
|
+
return await _nativeTypeViaClipboard(text);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1457
1278
|
// Use execCommand("insertText") — the ONLY approach that works for BOTH:
|
|
1458
1279
|
// 1. Regular inputs/textareas (execCommand works natively)
|
|
1459
1280
|
// 2. ContentEditable (ProseMirror/Draft.js/Slate) — execCommand causes real DOM mutation
|
|
@@ -1473,6 +1294,11 @@ export async function typeText({ text, selector, ref }) {
|
|
|
1473
1294
|
`if('value' in el){var start=el.selectionStart||0;el.value=el.value.substring(0,start)+'${safeText}'+el.value.substring(el.selectionEnd||start);el.selectionStart=el.selectionEnd=start+${text.length};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));return 'Typed '+${text.length}+' chars via value set';}return 'Could not type';})()`
|
|
1474
1295
|
);
|
|
1475
1296
|
|
|
1297
|
+
// If JS typing failed and we're somehow in an iframe context, try native fallback
|
|
1298
|
+
if (result === 'Could not type') {
|
|
1299
|
+
return await _nativeTypeViaClipboard(text);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1476
1302
|
return result;
|
|
1477
1303
|
}
|
|
1478
1304
|
|
|
@@ -1678,7 +1504,7 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1678
1504
|
}
|
|
1679
1505
|
// Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
|
|
1680
1506
|
// Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit
|
|
1681
|
-
const jpgFile = tmpFile.replace(
|
|
1507
|
+
const jpgFile = tmpFile.replace(/\.png$/, '.jpg');
|
|
1682
1508
|
try {
|
|
1683
1509
|
await execFileAsync("sips", [
|
|
1684
1510
|
"-s", "format", "jpeg",
|
|
@@ -3433,6 +3259,8 @@ export async function scrollToElement({ selector, text, block = "center", timeou
|
|
|
3433
3259
|
// Navigate + wait + read — the most common 3-step workflow
|
|
3434
3260
|
export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
3435
3261
|
await refreshTargetWindow();
|
|
3262
|
+
// Suppress onbeforeunload dialogs (same as navigate())
|
|
3263
|
+
await runJS("window.onbeforeunload=null", { timeout: 2000 }).catch(() => {});
|
|
3436
3264
|
let targetUrl = url;
|
|
3437
3265
|
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
|
|
3438
3266
|
const safeUrl = targetUrl.replace(/"/g, '\\"');
|