safari-mcp 2.7.8 → 2.7.10
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 +28 -5
- package/extension/popup/popup.html +1 -37
- package/extension/popup/popup.js +34 -0
- package/index.js +61 -5
- package/package.json +5 -1
- package/safari-helper +0 -0
- package/safari-helper.swift +59 -0
- package/safari.js +120 -0
package/README.md
CHANGED
|
@@ -426,13 +426,22 @@ The extension requires a one-time build with Xcode (free, included with macOS).
|
|
|
426
426
|
# 1. Clone the repo (the npm package does not include the Xcode project)
|
|
427
427
|
git clone https://github.com/achiya-automation/safari-mcp.git
|
|
428
428
|
cd safari-mcp
|
|
429
|
+
|
|
430
|
+
# 2. Build the extension
|
|
429
431
|
xcodebuild -project "xcode/Safari MCP/Safari MCP.xcodeproj" \
|
|
430
432
|
-scheme "Safari MCP (macOS)" -configuration Release build
|
|
431
433
|
|
|
432
|
-
#
|
|
433
|
-
|
|
434
|
+
# 3. Ad-hoc sign the built app so Safari will load it
|
|
435
|
+
# (xcodebuild without a signing identity produces a bundle Safari silently rejects)
|
|
436
|
+
APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData/Safari_MCP-*/Build/Products/Release -name "Safari MCP.app" -maxdepth 2 | head -1)
|
|
437
|
+
codesign --sign - --force --deep "$APP_PATH"
|
|
438
|
+
|
|
439
|
+
# 4. Open the app (needed once so Safari registers the extension)
|
|
440
|
+
open "$APP_PATH"
|
|
434
441
|
```
|
|
435
442
|
|
|
443
|
+
Alternatively, open `xcode/Safari MCP/Safari MCP.xcodeproj` directly in Xcode, select your Apple ID under Signing & Capabilities, and click Run. A free personal Apple Developer account is sufficient for local use.
|
|
444
|
+
|
|
436
445
|
Then in Safari:
|
|
437
446
|
1. Safari → Settings → Advanced → enable **Show features for web developers**
|
|
438
447
|
2. Safari → Develop → **Allow Unsigned Extensions** (required each Safari restart)
|
|
@@ -456,8 +465,21 @@ Safari MCP needs these one-time permissions:
|
|
|
456
465
|
| Permission | Where | Why |
|
|
457
466
|
|-----------|-------|-----|
|
|
458
467
|
| JavaScript from Apple Events | Safari → Develop menu | Required for `do JavaScript` |
|
|
459
|
-
|
|
|
460
|
-
|
|
|
468
|
+
| Automation → Safari | System Settings → Privacy & Security → Automation | Required for all AppleScript-backed tools |
|
|
469
|
+
| Screen Recording | System Settings → Privacy & Security → Screen Recording | Required for `safari_screenshot` |
|
|
470
|
+
| Accessibility | System Settings → Privacy & Security → Accessibility | Required for `safari_save_pdf` only |
|
|
471
|
+
|
|
472
|
+
### Granting Automation → Safari (important for IDE users)
|
|
473
|
+
|
|
474
|
+
macOS TCC grants Automation permission to the **parent process** that spawns the MCP server, not to `safari-mcp` itself. So you need to grant **Automation → Safari** to the app that runs Claude Code / Cursor / Windsurf — typically **Visual Studio Code** or **Terminal**.
|
|
475
|
+
|
|
476
|
+
If the permission dialog never appears automatically, run this command once from a Terminal that already has Automation permission:
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
osascript -e 'tell application "Safari" to get URL of current tab of window 1'
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
That call registers the Terminal app in the Automation database and then triggers the prompt for Safari. After you approve it, subsequent MCP calls from any child process chain will work.
|
|
461
483
|
|
|
462
484
|
---
|
|
463
485
|
|
|
@@ -466,6 +488,7 @@ Safari MCP needs these one-time permissions:
|
|
|
466
488
|
| Issue | Fix |
|
|
467
489
|
|-------|-----|
|
|
468
490
|
| "AppleScript error" | Enable "Allow JavaScript from Apple Events" in Safari → Develop |
|
|
491
|
+
| "Not authorized to send Apple events to Safari" | Grant Automation → Safari to your IDE (see above) |
|
|
469
492
|
| Screenshots empty | Grant Screen Recording permission to Terminal/VS Code |
|
|
470
493
|
| Tab not found | Call `safari_list_tabs` to refresh tab indices |
|
|
471
494
|
| Hebrew keyboard issues | All typing uses JS events — immune to keyboard layout |
|
|
@@ -515,7 +538,7 @@ Your support funds:
|
|
|
515
538
|
|
|
516
539
|
## Community
|
|
517
540
|
|
|
518
|
-
**2,000+
|
|
541
|
+
**2,000+ monthly npm downloads** — developers are using Safari MCP to build AI agents on macOS.
|
|
519
542
|
|
|
520
543
|
- [GitHub Discussions](https://github.com/achiya-automation/safari-mcp/discussions) — ask questions, share use cases
|
|
521
544
|
- [Issues](https://github.com/achiya-automation/safari-mcp/issues) — bug reports and feature requests
|
|
@@ -37,42 +37,6 @@
|
|
|
37
37
|
<span class="slider"></span>
|
|
38
38
|
</label>
|
|
39
39
|
</div>
|
|
40
|
-
<script>
|
|
41
|
-
// Inline script — cannot be cached separately by Safari
|
|
42
|
-
function setStatus(c, t, i) {
|
|
43
|
-
document.getElementById("statusDot").className = "dot " + c;
|
|
44
|
-
document.getElementById("statusText").textContent = t;
|
|
45
|
-
document.getElementById("info").textContent = i;
|
|
46
|
-
}
|
|
47
|
-
async function getVal(k, d) {
|
|
48
|
-
try { const r = await browser.storage.local.get(k); return r[k] !== undefined ? r[k] : d; }
|
|
49
|
-
catch { return d; }
|
|
50
|
-
}
|
|
51
|
-
async function check() {
|
|
52
|
-
const on = await getVal("mcpEnabled", true);
|
|
53
|
-
document.getElementById("enableToggle").checked = on;
|
|
54
|
-
if (!on) { setStatus("paused", "Paused", "Toggle to resume"); return; }
|
|
55
|
-
// Ask background script for real-time status first
|
|
56
|
-
try {
|
|
57
|
-
const resp = await browser.runtime.sendMessage({ action: "getStatus" });
|
|
58
|
-
if (resp) {
|
|
59
|
-
if (resp.connected) { setStatus("connected", "Connected", "Port 9224"); return; }
|
|
60
|
-
if (!resp.enabled) { setStatus("paused", "Paused", "Toggle to resume"); return; }
|
|
61
|
-
}
|
|
62
|
-
} catch {}
|
|
63
|
-
// Fallback: read from storage
|
|
64
|
-
const s = await getVal("mcpStatus", null);
|
|
65
|
-
if (s === "connected") setStatus("connected", "Connected", "Port 9224");
|
|
66
|
-
else if (s === "paused") setStatus("paused", "Paused", "Toggle to resume");
|
|
67
|
-
else if (s === "checking") setStatus("checking", "Connecting...", "Trying port 9224...");
|
|
68
|
-
else setStatus("disconnected", "Not connected", "Start the MCP server to connect");
|
|
69
|
-
}
|
|
70
|
-
document.getElementById("enableToggle").addEventListener("change", async (e) => {
|
|
71
|
-
await browser.storage.local.set({ mcpEnabled: e.target.checked });
|
|
72
|
-
try { browser.runtime.sendMessage({ action: "setEnabled", enabled: e.target.checked }); } catch {}
|
|
73
|
-
setTimeout(check, 500);
|
|
74
|
-
});
|
|
75
|
-
check();
|
|
76
|
-
</script>
|
|
40
|
+
<script src="popup.js"></script>
|
|
77
41
|
</body>
|
|
78
42
|
</html>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function setStatus(c, t, i) {
|
|
2
|
+
document.getElementById("statusDot").className = "dot " + c;
|
|
3
|
+
document.getElementById("statusText").textContent = t;
|
|
4
|
+
document.getElementById("info").textContent = i;
|
|
5
|
+
}
|
|
6
|
+
async function getVal(k, d) {
|
|
7
|
+
try { const r = await browser.storage.local.get(k); return r[k] !== undefined ? r[k] : d; }
|
|
8
|
+
catch { return d; }
|
|
9
|
+
}
|
|
10
|
+
async function check() {
|
|
11
|
+
const on = await getVal("mcpEnabled", true);
|
|
12
|
+
document.getElementById("enableToggle").checked = on;
|
|
13
|
+
if (!on) { setStatus("paused", "Paused", "Toggle to resume"); return; }
|
|
14
|
+
// Ask background script for real-time status first
|
|
15
|
+
try {
|
|
16
|
+
const resp = await browser.runtime.sendMessage({ action: "getStatus" });
|
|
17
|
+
if (resp) {
|
|
18
|
+
if (resp.connected) { setStatus("connected", "Connected", "Port 9224"); return; }
|
|
19
|
+
if (!resp.enabled) { setStatus("paused", "Paused", "Toggle to resume"); return; }
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
// Fallback: read from storage
|
|
23
|
+
const s = await getVal("mcpStatus", null);
|
|
24
|
+
if (s === "connected") setStatus("connected", "Connected", "Port 9224");
|
|
25
|
+
else if (s === "paused") setStatus("paused", "Paused", "Toggle to resume");
|
|
26
|
+
else if (s === "checking") setStatus("checking", "Connecting...", "Trying port 9224...");
|
|
27
|
+
else setStatus("disconnected", "Not connected", "Start the MCP server to connect");
|
|
28
|
+
}
|
|
29
|
+
document.getElementById("enableToggle").addEventListener("change", async (e) => {
|
|
30
|
+
await browser.storage.local.set({ mcpEnabled: e.target.checked });
|
|
31
|
+
try { browser.runtime.sendMessage({ action: "setEnabled", enabled: e.target.checked }); } catch {}
|
|
32
|
+
setTimeout(check, 500);
|
|
33
|
+
});
|
|
34
|
+
check();
|
package/index.js
CHANGED
|
@@ -13,9 +13,10 @@ import { WebSocketServer } from "ws";
|
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { homedir } from "node:os";
|
|
19
20
|
|
|
20
21
|
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap on POST body — prevents DoS
|
|
21
22
|
|
|
@@ -51,6 +52,36 @@ try {
|
|
|
51
52
|
// ========== SESSION ID (unique per MCP process — enables per-session tab tracking) ==========
|
|
52
53
|
const SESSION_ID = randomUUID().slice(0, 8);
|
|
53
54
|
|
|
55
|
+
// ========== PERSISTENT TAB OWNERSHIP ==========
|
|
56
|
+
// The in-memory _ownedTabURLs set is wiped when the MCP process restarts
|
|
57
|
+
// (Claude Code periodically recycles MCP servers). Without persistence, every
|
|
58
|
+
// restart re-triggers "Tab safety: no tabs opened yet" errors forcing a
|
|
59
|
+
// re-open of every tab. Persist the set to a JSON file with a TTL so tabs
|
|
60
|
+
// remain "owned" across process restarts for up to OWNERSHIP_TTL_MS.
|
|
61
|
+
const OWNERSHIP_DIR = join(homedir(), ".safari-mcp");
|
|
62
|
+
const OWNERSHIP_FILE = join(OWNERSHIP_DIR, "owned-tabs.json");
|
|
63
|
+
const OWNERSHIP_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
64
|
+
|
|
65
|
+
function _loadOwnershipFile() {
|
|
66
|
+
try {
|
|
67
|
+
if (!existsSync(OWNERSHIP_FILE)) return [];
|
|
68
|
+
const raw = readFileSync(OWNERSHIP_FILE, "utf8");
|
|
69
|
+
const data = JSON.parse(raw);
|
|
70
|
+
if (!Array.isArray(data)) return [];
|
|
71
|
+
const cutoff = Date.now() - OWNERSHIP_TTL_MS;
|
|
72
|
+
return data.filter(e => e && typeof e.url === "string" && typeof e.ts === "number" && e.ts > cutoff);
|
|
73
|
+
} catch { return []; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _saveOwnershipFile(urls) {
|
|
77
|
+
try {
|
|
78
|
+
if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const entries = Array.from(urls).map(url => ({ url, ts: now }));
|
|
81
|
+
writeFileSync(OWNERSHIP_FILE, JSON.stringify(entries), { mode: 0o600 });
|
|
82
|
+
} catch { /* best-effort */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
// ========== MEMORY GUARD: track & auto-close MCP-opened tabs ==========
|
|
55
86
|
const MAX_TABS = parseInt(process.env.MCP_MAX_TABS || "6", 10);
|
|
56
87
|
const MEMORY_CHECK_INTERVAL_MS = parseInt(process.env.MCP_MEMORY_CHECK_MS || "60000", 10);
|
|
@@ -63,7 +94,8 @@ const _openedTabs = new Map();
|
|
|
63
94
|
// Tracks URLs of tabs opened by this MCP session.
|
|
64
95
|
// Any tool that modifies a tab (navigate, click, fill, etc.) is blocked
|
|
65
96
|
// unless the current tab was opened via safari_new_tab.
|
|
66
|
-
|
|
97
|
+
// Hydrated from ~/.safari-mcp/owned-tabs.json so ownership survives MCP restarts.
|
|
98
|
+
const _ownedTabURLs = new Set(_loadOwnershipFile().map(e => e.url));
|
|
67
99
|
|
|
68
100
|
function _isURLOwned(url) {
|
|
69
101
|
if (!url) return false;
|
|
@@ -96,11 +128,17 @@ function _isURLOwned(url) {
|
|
|
96
128
|
}
|
|
97
129
|
|
|
98
130
|
function _addOwnedURL(url) {
|
|
99
|
-
if (url && url !== 'about:blank' && url !== 'favorites://')
|
|
131
|
+
if (url && url !== 'about:blank' && url !== 'favorites://') {
|
|
132
|
+
_ownedTabURLs.add(url);
|
|
133
|
+
_saveOwnershipFile(_ownedTabURLs);
|
|
134
|
+
}
|
|
100
135
|
}
|
|
101
136
|
|
|
102
137
|
function _removeOwnedURL(url) {
|
|
103
|
-
if (url)
|
|
138
|
+
if (url) {
|
|
139
|
+
_ownedTabURLs.delete(url);
|
|
140
|
+
_saveOwnershipFile(_ownedTabURLs);
|
|
141
|
+
}
|
|
104
142
|
}
|
|
105
143
|
|
|
106
144
|
function _updateOwnedURL(oldUrl, newUrl) {
|
|
@@ -147,7 +185,7 @@ let _lastMemoryWarningTime = 0;
|
|
|
147
185
|
|
|
148
186
|
function _getWebKitMemoryMB() {
|
|
149
187
|
try {
|
|
150
|
-
const pids = execFileSync("pgrep", ["-
|
|
188
|
+
const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
151
189
|
if (!pids) return 0;
|
|
152
190
|
const pidList = pids.split("\n").join(",");
|
|
153
191
|
const psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
|
|
@@ -953,6 +991,24 @@ server.tool(
|
|
|
953
991
|
}
|
|
954
992
|
);
|
|
955
993
|
|
|
994
|
+
server.tool(
|
|
995
|
+
"safari_native_hover",
|
|
996
|
+
"OS-level mouse hover via macOS CGEvent — moves the real cursor to an element to trigger native :hover / mouseenter handlers. Use for obfuscated UIs where JS-dispatched mouseenter isn't enough, like Discord server sidebars (tooltips only appear on real hover) or portal-rendered tooltips. After hover, call safari_wait_for or safari_evaluate to read the tooltip. Dwells for dwellMs to let tooltips render, then restores the original cursor position by default. Requires Safari window to be visible.",
|
|
997
|
+
{
|
|
998
|
+
ref: z.string().optional().describe("Ref ID from safari_snapshot"),
|
|
999
|
+
selector: z.string().optional().describe("CSS selector"),
|
|
1000
|
+
text: z.string().optional().describe("Visible text to find and hover"),
|
|
1001
|
+
x: z.coerce.number().optional().describe("Viewport X coordinate"),
|
|
1002
|
+
y: z.coerce.number().optional().describe("Viewport Y coordinate"),
|
|
1003
|
+
dwellMs: z.coerce.number().optional().default(500).describe("Milliseconds to dwell over the element so tooltips render (clamped 0-5000)"),
|
|
1004
|
+
restoreMouse: z.boolean().optional().default(true).describe("Restore cursor to original position after dwell"),
|
|
1005
|
+
},
|
|
1006
|
+
async (args) => {
|
|
1007
|
+
const result = await safari.nativeHover(args);
|
|
1008
|
+
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
1009
|
+
}
|
|
1010
|
+
);
|
|
1011
|
+
|
|
956
1012
|
// ========== FORM INPUT ==========
|
|
957
1013
|
|
|
958
1014
|
server.tool(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.10",
|
|
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",
|
|
@@ -71,5 +71,9 @@
|
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
73
73
|
"ws": "^8.19.0"
|
|
74
|
+
},
|
|
75
|
+
"overrides": {
|
|
76
|
+
"hono": "^4.12.12",
|
|
77
|
+
"@hono/node-server": "^1.19.13"
|
|
74
78
|
}
|
|
75
79
|
}
|
package/safari-helper
CHANGED
|
Binary file
|
package/safari-helper.swift
CHANGED
|
@@ -14,6 +14,53 @@ import Darwin
|
|
|
14
14
|
import CoreGraphics
|
|
15
15
|
import AppKit
|
|
16
16
|
|
|
17
|
+
// ========== CGEvent Native Hover ==========
|
|
18
|
+
// Moves the mouse cursor to a target position to trigger native :hover / mouseenter
|
|
19
|
+
// without clicking. Used for revealing tooltips on obfuscated UIs (Discord sidebar,
|
|
20
|
+
// virtualized server lists, custom React tooltips) where JS-dispatched mouseenter
|
|
21
|
+
// events aren't enough because the rendering depends on CSS :hover or real pointer
|
|
22
|
+
// position. Optionally restores the original cursor position after dwell.
|
|
23
|
+
func performNativeHover(x: Double, y: Double, windowId: Int64 = 0, dwellMs: Int = 500, restoreMouse: Bool = true) -> [String: Any] {
|
|
24
|
+
let point = CGPoint(x: x, y: y)
|
|
25
|
+
let savedPosition = CGEvent(source: nil)?.location ?? CGPoint.zero
|
|
26
|
+
|
|
27
|
+
// Get Safari PID for process-targeted event posting (background hover, no focus steal)
|
|
28
|
+
var safariPID: pid_t = 0
|
|
29
|
+
if windowId > 0 {
|
|
30
|
+
let ws = NSWorkspace.shared
|
|
31
|
+
for app in ws.runningApplications {
|
|
32
|
+
if app.bundleIdentifier == "com.apple.Safari" {
|
|
33
|
+
safariPID = app.processIdentifier
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let kWindowField = CGEventField(rawValue: 91)! // windowUnderMousePointer
|
|
40
|
+
let kWindowHandlerField = CGEventField(rawValue: 92)! // windowThatCanHandleThisEvent
|
|
41
|
+
|
|
42
|
+
func postMove(_ position: CGPoint) {
|
|
43
|
+
guard let ev = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: position, mouseButton: .left) else { return }
|
|
44
|
+
if windowId > 0 && safariPID > 0 {
|
|
45
|
+
ev.setIntegerValueField(kWindowField, value: windowId)
|
|
46
|
+
ev.setIntegerValueField(kWindowHandlerField, value: windowId)
|
|
47
|
+
ev.postToPid(safariPID)
|
|
48
|
+
} else {
|
|
49
|
+
ev.post(tap: .cghidEventTap)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
postMove(point)
|
|
54
|
+
let ms = max(0, min(dwellMs, 5000)) // clamp 0-5000ms to prevent runaway blocking
|
|
55
|
+
usleep(UInt32(ms * 1000))
|
|
56
|
+
if restoreMouse {
|
|
57
|
+
postMove(savedPosition)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let targetInfo = windowId > 0 ? " (window \(windowId), background)" : ""
|
|
61
|
+
return ["result": "hovered at (\(Int(x)),\(Int(y))) for \(ms)ms\(targetInfo)\(restoreMouse ? " (mouse restored)" : "")"]
|
|
62
|
+
}
|
|
63
|
+
|
|
17
64
|
// ========== CGEvent Native Click ==========
|
|
18
65
|
// Performs a REAL OS-level mouse click that produces isTrusted: true in the browser.
|
|
19
66
|
// Requires Accessibility permissions (same as AppleScript automation).
|
|
@@ -256,6 +303,18 @@ while let line = readLine(strippingNewline: true) {
|
|
|
256
303
|
continue
|
|
257
304
|
}
|
|
258
305
|
|
|
306
|
+
// Handle CGEvent hover command — native mouse move to trigger real :hover / mouseenter
|
|
307
|
+
// {"hover": {"x": 500, "y": 300, "windowId": 4127, "dwellMs": 500, "restoreMouse": true}}
|
|
308
|
+
if let hoverData = json["hover"] as? [String: Any],
|
|
309
|
+
let x = hoverData["x"] as? Double,
|
|
310
|
+
let y = hoverData["y"] as? Double {
|
|
311
|
+
let windowId = Int64((hoverData["windowId"] as? Int) ?? 0)
|
|
312
|
+
let dwellMs = (hoverData["dwellMs"] as? Int) ?? 500
|
|
313
|
+
let restoreMouse = (hoverData["restoreMouse"] as? Bool) ?? true
|
|
314
|
+
respond(performNativeHover(x: x, y: y, windowId: windowId, dwellMs: dwellMs, restoreMouse: restoreMouse))
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
|
|
259
318
|
// Handle CGEvent keyboard command
|
|
260
319
|
// {"keyboard": {"keyCode": 9, "flags": ["cmd"], "windowId": 4127}}
|
|
261
320
|
// keyCode 9 = V, flags: cmd/shift/alt/ctrl
|
package/safari.js
CHANGED
|
@@ -545,6 +545,44 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
|
|
|
545
545
|
});
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
// Sends a CGEvent hover command to the Swift helper daemon.
|
|
549
|
+
// Moves the cursor to (x, y), dwells to let tooltips render, optionally restores cursor.
|
|
550
|
+
function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
|
|
551
|
+
return new Promise((resolve, reject) => {
|
|
552
|
+
if (!_helperProc) startHelper();
|
|
553
|
+
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
554
|
+
reject(new Error("safari-helper not available for native hover"));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
let resolved = false;
|
|
558
|
+
const timer = setTimeout(() => {
|
|
559
|
+
if (resolved) return;
|
|
560
|
+
resolved = true;
|
|
561
|
+
const idx = _helperQueue.indexOf(cb);
|
|
562
|
+
if (idx >= 0) _helperQueue[idx] = () => {};
|
|
563
|
+
reject(new Error("native hover timeout"));
|
|
564
|
+
}, timeout);
|
|
565
|
+
|
|
566
|
+
function cb(line) {
|
|
567
|
+
if (resolved) return;
|
|
568
|
+
resolved = true;
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
try {
|
|
571
|
+
const parsed = JSON.parse(line);
|
|
572
|
+
if (parsed.error) reject(new Error(parsed.error));
|
|
573
|
+
else resolve(parsed.result ?? "");
|
|
574
|
+
} catch {
|
|
575
|
+
resolve(line);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
_helperQueue.push(cb);
|
|
580
|
+
const cmd = { hover: { x, y, dwellMs, restoreMouse } };
|
|
581
|
+
if (windowId) cmd.hover.windowId = windowId;
|
|
582
|
+
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
548
586
|
// Sends a CGEvent keyboard command to the Swift helper daemon.
|
|
549
587
|
// No focus stealing — sends key events directly to the target window via PID.
|
|
550
588
|
function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000) {
|
|
@@ -1120,6 +1158,88 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
|
|
|
1120
1158
|
return `${clickType}: ${label} at screen (${screenX},${screenY})`;
|
|
1121
1159
|
}
|
|
1122
1160
|
|
|
1161
|
+
// ========== NATIVE HOVER (OS-level CGEvent mouse move — triggers real :hover and mouseenter) ==========
|
|
1162
|
+
// JS-dispatched mouseenter events work for most React components, but some UIs
|
|
1163
|
+
// (Discord sidebar, virtualized CSS :hover tooltips, custom portal-rendered
|
|
1164
|
+
// tooltips) only respond to a real OS-level cursor position. This function
|
|
1165
|
+
// moves the physical cursor to the target, dwells for tooltips to render,
|
|
1166
|
+
// then optionally restores the cursor to its original position.
|
|
1167
|
+
export async function nativeHover({ selector, text, x, y, ref, dwellMs = 500, restoreMouse = true }) {
|
|
1168
|
+
await ensureHelpers();
|
|
1169
|
+
|
|
1170
|
+
// Step 1: Get element's viewport coordinates via JavaScript
|
|
1171
|
+
let viewportCoords;
|
|
1172
|
+
if (ref || selector || text) {
|
|
1173
|
+
let jsExpr;
|
|
1174
|
+
if (ref) {
|
|
1175
|
+
jsExpr = `(function(){
|
|
1176
|
+
var el = mcpFindRef('${ref}');
|
|
1177
|
+
if (!el) return JSON.stringify({error: 'Element not found: ref=${ref}'});
|
|
1178
|
+
el.scrollIntoView({block:'center', behavior:'instant'});
|
|
1179
|
+
var rect = el.getBoundingClientRect();
|
|
1180
|
+
return JSON.stringify({
|
|
1181
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1182
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1183
|
+
tag: el.tagName,
|
|
1184
|
+
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
|
|
1185
|
+
});
|
|
1186
|
+
})()`;
|
|
1187
|
+
} else if (selector) {
|
|
1188
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1189
|
+
jsExpr = `(function(){
|
|
1190
|
+
var el = document.querySelector('${sel}');
|
|
1191
|
+
if (!el) return JSON.stringify({error: 'Element not found: ${sel}'});
|
|
1192
|
+
el.scrollIntoView({block:'center', behavior:'instant'});
|
|
1193
|
+
var rect = el.getBoundingClientRect();
|
|
1194
|
+
return JSON.stringify({
|
|
1195
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1196
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1197
|
+
tag: el.tagName,
|
|
1198
|
+
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
|
|
1199
|
+
});
|
|
1200
|
+
})()`;
|
|
1201
|
+
} else {
|
|
1202
|
+
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1203
|
+
jsExpr = `(function(){
|
|
1204
|
+
var el = mcpFindText('${safeText}', true) || mcpFindText('${safeText}', false);
|
|
1205
|
+
if (!el) return JSON.stringify({error: 'Element not found with text: ${safeText}'});
|
|
1206
|
+
el.scrollIntoView({block:'center', behavior:'instant'});
|
|
1207
|
+
var rect = el.getBoundingClientRect();
|
|
1208
|
+
return JSON.stringify({
|
|
1209
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1210
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1211
|
+
tag: el.tagName,
|
|
1212
|
+
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
|
|
1213
|
+
});
|
|
1214
|
+
})()`;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const result = await runJS(jsExpr);
|
|
1218
|
+
try {
|
|
1219
|
+
viewportCoords = JSON.parse(result);
|
|
1220
|
+
} catch {
|
|
1221
|
+
throw new Error("Failed to get element coordinates: " + result);
|
|
1222
|
+
}
|
|
1223
|
+
if (viewportCoords.error) {
|
|
1224
|
+
throw new Error(viewportCoords.error);
|
|
1225
|
+
}
|
|
1226
|
+
} else if (x !== undefined && y !== undefined) {
|
|
1227
|
+
viewportCoords = { x: Number(x), y: Number(y), tag: 'point', text: '' };
|
|
1228
|
+
} else {
|
|
1229
|
+
throw new Error("nativeHover requires selector, text, ref, or x/y coordinates");
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const geo = await _getSafariWindowGeometry();
|
|
1233
|
+
const screenX = geo.windowX + viewportCoords.x;
|
|
1234
|
+
const screenY = geo.windowY + geo.toolbarHeight + viewportCoords.y;
|
|
1235
|
+
|
|
1236
|
+
if (!geo.windowId) throw new Error("Cannot native-hover without Safari window ID — would move mouse and steal focus");
|
|
1237
|
+
await _helperNativeHover(screenX, screenY, geo.windowId, dwellMs, restoreMouse);
|
|
1238
|
+
|
|
1239
|
+
const label = viewportCoords.tag + (viewportCoords.text ? ` "${viewportCoords.text}"` : '');
|
|
1240
|
+
return `Native hovered: ${label} at screen (${screenX},${screenY}) for ${dwellMs}ms${restoreMouse ? ' (mouse restored)' : ''}`;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1123
1243
|
// ========== FORM INPUT ==========
|
|
1124
1244
|
|
|
1125
1245
|
export async function fill({ selector, value, ref }) {
|