safari-mcp 2.1.8 → 2.2.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 +23 -10
- package/index.js +38 -13
- package/mcp-helpers.js +299 -0
- package/package.json +2 -1
- package/safari.js +27 -12
package/extension/background.js
CHANGED
|
@@ -957,7 +957,9 @@ async function handleCommand(type, payload) {
|
|
|
957
957
|
// Learn profile window from newly created tab
|
|
958
958
|
if (!_profileWindowId) { _profileWindowId = updated.windowId; browser.storage.local.set({ mcpProfileWindowId: _profileWindowId }).catch(() => {}); }
|
|
959
959
|
// CRITICAL: Set new tab as the target for this session's subsequent commands
|
|
960
|
-
|
|
960
|
+
// Use the requested URL (not updated.url) when page hasn't loaded yet (still about:blank)
|
|
961
|
+
const trackUrl = (updated.url && updated.url !== "about:blank") ? updated.url : (payload.url || updated.url);
|
|
962
|
+
_setSessionTab(sessionId, updated.id, trackUrl);
|
|
961
963
|
return { title: updated.title, url: updated.url, tabIndex: updated.index + 1 };
|
|
962
964
|
}
|
|
963
965
|
|
|
@@ -1049,7 +1051,7 @@ async function handleCommand(type, payload) {
|
|
|
1049
1051
|
|
|
1050
1052
|
// --- Snapshot (accessibility tree with ref IDs) ---
|
|
1051
1053
|
case "snapshot": {
|
|
1052
|
-
return await execInTab((rootSelector) => {
|
|
1054
|
+
return await execInTab((rootSelector, snapshotGen) => {
|
|
1053
1055
|
// Clean ALL stale data-mcp-ref attributes from previous snapshots.
|
|
1054
1056
|
// Without this, old refs remain on DOM and findByRef/CSS selector can target WRONG elements.
|
|
1055
1057
|
document.querySelectorAll("[data-mcp-ref]").forEach(function(el) { el.removeAttribute("data-mcp-ref"); });
|
|
@@ -1099,7 +1101,7 @@ async function handleCommand(type, payload) {
|
|
|
1099
1101
|
|
|
1100
1102
|
const interactive = isInteractive(el);
|
|
1101
1103
|
const currentId = id++;
|
|
1102
|
-
const refId = "
|
|
1104
|
+
const refId = snapshotGen + "_" + currentId;
|
|
1103
1105
|
|
|
1104
1106
|
let attrs = "";
|
|
1105
1107
|
if (interactive) {
|
|
@@ -1209,7 +1211,7 @@ async function handleCommand(type, payload) {
|
|
|
1209
1211
|
tree += "\n[WARNING: Snapshot truncated at " + MAX_ELEMENTS + " elements. Use selector parameter to focus on a specific section.]";
|
|
1210
1212
|
}
|
|
1211
1213
|
return tree;
|
|
1212
|
-
}, [payload.selector || null], tabId);
|
|
1214
|
+
}, [payload.selector || null, payload.gen != null ? payload.gen : 0], tabId);
|
|
1213
1215
|
}
|
|
1214
1216
|
|
|
1215
1217
|
// --- Double Click ---
|
|
@@ -1768,15 +1770,26 @@ async function getActiveTab() {
|
|
|
1768
1770
|
return tabs[0];
|
|
1769
1771
|
}
|
|
1770
1772
|
|
|
1773
|
+
// Track which tabs already have the deep query helpers injected
|
|
1774
|
+
const _helpersInjected = new Set();
|
|
1775
|
+
// Clean up when tabs are removed or navigated
|
|
1776
|
+
browser.tabs.onRemoved.addListener((tabId) => { _helpersInjected.delete(tabId); });
|
|
1777
|
+
browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
|
1778
|
+
if (changeInfo.status === "loading") _helpersInjected.delete(tabId);
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1771
1781
|
async function execInTab(func, args = [], tabId = null) {
|
|
1772
1782
|
const id = tabId || (await getActiveTab()).id;
|
|
1773
1783
|
try {
|
|
1774
|
-
// Auto-inject deep query helpers
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1784
|
+
// Auto-inject deep query helpers — skip if already injected for this tab+page
|
|
1785
|
+
if (!_helpersInjected.has(id)) {
|
|
1786
|
+
await browser.scripting.executeScript({
|
|
1787
|
+
target: { tabId: id },
|
|
1788
|
+
world: "MAIN",
|
|
1789
|
+
func: _deepQueryScript,
|
|
1790
|
+
}).catch(() => {});
|
|
1791
|
+
_helpersInjected.add(id);
|
|
1792
|
+
}
|
|
1780
1793
|
|
|
1781
1794
|
const results = await browser.scripting.executeScript({
|
|
1782
1795
|
target: { tabId: id },
|
package/index.js
CHANGED
|
@@ -81,9 +81,17 @@ let _memoryCheckTimer = null;
|
|
|
81
81
|
function _startMemoryMonitor() {
|
|
82
82
|
_memoryCheckTimer = setInterval(async () => {
|
|
83
83
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
// Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
|
|
85
|
+
let psOut = "0";
|
|
86
|
+
try {
|
|
87
|
+
const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8" }).trim();
|
|
88
|
+
if (pids) {
|
|
89
|
+
const pidList = pids.split("\n").join(",");
|
|
90
|
+
psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
|
|
91
|
+
const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
|
|
92
|
+
psOut = String(totalKB / 1024);
|
|
93
|
+
}
|
|
94
|
+
} catch { psOut = "0"; }
|
|
87
95
|
const webkitMB = parseFloat(psOut) || 0;
|
|
88
96
|
if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
|
|
89
97
|
console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
|
|
@@ -491,7 +499,7 @@ const _commandTimeouts = {
|
|
|
491
499
|
wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
|
|
492
500
|
reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
|
|
493
501
|
double_click: 10000, right_click: 10000, clear_field: 5000, select_option: 5000, fill_form: 10000,
|
|
494
|
-
get_url: 3000, get_title: 3000,
|
|
502
|
+
replace_editor: 10000, get_url: 3000, get_title: 3000,
|
|
495
503
|
};
|
|
496
504
|
|
|
497
505
|
// Commands where null result means failure (should fall back to AppleScript)
|
|
@@ -628,8 +636,9 @@ server.tool(
|
|
|
628
636
|
"PREFERRED way to see page state. Returns accessibility tree with ref IDs for every interactive element. Use refs with click/fill/type instead of CSS selectors. Workflow: snapshot → see refs → click({ref:'0_5'}). PREFER THIS over safari_screenshot (cheaper, structured text vs heavy image) and over safari_read_page (includes interactive refs). Use safari_screenshot only when you need to see visual layout/styling.",
|
|
629
637
|
{ selector: z.string().optional().describe("CSS selector for subtree (default: full page)") },
|
|
630
638
|
async (args) => {
|
|
639
|
+
const gen = safari.getNextSnapshotGen();
|
|
631
640
|
const result = await extensionOrFallback(
|
|
632
|
-
"snapshot", { selector: args.selector },
|
|
641
|
+
"snapshot", { selector: args.selector, gen },
|
|
633
642
|
() => safari.takeSnapshot(args)
|
|
634
643
|
);
|
|
635
644
|
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
@@ -1083,20 +1092,36 @@ server.tool(
|
|
|
1083
1092
|
// Get current tab list
|
|
1084
1093
|
const beforeRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
|
|
1085
1094
|
const beforeTabs = typeof beforeRaw === 'string' ? JSON.parse(beforeRaw) : beforeRaw;
|
|
1086
|
-
const
|
|
1095
|
+
const beforeIds = new Set(beforeTabs.map(t => `${t.index}:${t.url}`));
|
|
1096
|
+
const beforeCount = beforeTabs.length;
|
|
1087
1097
|
|
|
1088
|
-
// Poll for new tab
|
|
1098
|
+
// Poll for new tab — detect by count increase + new entries (handles about:blank tabs)
|
|
1089
1099
|
const deadline = Date.now() + timeoutMs;
|
|
1090
1100
|
while (Date.now() < deadline) {
|
|
1091
1101
|
await new Promise(r => setTimeout(r, 500));
|
|
1092
1102
|
const nowRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
|
|
1093
1103
|
const nowTabs = typeof nowRaw === 'string' ? JSON.parse(nowRaw) : nowRaw;
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1104
|
+
if (nowTabs.length > beforeCount) {
|
|
1105
|
+
// Find the new tab(s) — could be about:blank initially (OAuth popups)
|
|
1106
|
+
for (const tab of nowTabs) {
|
|
1107
|
+
if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
|
|
1108
|
+
// Wait briefly for about:blank to resolve to actual URL
|
|
1109
|
+
if (tab.url === 'about:blank') {
|
|
1110
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
1111
|
+
const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
|
|
1112
|
+
const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
|
|
1113
|
+
const resolved = refreshedTabs.find(t => t.index === tab.index);
|
|
1114
|
+
if (resolved && resolved.url !== 'about:blank') {
|
|
1115
|
+
if (urlContains && !resolved.url.includes(urlContains)) continue;
|
|
1116
|
+
await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
|
|
1117
|
+
return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
|
|
1118
|
+
}
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (urlContains && !tab.url.includes(urlContains)) continue;
|
|
1122
|
+
await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
|
|
1123
|
+
return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
|
|
1124
|
+
}
|
|
1100
1125
|
}
|
|
1101
1126
|
}
|
|
1102
1127
|
}
|
package/mcp-helpers.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// MCP Click/Interaction Helpers — injected into every page
|
|
2
|
+
// This file is loaded by safari.js at startup, escaped, and injected via AppleScript `do JavaScript`.
|
|
3
|
+
// It is also used by the extension's execInTab for the AppleScript fallback path.
|
|
4
|
+
// IMPORTANT: Keep this file compatible with all browsers — no ES6+ modules, use var where possible.
|
|
5
|
+
|
|
6
|
+
if (window.__mcpVersion !== 5) {
|
|
7
|
+
window.__mcpVersion = 5;
|
|
8
|
+
window.__mcpRefs = window.__mcpRefs || {};
|
|
9
|
+
window.__mcpCachedRoots = null;
|
|
10
|
+
window.__mcpRootsDirty = true;
|
|
11
|
+
if (!window.__mcpRootsObserver) {
|
|
12
|
+
window.__mcpRootsObserver = new MutationObserver(function() { window.__mcpRootsDirty = true; });
|
|
13
|
+
window.__mcpRootsObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
14
|
+
}
|
|
15
|
+
window.mcpCollectRoots = function() {
|
|
16
|
+
if (!window.__mcpRootsDirty && window.__mcpCachedRoots) return window.__mcpCachedRoots;
|
|
17
|
+
var roots = [];
|
|
18
|
+
function collect(root) {
|
|
19
|
+
roots.push(root);
|
|
20
|
+
var all = root.querySelectorAll('*');
|
|
21
|
+
for (var i = 0; i < all.length; i++) {
|
|
22
|
+
if (all[i].shadowRoot) collect(all[i].shadowRoot);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
collect(document);
|
|
26
|
+
var iframes = document.querySelectorAll('iframe');
|
|
27
|
+
for (var i = 0; i < iframes.length; i++) {
|
|
28
|
+
try { var doc = iframes[i].contentDocument; if (doc) collect(doc); } catch (e) {}
|
|
29
|
+
}
|
|
30
|
+
window.__mcpCachedRoots = roots;
|
|
31
|
+
window.__mcpRootsDirty = false;
|
|
32
|
+
return roots;
|
|
33
|
+
};
|
|
34
|
+
window.mcpQuerySelectorDeep = function(selector) {
|
|
35
|
+
try {
|
|
36
|
+
var direct = document.querySelector(selector);
|
|
37
|
+
if (direct) return direct;
|
|
38
|
+
} catch (e) { return null; }
|
|
39
|
+
var roots = window.mcpCollectRoots();
|
|
40
|
+
for (var i = 1; i < roots.length; i++) {
|
|
41
|
+
try {
|
|
42
|
+
var found = roots[i].querySelector(selector);
|
|
43
|
+
if (found) return found;
|
|
44
|
+
} catch (e) {}
|
|
45
|
+
}
|
|
46
|
+
var iframes = document.querySelectorAll('iframe');
|
|
47
|
+
for (var i = 0; i < iframes.length; i++) {
|
|
48
|
+
try {
|
|
49
|
+
var doc = iframes[i].contentDocument;
|
|
50
|
+
if (doc) { var found = doc.querySelector(selector); if (found) return found; }
|
|
51
|
+
} catch (e) {}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
window.mcpElementFromPoint = function(x, y) {
|
|
56
|
+
var el = document.elementFromPoint(x, y);
|
|
57
|
+
while (el && el.shadowRoot) {
|
|
58
|
+
try {
|
|
59
|
+
if (typeof el.shadowRoot.elementFromPoint !== 'function') break;
|
|
60
|
+
var inner = el.shadowRoot.elementFromPoint(x, y);
|
|
61
|
+
if (!inner || inner === el) break;
|
|
62
|
+
el = inner;
|
|
63
|
+
} catch (e) { break; }
|
|
64
|
+
}
|
|
65
|
+
return el;
|
|
66
|
+
};
|
|
67
|
+
window.mcpIsVisible = function(el) {
|
|
68
|
+
if (!el || el.nodeType !== 1 || !el.isConnected) return false;
|
|
69
|
+
var cs = window.getComputedStyle(el);
|
|
70
|
+
if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || cs.pointerEvents === 'none' || cs.opacity === '0') return false;
|
|
71
|
+
var r = el.getBoundingClientRect();
|
|
72
|
+
return r.width > 0 && r.height > 0;
|
|
73
|
+
};
|
|
74
|
+
window.mcpIsActionable = function(el) {
|
|
75
|
+
if (!window.mcpIsVisible(el)) return false;
|
|
76
|
+
if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false;
|
|
77
|
+
if (el.matches && el.matches('input[type="hidden"]')) return false;
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
window.mcpPickActionable = function(el) {
|
|
81
|
+
var node = el && el.nodeType === 1 ? el : (el && el.parentElement) || null;
|
|
82
|
+
while (node) {
|
|
83
|
+
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;
|
|
84
|
+
node = node.parentElement;
|
|
85
|
+
}
|
|
86
|
+
return el && el.nodeType === 1 ? el : null;
|
|
87
|
+
};
|
|
88
|
+
window.mcpFindByAttr = function(attr, value, selector) {
|
|
89
|
+
if (!value) return null;
|
|
90
|
+
var roots = window.mcpCollectRoots();
|
|
91
|
+
var sel = selector || ('[' + attr + ']');
|
|
92
|
+
for (var i = 0; i < roots.length; i++) {
|
|
93
|
+
var els = roots[i].querySelectorAll(sel);
|
|
94
|
+
for (var j = 0; j < els.length; j++) {
|
|
95
|
+
var current = attr === 'href' ? els[j].href : els[j].getAttribute(attr);
|
|
96
|
+
if (current === value && window.mcpIsVisible(els[j])) return els[j];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
window.mcpNormalizeText = function(value) {
|
|
102
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
103
|
+
};
|
|
104
|
+
window.mcpResolveTarget = function(el) {
|
|
105
|
+
var base = window.mcpPickActionable(el) || el;
|
|
106
|
+
if (!base) return null;
|
|
107
|
+
try { base.scrollIntoView({ block: 'center', inline: 'center' }); } catch (e) {}
|
|
108
|
+
var r = base.getBoundingClientRect();
|
|
109
|
+
var dx = Math.min(12, Math.max(4, r.width / 4));
|
|
110
|
+
var dy = Math.min(12, Math.max(4, r.height / 4));
|
|
111
|
+
var pts = [
|
|
112
|
+
[r.left + r.width / 2, r.top + r.height / 2],
|
|
113
|
+
[r.left + dx, r.top + r.height / 2],
|
|
114
|
+
[r.right - dx, r.top + r.height / 2],
|
|
115
|
+
[r.left + r.width / 2, r.top + dy],
|
|
116
|
+
[r.left + r.width / 2, r.bottom - dy]
|
|
117
|
+
];
|
|
118
|
+
for (var i = 0; i < pts.length; i++) {
|
|
119
|
+
var x = pts[i][0], y = pts[i][1];
|
|
120
|
+
if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;
|
|
121
|
+
var hit = window.mcpElementFromPoint(x, y);
|
|
122
|
+
if (!hit) continue;
|
|
123
|
+
if (base === hit || base.contains(hit) || hit.contains(base)) return window.mcpPickActionable(hit) || hit;
|
|
124
|
+
// Overlay pattern: transparent button/link overlays a text label (e.g. dropdown items).
|
|
125
|
+
// If the hit is an interactive element sharing a nearby common ancestor, prefer it.
|
|
126
|
+
var hitAction = window.mcpPickActionable(hit);
|
|
127
|
+
if (hitAction && hitAction !== base && hitAction.matches && hitAction.matches('button,a[href],[role="button"],[role="option"],[role="menuitem"]')) {
|
|
128
|
+
var p = base.parentElement;
|
|
129
|
+
for (var k = 0; k < 4 && p; k++) {
|
|
130
|
+
if (p.contains(hitAction)) return hitAction;
|
|
131
|
+
p = p.parentElement;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return base;
|
|
136
|
+
};
|
|
137
|
+
window.mcpClick = function(el) {
|
|
138
|
+
var target = window.mcpResolveTarget(el);
|
|
139
|
+
if (!window.mcpIsActionable(target)) return false;
|
|
140
|
+
var r = target.getBoundingClientRect();
|
|
141
|
+
var x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
142
|
+
var beforeUrl = location.href;
|
|
143
|
+
var anchor = target.closest ? target.closest('a[href]') : null;
|
|
144
|
+
var href = anchor && anchor.href && !anchor.href.startsWith('javascript:') ? anchor.href : '';
|
|
145
|
+
var s = { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, button: 0, detail: 1 };
|
|
146
|
+
var p = { ...s, pointerId: 1, pointerType: 'mouse', isPrimary: true, width: 1, height: 1, pressure: 0.5 };
|
|
147
|
+
target.dispatchEvent(new PointerEvent('pointerover', { ...p, buttons: 0 }));
|
|
148
|
+
target.dispatchEvent(new MouseEvent('mouseover', { ...s, buttons: 0 }));
|
|
149
|
+
target.dispatchEvent(new PointerEvent('pointerenter', { ...p, buttons: 0 }));
|
|
150
|
+
target.dispatchEvent(new MouseEvent('mouseenter', { ...s, buttons: 0 }));
|
|
151
|
+
target.dispatchEvent(new PointerEvent('pointermove', { ...p, buttons: 0 }));
|
|
152
|
+
target.dispatchEvent(new MouseEvent('mousemove', { ...s, buttons: 0 }));
|
|
153
|
+
target.dispatchEvent(new PointerEvent('pointerdown', { ...p, buttons: 1 }));
|
|
154
|
+
target.dispatchEvent(new MouseEvent('mousedown', { ...s, buttons: 1 }));
|
|
155
|
+
if (target.focus) { try { target.focus({ preventScroll: true }); } catch (e) { try { target.focus(); } catch (_) {} } }
|
|
156
|
+
target.dispatchEvent(new PointerEvent('pointerup', { ...p, buttons: 0, pressure: 0 }));
|
|
157
|
+
target.dispatchEvent(new MouseEvent('mouseup', { ...s, buttons: 0 }));
|
|
158
|
+
try {
|
|
159
|
+
if (typeof target.click === 'function') {
|
|
160
|
+
target.click();
|
|
161
|
+
if (href && href !== beforeUrl) {
|
|
162
|
+
location.href = href;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {}
|
|
166
|
+
target.dispatchEvent(new MouseEvent('click', { ...s, buttons: 0 }));
|
|
167
|
+
if (href && href !== beforeUrl) {
|
|
168
|
+
location.href = href;
|
|
169
|
+
}
|
|
170
|
+
var form = target.closest ? target.closest('form') : null;
|
|
171
|
+
if (form && (target.type === 'submit' || (target.tagName === 'BUTTON' && target.type !== 'button' && target.type !== 'reset'))) {
|
|
172
|
+
try { form.requestSubmit ? form.requestSubmit(target.type === 'submit' ? target : undefined) : form.submit(); } catch (e) {}
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
};
|
|
176
|
+
window.mcpReactClick = function(el) {
|
|
177
|
+
var startNode = window.mcpResolveTarget(el) || el;
|
|
178
|
+
if (!startNode) return false;
|
|
179
|
+
function makeSynth(targetNode, type) {
|
|
180
|
+
var r = targetNode.getBoundingClientRect();
|
|
181
|
+
return { type: type || 'click', target: targetNode, currentTarget: targetNode,
|
|
182
|
+
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,
|
|
183
|
+
preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new MouseEvent(type || 'click'), persist: function() {}, bubbles: true, cancelable: true };
|
|
184
|
+
}
|
|
185
|
+
// For React radio/checkbox inputs, onChange is the primary handler (not onClick)
|
|
186
|
+
var isToggle = startNode.tagName === 'INPUT' && (startNode.type === 'radio' || startNode.type === 'checkbox');
|
|
187
|
+
function tryOnChange(propsObj, targetNode) {
|
|
188
|
+
if (!isToggle || !propsObj.onChange) return false;
|
|
189
|
+
var newChecked = startNode.type === 'radio' ? true : !startNode.checked;
|
|
190
|
+
propsObj.onChange({ target: { value: startNode.value, checked: newChecked, type: startNode.type, name: startNode.name, id: startNode.id, tagName: 'INPUT' },
|
|
191
|
+
currentTarget: targetNode, preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new Event('change'), persist: function() {}, bubbles: true, cancelable: true, type: 'change' });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
var node = startNode;
|
|
195
|
+
for (var depth = 0; depth < 15 && node; depth++) {
|
|
196
|
+
var pk = Object.keys(node).find(function(k) { return k.startsWith('__reactProps$'); });
|
|
197
|
+
if (pk && node[pk]) {
|
|
198
|
+
var props = node[pk];
|
|
199
|
+
if (tryOnChange(props, node)) return true;
|
|
200
|
+
if (props.onClick) { props.onClick(makeSynth(node, 'click')); return true; }
|
|
201
|
+
if (props.onMouseDown) { props.onMouseDown(makeSynth(node, 'mousedown')); return true; }
|
|
202
|
+
if (props.onPointerDown) { props.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
|
|
203
|
+
}
|
|
204
|
+
var fk = Object.keys(node).find(function(k) { return k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'); });
|
|
205
|
+
if (fk) {
|
|
206
|
+
var f = node[fk];
|
|
207
|
+
while (f) {
|
|
208
|
+
if (f.memoizedProps) {
|
|
209
|
+
if (tryOnChange(f.memoizedProps, node)) return true;
|
|
210
|
+
if (f.memoizedProps.onClick) { f.memoizedProps.onClick(makeSynth(node, 'click')); return true; }
|
|
211
|
+
if (f.memoizedProps.onMouseDown) { f.memoizedProps.onMouseDown(makeSynth(node, 'mousedown')); return true; }
|
|
212
|
+
if (f.memoizedProps.onPointerDown) { f.memoizedProps.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
|
|
213
|
+
}
|
|
214
|
+
f = f.return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
node = node.parentElement;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
};
|
|
221
|
+
window.mcpClickWithReact = function(el) {
|
|
222
|
+
var target = window.mcpResolveTarget(el) || el;
|
|
223
|
+
var reactFired = false;
|
|
224
|
+
try { reactFired = window.mcpReactClick(target); } catch (e) {}
|
|
225
|
+
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
|
226
|
+
if (!reactFired || anchor) window.mcpClick(target);
|
|
227
|
+
return target;
|
|
228
|
+
};
|
|
229
|
+
window.mcpFindRef = function(ref) {
|
|
230
|
+
var el = window.mcpQuerySelectorDeep('[data-mcp-ref="' + ref + '"]');
|
|
231
|
+
if (el) return el;
|
|
232
|
+
if (!window.__mcpRefs || !window.__mcpRefs[ref]) return null;
|
|
233
|
+
var m = window.__mcpRefs[ref];
|
|
234
|
+
if (m.id) { el = window.mcpFindByAttr('id', m.id); if (el) return el; }
|
|
235
|
+
if (m.testid) { el = window.mcpFindByAttr('data-testid', m.testid); if (el) return el; }
|
|
236
|
+
if (m.nameAttr) { el = window.mcpFindByAttr('name', m.nameAttr); if (el) return el; }
|
|
237
|
+
if (m.href) { el = window.mcpFindByAttr('href', m.href, 'a[href]'); if (el) return el; }
|
|
238
|
+
if (m.al) { el = window.mcpFindByAttr('aria-label', m.al); if (el) return el; }
|
|
239
|
+
if (m.ph) { el = window.mcpFindByAttr('placeholder', m.ph); if (el) return el; }
|
|
240
|
+
if (m.text) {
|
|
241
|
+
el = window.mcpFindText(m.text, true) || window.mcpFindText(m.text, false);
|
|
242
|
+
if (el) return el;
|
|
243
|
+
}
|
|
244
|
+
if (m.cx !== undefined && m.cy !== undefined) {
|
|
245
|
+
try {
|
|
246
|
+
window.scrollTo(window.scrollX, Math.max(0, m.cy - window.innerHeight / 2));
|
|
247
|
+
} catch (e) {}
|
|
248
|
+
el = window.mcpElementFromPoint(m.cx - window.scrollX, m.cy - window.scrollY);
|
|
249
|
+
if (el) return window.mcpPickActionable(el) || el;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
window.mcpFindText = function(text, exact) {
|
|
254
|
+
var needle = window.mcpNormalizeText(text);
|
|
255
|
+
var best = null, bestScore = Infinity;
|
|
256
|
+
function consider(node) {
|
|
257
|
+
var target = window.mcpPickActionable(node) || node;
|
|
258
|
+
if (!window.mcpIsVisible(target)) return;
|
|
259
|
+
var r = target.getBoundingClientRect();
|
|
260
|
+
var area = r.width * r.height;
|
|
261
|
+
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]');
|
|
262
|
+
var score = area + (interactive ? 0 : 1000000);
|
|
263
|
+
if (score < bestScore) { best = target; bestScore = score; }
|
|
264
|
+
}
|
|
265
|
+
var roots = window.mcpCollectRoots();
|
|
266
|
+
for (var i = 0; i < roots.length; i++) {
|
|
267
|
+
var attrEls = roots[i].querySelectorAll('[aria-label],[placeholder],[title],[data-testid],[alt]');
|
|
268
|
+
for (var j = 0; j < attrEls.length; j++) {
|
|
269
|
+
var a = attrEls[j];
|
|
270
|
+
var vals = [a.getAttribute('aria-label'), a.getAttribute('placeholder'), a.getAttribute('title'), a.getAttribute('data-testid'), a.getAttribute('alt')];
|
|
271
|
+
for (var k = 0; k < vals.length; k++) {
|
|
272
|
+
var val = vals[k];
|
|
273
|
+
if (!val) continue;
|
|
274
|
+
var normalized = window.mcpNormalizeText(val);
|
|
275
|
+
if (exact ? normalized === needle : normalized.includes(needle)) { consider(a); break; }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
var tw = document.createTreeWalker(roots[i], NodeFilter.SHOW_TEXT, null);
|
|
279
|
+
while (tw.nextNode()) {
|
|
280
|
+
var n = tw.currentNode;
|
|
281
|
+
var t = window.mcpNormalizeText(n.textContent);
|
|
282
|
+
if (!t) continue;
|
|
283
|
+
if (exact ? (t !== needle) : !t.includes(needle)) continue;
|
|
284
|
+
if (n.parentElement) consider(n.parentElement);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!best) {
|
|
288
|
+
var allEls = document.querySelectorAll('*');
|
|
289
|
+
for (var i = 0; i < allEls.length; i++) {
|
|
290
|
+
var el = allEls[i];
|
|
291
|
+
var it = window.mcpNormalizeText(el.innerText);
|
|
292
|
+
if (!it) continue;
|
|
293
|
+
if (exact ? (it !== needle) : !it.includes(needle)) continue;
|
|
294
|
+
consider(el);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return best;
|
|
298
|
+
};
|
|
299
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"files": [
|
|
50
50
|
"index.js",
|
|
51
51
|
"safari.js",
|
|
52
|
+
"mcp-helpers.js",
|
|
52
53
|
"safari-helper",
|
|
53
54
|
"safari-helper.swift",
|
|
54
55
|
"extension/",
|
package/safari.js
CHANGED
|
@@ -8,6 +8,7 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join, dirname } from "node:path";
|
|
10
10
|
import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
// Extension bridge is handled by index.js (WebSocket server on port 9223)
|
|
13
14
|
|
|
@@ -673,7 +674,12 @@ export async function getPageSource({ maxLength = 200000 } = {}) {
|
|
|
673
674
|
|
|
674
675
|
// Inject click helpers ONCE per page (cached on window.__mcp)
|
|
675
676
|
// Includes: mcpClick (full event sequence), mcpReactClick (React Fiber), mcpFindText (fast TreeWalker)
|
|
676
|
-
|
|
677
|
+
// Loaded from mcp-helpers.js at startup — enables syntax highlighting, linting, and easier maintenance
|
|
678
|
+
const INJECT_MCP_HELPERS = readFileSync(join(__dirname, 'mcp-helpers.js'), 'utf8');
|
|
679
|
+
|
|
680
|
+
// NOTE: Helper code previously inlined here (~300 lines) is now in mcp-helpers.js
|
|
681
|
+
/* eslint-disable */
|
|
682
|
+
const _LEGACY_INLINE_START = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
|
|
677
683
|
window.__mcpRefs=window.__mcpRefs||{};
|
|
678
684
|
window.__mcpCachedRoots=null;
|
|
679
685
|
window.__mcpRootsDirty=true;
|
|
@@ -1773,7 +1779,7 @@ export async function screenshotElement({ selector }) {
|
|
|
1773
1779
|
await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
1774
1780
|
const { x, y, w, h } = JSON.parse(bounds);
|
|
1775
1781
|
// Use sips to crop (macOS built-in)
|
|
1776
|
-
const toolbarHeight =
|
|
1782
|
+
const toolbarHeight = 74; // Safari toolbar approximate height (matches _getSafariWindowGeometry)
|
|
1777
1783
|
const cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
|
|
1778
1784
|
await execFileAsync("sips", [
|
|
1779
1785
|
"-c", String(h), String(w),
|
|
@@ -2439,6 +2445,7 @@ export async function savePDF({ path: pdfPath }) {
|
|
|
2439
2445
|
// instead of guessing CSS selectors. Much faster, no hallucination risk.
|
|
2440
2446
|
|
|
2441
2447
|
let _snapshotGen = 0;
|
|
2448
|
+
export function getNextSnapshotGen() { return _snapshotGen++; }
|
|
2442
2449
|
|
|
2443
2450
|
export async function takeSnapshot({ selector } = {}) {
|
|
2444
2451
|
const gen = _snapshotGen++;
|
|
@@ -2793,7 +2800,13 @@ export async function importStorageState({ state }) {
|
|
|
2793
2800
|
const parsed = typeof state === "string" ? JSON.parse(state) : state;
|
|
2794
2801
|
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
2795
2802
|
const cmds = [];
|
|
2796
|
-
|
|
2803
|
+
// Cookies must be set one at a time — document.cookie only accepts one cookie per assignment
|
|
2804
|
+
if (parsed.cookies) {
|
|
2805
|
+
const cookiePairs = String(parsed.cookies).split(/;\s*/);
|
|
2806
|
+
for (const pair of cookiePairs) {
|
|
2807
|
+
if (pair.trim()) cmds.push(`document.cookie='${esc(pair.trim())}'`);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2797
2810
|
if (parsed.localStorage) {
|
|
2798
2811
|
for (const [k, v] of Object.entries(parsed.localStorage)) {
|
|
2799
2812
|
cmds.push(`localStorage.setItem('${esc(k)}','${esc(v)}')`);
|
|
@@ -3368,8 +3381,9 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
|
3368
3381
|
const safeUrl = targetUrl.replace(/"/g, '\\"');
|
|
3369
3382
|
const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
|
|
3370
3383
|
await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
|
|
3384
|
+
_activeTabURL = targetUrl;
|
|
3371
3385
|
// Single JS call: poll readyState + return page data — 1 call instead of 30+
|
|
3372
|
-
|
|
3386
|
+
const navResult = await runJS(
|
|
3373
3387
|
`(async function(){
|
|
3374
3388
|
for(var i=0;i<60;i++){
|
|
3375
3389
|
if(document.readyState==='complete')break;
|
|
@@ -3379,6 +3393,12 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
|
3379
3393
|
})()`,
|
|
3380
3394
|
{ timeout: 30000 }
|
|
3381
3395
|
);
|
|
3396
|
+
// Update _activeTabURL with the actual URL after navigation
|
|
3397
|
+
try {
|
|
3398
|
+
const parsed = JSON.parse(navResult);
|
|
3399
|
+
if (parsed.url && parsed.url !== 'about:blank') _activeTabURL = parsed.url;
|
|
3400
|
+
} catch {}
|
|
3401
|
+
return navResult;
|
|
3382
3402
|
}
|
|
3383
3403
|
|
|
3384
3404
|
// Click + wait for navigation or element — common after clicking a link/button
|
|
@@ -3474,11 +3494,6 @@ export async function analyzePage() {
|
|
|
3474
3494
|
}
|
|
3475
3495
|
|
|
3476
3496
|
// ========== GRACEFUL SHUTDOWN ==========
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
}
|
|
3481
|
-
|
|
3482
|
-
process.on("exit", cleanup);
|
|
3483
|
-
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
3484
|
-
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
3497
|
+
// NOTE: Signal handlers are registered at the top of the file (cleanupHelper + process.exit).
|
|
3498
|
+
// _drainHelperQueue is called from cleanupHelper via process.on("exit").
|
|
3499
|
+
process.on("exit", () => { _drainHelperQueue("shutting down"); });
|