safari-mcp 2.1.7 → 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 +51 -15
- package/mcp-helpers.js +299 -0
- package/package.json +2 -1
- package/safari.js +47 -32
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`);
|
|
@@ -170,8 +178,19 @@ function _setupExtensionListener(ws) {
|
|
|
170
178
|
// ========== HTTP POLLING SERVER (for Safari extensions — WebSocket blocked) ==========
|
|
171
179
|
try {
|
|
172
180
|
const httpServer = createServer((req, res) => {
|
|
173
|
-
// CORS headers
|
|
174
|
-
|
|
181
|
+
// CORS headers — restricted to browser extension origin only.
|
|
182
|
+
// Safari extensions use moz-extension:// or safari-web-extension:// origins.
|
|
183
|
+
// "*" was a security risk: any webpage could POST to localhost:9224 and execute MCP commands.
|
|
184
|
+
const origin = req.headers.origin || "";
|
|
185
|
+
const isSafeOrigin = !origin || origin.startsWith("safari-web-extension://") || origin.startsWith("moz-extension://") || origin.startsWith("chrome-extension://");
|
|
186
|
+
if (isSafeOrigin) {
|
|
187
|
+
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
|
188
|
+
} else {
|
|
189
|
+
// Block cross-origin requests from web pages
|
|
190
|
+
res.writeHead(403);
|
|
191
|
+
res.end("Forbidden: cross-origin request blocked");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
175
194
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
176
195
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
177
196
|
|
|
@@ -480,7 +499,7 @@ const _commandTimeouts = {
|
|
|
480
499
|
wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
|
|
481
500
|
reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
|
|
482
501
|
double_click: 10000, right_click: 10000, clear_field: 5000, select_option: 5000, fill_form: 10000,
|
|
483
|
-
get_url: 3000, get_title: 3000,
|
|
502
|
+
replace_editor: 10000, get_url: 3000, get_title: 3000,
|
|
484
503
|
};
|
|
485
504
|
|
|
486
505
|
// Commands where null result means failure (should fall back to AppleScript)
|
|
@@ -617,8 +636,9 @@ server.tool(
|
|
|
617
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.",
|
|
618
637
|
{ selector: z.string().optional().describe("CSS selector for subtree (default: full page)") },
|
|
619
638
|
async (args) => {
|
|
639
|
+
const gen = safari.getNextSnapshotGen();
|
|
620
640
|
const result = await extensionOrFallback(
|
|
621
|
-
"snapshot", { selector: args.selector },
|
|
641
|
+
"snapshot", { selector: args.selector, gen },
|
|
622
642
|
() => safari.takeSnapshot(args)
|
|
623
643
|
);
|
|
624
644
|
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
@@ -1072,20 +1092,36 @@ server.tool(
|
|
|
1072
1092
|
// Get current tab list
|
|
1073
1093
|
const beforeRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
|
|
1074
1094
|
const beforeTabs = typeof beforeRaw === 'string' ? JSON.parse(beforeRaw) : beforeRaw;
|
|
1075
|
-
const
|
|
1095
|
+
const beforeIds = new Set(beforeTabs.map(t => `${t.index}:${t.url}`));
|
|
1096
|
+
const beforeCount = beforeTabs.length;
|
|
1076
1097
|
|
|
1077
|
-
// Poll for new tab
|
|
1098
|
+
// Poll for new tab — detect by count increase + new entries (handles about:blank tabs)
|
|
1078
1099
|
const deadline = Date.now() + timeoutMs;
|
|
1079
1100
|
while (Date.now() < deadline) {
|
|
1080
1101
|
await new Promise(r => setTimeout(r, 500));
|
|
1081
1102
|
const nowRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
|
|
1082
1103
|
const nowTabs = typeof nowRaw === 'string' ? JSON.parse(nowRaw) : nowRaw;
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
+
}
|
|
1089
1125
|
}
|
|
1090
1126
|
}
|
|
1091
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
|
|
|
@@ -650,7 +651,7 @@ export async function reload(hardReload = false) {
|
|
|
650
651
|
|
|
651
652
|
export async function readPage({ selector, maxLength = 50000 } = {}) {
|
|
652
653
|
if (selector) {
|
|
653
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
654
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
654
655
|
return runJS(
|
|
655
656
|
`(function(){
|
|
656
657
|
var el = document.querySelector('${sel}');
|
|
@@ -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;
|
|
@@ -1044,14 +1050,14 @@ export async function click({ selector, text, x, y, ref }) {
|
|
|
1044
1050
|
}
|
|
1045
1051
|
|
|
1046
1052
|
if (selector) {
|
|
1047
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
1053
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1048
1054
|
return clickWithRetry(
|
|
1049
1055
|
`(function(){var el=mcpQuerySelectorDeep('${sel}');if(!el)return 'Element not found: ${sel}';var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
|
|
1050
1056
|
);
|
|
1051
1057
|
}
|
|
1052
1058
|
|
|
1053
1059
|
if (text) {
|
|
1054
|
-
const safeText = text.replace(/'/g, "\\'");
|
|
1060
|
+
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1055
1061
|
return clickWithRetry(
|
|
1056
1062
|
`(function(){var el=mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false);if(!el)return 'Element not found with text: ${safeText}';var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' "'+((target.innerText||target.textContent)||'').trim().substring(0,50)+'"';})()`
|
|
1057
1063
|
);
|
|
@@ -1068,7 +1074,7 @@ export async function click({ selector, text, x, y, ref }) {
|
|
|
1068
1074
|
export async function doubleClick({ selector, x, y, ref }) {
|
|
1069
1075
|
if (ref) selector = refSelector(ref);
|
|
1070
1076
|
if (selector) {
|
|
1071
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
1077
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1072
1078
|
return runJS(
|
|
1073
1079
|
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true,cancelable:true}));return 'Double-clicked: '+el.tagName;})()`
|
|
1074
1080
|
);
|
|
@@ -1083,7 +1089,7 @@ export async function doubleClick({ selector, x, y, ref }) {
|
|
|
1083
1089
|
|
|
1084
1090
|
export async function rightClick({ selector, x, y }) {
|
|
1085
1091
|
if (selector) {
|
|
1086
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
1092
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1087
1093
|
return runJS(
|
|
1088
1094
|
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,button:2}));return 'Right-clicked: '+el.tagName;})()`
|
|
1089
1095
|
);
|
|
@@ -1122,7 +1128,7 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
|
|
|
1122
1128
|
});
|
|
1123
1129
|
})()`;
|
|
1124
1130
|
} else if (selector) {
|
|
1125
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
1131
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1126
1132
|
jsExpr = `(function(){
|
|
1127
1133
|
var el = document.querySelector('${sel}');
|
|
1128
1134
|
if (!el) return JSON.stringify({error: 'Element not found: ${sel}'});
|
|
@@ -1136,7 +1142,7 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
|
|
|
1136
1142
|
});
|
|
1137
1143
|
})()`;
|
|
1138
1144
|
} else {
|
|
1139
|
-
const safeText = text.replace(/'/g, "\\'");
|
|
1145
|
+
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1140
1146
|
jsExpr = `(function(){
|
|
1141
1147
|
var el = mcpFindText('${safeText}', true) || mcpFindText('${safeText}', false);
|
|
1142
1148
|
if (!el) return JSON.stringify({error: 'Element not found with text: ${safeText}'});
|
|
@@ -1194,7 +1200,7 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
|
|
|
1194
1200
|
|
|
1195
1201
|
export async function fill({ selector, value, ref }) {
|
|
1196
1202
|
if (ref) selector = refSelector(ref);
|
|
1197
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
1203
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1198
1204
|
// Proper escaping order: backslashes first, then quotes
|
|
1199
1205
|
const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
1200
1206
|
const result = await runJS(
|
|
@@ -1248,14 +1254,14 @@ export async function fill({ selector, value, ref }) {
|
|
|
1248
1254
|
}
|
|
1249
1255
|
|
|
1250
1256
|
export async function clearField({ selector }) {
|
|
1251
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
1257
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1252
1258
|
return runJS(
|
|
1253
1259
|
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';if(el.isContentEditable){el.focus();document.execCommand('selectAll');document.execCommand('delete');el.dispatchEvent(new Event('input',{bubbles:true}));return 'Cleared (contenteditable)';}var t=el._valueTracker;if(t)t.setValue('x');var p=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;var d=Object.getOwnPropertyDescriptor(p,'value');if(d&&d.set)d.set.call(el,'');else el.value='';el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Cleared';})()`
|
|
1254
1260
|
);
|
|
1255
1261
|
}
|
|
1256
1262
|
|
|
1257
1263
|
export async function selectOption({ selector, value }) {
|
|
1258
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
1264
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1259
1265
|
const val = value.replace(/'/g, "\\'");
|
|
1260
1266
|
return runJS(
|
|
1261
1267
|
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';el.focus();var t=el._valueTracker;if(t)t.setValue('');var d=Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value');if(d&&d.set){d.set.call(el,'${val}');}else{el.value='${val}';}var m=false;for(var i=0;i<el.options.length;i++){if(el.options[i].value==='${val}'){el.selectedIndex=i;m=true;break;}}if(!m||el.value!=='${val}'){var norm=function(s){return s.replace(/[\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]/g,'').replace(/[\\u2010-\\u2015\\u2212\\uFE58\\uFE63\\uFF0D]/g,'-').replace(/\\s*-\\s*/g,'-').replace(/\\s+/g,' ').trim();};var cv=norm('${val}');for(var i=0;i<el.options.length;i++){if(norm(el.options[i].value)===cv||norm(el.options[i].text)===cv){el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}if(!m){for(var i=0;i<el.options.length;i++){var nv=norm(el.options[i].value),nt=norm(el.options[i].text);if(nv.indexOf(cv)>=0||nt.indexOf(cv)>=0||cv.indexOf(nv)>=0||cv.indexOf(nt)>=0){if(i===0&&el.options.length>1)continue;el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}}}el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Selected: '+el.value+' (index '+el.selectedIndex+')';})()`
|
|
@@ -1423,7 +1429,7 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
1423
1429
|
export async function typeText({ text, selector, ref }) {
|
|
1424
1430
|
if (ref) selector = refSelector(ref);
|
|
1425
1431
|
if (selector) {
|
|
1426
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
1432
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1427
1433
|
await runJS(`document.querySelector('${sel}')?.focus()`);
|
|
1428
1434
|
// Quick poll for focus to settle (was 200ms fixed sleep)
|
|
1429
1435
|
await new Promise((r) => setTimeout(r, 30));
|
|
@@ -1706,7 +1712,7 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1706
1712
|
// ========== ELEMENT SCREENSHOT ==========
|
|
1707
1713
|
|
|
1708
1714
|
export async function screenshotElement({ selector }) {
|
|
1709
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
1715
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1710
1716
|
// Use html2canvas-like approach: capture element via SVG foreignObject
|
|
1711
1717
|
const result = await runJS(
|
|
1712
1718
|
`(async function(){
|
|
@@ -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),
|
|
@@ -2010,14 +2016,14 @@ export async function evaluate({ script }) {
|
|
|
2010
2016
|
// ========== ELEMENT INFO ==========
|
|
2011
2017
|
|
|
2012
2018
|
export async function getElementInfo({ selector }) {
|
|
2013
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
2019
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2014
2020
|
return runJS(
|
|
2015
2021
|
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';var r=el.getBoundingClientRect();return JSON.stringify({tag:el.tagName,text:el.textContent.trim().substring(0,200),href:el.href||'',value:el.value||'',visible:r.width>0&&r.height>0,rect:{x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)},attrs:Object.fromEntries([...el.attributes].map(function(a){return[a.name,a.value.substring(0,100)]}))})})()`
|
|
2016
2022
|
);
|
|
2017
2023
|
}
|
|
2018
2024
|
|
|
2019
2025
|
export async function querySelectorAll({ selector, limit = 20 }) {
|
|
2020
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
2026
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2021
2027
|
return runJS(
|
|
2022
2028
|
`JSON.stringify([...document.querySelectorAll('${sel}')].slice(0,${Number(limit)}).map(function(el,i){return{index:i,tag:el.tagName,text:el.textContent.trim().substring(0,100),href:el.href||undefined,value:el.value||undefined}}))`
|
|
2023
2029
|
);
|
|
@@ -2028,7 +2034,7 @@ export async function querySelectorAll({ selector, limit = 20 }) {
|
|
|
2028
2034
|
export async function hover({ selector, x, y, ref }) {
|
|
2029
2035
|
if (ref) selector = refSelector(ref);
|
|
2030
2036
|
if (selector) {
|
|
2031
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
2037
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2032
2038
|
return runJS(
|
|
2033
2039
|
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));el.dispatchEvent(new MouseEvent('mouseenter',{bubbles:true}));return 'Hovered: '+el.tagName;})()`
|
|
2034
2040
|
);
|
|
@@ -2045,7 +2051,7 @@ export async function hover({ selector, x, y, ref }) {
|
|
|
2045
2051
|
|
|
2046
2052
|
export async function handleDialog({ action = "accept", text }) {
|
|
2047
2053
|
if (text !== undefined) {
|
|
2048
|
-
const safeText = text.replace(/'/g, "\\'");
|
|
2054
|
+
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2049
2055
|
await runJS(
|
|
2050
2056
|
`window.__mcp_dialog_response='${safeText}';window.__origPrompt=window.prompt;window.prompt=function(){var r=window.__mcp_dialog_response;window.prompt=window.__origPrompt;return r;}`
|
|
2051
2057
|
);
|
|
@@ -2159,7 +2165,7 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
2159
2165
|
end tell`
|
|
2160
2166
|
).catch(() => {}); // Ignore if no dialog open
|
|
2161
2167
|
|
|
2162
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
2168
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2163
2169
|
const { basename, extname } = await import("node:path");
|
|
2164
2170
|
const fileName = basename(filePath);
|
|
2165
2171
|
const ext = extname(filePath).toLowerCase().replace(".", "");
|
|
@@ -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)}')`);
|
|
@@ -3181,7 +3194,7 @@ export async function overrideGeolocation({ latitude, longitude, accuracy = 100
|
|
|
3181
3194
|
// ========== COMPUTED STYLES ==========
|
|
3182
3195
|
|
|
3183
3196
|
export async function getComputedStyles({ selector, properties }) {
|
|
3184
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
3197
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
3185
3198
|
const propsFilter = properties
|
|
3186
3199
|
? `.filter(function(p){return [${properties.map((p) => `'${p}'`).join(",")}].includes(p)})`
|
|
3187
3200
|
: "";
|
|
@@ -3320,14 +3333,14 @@ export async function detectForms() {
|
|
|
3320
3333
|
|
|
3321
3334
|
export async function scrollToElement({ selector, text, block = "center", timeout = 10000 }) {
|
|
3322
3335
|
if (selector) {
|
|
3323
|
-
const sel = selector.replace(/'/g, "\\'");
|
|
3336
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
3324
3337
|
return runJS(
|
|
3325
3338
|
`(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({behavior:'smooth',block:'${block}'});var r=el.getBoundingClientRect();return 'Scrolled to: '+el.tagName+' at y='+Math.round(r.y);})()`
|
|
3326
3339
|
);
|
|
3327
3340
|
}
|
|
3328
3341
|
if (text) {
|
|
3329
3342
|
// Virtual DOM scroll: scroll down repeatedly until text appears (for Airtable, etc.)
|
|
3330
|
-
const safeText = text.replace(/'/g, "\\'");
|
|
3343
|
+
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
3331
3344
|
return runJS(
|
|
3332
3345
|
`(async function(){
|
|
3333
3346
|
var deadline = Date.now() + ${Number(timeout)};
|
|
@@ -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"); });
|