safari-mcp 2.3.1 → 2.4.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.4.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.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
  }
@@ -696,301 +696,8 @@ export async function getPageSource({ maxLength = 200000 } = {}) {
696
696
  // Loaded from mcp-helpers.js at startup — enables syntax highlighting, linting, and easier maintenance
697
697
  const INJECT_MCP_HELPERS = readFileSync(join(__dirname, 'mcp-helpers.js'), 'utf8');
698
698
 
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
- }`;
699
+ // NOTE: ~300 lines of legacy inline helpers were here — now in mcp-helpers.js (deleted in v2.4.0)
700
+
994
701
 
995
702
  // Precomputed escaped helper string — avoids re-escaping ~4KB on every injection call
996
703
  const _HELPERS_ESCAPED = INJECT_MCP_HELPERS
@@ -1219,6 +926,7 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
1219
926
 
1220
927
  export async function fill({ selector, value, ref }) {
1221
928
  if (ref) selector = refSelector(ref);
929
+ if (!selector) throw new Error("fill requires selector or ref");
1222
930
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1223
931
  // Proper escaping order: backslashes first, then quotes
1224
932
  const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
@@ -1678,7 +1386,7 @@ export async function screenshot({ fullPage = false } = {}) {
1678
1386
  }
1679
1387
  // Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
1680
1388
  // Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit
1681
- const jpgFile = tmpFile.replace('.png', '.jpg');
1389
+ const jpgFile = tmpFile.replace(/\.png$/, '.jpg');
1682
1390
  try {
1683
1391
  await execFileAsync("sips", [
1684
1392
  "-s", "format", "jpeg",
@@ -3433,6 +3141,8 @@ export async function scrollToElement({ selector, text, block = "center", timeou
3433
3141
  // Navigate + wait + read — the most common 3-step workflow
3434
3142
  export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
3435
3143
  await refreshTargetWindow();
3144
+ // Suppress onbeforeunload dialogs (same as navigate())
3145
+ await runJS("window.onbeforeunload=null", { timeout: 2000 }).catch(() => {});
3436
3146
  let targetUrl = url;
3437
3147
  if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
3438
3148
  const safeUrl = targetUrl.replace(/"/g, '\\"');