safari-mcp 2.4.0 → 2.5.1
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 +31 -1
- package/package.json +1 -1
- package/safari-helper +0 -0
- package/safari-helper.swift +92 -1
- package/safari.js +118 -0
package/extension/background.js
CHANGED
|
@@ -405,7 +405,7 @@ async function handleCommand(type, payload) {
|
|
|
405
405
|
|
|
406
406
|
// --- Click & Input ---
|
|
407
407
|
case "click": {
|
|
408
|
-
|
|
408
|
+
const result = await execInTab((selector, text, x, y, ref) => {
|
|
409
409
|
// Use shared deep query (defined by ensureHelpers / _deepQueryScript)
|
|
410
410
|
const dq = window.__mcpDeepQuery || document.querySelector.bind(document);
|
|
411
411
|
|
|
@@ -633,6 +633,36 @@ async function handleCommand(type, payload) {
|
|
|
633
633
|
|
|
634
634
|
return "Clicked: " + el.tagName + (el.textContent ? ' "' + el.textContent.trim().substring(0, 50) + '"' : "");
|
|
635
635
|
}, [payload.selector, payload.text, payload.x, payload.y, payload.ref], tabId);
|
|
636
|
+
|
|
637
|
+
// Fallback: if element not found in main frame, try all frames (cross-origin iframes)
|
|
638
|
+
if (result && (result.startsWith("Element not found") || result === "No click target")) {
|
|
639
|
+
const iframeResult = await execInAllFrames((selector, text) => {
|
|
640
|
+
let el = null;
|
|
641
|
+
if (selector) {
|
|
642
|
+
el = document.querySelector(selector);
|
|
643
|
+
} else if (text) {
|
|
644
|
+
// Search interactive elements by text
|
|
645
|
+
const candidates = document.querySelectorAll("button, a, [role='button'], input[type='submit']");
|
|
646
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
647
|
+
const t = (candidates[i].innerText || candidates[i].textContent || "").trim();
|
|
648
|
+
if (t === text) { el = candidates[i]; break; }
|
|
649
|
+
}
|
|
650
|
+
// Fuzzy: contains match
|
|
651
|
+
if (!el) {
|
|
652
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
653
|
+
const t = (candidates[i].innerText || candidates[i].textContent || "").trim();
|
|
654
|
+
if (t.includes(text) || text.includes(t)) { el = candidates[i]; break; }
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (!el) return null;
|
|
659
|
+
el.scrollIntoView({ block: "center", behavior: "instant" });
|
|
660
|
+
el.click();
|
|
661
|
+
return "Clicked (iframe): " + el.tagName + (el.textContent ? ' "' + el.textContent.trim().substring(0, 50) + '"' : "");
|
|
662
|
+
}, [payload.selector, payload.text], tabId);
|
|
663
|
+
if (iframeResult) return iframeResult;
|
|
664
|
+
}
|
|
665
|
+
return result;
|
|
636
666
|
}
|
|
637
667
|
|
|
638
668
|
// --- Click + Read (combo — saves 1 full MCP round-trip) ---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
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-helper
CHANGED
|
Binary file
|
package/safari-helper.swift
CHANGED
|
@@ -128,6 +128,57 @@ func performNativeClick(x: Double, y: Double, doubleClick: Bool = false, windowI
|
|
|
128
128
|
return ["result": "clicked at (\(Int(x)),\(Int(y)))\(doubleClick ? " (double)" : "")\(targetInfo)"]
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// ========== CGEvent Native Keyboard ==========
|
|
132
|
+
// Sends keyboard events (keystrokes, shortcuts) targeted to a specific window
|
|
133
|
+
// WITHOUT activating Safari or stealing focus. Same principle as native click.
|
|
134
|
+
|
|
135
|
+
func performNativeKeyboard(keyCode: UInt16, flags: CGEventFlags = [], windowId: Int64 = 0) -> [String: Any] {
|
|
136
|
+
// Get Safari PID
|
|
137
|
+
var safariPID: pid_t = 0
|
|
138
|
+
if windowId > 0 {
|
|
139
|
+
let ws = NSWorkspace.shared
|
|
140
|
+
for app in ws.runningApplications {
|
|
141
|
+
if app.bundleIdentifier == "com.apple.Safari" {
|
|
142
|
+
safariPID = app.processIdentifier
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if safariPID == 0 {
|
|
147
|
+
return ["error": "Safari process not found"]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let kWindowField = CGEventField(rawValue: 91)!
|
|
152
|
+
let kWindowHandlerField = CGEventField(rawValue: 92)!
|
|
153
|
+
|
|
154
|
+
guard let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true) else {
|
|
155
|
+
return ["error": "Failed to create key down event"]
|
|
156
|
+
}
|
|
157
|
+
guard let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) else {
|
|
158
|
+
return ["error": "Failed to create key up event"]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
keyDown.flags = flags
|
|
162
|
+
keyUp.flags = flags
|
|
163
|
+
|
|
164
|
+
if windowId > 0 {
|
|
165
|
+
keyDown.setIntegerValueField(kWindowField, value: windowId)
|
|
166
|
+
keyDown.setIntegerValueField(kWindowHandlerField, value: windowId)
|
|
167
|
+
keyUp.setIntegerValueField(kWindowField, value: windowId)
|
|
168
|
+
keyUp.setIntegerValueField(kWindowHandlerField, value: windowId)
|
|
169
|
+
keyDown.postToPid(safariPID)
|
|
170
|
+
usleep(30_000)
|
|
171
|
+
keyUp.postToPid(safariPID)
|
|
172
|
+
} else {
|
|
173
|
+
keyDown.post(tap: .cghidEventTap)
|
|
174
|
+
usleep(30_000)
|
|
175
|
+
keyUp.post(tap: .cghidEventTap)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let targetInfo = windowId > 0 ? " (window \(windowId), background)" : ""
|
|
179
|
+
return ["result": "key \(keyCode) pressed\(targetInfo)"]
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
// ========== Response Helper ==========
|
|
132
183
|
|
|
133
184
|
func respond(_ obj: [String: Any]) {
|
|
@@ -166,6 +217,24 @@ if args.count >= 4 && args[1] == "--click" {
|
|
|
166
217
|
exit(result["error"] != nil ? 1 : 0)
|
|
167
218
|
}
|
|
168
219
|
|
|
220
|
+
// ========== CLI Mode: --paste --window WID ==========
|
|
221
|
+
// Convenience: Cmd+V via CGEvent (no activate needed)
|
|
222
|
+
if args.count >= 2 && args[1] == "--paste" {
|
|
223
|
+
var windowId: Int64 = 0
|
|
224
|
+
var i = 2
|
|
225
|
+
while i < args.count {
|
|
226
|
+
if args[i] == "--window" && i + 1 < args.count {
|
|
227
|
+
windowId = Int64(args[i + 1]) ?? 0
|
|
228
|
+
i += 1
|
|
229
|
+
}
|
|
230
|
+
i += 1
|
|
231
|
+
}
|
|
232
|
+
// keyCode 9 = V key
|
|
233
|
+
let result = performNativeKeyboard(keyCode: 9, flags: .maskCommand, windowId: windowId)
|
|
234
|
+
respond(result)
|
|
235
|
+
exit(result["error"] != nil ? 1 : 0)
|
|
236
|
+
}
|
|
237
|
+
|
|
169
238
|
// ========== Daemon Mode: JSON lines on stdin ==========
|
|
170
239
|
|
|
171
240
|
while let line = readLine(strippingNewline: true) {
|
|
@@ -187,9 +256,31 @@ while let line = readLine(strippingNewline: true) {
|
|
|
187
256
|
continue
|
|
188
257
|
}
|
|
189
258
|
|
|
259
|
+
// Handle CGEvent keyboard command
|
|
260
|
+
// {"keyboard": {"keyCode": 9, "flags": ["cmd"], "windowId": 4127}}
|
|
261
|
+
// keyCode 9 = V, flags: cmd/shift/alt/ctrl
|
|
262
|
+
if let kbData = json["keyboard"] as? [String: Any],
|
|
263
|
+
let keyCode = kbData["keyCode"] as? Int {
|
|
264
|
+
let windowId = Int64((kbData["windowId"] as? Int) ?? 0)
|
|
265
|
+
var flags: CGEventFlags = []
|
|
266
|
+
if let flagNames = kbData["flags"] as? [String] {
|
|
267
|
+
for f in flagNames {
|
|
268
|
+
switch f.lowercased() {
|
|
269
|
+
case "cmd": flags.insert(.maskCommand)
|
|
270
|
+
case "shift": flags.insert(.maskShift)
|
|
271
|
+
case "alt", "option": flags.insert(.maskAlternate)
|
|
272
|
+
case "ctrl", "control": flags.insert(.maskControl)
|
|
273
|
+
default: break
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
respond(performNativeKeyboard(keyCode: UInt16(keyCode), flags: flags, windowId: windowId))
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
|
|
190
281
|
// Handle AppleScript command
|
|
191
282
|
guard let script = json["script"] as? String else {
|
|
192
|
-
respond(["error": "invalid input — expected 'script' or '
|
|
283
|
+
respond(["error": "invalid input — expected 'script', 'click', or 'keyboard'"])
|
|
193
284
|
continue
|
|
194
285
|
}
|
|
195
286
|
|
package/safari.js
CHANGED
|
@@ -420,6 +420,44 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
|
|
|
420
420
|
});
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
// Sends a CGEvent keyboard command to the Swift helper daemon.
|
|
424
|
+
// No focus stealing — sends key events directly to the target window via PID.
|
|
425
|
+
function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000) {
|
|
426
|
+
return new Promise((resolve, reject) => {
|
|
427
|
+
if (!_helperProc) startHelper();
|
|
428
|
+
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
429
|
+
reject(new Error("safari-helper not available for native keyboard"));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
let resolved = false;
|
|
433
|
+
const timer = setTimeout(() => {
|
|
434
|
+
if (resolved) return;
|
|
435
|
+
resolved = true;
|
|
436
|
+
const idx = _helperQueue.indexOf(cb);
|
|
437
|
+
if (idx >= 0) _helperQueue.splice(idx, 1);
|
|
438
|
+
reject(new Error("native keyboard timeout"));
|
|
439
|
+
}, timeout);
|
|
440
|
+
|
|
441
|
+
function cb(line) {
|
|
442
|
+
if (resolved) return;
|
|
443
|
+
resolved = true;
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
try {
|
|
446
|
+
const parsed = JSON.parse(line);
|
|
447
|
+
if (parsed.error) reject(new Error(parsed.error));
|
|
448
|
+
else resolve(parsed.result ?? "");
|
|
449
|
+
} catch {
|
|
450
|
+
resolve(line);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
_helperQueue.push(cb);
|
|
455
|
+
const cmd = { keyboard: { keyCode, flags } };
|
|
456
|
+
if (windowId) cmd.keyboard.windowId = windowId;
|
|
457
|
+
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
423
461
|
// Get Safari window bounds, toolbar height, and window ID for coordinate calculation
|
|
424
462
|
async function _getSafariWindowGeometry() {
|
|
425
463
|
await refreshTargetWindow();
|
|
@@ -1070,6 +1108,19 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
1070
1108
|
|
|
1071
1109
|
// Cmd+V (paste) — read clipboard via AppleScript (no activate!), inject via JS
|
|
1072
1110
|
if (k === "v") {
|
|
1111
|
+
// Cross-origin iframe: JS can't paste into it, use CGEvent Cmd+V (no focus steal)
|
|
1112
|
+
const activeTag = await runJS(`document.activeElement ? document.activeElement.tagName : ''`);
|
|
1113
|
+
if (activeTag === 'IFRAME') {
|
|
1114
|
+
try {
|
|
1115
|
+
const geo = await _getSafariWindowGeometry();
|
|
1116
|
+
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
|
|
1117
|
+
} catch {
|
|
1118
|
+
await _helperNativeKeyboard(9, ["cmd"], 0);
|
|
1119
|
+
}
|
|
1120
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1121
|
+
return `Pressed: ${modifiers.join("+")}+v (CGEvent Cmd+V into iframe, no focus steal)`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1073
1124
|
const clipText = await osascript(`the clipboard as text`).catch(() => "");
|
|
1074
1125
|
if (clipText) {
|
|
1075
1126
|
const escaped = clipText.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
@@ -1153,6 +1204,61 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
1153
1204
|
return `Pressed: ${modifiers.length ? modifiers.join("+") + "+" : ""}${key}`;
|
|
1154
1205
|
}
|
|
1155
1206
|
|
|
1207
|
+
// Native typing via clipboard paste — for cross-origin iframes (Intercom, Zendesk, etc.)
|
|
1208
|
+
// JS can't access content inside cross-origin iframes, so we use OS-level CGEvent:
|
|
1209
|
+
// 1. Save current clipboard
|
|
1210
|
+
// 2. Set clipboard to our text
|
|
1211
|
+
// 3. Cmd+V via CGEvent keyboard (targeted to window — NO focus steal, NO activate)
|
|
1212
|
+
// 4. Restore clipboard
|
|
1213
|
+
// Requires nativeClick to have focused the input inside the iframe first.
|
|
1214
|
+
async function _nativeTypeViaClipboard(text) {
|
|
1215
|
+
// Save current clipboard
|
|
1216
|
+
let savedClipboard = null;
|
|
1217
|
+
try {
|
|
1218
|
+
const { stdout } = await execFileAsync("pbpaste", []);
|
|
1219
|
+
savedClipboard = stdout;
|
|
1220
|
+
} catch {}
|
|
1221
|
+
|
|
1222
|
+
// Set clipboard to our text via pipe (safe from shell injection)
|
|
1223
|
+
await new Promise((resolve, reject) => {
|
|
1224
|
+
const proc = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
1225
|
+
proc.stdin.write(text);
|
|
1226
|
+
proc.stdin.end();
|
|
1227
|
+
proc.on("close", resolve);
|
|
1228
|
+
proc.on("error", reject);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
// Paste via CGEvent Cmd+V targeted to Safari window — NO activate, NO focus steal
|
|
1232
|
+
try {
|
|
1233
|
+
const geo = await _getSafariWindowGeometry();
|
|
1234
|
+
// keyCode 9 = V key, flags: ["cmd"]
|
|
1235
|
+
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
|
|
1236
|
+
} catch {
|
|
1237
|
+
// Fallback: try without windowId (global)
|
|
1238
|
+
await _helperNativeKeyboard(9, ["cmd"], 0);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Wait for paste to settle
|
|
1242
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1243
|
+
|
|
1244
|
+
// Restore clipboard after delay
|
|
1245
|
+
if (savedClipboard !== null) {
|
|
1246
|
+
setTimeout(async () => {
|
|
1247
|
+
try {
|
|
1248
|
+
await new Promise((resolve, reject) => {
|
|
1249
|
+
const proc = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
1250
|
+
proc.stdin.write(savedClipboard);
|
|
1251
|
+
proc.stdin.end();
|
|
1252
|
+
proc.on("close", resolve);
|
|
1253
|
+
proc.on("error", reject);
|
|
1254
|
+
});
|
|
1255
|
+
} catch {}
|
|
1256
|
+
}, 3000);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
return `Typed ${text.length} chars (native paste into iframe)`;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1156
1262
|
export async function typeText({ text, selector, ref }) {
|
|
1157
1263
|
if (ref) selector = refSelector(ref);
|
|
1158
1264
|
if (selector) {
|
|
@@ -1162,6 +1268,13 @@ export async function typeText({ text, selector, ref }) {
|
|
|
1162
1268
|
await new Promise((r) => setTimeout(r, 30));
|
|
1163
1269
|
}
|
|
1164
1270
|
|
|
1271
|
+
// Cross-origin iframe detection: JS can't access content inside cross-origin iframes.
|
|
1272
|
+
// When activeElement is an IFRAME, use native clipboard paste via System Events.
|
|
1273
|
+
const activeTag = await runJS(`document.activeElement ? document.activeElement.tagName : ''`);
|
|
1274
|
+
if (activeTag === 'IFRAME') {
|
|
1275
|
+
return await _nativeTypeViaClipboard(text);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1165
1278
|
// Use execCommand("insertText") — the ONLY approach that works for BOTH:
|
|
1166
1279
|
// 1. Regular inputs/textareas (execCommand works natively)
|
|
1167
1280
|
// 2. ContentEditable (ProseMirror/Draft.js/Slate) — execCommand causes real DOM mutation
|
|
@@ -1181,6 +1294,11 @@ export async function typeText({ text, selector, ref }) {
|
|
|
1181
1294
|
`if('value' in el){var start=el.selectionStart||0;el.value=el.value.substring(0,start)+'${safeText}'+el.value.substring(el.selectionEnd||start);el.selectionStart=el.selectionEnd=start+${text.length};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));return 'Typed '+${text.length}+' chars via value set';}return 'Could not type';})()`
|
|
1182
1295
|
);
|
|
1183
1296
|
|
|
1297
|
+
// If JS typing failed and we're somehow in an iframe context, try native fallback
|
|
1298
|
+
if (result === 'Could not type') {
|
|
1299
|
+
return await _nativeTypeViaClipboard(text);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1184
1302
|
return result;
|
|
1185
1303
|
}
|
|
1186
1304
|
|