safari-mcp 2.11.8 โ 2.12.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/README.md +1 -1
- package/extension/background.js +37 -19
- package/extension/content.js +5 -3
- package/extension/manifest.json +1 -1
- package/index.js +20 -16
- package/package.json +1 -1
- package/safari.js +219 -167
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ Your AI drives the **Safari you're already logged into** โ Gmail, GitHub, Ahre
|
|
|
46
46
|
|
|
47
47
|
Native WebKit. ~60% less CPU. Background operation. 80 tools. One `npx` command. macOS only.
|
|
48
48
|
|
|
49
|
-
> ๐ฐ **Featured on
|
|
49
|
+
> ๐ฐ **Featured on freeCodeCamp:** [How to Connect Your AI Coding Agent to a Browser on macOS](https://www.freecodecamp.org/news/how-to-connect-your-ai-coding-agent-to-a-browser-on-macos/) ยท [HackerNoon: Reverse-Engineering React, Shadow DOM, and CSP](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
|
|
50
50
|
|
|
51
51
|
---
|
|
52
52
|
|
package/extension/background.js
CHANGED
|
@@ -145,14 +145,18 @@ async function pollForCommands() {
|
|
|
145
145
|
});
|
|
146
146
|
clearTimeout(timeout);
|
|
147
147
|
if (res.status === 200) {
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
// A single malformed/truncated body must NOT tear down the poll loop โ a bad
|
|
149
|
+
// packet used to throw SyntaxError here, fall through to "server gone", and
|
|
150
|
+
// trigger a multi-second reconnect backoff. Skip the bad packet and keep polling.
|
|
151
|
+
const msg = await res.json().catch(() => null);
|
|
152
|
+
if (msg) await executeAndReply(msg);
|
|
150
153
|
}
|
|
151
154
|
// 204 = no command, loop immediately to keep connection active
|
|
152
155
|
} catch (err) {
|
|
153
156
|
if (err.name === "AbortError") {
|
|
154
|
-
//
|
|
155
|
-
|
|
157
|
+
// 90s safety-timeout abort while still connected โ continue the loop in place.
|
|
158
|
+
// (Re-calling pollForCommands() here risked two overlapping loops posting dup results.)
|
|
159
|
+
if (isConnected && _enabled) continue;
|
|
156
160
|
return; // Intentional abort (disable/new connect)
|
|
157
161
|
}
|
|
158
162
|
// Server gone โ reconnect via shared scheduler (prevents duplicate timers)
|
|
@@ -241,7 +245,12 @@ async function handleCommand(type, payload) {
|
|
|
241
245
|
return true;
|
|
242
246
|
}, [], tabId).catch(() => true);
|
|
243
247
|
|
|
244
|
-
|
|
248
|
+
// Don't hard-reload OAuth/redirect callback pages: a sparse-but-valid callback
|
|
249
|
+
// (code=/token=/state= in the URL) would be reloaded, dropping its POST data and
|
|
250
|
+
// breaking the auth flow. Only reload normal http(s) pages without auth params.
|
|
251
|
+
const reloadable = /^https?:/i.test(payload.url || "") &&
|
|
252
|
+
!/[?&#](code|token|access_token|id_token|state|session_state)=/i.test(payload.url || "");
|
|
253
|
+
if (!hasContent && reloadable) {
|
|
245
254
|
// Try hard reload once
|
|
246
255
|
await browser.tabs.reload(tabId, { bypassCache: true });
|
|
247
256
|
await waitForTabLoad(tabId, 15000);
|
|
@@ -459,12 +468,17 @@ async function handleCommand(type, payload) {
|
|
|
459
468
|
return null;
|
|
460
469
|
}
|
|
461
470
|
const m = refs[refId];
|
|
471
|
+
// Escape attribute values + guard the query โ a page-controlled aria-label/name/
|
|
472
|
+
// placeholder containing a double-quote would otherwise build an invalid selector
|
|
473
|
+
// and throw a DOMException that surfaces as a misleading "element not found".
|
|
474
|
+
const dqAttr = (sel) => { try { return dq(sel); } catch (_e) { return null; } };
|
|
475
|
+
const aq = (v) => String(v).replace(/["\\]/g, "\\$&");
|
|
462
476
|
if (m.id) { el = document.getElementById(m.id); if (el) return el; }
|
|
463
|
-
if (m.nameAttr) { el =
|
|
464
|
-
if (m.al) { el =
|
|
465
|
-
if (m.ph) { el =
|
|
477
|
+
if (m.nameAttr) { el = dqAttr('[name="' + aq(m.nameAttr) + '"]'); if (el) return el; }
|
|
478
|
+
if (m.al) { el = dqAttr('[aria-label="' + aq(m.al) + '"]'); if (el) return el; }
|
|
479
|
+
if (m.ph) { el = dqAttr('[placeholder="' + aq(m.ph) + '"]'); if (el) return el; }
|
|
466
480
|
// Type attribute fallback (input type="email", type="url", etc.)
|
|
467
|
-
if (m.inputType) { el =
|
|
481
|
+
if (m.inputType) { el = dqAttr(m.tag.toLowerCase() + '[type="' + aq(m.inputType) + '"]'); if (el) return el; }
|
|
468
482
|
// Coordinate fallback โ scroll into view then hit-test
|
|
469
483
|
if (m.cx !== undefined && m.cy !== undefined) {
|
|
470
484
|
window.scrollTo(window.scrollX, Math.max(0, m.cy - window.innerHeight / 2));
|
|
@@ -1233,27 +1247,31 @@ async function handleCommand(type, payload) {
|
|
|
1233
1247
|
attrs = ` ref="${refId}"`;
|
|
1234
1248
|
}
|
|
1235
1249
|
|
|
1250
|
+
// Escape page-controlled attribute values: a crafted aria-label/title/value with
|
|
1251
|
+
// a double-quote could otherwise break the pseudo-XML snapshot and inject a fake
|
|
1252
|
+
// ref=""/role="" that steers the agent into clicking the wrong element.
|
|
1253
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
1236
1254
|
const role = el.getAttribute("role");
|
|
1237
|
-
if (role) attrs += ` role="${role}"`;
|
|
1238
|
-
if (el.id) attrs += ` id="${el.id}"`;
|
|
1255
|
+
if (role) attrs += ` role="${esc(role)}"`;
|
|
1256
|
+
if (el.id) attrs += ` id="${esc(el.id)}"`;
|
|
1239
1257
|
const al = el.getAttribute("aria-label");
|
|
1240
|
-
if (al) attrs += ` aria-label="${al}"`;
|
|
1258
|
+
if (al) attrs += ` aria-label="${esc(al)}"`;
|
|
1241
1259
|
const title = el.getAttribute("title");
|
|
1242
|
-
if (title) attrs += ` title="${title.substring(0, 80)}"`;
|
|
1260
|
+
if (title) attrs += ` title="${esc(title.substring(0, 80))}"`;
|
|
1243
1261
|
if (el.value && ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) {
|
|
1244
|
-
attrs += ` value="${String(el.value).substring(0, 50)}"`;
|
|
1262
|
+
attrs += ` value="${esc(String(el.value).substring(0, 50))}"`;
|
|
1245
1263
|
}
|
|
1246
|
-
if (el.type && el.tagName === "INPUT") attrs += ` type="${el.type}"`;
|
|
1247
|
-
if (el.href && el.tagName === "A") attrs += ` href="${el.href.substring(0, 100)}"`;
|
|
1264
|
+
if (el.type && el.tagName === "INPUT") attrs += ` type="${esc(el.type)}"`;
|
|
1265
|
+
if (el.href && el.tagName === "A") attrs += ` href="${esc(el.href.substring(0, 100))}"`;
|
|
1248
1266
|
if (el.disabled) attrs += " disabled";
|
|
1249
1267
|
const ph = el.getAttribute("placeholder");
|
|
1250
|
-
if (ph) attrs += ` placeholder="${ph}"`;
|
|
1268
|
+
if (ph) attrs += ` placeholder="${esc(ph)}"`;
|
|
1251
1269
|
// For interactive elements with no visible text โ show alt/aria-describedby hint
|
|
1252
|
-
if (interactive && el.tagName === "IMG" && el.alt) attrs += ` alt="${el.alt.substring(0, 80)}"`;
|
|
1270
|
+
if (interactive && el.tagName === "IMG" && el.alt) attrs += ` alt="${esc(el.alt.substring(0, 80))}"`;
|
|
1253
1271
|
const ariaDesc = el.getAttribute("aria-describedby");
|
|
1254
1272
|
if (ariaDesc) {
|
|
1255
1273
|
const descEl = document.getElementById(ariaDesc);
|
|
1256
|
-
if (descEl) attrs += ` described="${descEl.textContent.trim().substring(0, 80)}"`;
|
|
1274
|
+
if (descEl) attrs += ` described="${esc(descEl.textContent.trim().substring(0, 80))}"`;
|
|
1257
1275
|
}
|
|
1258
1276
|
|
|
1259
1277
|
// Self-closing for some tags
|
package/extension/content.js
CHANGED
|
@@ -28,10 +28,12 @@ if (!window.__mcpShadowPatched) {
|
|
|
28
28
|
|
|
29
29
|
if (!window.__mcpTrustedPolicy && window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {
|
|
30
30
|
try {
|
|
31
|
+
// Register ONLY createScript โ the single capability the bridge uses (background.js
|
|
32
|
+
// evaluate sets script.textContent via createScript). A world-accessible pass-through
|
|
33
|
+
// createHTML would let the page's own scripts wrap arbitrary HTML as trusted, defeating
|
|
34
|
+
// its Trusted-Types protection; createScriptURL is likewise unused. Least privilege.
|
|
31
35
|
window.__mcpTrustedPolicy = window.trustedTypes.createPolicy("mcpBridge", {
|
|
32
|
-
createScript: function (s) { return s; }
|
|
33
|
-
createScriptURL: function (s) { return s; },
|
|
34
|
-
createHTML: function (s) { return s; }
|
|
36
|
+
createScript: function (s) { return s; }
|
|
35
37
|
});
|
|
36
38
|
} catch (_e) {
|
|
37
39
|
// Page already restricts policies โ rare since content script runs at document_start
|
package/extension/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Safari MCP Bridge",
|
|
4
4
|
"description": "Connects AI agents to Safari โ fast JS execution, screenshots, clicks with your real cookies/logins",
|
|
5
|
-
"version": "2.10.
|
|
5
|
+
"version": "2.10.2",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
package/index.js
CHANGED
|
@@ -96,13 +96,13 @@ function _isURLOwned(url) {
|
|
|
96
96
|
if (new URL(owned).origin === urlOrigin && urlBase.startsWith(normalize(owned))) return true;
|
|
97
97
|
} catch {}
|
|
98
98
|
}
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
// NOTE: a broader "own ANY URL on this origin โ treat ALL same-origin URLs as owned"
|
|
100
|
+
// check used to live here. It defeated tab-safety: once this session opened a single
|
|
101
|
+
// tab on github.com / google.com / etc., EVERY tab on that origin โ including the
|
|
102
|
+
// user's own open tabs โ counted as ours, so a click/fill could land in the user's tab.
|
|
103
|
+
// Same-origin redirects stay covered by the path-prefix check above plus final-URL
|
|
104
|
+
// tracking in safari_navigate (a redirect that changes the path entirely now needs an
|
|
105
|
+
// explicit safari_navigate, which is the safe default).
|
|
106
106
|
} catch {}
|
|
107
107
|
return false;
|
|
108
108
|
}
|
|
@@ -373,7 +373,7 @@ try {
|
|
|
373
373
|
// POST /result โ extension sends command result
|
|
374
374
|
if (req.method === "POST" && req.url === "/result") {
|
|
375
375
|
let body = "";
|
|
376
|
-
req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
376
|
+
req.on("data", (chunk) => { if (res.headersSent) return; body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
377
377
|
req.on("end", () => {
|
|
378
378
|
try {
|
|
379
379
|
const msg = JSON.parse(body);
|
|
@@ -414,7 +414,7 @@ try {
|
|
|
414
414
|
// POST /verify-profile โ extension asks server to check which profile has a nonce tab
|
|
415
415
|
if (req.method === "POST" && req.url === "/verify-profile") {
|
|
416
416
|
let body = "";
|
|
417
|
-
req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
417
|
+
req.on("data", (chunk) => { if (res.headersSent) return; body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
418
418
|
req.on("end", async () => {
|
|
419
419
|
try {
|
|
420
420
|
const { nonce, expectedProfile } = JSON.parse(body);
|
|
@@ -482,7 +482,7 @@ try {
|
|
|
482
482
|
return;
|
|
483
483
|
}
|
|
484
484
|
let body = "";
|
|
485
|
-
req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
485
|
+
req.on("data", (chunk) => { if (res.headersSent) return; body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
486
486
|
req.on("end", async () => {
|
|
487
487
|
try {
|
|
488
488
|
const { type, payload } = JSON.parse(body);
|
|
@@ -547,8 +547,8 @@ async function _checkPrimaryExtension() {
|
|
|
547
547
|
} catch {
|
|
548
548
|
_primaryHasExtension = false;
|
|
549
549
|
}
|
|
550
|
-
// Re-check every 10s
|
|
551
|
-
setTimeout(_checkPrimaryExtension, 10000);
|
|
550
|
+
// Re-check every 10s (unref'd โ must not keep the Node process alive on its own)
|
|
551
|
+
setTimeout(_checkPrimaryExtension, 10000).unref();
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
// Send command to primary instance's extension via proxy
|
|
@@ -568,7 +568,7 @@ async function _proxyToExtension(type, payload, timeoutMs = 30000) {
|
|
|
568
568
|
let _extensionLastPollTime = 0;
|
|
569
569
|
// Detect stale HTTP connection (no poll in 30s = disconnected)
|
|
570
570
|
// Only applies to primary instance (extension host) โ not proxy mode
|
|
571
|
-
setInterval(() => {
|
|
571
|
+
const _staleHttpTimer = setInterval(() => {
|
|
572
572
|
if (_isExtensionHost && _extensionConnected && !_extensionWs && _extensionLastPollTime > 0) {
|
|
573
573
|
if (Date.now() - _extensionLastPollTime > 30000) {
|
|
574
574
|
_extensionConnected = false;
|
|
@@ -577,6 +577,7 @@ setInterval(() => {
|
|
|
577
577
|
}
|
|
578
578
|
}
|
|
579
579
|
}, 5000);
|
|
580
|
+
_staleHttpTimer.unref(); // stale-detection must not keep the Node process alive on its own
|
|
580
581
|
|
|
581
582
|
// ========== SHARED EXTENSION LOGIC ==========
|
|
582
583
|
|
|
@@ -749,11 +750,12 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
749
750
|
}
|
|
750
751
|
} finally {
|
|
751
752
|
safari.setFocusGuard(false);
|
|
753
|
+
// Restore focus in finally โ even when the operation threw, Safari may have come to
|
|
754
|
+
// the front mid-op and must be sent back, or the user is left staring at the
|
|
755
|
+
// automation window with their keystrokes landing in it.
|
|
756
|
+
await safari.restoreFocusIfStolen(savedApp);
|
|
752
757
|
}
|
|
753
758
|
|
|
754
|
-
// Restore focus if Safari stole it
|
|
755
|
-
await safari.restoreFocusIfStolen(savedApp);
|
|
756
|
-
|
|
757
759
|
return result;
|
|
758
760
|
}
|
|
759
761
|
|
|
@@ -1478,6 +1480,7 @@ server.tool(
|
|
|
1478
1480
|
await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
|
|
1479
1481
|
safari.setActiveTabIndex(resolved.index);
|
|
1480
1482
|
safari.setActiveTabURL(resolved.url);
|
|
1483
|
+
_trackTab(resolved.index, resolved.url); // own the popup, else the next interaction trips the tab-safety guard
|
|
1481
1484
|
return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
|
|
1482
1485
|
}
|
|
1483
1486
|
continue;
|
|
@@ -1486,6 +1489,7 @@ server.tool(
|
|
|
1486
1489
|
await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
|
|
1487
1490
|
safari.setActiveTabIndex(tab.index);
|
|
1488
1491
|
safari.setActiveTabURL(tab.url);
|
|
1492
|
+
_trackTab(tab.index, tab.url); // own the new tab, else the next interaction trips the tab-safety guard
|
|
1489
1493
|
return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
|
|
1490
1494
|
}
|
|
1491
1495
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents โ native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join, dirname, resolve as resolvePath } from "node:path";
|
|
10
10
|
import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
|
|
11
|
-
import { readFileSync } from "node:fs";
|
|
11
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
14
|
// Extension bridge is handled by index.js (WebSocket server on port 9223)
|
|
@@ -307,13 +307,23 @@ if (SAFARI_PROFILE) {
|
|
|
307
307
|
|
|
308
308
|
// Initialize profile window at startup (ES module top-level await)
|
|
309
309
|
if (SAFARI_PROFILE) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
310
|
+
// Run profile-window detection off the critical path so module init โ and
|
|
311
|
+
// therefore the MCP initialize handshake โ completes immediately. Tool calls
|
|
312
|
+
// that arrive before this finishes already trigger lazy refresh via
|
|
313
|
+
// getTargetWindowRef(), so correctness is preserved.
|
|
314
|
+
// Why this matters: a blocking `await refreshTargetWindow(true)` here could
|
|
315
|
+
// run >30s when Safari was busy or AppleScript was stalled, tripping Claude
|
|
316
|
+
// Code's 30s MCP timeout and leaving the conversation's tool catalog without
|
|
317
|
+
// safari tools until a new conversation is started.
|
|
318
|
+
(async () => {
|
|
319
|
+
await new Promise(r => setTimeout(r, 50)); // Let helper process initialize
|
|
320
|
+
await refreshTargetWindow(true);
|
|
321
|
+
if (_targetWindowRef) {
|
|
322
|
+
_logProfile(`Startup: Profile "${SAFARI_PROFILE}" โ targeting ${_targetWindowRef}`);
|
|
323
|
+
} else {
|
|
324
|
+
_logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
|
|
325
|
+
}
|
|
326
|
+
})();
|
|
317
327
|
}
|
|
318
328
|
|
|
319
329
|
// Detect stale window ID errors and invalidate cache
|
|
@@ -1021,7 +1031,9 @@ export async function navigate(url) {
|
|
|
1021
1031
|
targetUrl = "https://" + targetUrl;
|
|
1022
1032
|
}
|
|
1023
1033
|
|
|
1024
|
-
|
|
1034
|
+
// Escape backslash first, then quotes; strip CR/LF โ a newline would break out of
|
|
1035
|
+
// the AppleScript string literal and allow AppleScript injection.
|
|
1036
|
+
const safeUrl = targetUrl.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
|
|
1025
1037
|
// Resolve tab by URL first (in case indices shifted)
|
|
1026
1038
|
if (_activeTabURL) await resolveActiveTab();
|
|
1027
1039
|
_assertNotFallingBackToUserTab('navigate');
|
|
@@ -1102,7 +1114,7 @@ export async function navigate(url) {
|
|
|
1102
1114
|
try {
|
|
1103
1115
|
const parsed = JSON.parse(result);
|
|
1104
1116
|
if (parsed.blocked && url.startsWith("http://")) {
|
|
1105
|
-
const httpUrl = url.replace(/"/g, '\\"');
|
|
1117
|
+
const httpUrl = url.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
|
|
1106
1118
|
await osascriptFast(
|
|
1107
1119
|
`tell application "Safari" to set URL of ${navTarget} to "${httpUrl}"`
|
|
1108
1120
|
);
|
|
@@ -1151,37 +1163,58 @@ export async function navigate(url) {
|
|
|
1151
1163
|
return result;
|
|
1152
1164
|
}
|
|
1153
1165
|
|
|
1166
|
+
// Poll document.readyState from the Node side and return {title,url[,text]} once the
|
|
1167
|
+
// page settles. `do JavaScript` returns immediately and never awaits an async IIFE
|
|
1168
|
+
// (see _evaluateAsync), so any in-page `await` loop is fire-and-forget โ page-load
|
|
1169
|
+
// waits MUST be driven from Node. Shared by goBack/goForward/reload/navigateAndRead.
|
|
1170
|
+
async function _pollReadyAndRead(navIndex, { maxLength } = {}) {
|
|
1171
|
+
const readExpr = maxLength != null
|
|
1172
|
+
? `JSON.stringify({title:document.title,url:location.href,text:document.body?document.body.innerText.substring(0,${Number(maxLength)}):''})`
|
|
1173
|
+
: `JSON.stringify({title:document.title,url:location.href})`;
|
|
1174
|
+
let result = '{}';
|
|
1175
|
+
for (let poll = 0; poll < 60; poll++) {
|
|
1176
|
+
await new Promise(r => setTimeout(r, poll < 10 ? 200 : 500));
|
|
1177
|
+
try {
|
|
1178
|
+
const state = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
|
|
1179
|
+
if (state === 'complete' || state === 'interactive') {
|
|
1180
|
+
result = await runJS(readExpr, { tabIndex: navIndex, timeout: 5000 });
|
|
1181
|
+
if (state === 'complete') break;
|
|
1182
|
+
if (poll > 10) break; // interactive after ~2s is good enough
|
|
1183
|
+
}
|
|
1184
|
+
} catch { /* page still loading, retry */ }
|
|
1185
|
+
}
|
|
1186
|
+
return result;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1154
1189
|
export async function goBack() {
|
|
1155
|
-
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
);
|
|
1190
|
+
await refreshTargetWindow();
|
|
1191
|
+
const navIndex = _activeTabIndex;
|
|
1192
|
+
// history.back() is synchronous; the page-load wait is polled from Node (see _pollReadyAndRead).
|
|
1193
|
+
await runJS("history.back()", { tabIndex: navIndex, timeout: 5000 });
|
|
1194
|
+
const result = await _pollReadyAndRead(navIndex);
|
|
1160
1195
|
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
1161
1196
|
return result;
|
|
1162
1197
|
}
|
|
1163
1198
|
|
|
1164
1199
|
export async function goForward() {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
);
|
|
1200
|
+
await refreshTargetWindow();
|
|
1201
|
+
const navIndex = _activeTabIndex;
|
|
1202
|
+
await runJS("history.forward()", { tabIndex: navIndex, timeout: 5000 });
|
|
1203
|
+
const result = await _pollReadyAndRead(navIndex);
|
|
1169
1204
|
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
1170
1205
|
return result;
|
|
1171
1206
|
}
|
|
1172
1207
|
|
|
1173
1208
|
export async function reload(hardReload = false) {
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
{ timeout: 10000 }
|
|
1181
|
-
);
|
|
1209
|
+
await refreshTargetWindow();
|
|
1210
|
+
const navIndex = _activeTabIndex;
|
|
1211
|
+
// Reload destroys JS context โ fire it, then poll readyState from Node.
|
|
1212
|
+
await runJS(hardReload ? "location.reload(true)" : "location.reload()", { tabIndex: navIndex });
|
|
1213
|
+
await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start
|
|
1214
|
+
const result = await _pollReadyAndRead(navIndex);
|
|
1182
1215
|
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
1183
1216
|
// A reload destroys the JS context โ re-stamp marker + visibility spoof.
|
|
1184
|
-
await _stampTab(
|
|
1217
|
+
await _stampTab(navIndex);
|
|
1185
1218
|
return result;
|
|
1186
1219
|
}
|
|
1187
1220
|
|
|
@@ -2275,9 +2308,10 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
2275
2308
|
// real paste pipeline.
|
|
2276
2309
|
async function _nativeTypeViaClipboard(text) {
|
|
2277
2310
|
await _acquireClipboardLock();
|
|
2311
|
+
let savedClipboard;
|
|
2278
2312
|
try {
|
|
2279
2313
|
// Save current clipboard
|
|
2280
|
-
|
|
2314
|
+
savedClipboard = await _saveClipboard();
|
|
2281
2315
|
|
|
2282
2316
|
// Set clipboard to our text via pipe (safe from shell injection)
|
|
2283
2317
|
await new Promise((resolve, reject) => {
|
|
@@ -2292,23 +2326,19 @@ async function _nativeTypeViaClipboard(text) {
|
|
|
2292
2326
|
// MUST have windowId โ global CGEvent (windowId=0) would steal focus and move mouse
|
|
2293
2327
|
const geo = await _getSafariWindowGeometry();
|
|
2294
2328
|
if (!geo.windowId) {
|
|
2295
|
-
// Can't get window ID โ restore clipboard and let catch block release lock
|
|
2296
|
-
await _restoreClipboard(savedClipboard);
|
|
2297
2329
|
throw new Error("Cannot native-paste without Safari window ID โ would steal focus");
|
|
2298
2330
|
}
|
|
2299
2331
|
// keyCode 9 = V key, flags: ["cmd"]
|
|
2300
2332
|
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
|
|
2301
2333
|
|
|
2302
|
-
// Wait for paste to settle
|
|
2334
|
+
// Wait for paste to settle
|
|
2303
2335
|
await new Promise(r => setTimeout(r, 100)); // 100ms is enough for Cmd+V to process
|
|
2304
|
-
await _restoreClipboard(savedClipboard);
|
|
2305
|
-
_releaseClipboardLock();
|
|
2306
|
-
|
|
2307
2336
|
return `Typed ${text.length} chars (native paste into iframe)`;
|
|
2308
|
-
}
|
|
2309
|
-
//
|
|
2337
|
+
} finally {
|
|
2338
|
+
// ALWAYS restore the user's clipboard + release the lock โ even if the daemon died
|
|
2339
|
+
// mid-paste โ so the user never silently inherits the tool's pasted text.
|
|
2340
|
+
if (savedClipboard !== undefined) await _restoreClipboard(savedClipboard).catch(() => {});
|
|
2310
2341
|
if (_clipboardLocked) _releaseClipboardLock();
|
|
2311
|
-
throw err;
|
|
2312
2342
|
}
|
|
2313
2343
|
}
|
|
2314
2344
|
|
|
@@ -2665,9 +2695,12 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
2665
2695
|
const skipScreencapture = isBackgroundTab;
|
|
2666
2696
|
|
|
2667
2697
|
// Try screencapture โ use osascript's do shell script to bypass VS Code permission issue
|
|
2668
|
-
const
|
|
2698
|
+
const windowIdRaw = !skipScreencapture ? await osascript(
|
|
2669
2699
|
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
2670
2700
|
).catch(() => null) : null;
|
|
2701
|
+
// Window IDs are OS-assigned integers โ reject anything non-numeric before it reaches
|
|
2702
|
+
// `do shell script "screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
|
|
2703
|
+
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
2671
2704
|
|
|
2672
2705
|
// On macOS Tahoe, screencapture -l may briefly steal focus.
|
|
2673
2706
|
// Save frontmost app via daemon so we can hide Safari if it stole focus.
|
|
@@ -2765,7 +2798,10 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
2765
2798
|
{ timeout: 30000 }
|
|
2766
2799
|
);
|
|
2767
2800
|
|
|
2768
|
-
|
|
2801
|
+
// canvas/SVG returns a Promise `do JavaScript` can't await โ guard against the
|
|
2802
|
+
// unsettled "[object Promise]"/empty value and only return a real base64 PNG.
|
|
2803
|
+
const looksBase64 = typeof dataUrl === 'string' && dataUrl.length > 100 && /^[A-Za-z0-9+/]+={0,2}$/.test(dataUrl.slice(0, 120));
|
|
2804
|
+
if (looksBase64) {
|
|
2769
2805
|
return dataUrl;
|
|
2770
2806
|
}
|
|
2771
2807
|
|
|
@@ -2829,25 +2865,32 @@ export async function screenshotElement({ selector }) {
|
|
|
2829
2865
|
{ timeout: 15000 }
|
|
2830
2866
|
);
|
|
2831
2867
|
|
|
2832
|
-
|
|
2868
|
+
// The canvas/SVG path returns a Promise that `do JavaScript` can't await (so it yields
|
|
2869
|
+
// "[object Promise]"/empty), and foreignObject can't render cross-origin images/fonts.
|
|
2870
|
+
// Treat anything that isn't a valid base64 PNG as a render failure and fall through to
|
|
2871
|
+
// the reliable screencapture+crop path.
|
|
2872
|
+
const looksBase64 = typeof result === 'string' && result.length > 100 && /^[A-Za-z0-9+/]+={0,2}$/.test(result.slice(0, 120));
|
|
2873
|
+
if (!looksBase64) {
|
|
2833
2874
|
// Fallback: use screencapture + crop
|
|
2834
2875
|
const tmpFile = join(tmpdir(), `safari-el-${Date.now()}.png`);
|
|
2876
|
+
let cropFile = null;
|
|
2835
2877
|
try {
|
|
2836
|
-
const
|
|
2878
|
+
const windowIdRaw = await osascript(`tell application "Safari" to return id of ${getTargetWindowRef()}`).catch(() => null);
|
|
2879
|
+
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
2837
2880
|
if (!windowId) throw new Error("Cannot get Safari window ID");
|
|
2838
2881
|
|
|
2839
2882
|
// Get element bounds relative to screen
|
|
2840
2883
|
const bounds = await runJS(
|
|
2841
2884
|
`(function(){var el=document.querySelector('${sel}');if(!el)return '';var r=el.getBoundingClientRect();return JSON.stringify({x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)});})()`
|
|
2842
2885
|
);
|
|
2843
|
-
if (!bounds) throw new Error(result);
|
|
2886
|
+
if (!bounds) throw new Error(typeof result === 'string' && result.startsWith('Element') ? result : 'Element not found for screenshot');
|
|
2844
2887
|
|
|
2845
2888
|
// Full window screenshot then crop with sips
|
|
2846
2889
|
await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
2847
2890
|
const { x, y, w, h } = JSON.parse(bounds);
|
|
2848
2891
|
// Use sips to crop (macOS built-in)
|
|
2849
2892
|
const toolbarHeight = 74; // Safari toolbar approximate height (matches _getSafariWindowGeometry)
|
|
2850
|
-
|
|
2893
|
+
cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
|
|
2851
2894
|
await execFileAsync("sips", [
|
|
2852
2895
|
"-c", String(h), String(w),
|
|
2853
2896
|
"--cropOffset", String(y + toolbarHeight), String(x),
|
|
@@ -2859,8 +2902,7 @@ export async function screenshotElement({ selector }) {
|
|
|
2859
2902
|
if (data.length > 100) return data.toString("base64");
|
|
2860
2903
|
} catch (e) {
|
|
2861
2904
|
await unlink(tmpFile).catch(() => {});
|
|
2862
|
-
|
|
2863
|
-
await unlink(tmpFile.replace('safari-el-', 'safari-el-crop-')).catch(() => {});
|
|
2905
|
+
if (cropFile) await unlink(cropFile).catch(() => {}); // captured path โ old code rebuilt it with the wrong timestamp and leaked it
|
|
2864
2906
|
throw new Error(`Element screenshot failed: ${e.message}`);
|
|
2865
2907
|
}
|
|
2866
2908
|
}
|
|
@@ -2916,7 +2958,7 @@ export async function listTabs() {
|
|
|
2916
2958
|
|
|
2917
2959
|
export async function newTab(url = "") {
|
|
2918
2960
|
await refreshTargetWindow();
|
|
2919
|
-
const safeUrl = url ? url.replace(/"/g, '\\"') : "";
|
|
2961
|
+
const safeUrl = url ? url.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '') : "";
|
|
2920
2962
|
try {
|
|
2921
2963
|
if (url) {
|
|
2922
2964
|
await osascript(`tell application "Safari"\ntell ${getTargetWindowRef()}\nset userTab to current tab\nmake new tab with properties {URL:"${safeUrl}"}\nset current tab to userTab\nend tell\nend tell`);
|
|
@@ -3263,11 +3305,11 @@ export async function getCookies() {
|
|
|
3263
3305
|
|
|
3264
3306
|
export async function getLocalStorage({ key }) {
|
|
3265
3307
|
if (key) {
|
|
3266
|
-
const safeKey = key.replace(/'/g, "\\'");
|
|
3308
|
+
const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
3267
3309
|
return runJS(`localStorage.getItem('${safeKey}')`);
|
|
3268
3310
|
}
|
|
3269
3311
|
return runJS(
|
|
3270
|
-
"JSON.stringify(Object.fromEntries(Object.keys(localStorage).map(function(k){
|
|
3312
|
+
"JSON.stringify(Object.fromEntries(Object.keys(localStorage).map(function(k){var v=localStorage.getItem(k);return[k,v==null?null:v.substring(0,200)]})))"
|
|
3271
3313
|
);
|
|
3272
3314
|
}
|
|
3273
3315
|
|
|
@@ -3322,15 +3364,23 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
|
|
|
3322
3364
|
// Prevent reading sensitive system files via upload/paste tools
|
|
3323
3365
|
function _validateFilePath(filePath) {
|
|
3324
3366
|
const resolved = resolvePath(filePath);
|
|
3325
|
-
|
|
3367
|
+
// resolve() already collapses "..", so checking the RESOLVED path for it is a dead no-op.
|
|
3368
|
+
// Reject traversal sequences in the RAW input instead; the allowlist below is the real guard.
|
|
3369
|
+
if (/(^|[/\\])\.\.([/\\]|$)/.test(filePath)) throw new Error("Path traversal not allowed: " + filePath);
|
|
3326
3370
|
const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3371
|
+
// Resolve symlinks when the target exists, so a symlink under /Users/ pointing at /etc
|
|
3372
|
+
// can't slip past the allowlist. Falls back to the lexical path when the file doesn't
|
|
3373
|
+
// exist yet (e.g. savePDF's output path).
|
|
3374
|
+
let real = resolved;
|
|
3375
|
+
try { real = realpathSync(resolved); } catch { /* not created yet โ keep lexical path */ }
|
|
3376
|
+
for (const checkPath of new Set([resolved, real])) {
|
|
3377
|
+
const lower = checkPath.toLowerCase();
|
|
3378
|
+
for (const b of blocked) {
|
|
3379
|
+
if (lower.includes(b)) throw new Error("Blocked: sensitive path " + filePath);
|
|
3380
|
+
}
|
|
3381
|
+
if (!checkPath.startsWith('/Users/') && !checkPath.startsWith('/tmp/') && !checkPath.startsWith('/var/folders/') && !checkPath.startsWith('/private/tmp/')) {
|
|
3382
|
+
throw new Error("File path must be under /Users/, /tmp/, or /var/folders/: " + filePath);
|
|
3383
|
+
}
|
|
3334
3384
|
}
|
|
3335
3385
|
}
|
|
3336
3386
|
|
|
@@ -3412,9 +3462,11 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
3412
3462
|
const mime = mimeMap[ext] || "application/octet-stream";
|
|
3413
3463
|
const safeName = fileName.replace(/'/g, "\\'");
|
|
3414
3464
|
|
|
3415
|
-
// Send to Safari via runJSLarge (handles files >260KB via temp file)
|
|
3465
|
+
// Send to Safari via runJSLarge (handles files >260KB via temp file).
|
|
3466
|
+
// Fully synchronous IIFE โ `do JavaScript` can't await a Promise, so an `async`
|
|
3467
|
+
// wrapper would hand back "[object Promise]" before the body settled.
|
|
3416
3468
|
const result = await runJSLarge(
|
|
3417
|
-
`(
|
|
3469
|
+
`(function(){
|
|
3418
3470
|
// Deep query: main document โ shadow DOM โ iframes
|
|
3419
3471
|
function deepQuery(sel) {
|
|
3420
3472
|
var el = document.querySelector(sel);
|
|
@@ -3461,8 +3513,8 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
3461
3513
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3462
3514
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3463
3515
|
|
|
3464
|
-
//
|
|
3465
|
-
|
|
3516
|
+
// No await here: do-JavaScript cannot await a Promise, and the drop events above
|
|
3517
|
+
// already dispatched synchronously. A framework that needs a tick is surfaced by the hint below.
|
|
3466
3518
|
|
|
3467
3519
|
// Re-check after drop
|
|
3468
3520
|
if (el.files && el.files.length > 0) {
|
|
@@ -3476,6 +3528,8 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
3476
3528
|
{ timeout: 30000 }
|
|
3477
3529
|
);
|
|
3478
3530
|
|
|
3531
|
+
// Clean up the temp PNG produced by any sips image conversion above (was leaked before).
|
|
3532
|
+
if (resolvedPath !== filePath) await unlink(resolvedPath).catch(() => {});
|
|
3479
3533
|
return result;
|
|
3480
3534
|
}
|
|
3481
3535
|
|
|
@@ -3567,11 +3621,12 @@ export async function emulate({ device, width, height, userAgent, scale = 1 }) {
|
|
|
3567
3621
|
`(function(){var m=document.querySelector('meta[name=viewport]');if(!m){m=document.createElement('meta');m.name='viewport';document.head.appendChild(m);}m.content='width=${w},initial-scale=${scale}';})()`
|
|
3568
3622
|
);
|
|
3569
3623
|
|
|
3570
|
-
// Reload to apply changes
|
|
3571
|
-
//
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
);
|
|
3624
|
+
// Reload to apply changes, then wait for load โ polled from Node (`do JavaScript`
|
|
3625
|
+
// can't await an in-page loop).
|
|
3626
|
+
const navIndex = _activeTabIndex;
|
|
3627
|
+
await runJS("location.reload()", { tabIndex: navIndex });
|
|
3628
|
+
await new Promise(r => setTimeout(r, 200));
|
|
3629
|
+
await _pollReadyAndRead(navIndex);
|
|
3575
3630
|
|
|
3576
3631
|
return JSON.stringify({
|
|
3577
3632
|
device: device || "custom",
|
|
@@ -3591,9 +3646,11 @@ export async function resetEmulation() {
|
|
|
3591
3646
|
await osascript(
|
|
3592
3647
|
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, 1440, 900}`
|
|
3593
3648
|
);
|
|
3594
|
-
await
|
|
3595
|
-
|
|
3596
|
-
);
|
|
3649
|
+
// Reload + wait for load โ polled from Node (`do JavaScript` can't await an in-page loop).
|
|
3650
|
+
const navIndex = _activeTabIndex;
|
|
3651
|
+
await runJS("location.reload()", { tabIndex: navIndex });
|
|
3652
|
+
await new Promise(r => setTimeout(r, 200));
|
|
3653
|
+
await _pollReadyAndRead(navIndex);
|
|
3597
3654
|
return "Emulation reset to desktop";
|
|
3598
3655
|
}
|
|
3599
3656
|
|
|
@@ -3618,6 +3675,7 @@ export async function clearConsoleCapture() {
|
|
|
3618
3675
|
|
|
3619
3676
|
export async function savePDF({ path: pdfPath }) {
|
|
3620
3677
|
await refreshTargetWindow();
|
|
3678
|
+
_validateFilePath(pdfPath); // allowlist (/Users//tmp//var-folders) + block sensitive paths โ prevents arbitrary overwrite
|
|
3621
3679
|
// NO focus stealing โ uses screencapture + Python Quartz to generate PDF
|
|
3622
3680
|
|
|
3623
3681
|
// Step 1: Get full page dimensions
|
|
@@ -3635,9 +3693,11 @@ export async function savePDF({ path: pdfPath }) {
|
|
|
3635
3693
|
await new Promise(r => setTimeout(r, 500)); // Let page reflow
|
|
3636
3694
|
|
|
3637
3695
|
// Step 3: Take screenshot via screencapture -l (window-targeted, NO focus steal)
|
|
3638
|
-
const
|
|
3696
|
+
const windowIdRaw = await osascript(
|
|
3639
3697
|
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
3640
3698
|
);
|
|
3699
|
+
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
3700
|
+
if (!windowId) throw new Error("Cannot get Safari window ID for PDF capture");
|
|
3641
3701
|
const tmpPng = join(tmpdir(), `safari-mcp-pdf-${Date.now()}.png`);
|
|
3642
3702
|
try {
|
|
3643
3703
|
// Use do shell script to inherit osascript's Screen Recording permission
|
|
@@ -3656,17 +3716,17 @@ export async function savePDF({ path: pdfPath }) {
|
|
|
3656
3716
|
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`
|
|
3657
3717
|
).catch(() => {});
|
|
3658
3718
|
|
|
3659
|
-
// Step 5: Convert screenshot to PDF using Python3 + macOS Quartz (no external deps)
|
|
3660
|
-
|
|
3661
|
-
|
|
3719
|
+
// Step 5: Convert screenshot to PDF using Python3 + macOS Quartz (no external deps).
|
|
3720
|
+
// Paths are passed as argv โ NOT interpolated into the source โ so there is no shell
|
|
3721
|
+
// or Python string-escaping to get wrong, and no code-injection surface.
|
|
3662
3722
|
try {
|
|
3663
3723
|
await execFileAsync("python3", ["-c", `
|
|
3664
3724
|
import sys
|
|
3665
3725
|
from Quartz import CGImageSourceCreateWithURL, CGImageSourceCreateImageAtIndex, CGImageGetWidth, CGImageGetHeight, CGPDFContextCreateWithURL, CGRectMake, CGPDFContextBeginPage, CGPDFContextEndPage, CGContextDrawImage
|
|
3666
3726
|
from CoreFoundation import CFURLCreateFromFileSystemRepresentation
|
|
3667
3727
|
|
|
3668
|
-
png_path =
|
|
3669
|
-
pdf_path =
|
|
3728
|
+
png_path = sys.argv[1].encode('utf-8')
|
|
3729
|
+
pdf_path = sys.argv[2].encode('utf-8')
|
|
3670
3730
|
|
|
3671
3731
|
src_url = CFURLCreateFromFileSystemRepresentation(None, png_path, len(png_path), False)
|
|
3672
3732
|
img_src = CGImageSourceCreateWithURL(src_url, None)
|
|
@@ -3685,7 +3745,7 @@ CGContextDrawImage(ctx, CGRectMake(0, 0, w, h), img)
|
|
|
3685
3745
|
CGPDFContextEndPage(ctx)
|
|
3686
3746
|
del ctx
|
|
3687
3747
|
print(f"OK {w}x{h}")
|
|
3688
|
-
`.trim()], { timeout: 15000 });
|
|
3748
|
+
`.trim(), tmpPng, pdfPath], { timeout: 15000 });
|
|
3689
3749
|
} catch (err) {
|
|
3690
3750
|
throw new Error(`PDF conversion failed: ${err.message}`);
|
|
3691
3751
|
} finally {
|
|
@@ -3977,8 +4037,8 @@ export async function getAccessibilityTree({ selector, maxDepth = 5 }) {
|
|
|
3977
4037
|
// ========== COOKIE CRUD ==========
|
|
3978
4038
|
|
|
3979
4039
|
export async function setCookie({ name, value, domain, path: cookiePath, expires, secure, sameSite, httpOnly }) {
|
|
3980
|
-
const safeName = name.replace(/'/g, "\\'");
|
|
3981
|
-
const safeValue = value.replace(/'/g, "\\'");
|
|
4040
|
+
const safeName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4041
|
+
const safeValue = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
3982
4042
|
let cookie = `${safeName}=${safeValue}`;
|
|
3983
4043
|
if (cookiePath) cookie += `; path=${cookiePath}`;
|
|
3984
4044
|
if (domain) cookie += `; domain=${domain}`;
|
|
@@ -3995,7 +4055,7 @@ export async function deleteCookies({ name, all }) {
|
|
|
3995
4055
|
);
|
|
3996
4056
|
}
|
|
3997
4057
|
if (name) {
|
|
3998
|
-
const safeName = name.replace(/'/g, "\\'");
|
|
4058
|
+
const safeName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
3999
4059
|
return runJS(
|
|
4000
4060
|
`document.cookie='${safeName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; 'Deleted cookie: ${safeName}'`
|
|
4001
4061
|
);
|
|
@@ -4007,29 +4067,29 @@ export async function deleteCookies({ name, all }) {
|
|
|
4007
4067
|
|
|
4008
4068
|
export async function getSessionStorage({ key }) {
|
|
4009
4069
|
if (key) {
|
|
4010
|
-
const safeKey = key.replace(/'/g, "\\'");
|
|
4070
|
+
const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4011
4071
|
return runJS(`sessionStorage.getItem('${safeKey}')`);
|
|
4012
4072
|
}
|
|
4013
4073
|
return runJS(
|
|
4014
|
-
"JSON.stringify(Object.fromEntries(Object.keys(sessionStorage).map(function(k){
|
|
4074
|
+
"JSON.stringify(Object.fromEntries(Object.keys(sessionStorage).map(function(k){var v=sessionStorage.getItem(k);return[k,v==null?null:v.substring(0,200)]})))"
|
|
4015
4075
|
);
|
|
4016
4076
|
}
|
|
4017
4077
|
|
|
4018
4078
|
export async function setSessionStorage({ key, value }) {
|
|
4019
|
-
const safeKey = key.replace(/'/g, "\\'");
|
|
4020
|
-
const safeValue = value.replace(/'/g, "\\'");
|
|
4079
|
+
const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4080
|
+
const safeValue = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4021
4081
|
return runJS(`sessionStorage.setItem('${safeKey}','${safeValue}'); 'Set sessionStorage: ${safeKey}'`);
|
|
4022
4082
|
}
|
|
4023
4083
|
|
|
4024
4084
|
export async function setLocalStorage({ key, value }) {
|
|
4025
|
-
const safeKey = key.replace(/'/g, "\\'");
|
|
4026
|
-
const safeValue = value.replace(/'/g, "\\'");
|
|
4085
|
+
const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4086
|
+
const safeValue = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4027
4087
|
return runJS(`localStorage.setItem('${safeKey}','${safeValue}'); 'Set localStorage: ${safeKey}'`);
|
|
4028
4088
|
}
|
|
4029
4089
|
|
|
4030
4090
|
export async function deleteLocalStorage({ key }) {
|
|
4031
4091
|
if (key) {
|
|
4032
|
-
const safeKey = key.replace(/'/g, "\\'");
|
|
4092
|
+
const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4033
4093
|
return runJS(`localStorage.removeItem('${safeKey}'); 'Deleted localStorage: ${safeKey}'`);
|
|
4034
4094
|
}
|
|
4035
4095
|
return runJS("var n=localStorage.length; localStorage.clear(); 'Cleared localStorage: '+n+' items'");
|
|
@@ -4037,7 +4097,7 @@ export async function deleteLocalStorage({ key }) {
|
|
|
4037
4097
|
|
|
4038
4098
|
export async function deleteSessionStorage({ key }) {
|
|
4039
4099
|
if (key) {
|
|
4040
|
-
const safeKey = key.replace(/'/g, "\\'");
|
|
4100
|
+
const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4041
4101
|
return runJS(`sessionStorage.removeItem('${safeKey}'); 'Deleted sessionStorage: ${safeKey}'`);
|
|
4042
4102
|
}
|
|
4043
4103
|
return runJS("var n=sessionStorage.length; sessionStorage.clear(); 'Cleared sessionStorage: '+n+' items'");
|
|
@@ -4138,8 +4198,10 @@ export async function clipboardWrite({ text, restore = true }) {
|
|
|
4138
4198
|
|
|
4139
4199
|
// Intercept fetch/XHR requests matching a URL pattern and return mock responses
|
|
4140
4200
|
export async function mockNetworkRoute({ urlPattern, response }) {
|
|
4141
|
-
|
|
4142
|
-
|
|
4201
|
+
// Escape backslash FIRST, then quotes โ the reverse order double-escapes the quote
|
|
4202
|
+
// (\' โ \\') and breaks out of the JS string literal.
|
|
4203
|
+
const safePattern = urlPattern.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4204
|
+
const safeBody = (response.body || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n");
|
|
4143
4205
|
const status = response.status || 200;
|
|
4144
4206
|
const contentType = response.contentType || "application/json";
|
|
4145
4207
|
|
|
@@ -4463,7 +4525,7 @@ export async function overrideGeolocation({ latitude, longitude, accuracy = 100
|
|
|
4463
4525
|
export async function getComputedStyles({ selector, properties }) {
|
|
4464
4526
|
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4465
4527
|
const propsFilter = properties
|
|
4466
|
-
? `.filter(function(p){return [${properties.map((p) => `'${p}'`).join(",")}].includes(p)})`
|
|
4528
|
+
? `.filter(function(p){return [${properties.map((p) => `'${String(p).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(",")}].includes(p)})`
|
|
4467
4529
|
: "";
|
|
4468
4530
|
return runJS(
|
|
4469
4531
|
`(function(){
|
|
@@ -4483,7 +4545,8 @@ export async function getComputedStyles({ selector, properties }) {
|
|
|
4483
4545
|
export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
|
|
4484
4546
|
const safeDb = dbName.replace(/'/g, "\\'");
|
|
4485
4547
|
const safeStore = storeName.replace(/'/g, "\\'");
|
|
4486
|
-
|
|
4548
|
+
// `do JavaScript` can't await a Promise โ route async work through the Node-side poller.
|
|
4549
|
+
return _evaluateAsync(
|
|
4487
4550
|
`(async function(){
|
|
4488
4551
|
return new Promise(function(resolve, reject) {
|
|
4489
4552
|
var request = indexedDB.open('${safeDb}');
|
|
@@ -4506,13 +4569,12 @@ export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
|
|
|
4506
4569
|
cursor.onerror = function() { resolve(JSON.stringify({ error: 'Cursor error' })); db.close(); };
|
|
4507
4570
|
};
|
|
4508
4571
|
});
|
|
4509
|
-
})()
|
|
4510
|
-
{ timeout: 15000 }
|
|
4572
|
+
})()`
|
|
4511
4573
|
);
|
|
4512
4574
|
}
|
|
4513
4575
|
|
|
4514
4576
|
export async function listIndexedDBs() {
|
|
4515
|
-
return
|
|
4577
|
+
return _evaluateAsync(
|
|
4516
4578
|
`(async function(){
|
|
4517
4579
|
try {
|
|
4518
4580
|
var dbs = await indexedDB.databases();
|
|
@@ -4606,34 +4668,31 @@ export async function scrollToElement({ selector, text, block = "center", timeou
|
|
|
4606
4668
|
);
|
|
4607
4669
|
}
|
|
4608
4670
|
if (text) {
|
|
4609
|
-
// Virtual DOM scroll: scroll down repeatedly until text appears (for Airtable, etc.)
|
|
4671
|
+
// Virtual DOM scroll: scroll down repeatedly until text appears (for Airtable, etc.).
|
|
4672
|
+
// Each check+scroll is one synchronous step; the loop is driven from Node because
|
|
4673
|
+
// `do JavaScript` can't await an in-page delay (see _evaluateAsync).
|
|
4610
4674
|
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
}
|
|
4633
|
-
return 'Timeout: text not found within ${timeout}ms';
|
|
4634
|
-
})()`,
|
|
4635
|
-
{ timeout: timeout + 5000 }
|
|
4636
|
-
);
|
|
4675
|
+
const safeBlock = String(block).replace(/[^a-z]/gi, '') || 'center';
|
|
4676
|
+
const navIndex = _activeTabIndex;
|
|
4677
|
+
const stepJs =
|
|
4678
|
+
`(function(){` +
|
|
4679
|
+
`var scrollable=document.querySelector('[class*="grid"],[class*="virtual"],[class*="scroll"],[role="grid"],[role="table"]')||document.scrollingElement||document.documentElement;` +
|
|
4680
|
+
`var tw=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,null);` +
|
|
4681
|
+
`while(tw.nextNode()){if(tw.currentNode.textContent.trim().includes('${safeText}')){var el=tw.currentNode.parentElement;el.scrollIntoView({behavior:'smooth',block:'${safeBlock}'});return 'Found and scrolled to: "'+el.textContent.trim().substring(0,50)+'"';}}` +
|
|
4682
|
+
`var curY=scrollable.scrollTop;scrollable.scrollBy(0,500);return 'SCROLL:'+curY;})()`;
|
|
4683
|
+
const deadline = Date.now() + Number(timeout);
|
|
4684
|
+
let lastY = -1;
|
|
4685
|
+
while (Date.now() < deadline) {
|
|
4686
|
+
const r = await runJS(stepJs, { tabIndex: navIndex, timeout: 5000 }).catch(() => '');
|
|
4687
|
+
if (typeof r === 'string' && r.startsWith('Found')) return r;
|
|
4688
|
+
if (typeof r === 'string' && r.startsWith('SCROLL:')) {
|
|
4689
|
+
const curY = parseInt(r.slice(7), 10);
|
|
4690
|
+
if (curY === lastY) return `Text not found: ${text} (scrolled to bottom)`;
|
|
4691
|
+
lastY = curY;
|
|
4692
|
+
}
|
|
4693
|
+
await new Promise(res => setTimeout(res, 300));
|
|
4694
|
+
}
|
|
4695
|
+
return `Timeout: text not found within ${timeout}ms`;
|
|
4637
4696
|
}
|
|
4638
4697
|
throw new Error("scrollToElement requires selector or text");
|
|
4639
4698
|
}
|
|
@@ -4647,23 +4706,17 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
|
4647
4706
|
await runJS("window.onbeforeunload=null", { timeout: 2000 }).catch(() => {});
|
|
4648
4707
|
let targetUrl = url;
|
|
4649
4708
|
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
|
|
4650
|
-
|
|
4709
|
+
// Escape backslash first, then quotes; strip CR/LF โ a newline would break out of
|
|
4710
|
+
// the AppleScript string literal and allow AppleScript injection.
|
|
4711
|
+
const safeUrl = targetUrl.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
|
|
4651
4712
|
if (_activeTabURL) await resolveActiveTab();
|
|
4652
4713
|
_assertNotFallingBackToUserTab('navigateAndRead');
|
|
4653
|
-
const
|
|
4714
|
+
const navIndex = _activeTabIndex;
|
|
4715
|
+
const navTarget = navIndex ? `tab ${navIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
|
|
4654
4716
|
await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
|
|
4655
4717
|
_activeTabURL = targetUrl;
|
|
4656
|
-
//
|
|
4657
|
-
const navResult = await
|
|
4658
|
-
`(async function(){
|
|
4659
|
-
for(var i=0;i<60;i++){
|
|
4660
|
-
if(document.readyState==='complete')break;
|
|
4661
|
-
await new Promise(function(r){setTimeout(r,i<10?200:500)});
|
|
4662
|
-
}
|
|
4663
|
-
return JSON.stringify({title:document.title,url:location.href,text:document.body.innerText.substring(0,${Number(maxLength)})});
|
|
4664
|
-
})()`,
|
|
4665
|
-
{ timeout: 30000 }
|
|
4666
|
-
);
|
|
4718
|
+
// Poll readyState from Node, then read โ `do JavaScript` can't await an async IIFE.
|
|
4719
|
+
const navResult = await _pollReadyAndRead(navIndex, { maxLength });
|
|
4667
4720
|
// Update _activeTabURL with the actual URL after navigation
|
|
4668
4721
|
try {
|
|
4669
4722
|
const parsed = JSON.parse(navResult);
|
|
@@ -4674,13 +4727,14 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
|
4674
4727
|
|
|
4675
4728
|
// Click + wait for navigation or element โ common after clicking a link/button
|
|
4676
4729
|
export async function clickAndWait({ selector, text, waitFor: waitSelector, timeout = 10000 }) {
|
|
4677
|
-
const
|
|
4678
|
-
const
|
|
4679
|
-
const
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4730
|
+
const esc = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4731
|
+
const safeSel = selector ? esc(selector) : "";
|
|
4732
|
+
const safeText = text ? esc(text) : "";
|
|
4733
|
+
const safeWait = waitSelector ? esc(waitSelector) : "";
|
|
4734
|
+
const navIndex = _activeTabIndex;
|
|
4735
|
+
// Step 1: find + click โ fully synchronous, so it runs inside one `do JavaScript`.
|
|
4736
|
+
const clickResult = await runJS(
|
|
4737
|
+
`(function(){
|
|
4684
4738
|
var el;
|
|
4685
4739
|
${safeSel ? `el = document.querySelector('${safeSel}');` : ""}
|
|
4686
4740
|
${safeText && !safeSel ? `
|
|
@@ -4690,31 +4744,35 @@ export async function clickAndWait({ selector, text, waitFor: waitSelector, time
|
|
|
4690
4744
|
if(!el) return JSON.stringify({error:'Element not found'});
|
|
4691
4745
|
el.scrollIntoView({block:'center'});
|
|
4692
4746
|
el.click();
|
|
4693
|
-
|
|
4694
|
-
var deadline = Date.now() + ${Number(timeout)};
|
|
4695
|
-
${safeWait ? `
|
|
4696
|
-
while(Date.now()<deadline){
|
|
4697
|
-
if(document.querySelector('${safeWait}'))break;
|
|
4698
|
-
await new Promise(function(r){setTimeout(r,200)});
|
|
4699
|
-
}
|
|
4700
|
-
` : `
|
|
4701
|
-
await new Promise(function(r){setTimeout(r,300)});
|
|
4702
|
-
while(Date.now()<deadline){
|
|
4703
|
-
if(document.readyState==='complete')break;
|
|
4704
|
-
await new Promise(function(r){setTimeout(r,200)});
|
|
4705
|
-
}
|
|
4706
|
-
`}
|
|
4707
|
-
return JSON.stringify({title:document.title,url:location.href,clicked:el.tagName+' "'+el.textContent.trim().substring(0,50)+'"'});
|
|
4747
|
+
return JSON.stringify({clicked:el.tagName+' "'+el.textContent.trim().substring(0,50)+'"'});
|
|
4708
4748
|
})()`,
|
|
4709
|
-
{
|
|
4749
|
+
{ tabIndex: navIndex, timeout: 10000 }
|
|
4710
4750
|
);
|
|
4751
|
+
let clickedInfo = '';
|
|
4752
|
+
try { const c = JSON.parse(clickResult); if (c.error) return clickResult; clickedInfo = c.clicked || ''; } catch {}
|
|
4753
|
+
// Step 2: wait from Node โ `do JavaScript` can't await an in-page loop.
|
|
4754
|
+
const deadline = Date.now() + Number(timeout);
|
|
4755
|
+
await new Promise(r => setTimeout(r, 300));
|
|
4756
|
+
while (Date.now() < deadline) {
|
|
4757
|
+
try {
|
|
4758
|
+
if (safeWait) {
|
|
4759
|
+
if (await runJS(`document.querySelector('${safeWait}')?'1':''`, { tabIndex: navIndex, timeout: 5000 }) === '1') break;
|
|
4760
|
+
} else if (await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 }) === 'complete') {
|
|
4761
|
+
break;
|
|
4762
|
+
}
|
|
4763
|
+
} catch { /* page navigating */ }
|
|
4764
|
+
await new Promise(r => setTimeout(r, 200));
|
|
4765
|
+
}
|
|
4766
|
+
const final = await runJS(`JSON.stringify({title:document.title,url:location.href})`, { tabIndex: navIndex, timeout: 5000 });
|
|
4767
|
+
try { const p = JSON.parse(final); p.clicked = clickedInfo; return JSON.stringify(p); } catch { return final; }
|
|
4711
4768
|
}
|
|
4712
4769
|
|
|
4713
4770
|
// Fill form + submit โ common for login, search, etc.
|
|
4714
4771
|
export async function fillAndSubmit({ fields, submitSelector }) {
|
|
4715
4772
|
await fillForm({ fields });
|
|
4773
|
+
const navIndex = _activeTabIndex;
|
|
4716
4774
|
if (submitSelector) {
|
|
4717
|
-
const sel = submitSelector.replace(/'/g, "\\'");
|
|
4775
|
+
const sel = submitSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4718
4776
|
await runJS(
|
|
4719
4777
|
`(function(){var el=document.querySelector('${sel}');if(el)el.click();})()`
|
|
4720
4778
|
);
|
|
@@ -4724,15 +4782,9 @@ export async function fillAndSubmit({ fields, submitSelector }) {
|
|
|
4724
4782
|
`(function(){var btn=document.querySelector('[type=submit],button:not([type])');if(btn)btn.click();})()`
|
|
4725
4783
|
);
|
|
4726
4784
|
}
|
|
4727
|
-
// Wait for navigation/reload
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
await new Promise(function(r){setTimeout(r,300)});
|
|
4731
|
-
for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}
|
|
4732
|
-
return JSON.stringify({title:document.title,url:location.href});
|
|
4733
|
-
})()`,
|
|
4734
|
-
{ timeout: 15000 }
|
|
4735
|
-
);
|
|
4785
|
+
// Wait for navigation/reload โ polled from Node (`do JavaScript` can't await).
|
|
4786
|
+
await new Promise(r => setTimeout(r, 300));
|
|
4787
|
+
return _pollReadyAndRead(navIndex);
|
|
4736
4788
|
}
|
|
4737
4789
|
|
|
4738
4790
|
// Full page analysis โ extracts everything in ONE call
|