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.
@@ -405,7 +405,7 @@ async function handleCommand(type, payload) {
405
405
 
406
406
  // --- Click & Input ---
407
407
  case "click": {
408
- return await execInTab((selector, text, x, y, ref) => {
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.4.0",
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
@@ -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 'click'"])
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