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.
@@ -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.opacity === "0") {
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 = promisify(execFile);
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.1",
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
@@ -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 'click'"])
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: Helper code previously inlined here (~300 lines) is now in mcp-helpers.js
700
- /* eslint-disable */
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('.png', '.jpg');
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, '\\"');