UNPKG

safari-mcp

Version:

Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.

468 lines (466 loc) • 24.6 kB
// MCP Click/Interaction Helpers — injected into every page // This file is loaded by safari.js at startup, escaped, and injected via AppleScript `do JavaScript`. // It is also used by the extension's execInTab for the AppleScript fallback path. // IMPORTANT: Keep compatible with all browsers — no ES6+ modules, use var where possible. if (window.__mcpVersion !== 7) { window.__mcpVersion = 7; window.__mcpRefs = window.__mcpRefs || {}; window.__mcpCachedRoots = null; window.__mcpRootsDirty = true; if (!window.__mcpRootsObserver) { window.__mcpRootsObserver = new MutationObserver(function() { window.__mcpRootsDirty = true; }); window.__mcpRootsObserver.observe(document.documentElement, { childList: true, subtree: true }); } window.mcpCollectRoots = function() { if (!window.__mcpRootsDirty && window.__mcpCachedRoots) return window.__mcpCachedRoots; var roots = []; // Closed roots come from the getter the extension's content.js installs at // document_start; without it mcpFindRef fell back to coordinates and returned the host. var getShadowRoot = window.__mcpGetShadowRoot || function(el) { return el.shadowRoot; }; function collect(root) { roots.push(root); var all = root.querySelectorAll('*'); for (var i = 0; i < all.length; i++) { var shadow = getShadowRoot(all[i]); if (shadow) collect(shadow); } } collect(document); var iframes = document.querySelectorAll('iframe'); for (var i = 0; i < iframes.length; i++) { try { var doc = iframes[i].contentDocument; if (doc) collect(doc); } catch (e) {} } window.__mcpCachedRoots = roots; window.__mcpRootsDirty = false; return roots; }; window.mcpQuerySelectorDeep = function(selector) { try { var direct = document.querySelector(selector); if (direct) return direct; } catch (e) { return null; } var roots = window.mcpCollectRoots(); for (var i = 1; i < roots.length; i++) { try { var found = roots[i].querySelector(selector); if (found) return found; } catch (e) {} } var iframes = document.querySelectorAll('iframe'); for (var i = 0; i < iframes.length; i++) { try { var doc = iframes[i].contentDocument; if (doc) { var found = doc.querySelector(selector); if (found) return found; } } catch (e) {} } return null; }; window.mcpElementFromPoint = function(x, y) { var el = document.elementFromPoint(x, y); while (el && el.shadowRoot) { try { if (typeof el.shadowRoot.elementFromPoint !== 'function') break; var inner = el.shadowRoot.elementFromPoint(x, y); if (!inner || inner === el) break; el = inner; } catch (e) { break; } } return el; }; window.mcpIsVisible = function(el) { if (!el || el.nodeType !== 1 || !el.isConnected) return false; var cs = window.getComputedStyle(el); if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || parseFloat(cs.opacity) === 0) return false; var r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; window.mcpIsActionable = function(el) { if (!window.mcpIsVisible(el)) return false; if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false; if (el.matches && el.matches('input[type="hidden"]')) return false; var cs = window.getComputedStyle(el); if (cs && cs.pointerEvents === 'none') return false; return true; }; window.mcpPickActionable = function(el) { var node = el && el.nodeType === 1 ? el : (el && el.parentElement) || null; while (node) { 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; node = node.parentElement; } return el && el.nodeType === 1 ? el : null; }; window.mcpFindByAttr = function(attr, value, selector) { if (!value) return null; var roots = window.mcpCollectRoots(); var sel = selector || ('[' + attr + ']'); for (var i = 0; i < roots.length; i++) { var els = roots[i].querySelectorAll(sel); for (var j = 0; j < els.length; j++) { var current = attr === 'href' ? els[j].href : els[j].getAttribute(attr); if (current === value && window.mcpIsVisible(els[j])) return els[j]; } } return null; }; window.mcpNormalizeText = function(value) { return String(value || '').replace(/\s+/g, ' ').trim(); }; window.mcpResolveTarget = function(el) { var base = window.mcpPickActionable(el) || el; if (!base) return null; // A form control stays the target. A consent checkbox's label often wraps a policy link, // and the probe points below can land on that link (or on the label around a hidden // input), so the click would follow the link instead of toggling the box. var baseIsControl = !!(base.matches && base.matches('input:not([type="hidden"]),select,textarea,label')); var stealsControl = function(cand) { if (!baseIsControl || cand === base) return false; return cand.contains(base) || !!(cand.matches && cand.matches('a[href],button,input,select,textarea')); }; try { base.scrollIntoView({ block: 'center', inline: 'center' }); } catch (e) {} var r = base.getBoundingClientRect(); var dx = Math.min(12, Math.max(4, r.width / 4)); var dy = Math.min(12, Math.max(4, r.height / 4)); var pts = [ [r.left + r.width / 2, r.top + r.height / 2], [r.left + dx, r.top + r.height / 2], [r.right - dx, r.top + r.height / 2], [r.left + r.width / 2, r.top + dy], [r.left + r.width / 2, r.bottom - dy] ]; for (var i = 0; i < pts.length; i++) { var x = pts[i][0], y = pts[i][1]; if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue; var hit = window.mcpElementFromPoint(x, y); if (!hit) continue; if (base === hit || base.contains(hit) || hit.contains(base)) { var picked = window.mcpPickActionable(hit) || hit; return stealsControl(picked) ? base : picked; } // Overlay pattern: transparent button/link overlays a text label (e.g. dropdown items). // If the hit is an interactive element sharing a nearby common ancestor, prefer it. var hitAction = window.mcpPickActionable(hit); if (hitAction && hitAction !== base && !stealsControl(hitAction) && hitAction.matches && hitAction.matches('button,a[href],[role="button"],[role="option"],[role="menuitem"]')) { var p = base.parentElement; for (var k = 0; k < 4 && p; k++) { if (p.contains(hitAction)) return hitAction; p = p.parentElement; } } } return base; }; window.mcpClick = function(el) { var target = window.mcpResolveTarget(el); if (!window.mcpIsActionable(target)) return false; var r = target.getBoundingClientRect(); var x = r.left + r.width / 2, y = r.top + r.height / 2; var beforeUrl = location.href; var anchor = target.closest ? target.closest('a[href]') : null; var href = anchor && anchor.href && !anchor.href.startsWith('javascript:') ? anchor.href : ''; // Dispatch from the LEAF under the click point, not from the actionable ancestor. // A real click's `event.target` is the topmost node at that pixel — the inner label — // while `currentTarget` is the <a>/<button>. Handlers that gate on e.target (Facebook's // rel="dialog" links, most SPA routers) see a target they never expected when the event // is fired straight at the <a>, bail out, and the click silently degrades into a plain // navigation. Firing from the leaf reproduces the real bubble path exactly. var from = window.mcpElementFromPoint(x, y); if (!from || !(target === from || target.contains(from))) from = target; // A leaf inside a link nested in the target (the policy link in a checkbox's label) // would carry the click into that link. var leafLink = from.closest ? from.closest('a[href]') : null; if (leafLink && leafLink !== target && target.contains(leafLink)) from = target; var s = { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, button: 0, detail: 1 }; var p = { ...s, pointerId: 1, pointerType: 'mouse', isPrimary: true, width: 1, height: 1, pressure: 0.5 }; from.dispatchEvent(new PointerEvent('pointerover', { ...p, buttons: 0 })); from.dispatchEvent(new MouseEvent('mouseover', { ...s, buttons: 0 })); from.dispatchEvent(new PointerEvent('pointerenter', { ...p, buttons: 0 })); from.dispatchEvent(new MouseEvent('mouseenter', { ...s, buttons: 0 })); from.dispatchEvent(new PointerEvent('pointermove', { ...p, buttons: 0 })); from.dispatchEvent(new MouseEvent('mousemove', { ...s, buttons: 0 })); from.dispatchEvent(new PointerEvent('pointerdown', { ...p, buttons: 1 })); from.dispatchEvent(new MouseEvent('mousedown', { ...s, buttons: 1 })); // Focus the actionable element, not the leaf — a <span> inside a button isn't focusable. if (target.focus) { try { target.focus({ preventScroll: true }); } catch (e) { try { target.focus(); } catch (_) {} } } from.dispatchEvent(new PointerEvent('pointerup', { ...p, buttons: 0, pressure: 0 })); from.dispatchEvent(new MouseEvent('mouseup', { ...s, buttons: 0 })); // .click() on an anchor (or a node inside one) triggers the browser's own navigation // with target === the anchor, which is the exact shape the gate rejects. For anchors, // let the dispatched event below carry the default action instead. // A checkbox, a radio or a label bound to one toggles on the dispatched click below too, // so .click() first toggled it twice and left it as it was. The extension path already // dispatches a single click. var toggles = (target.tagName === 'INPUT' && (target.type === 'checkbox' || target.type === 'radio')) || (target.tagName === 'LABEL' && !!target.control); try { if (!href && !toggles && typeof from.click === 'function') from.click(); } catch (e) {} var clickEv = new MouseEvent('click', { ...s, buttons: 0 }); var notPrevented = from.dispatchEvent(clickEv); // Navigate only if nothing claimed the click. A handler that called preventDefault owns // it — it just opened a dialog or pushed an SPA route, and forcing location.href here // would tear that down. (dispatchEvent returns false iff preventDefault was called.) if (href && notPrevented && location.href === beforeUrl) { location.href = href; } // Vue v-model / Lit @change / framework-agnostic toggle sync — for checkbox/radio, // Vue 3 listens for `change` via v-model; some Vue components subscribe to `input` instead. // Native target.click() fires both, but in production-stripped Vue apps (no fiber/proxy // visible), the reactivity sometimes misses the events when fired in the same microtask // as a synthetic click. Belt-and-suspenders: re-dispatch input+change with composed:true // and reset React's _valueTracker so subsequent submits see the new value. if (target.tagName === 'INPUT' && (target.type === 'checkbox' || target.type === 'radio')) { try { if (target._valueTracker && typeof target._valueTracker.setValue === 'function') target._valueTracker.setValue(''); } catch (_e) {} var toggleOpts = { bubbles: true, cancelable: true, composed: true }; target.dispatchEvent(new Event('input', toggleOpts)); target.dispatchEvent(new Event('change', toggleOpts)); } // (Navigation is handled above, gated on notPrevented. A second unconditional // `location.href = href` used to live here and fired even when a handler had called // preventDefault — tearing down the dialog/route it had just opened.) var form = target.closest ? target.closest('form') : null; if (form && (target.type === 'submit' || (target.tagName === 'BUTTON' && target.type !== 'button' && target.type !== 'reset'))) { try { form.requestSubmit ? form.requestSubmit(target.type === 'submit' ? target : undefined) : form.submit(); } catch (e) {} } return true; }; window.mcpReactClick = function(el) { var startNode = window.mcpResolveTarget(el) || el; if (!startNode) return false; // React 18-compatible SyntheticEvent — includes isDefaultPrevented/isPropagationStopped // methods that React internally checks. Without them, some portal-rendered components // (HackerNoon modal Submit, Radix dropdowns) silently no-op. function makeSynth(targetNode, type) { var r = targetNode.getBoundingClientRect(); var native = type === 'click' || type === 'mousedown' || type === 'mouseup' || type === 'mousemove' ? new MouseEvent(type, { bubbles: true, cancelable: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2 }) : type === 'pointerdown' || type === 'pointerup' ? new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse' }) : new Event(type || 'click', { bubbles: true, cancelable: true }); var defaultPrevented = false, propagationStopped = false; return { type: type || 'click', target: targetNode, currentTarget: targetNode, 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, screenX: r.left + r.width / 2, screenY: r.top + r.height / 2, button: 0, buttons: 0, detail: 1, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, preventDefault: function() { defaultPrevented = true; if (native.preventDefault) native.preventDefault(); }, stopPropagation: function() { propagationStopped = true; if (native.stopPropagation) native.stopPropagation(); }, stopImmediatePropagation: function() { propagationStopped = true; }, isDefaultPrevented: function() { return defaultPrevented; }, isPropagationStopped: function() { return propagationStopped; }, defaultPrevented: false, cancelBubble: false, nativeEvent: native, persist: function() {}, bubbles: true, cancelable: true, eventPhase: 2, isTrusted: false, timeStamp: Date.now(), composed: true, view: window }; } // For React radio/checkbox inputs, onChange is the primary handler (not onClick) var isToggle = startNode.tagName === 'INPUT' && (startNode.type === 'radio' || startNode.type === 'checkbox'); function tryOnChange(propsObj, targetNode) { if (!isToggle || !propsObj.onChange) return false; var newChecked = startNode.type === 'radio' ? true : !startNode.checked; var ev = makeSynth(targetNode, 'change'); ev.target = { value: startNode.value, checked: newChecked, type: startNode.type, name: startNode.name, id: startNode.id, tagName: 'INPUT' }; propsObj.onClick && propsObj.onClick(ev); propsObj.onChange(ev); return true; } // Try a single React props bag: returns true if ANY handler fired. // Order matters — onPointerDown/onMouseDown sometimes opens modals, then onClick commits. function tryProps(propsObj, targetNode) { if (!propsObj) return false; if (tryOnChange(propsObj, targetNode)) return true; var fired = false; if (typeof propsObj.onPointerDown === 'function') { try { propsObj.onPointerDown(makeSynth(targetNode, 'pointerdown')); fired = true; } catch (_e) {} } if (typeof propsObj.onMouseDown === 'function') { try { propsObj.onMouseDown(makeSynth(targetNode, 'mousedown')); fired = true; } catch (_e) {} } if (typeof propsObj.onMouseUp === 'function') { try { propsObj.onMouseUp(makeSynth(targetNode, 'mouseup')); fired = true; } catch (_e) {} } if (typeof propsObj.onPointerUp === 'function') { try { propsObj.onPointerUp(makeSynth(targetNode, 'pointerup')); fired = true; } catch (_e) {} } if (typeof propsObj.onClick === 'function') { try { propsObj.onClick(makeSynth(targetNode, 'click')); fired = true; } catch (_e) {} } return fired; } var node = startNode; for (var depth = 0; depth < 15 && node; depth++) { var pk = Object.keys(node).find(function(k) { return k.startsWith('__reactProps$'); }); if (pk && node[pk] && tryProps(node[pk], node)) return true; var fk = Object.keys(node).find(function(k) { return k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'); }); if (fk) { // Walk fiber chain UP via .return (covers portals — Fiber tree is logical, not DOM-shaped). var f = node[fk], hops = 0; while (f && hops < 30) { if (tryProps(f.memoizedProps, node)) return true; // Components written as forwardRef put the click handler on stateNode.props if (f.stateNode && f.stateNode.props && tryProps(f.stateNode.props, node)) return true; f = f.return; hops++; } } node = node.parentElement; } return false; }; window.mcpClickWithReact = function(el) { var target = window.mcpResolveTarget(el) || el; var reactFired = false; try { reactFired = window.mcpReactClick(target); } catch (e) {} var anchor = target && target.closest ? target.closest('a[href]') : null; if (!reactFired || anchor) window.mcpClick(target); return target; }; window.mcpFindRef = function(ref) { var el = window.mcpQuerySelectorDeep('[data-mcp-ref="' + ref + '"]'); if (el) return el; if (!window.__mcpRefs || !window.__mcpRefs[ref]) return null; var m = window.__mcpRefs[ref]; if (m.id) { el = window.mcpFindByAttr('id', m.id); if (el) return el; } if (m.testid) { el = window.mcpFindByAttr('data-testid', m.testid); if (el) return el; } if (m.nameAttr) { el = window.mcpFindByAttr('name', m.nameAttr); if (el) return el; } if (m.href) { el = window.mcpFindByAttr('href', m.href, 'a[href]'); if (el) return el; } if (m.al) { el = window.mcpFindByAttr('aria-label', m.al); if (el) return el; } if (m.ph) { el = window.mcpFindByAttr('placeholder', m.ph); if (el) return el; } if (m.text) { el = window.mcpFindText(m.text, true) || window.mcpFindText(m.text, false); if (el) return el; } if (m.cx !== undefined && m.cy !== undefined) { try { window.scrollTo(window.scrollX, Math.max(0, m.cy - window.innerHeight / 2)); } catch (e) {} el = window.mcpElementFromPoint(m.cx - window.scrollX, m.cy - window.scrollY); if (el) return window.mcpPickActionable(el) || el; } return null; }; window.mcpFindText = function(text, exact) { var needle = window.mcpNormalizeText(text); var best = null, bestScore = Infinity; function consider(node) { var target = window.mcpPickActionable(node) || node; if (!window.mcpIsVisible(target)) return; var r = target.getBoundingClientRect(); var area = r.width * r.height; 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]'); var score = area + (interactive ? 0 : 1000000); if (score < bestScore) { best = target; bestScore = score; } } var roots = window.mcpCollectRoots(); for (var i = 0; i < roots.length; i++) { var attrEls = roots[i].querySelectorAll('[aria-label],[placeholder],[title],[data-testid],[alt]'); for (var j = 0; j < attrEls.length; j++) { var a = attrEls[j]; var vals = [a.getAttribute('aria-label'), a.getAttribute('placeholder'), a.getAttribute('title'), a.getAttribute('data-testid'), a.getAttribute('alt')]; for (var k = 0; k < vals.length; k++) { var val = vals[k]; if (!val) continue; var normalized = window.mcpNormalizeText(val); if (exact ? normalized === needle : normalized.includes(needle)) { consider(a); break; } } } var tw = document.createTreeWalker(roots[i], NodeFilter.SHOW_TEXT, null); while (tw.nextNode()) { var n = tw.currentNode; var t = window.mcpNormalizeText(n.textContent); if (!t) continue; if (exact ? (t !== needle) : !t.includes(needle)) continue; if (n.parentElement) consider(n.parentElement); } } if (!best) { var allEls = document.querySelectorAll('*'); for (var i = 0; i < allEls.length; i++) { var el = allEls[i]; var it = window.mcpNormalizeText(el.innerText); if (!it) continue; if (exact ? (it !== needle) : !it.includes(needle)) continue; consider(el); } } return best; }; // React-Select v5 / Radix-style controlled-select bypass: walks fiber up from the // target element to find a Select component (props.options + props.onChange), then // invokes onChange directly with the matching option. Avoids menu UI entirely — // critical for Cloudflare custom-token forms where the dropdown indicator stops // responding to dispatched clicks after a few rows. window.mcpReactSelectFindInstance = function(el) { if (!el) return null; var node = el; for (var d = 0; d < 25 && node; d++) { var keys = Object.keys(node); var fk; for (var ki = 0; ki < keys.length; ki++) { if (keys[ki].indexOf('__reactFiber$') === 0 || keys[ki].indexOf('__reactInternalInstance$') === 0) { fk = keys[ki]; break; } } if (fk) { var fiber = node[fk], hops = 0; while (fiber && hops < 60) { var p = fiber.memoizedProps || fiber.pendingProps || (fiber.stateNode && fiber.stateNode.props); if (p && Array.isArray(p.options) && typeof p.onChange === 'function') { return { props: p, fiber: fiber }; } fiber = fiber.return; hops++; } } node = node.parentElement; } return null; }; window.mcpReactSelectFlatten = function(options) { var flat = []; for (var i = 0; i < options.length; i++) { var o = options[i]; if (o && Array.isArray(o.options)) { for (var j = 0; j < o.options.length; j++) flat.push(o.options[j]); } else if (o) { flat.push(o); } } return flat; }; window.mcpReactSelectSet = function(el, optionLabel) { var found = window.mcpReactSelectFindInstance(el); if (!found) return JSON.stringify({ ok: false, error: 'no react-select Select component found in fiber tree' }); var p = found.props; var flat = window.mcpReactSelectFlatten(p.options); if (!flat.length) return JSON.stringify({ ok: false, error: 'Select component has no options (try opening the menu first to populate)' }); var needle = String(optionLabel); var target = null; for (var i = 0; i < flat.length; i++) { if (flat[i] && (flat[i].label === needle || flat[i].value === needle)) { target = flat[i]; break; } } if (!target) { var lcNeedle = needle.toLowerCase(); for (var k = 0; k < flat.length; k++) { var lab = String(flat[k] && (flat[k].label !== undefined ? flat[k].label : flat[k].value) || '').toLowerCase(); if (lab === lcNeedle) { target = flat[k]; break; } } } if (!target) { return JSON.stringify({ ok: false, error: 'option not found', searched: needle, total: flat.length, available: flat.slice(0, 30).map(function(o) { return o && (o.label !== undefined ? o.label : o.value); }) }); } var action = { action: 'select-option', option: target, name: p.name }; try { p.onChange(target, action); return JSON.stringify({ ok: true, selected: target.label !== undefined ? target.label : target.value }); } catch (e) { return JSON.stringify({ ok: false, error: 'onChange threw: ' + e.message }); } }; window.mcpReactSelectListOptions = function(el) { var found = window.mcpReactSelectFindInstance(el); if (!found) return JSON.stringify({ ok: false, error: 'no react-select Select component found' }); var flat = window.mcpReactSelectFlatten(found.props.options); return JSON.stringify({ ok: true, total: flat.length, options: flat.map(function(o) { return { label: o && o.label, value: o && o.value }; }) }); }; }