safari-mcp 2.1.5 → 2.1.7
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 +11 -2
- package/extension/background.js +30 -0
- package/glama.json +2 -0
- package/index.js +106 -1
- package/package.json +1 -1
- package/safari.js +81 -27
package/README.md
CHANGED
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
[](https://nodejs.org/)
|
|
10
10
|
[](https://www.apple.com/macos/)
|
|
11
11
|
[](https://modelcontextprotocol.io)
|
|
12
|
+
[](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
|
|
12
13
|
[](https://www.npmjs.com/package/safari-mcp)
|
|
13
14
|
[](https://github.com/achiya-automation/safari-mcp/stargazers)
|
|
14
15
|
[](https://glama.ai/mcp/servers/achiya-automation/safari-mcp)
|
|
15
16
|
|
|
16
17
|
**80 tools** · **Zero dependencies** · **~5ms per command** · **60% less CPU than Chrome**
|
|
17
18
|
|
|
18
|
-
[Quick Start](#quick-start) · [All 80 Tools](#tools-80) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture)
|
|
19
|
+
[Quick Start](#quick-start) · [All 80 Tools](#tools-80) · [Examples](examples/) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture) · [Changelog](CHANGELOG.md)
|
|
19
20
|
|
|
20
21
|

|
|
21
22
|
|
|
@@ -63,6 +64,12 @@
|
|
|
63
64
|
|
|
64
65
|
### Install
|
|
65
66
|
|
|
67
|
+
**Option A — npm (recommended):**
|
|
68
|
+
```bash
|
|
69
|
+
npm install -g safari-mcp
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Option B — from source:**
|
|
66
73
|
```bash
|
|
67
74
|
git clone https://github.com/achiya-automation/safari-mcp.git
|
|
68
75
|
cd safari-mcp
|
|
@@ -447,7 +454,9 @@ Safari MCP works with any MCP-compatible client:
|
|
|
447
454
|
|
|
448
455
|
## Contributing
|
|
449
456
|
|
|
450
|
-
PRs welcome!
|
|
457
|
+
PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
|
|
458
|
+
|
|
459
|
+
The codebase is two files:
|
|
451
460
|
- `safari.js` — Safari automation layer (AppleScript + JavaScript)
|
|
452
461
|
- `index.js` — MCP server with tool definitions
|
|
453
462
|
|
package/extension/background.js
CHANGED
|
@@ -92,6 +92,11 @@ async function connect() {
|
|
|
92
92
|
updateBadge("OFF");
|
|
93
93
|
return; // Do NOT poll — let the correct profile's extension handle commands
|
|
94
94
|
}
|
|
95
|
+
// Notify server that we passed profile verification
|
|
96
|
+
await fetch(`${HTTP_URL}/extension-verified`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
signal: AbortSignal.timeout(3000),
|
|
99
|
+
}).catch(() => {});
|
|
95
100
|
await _discoverProfileWindow();
|
|
96
101
|
}
|
|
97
102
|
isConnected = true;
|
|
@@ -1548,6 +1553,8 @@ async function handleCommand(type, payload) {
|
|
|
1548
1553
|
const _sessionTabCache = new Map();
|
|
1549
1554
|
const TAB_CACHE_MS = 3000; // Re-verify tab URL match every 3s
|
|
1550
1555
|
const _DEFAULT_SESSION = "__default__"; // Fallback for commands without sessionId
|
|
1556
|
+
const SESSION_MAX_AGE_MS = 5 * 60 * 1000; // 5 min — prune stale sessions
|
|
1557
|
+
const MAX_SESSIONS = 50; // Hard cap on session cache size
|
|
1551
1558
|
|
|
1552
1559
|
function _getSessionCache(sessionId) {
|
|
1553
1560
|
const sid = sessionId || _DEFAULT_SESSION;
|
|
@@ -1557,6 +1564,29 @@ function _getSessionCache(sessionId) {
|
|
|
1557
1564
|
return _sessionTabCache.get(sid);
|
|
1558
1565
|
}
|
|
1559
1566
|
|
|
1567
|
+
// Prune stale sessions — runs every 60s
|
|
1568
|
+
function _pruneSessionCache() {
|
|
1569
|
+
const now = Date.now();
|
|
1570
|
+
for (const [sid, cache] of _sessionTabCache) {
|
|
1571
|
+
if (sid === _DEFAULT_SESSION) continue;
|
|
1572
|
+
// Remove sessions with no active tab that haven't been used in 5 min
|
|
1573
|
+
if (!cache.tabId && (now - cache.time) > SESSION_MAX_AGE_MS) {
|
|
1574
|
+
_sessionTabCache.delete(sid);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
// Hard cap: if still too many, remove oldest
|
|
1578
|
+
if (_sessionTabCache.size > MAX_SESSIONS) {
|
|
1579
|
+
const sorted = [..._sessionTabCache.entries()]
|
|
1580
|
+
.filter(([sid]) => sid !== _DEFAULT_SESSION)
|
|
1581
|
+
.sort((a, b) => a[1].time - b[1].time);
|
|
1582
|
+
while (_sessionTabCache.size > MAX_SESSIONS && sorted.length) {
|
|
1583
|
+
const [sid] = sorted.shift();
|
|
1584
|
+
_sessionTabCache.delete(sid);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
setInterval(_pruneSessionCache, 60000);
|
|
1589
|
+
|
|
1560
1590
|
function _setSessionTab(sessionId, tabId, tabUrl) {
|
|
1561
1591
|
const cache = _getSessionCache(sessionId);
|
|
1562
1592
|
cache.tabId = tabId;
|
package/glama.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
|
2
3
|
"name": "Safari MCP Server",
|
|
3
4
|
"description": "Native Safari browser automation for AI agents. 80+ tools via AppleScript — zero overhead, keeps logins, runs silently in background.",
|
|
4
5
|
"repository": "https://github.com/achiya-automation/safari-mcp",
|
|
5
6
|
"homepage": "https://github.com/achiya-automation/safari-mcp",
|
|
6
7
|
"author": "achiya-automation",
|
|
8
|
+
"maintainers": ["achiya-automation"],
|
|
7
9
|
"license": "MIT",
|
|
8
10
|
"categories": ["browser-automation", "web-scraping", "developer-tools"],
|
|
9
11
|
"tags": ["safari", "macos", "browser", "automation", "applescript", "mcp"],
|
package/index.js
CHANGED
|
@@ -46,6 +46,77 @@ try {
|
|
|
46
46
|
// ========== SESSION ID (unique per MCP process — enables per-session tab tracking) ==========
|
|
47
47
|
const SESSION_ID = randomUUID().slice(0, 8);
|
|
48
48
|
|
|
49
|
+
// ========== MEMORY GUARD: track & auto-close MCP-opened tabs ==========
|
|
50
|
+
const MAX_TABS = parseInt(process.env.MCP_MAX_TABS || "6", 10);
|
|
51
|
+
const MEMORY_CHECK_INTERVAL_MS = parseInt(process.env.MCP_MEMORY_CHECK_MS || "60000", 10);
|
|
52
|
+
const WEBKIT_MEMORY_LIMIT_MB = parseInt(process.env.MCP_WEBKIT_LIMIT_MB || "3000", 10);
|
|
53
|
+
|
|
54
|
+
// Track tabs opened by THIS session (index → {url, openedAt})
|
|
55
|
+
const _openedTabs = new Map();
|
|
56
|
+
|
|
57
|
+
function _trackTab(tabIndex, url) {
|
|
58
|
+
_openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now() });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _untrackTab(tabIndex) {
|
|
62
|
+
_openedTabs.delete(tabIndex);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Close all MCP-opened tabs on process exit
|
|
66
|
+
async function _cleanupTabs() {
|
|
67
|
+
if (_openedTabs.size === 0) return;
|
|
68
|
+
console.error(`[Safari MCP] Cleanup: closing ${_openedTabs.size} MCP-opened tabs`);
|
|
69
|
+
const indices = [..._openedTabs.keys()].sort((a, b) => b - a);
|
|
70
|
+
for (const idx of indices) {
|
|
71
|
+
try {
|
|
72
|
+
safari.setActiveTabIndex(idx);
|
|
73
|
+
await safari.closeTab();
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
_openedTabs.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Periodic memory check — if WebKit exceeds limit, close oldest MCP tabs
|
|
80
|
+
let _memoryCheckTimer = null;
|
|
81
|
+
function _startMemoryMonitor() {
|
|
82
|
+
_memoryCheckTimer = setInterval(async () => {
|
|
83
|
+
try {
|
|
84
|
+
const psOut = execFileSync("bash", ["-c",
|
|
85
|
+
"ps aux | grep -i 'WebKit\\|WebContent' | grep -v grep | awk '{sum+=$6} END {print sum/1024}'"
|
|
86
|
+
], { encoding: "utf8" }).trim();
|
|
87
|
+
const webkitMB = parseFloat(psOut) || 0;
|
|
88
|
+
if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
|
|
89
|
+
console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
|
|
90
|
+
let oldestIdx = null, oldestTime = Infinity;
|
|
91
|
+
for (const [idx, info] of _openedTabs) {
|
|
92
|
+
if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
|
|
93
|
+
}
|
|
94
|
+
if (oldestIdx !== null) {
|
|
95
|
+
try {
|
|
96
|
+
safari.setActiveTabIndex(oldestIdx);
|
|
97
|
+
await safari.closeTab();
|
|
98
|
+
} catch {}
|
|
99
|
+
_untrackTab(oldestIdx);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
}, MEMORY_CHECK_INTERVAL_MS);
|
|
104
|
+
_memoryCheckTimer.unref();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Cleanup on exit
|
|
108
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
109
|
+
process.on(sig, async () => {
|
|
110
|
+
await _cleanupTabs();
|
|
111
|
+
process.exit(0);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
process.on("exit", () => {
|
|
115
|
+
if (_openedTabs.size > 0) {
|
|
116
|
+
console.error(`[Safari MCP] Exit: ${_openedTabs.size} tabs were tracked for cleanup`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
49
120
|
// ========== EXTENSION FOCUS SAFETY ==========
|
|
50
121
|
// When SAFARI_PROFILE is set, the extension's browser.scripting.executeScript()
|
|
51
122
|
// can steal window focus in Safari (bringing the automation window to front).
|
|
@@ -164,7 +235,9 @@ try {
|
|
|
164
235
|
|
|
165
236
|
// POST /connect — extension announces it's alive
|
|
166
237
|
if (req.method === "POST" && req.url === "/connect") {
|
|
167
|
-
|
|
238
|
+
// When SAFARI_PROFILE is set, don't mark as connected until profile is verified.
|
|
239
|
+
// A personal-profile extension connecting first would incorrectly set the flag.
|
|
240
|
+
if (!process.env.SAFARI_PROFILE && !_extensionConnected) {
|
|
168
241
|
_extensionConnected = true;
|
|
169
242
|
console.error("[Safari MCP] Extension connected via HTTP polling");
|
|
170
243
|
}
|
|
@@ -174,6 +247,18 @@ try {
|
|
|
174
247
|
return;
|
|
175
248
|
}
|
|
176
249
|
|
|
250
|
+
// POST /extension-verified — extension confirmed it's in the correct profile
|
|
251
|
+
if (req.method === "POST" && req.url === "/extension-verified") {
|
|
252
|
+
if (!_extensionConnected) {
|
|
253
|
+
_extensionConnected = true;
|
|
254
|
+
console.error("[Safari MCP] Extension connected and profile-verified via HTTP polling");
|
|
255
|
+
}
|
|
256
|
+
_extensionLastPollTime = Date.now();
|
|
257
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
258
|
+
res.end(JSON.stringify({ status: "verified" }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
177
262
|
// POST /verify-profile — extension asks server to check which profile has a nonce tab
|
|
178
263
|
if (req.method === "POST" && req.url === "/verify-profile") {
|
|
179
264
|
let body = "";
|
|
@@ -894,6 +979,22 @@ server.tool(
|
|
|
894
979
|
"Open a new tab, optionally with a URL",
|
|
895
980
|
{ url: z.string().optional().describe("URL to open (empty for blank tab)") },
|
|
896
981
|
async ({ url }) => {
|
|
982
|
+
// Enforce tab limit — close oldest MCP tab if at max
|
|
983
|
+
if (_openedTabs.size >= MAX_TABS) {
|
|
984
|
+
let oldestIdx = null, oldestTime = Infinity;
|
|
985
|
+
for (const [idx, info] of _openedTabs) {
|
|
986
|
+
if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
|
|
987
|
+
}
|
|
988
|
+
if (oldestIdx !== null) {
|
|
989
|
+
console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached — closing oldest tab #${oldestIdx}`);
|
|
990
|
+
try {
|
|
991
|
+
safari.setActiveTabIndex(oldestIdx);
|
|
992
|
+
await safari.closeTab();
|
|
993
|
+
} catch {}
|
|
994
|
+
_untrackTab(oldestIdx);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
897
998
|
const result = await extensionOrFallback(
|
|
898
999
|
"new_tab", { url },
|
|
899
1000
|
() => safari.newTab(url)
|
|
@@ -901,6 +1002,7 @@ server.tool(
|
|
|
901
1002
|
// Sync safari.js tracking when extension handled new_tab
|
|
902
1003
|
if (result?.tabIndex) {
|
|
903
1004
|
safari.setActiveTabIndex(result.tabIndex);
|
|
1005
|
+
_trackTab(result.tabIndex, url);
|
|
904
1006
|
}
|
|
905
1007
|
if (result?.url) {
|
|
906
1008
|
safari.setActiveTabURL(result.url);
|
|
@@ -914,7 +1016,9 @@ server.tool(
|
|
|
914
1016
|
"Close the current tab",
|
|
915
1017
|
{},
|
|
916
1018
|
async () => {
|
|
1019
|
+
const activeIdx = safari.getActiveTabIndex();
|
|
917
1020
|
const result = await extensionOrFallback("close_tab", {}, () => safari.closeTab());
|
|
1021
|
+
if (activeIdx !== null) _untrackTab(activeIdx);
|
|
918
1022
|
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
919
1023
|
}
|
|
920
1024
|
);
|
|
@@ -1720,5 +1824,6 @@ server.tool(
|
|
|
1720
1824
|
|
|
1721
1825
|
// ========== START SERVER ==========
|
|
1722
1826
|
|
|
1827
|
+
_startMemoryMonitor();
|
|
1723
1828
|
const transport = new StdioServerTransport();
|
|
1724
1829
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.7",
|
|
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
|
@@ -7,7 +7,7 @@ import { execFile, spawn } from "node:child_process";
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join, dirname } from "node:path";
|
|
10
|
-
import { readFile, writeFile, unlink } from "node:fs/promises";
|
|
10
|
+
import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
// Extension bridge is handled by index.js (WebSocket server on port 9223)
|
|
13
13
|
|
|
@@ -99,6 +99,16 @@ let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
|
|
|
99
99
|
let _lastResolveTime = 0; // Cache: skip resolve if verified recently
|
|
100
100
|
const RESOLVE_CACHE_MS = 1500; // Re-verify tab every 1.5 seconds (was 5s — too stale)
|
|
101
101
|
|
|
102
|
+
// ========== DIAGNOSTIC LOG ==========
|
|
103
|
+
// File-based log for profile/focus issues — survives MCP restart, visible to user
|
|
104
|
+
const _LOG_FILE = '/tmp/safari-mcp-profile.log';
|
|
105
|
+
function _logProfile(msg) {
|
|
106
|
+
const ts = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
|
107
|
+
const line = `[${ts}] ${msg}\n`;
|
|
108
|
+
console.error(`[Safari MCP] ${msg}`);
|
|
109
|
+
appendFile(_LOG_FILE, line).catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
// ========== PROFILE TARGETING ==========
|
|
103
113
|
// Set SAFARI_PROFILE env var to target a specific Safari profile window.
|
|
104
114
|
// Safari shows profile windows as: "ProfileName — Tab Title"
|
|
@@ -106,7 +116,7 @@ const SAFARI_PROFILE = process.env.SAFARI_PROFILE || null;
|
|
|
106
116
|
let _targetWindowRef = null; // null = not yet discovered. Updated by refreshTargetWindow()
|
|
107
117
|
let _targetWindowId = null; // Numeric window ID (for CGEvent window targeting)
|
|
108
118
|
let _targetWindowCacheTime = 0;
|
|
109
|
-
const TARGET_WINDOW_CACHE_MS =
|
|
119
|
+
const TARGET_WINDOW_CACHE_MS = 2000; // Reduced from 5s — faster detection of window changes
|
|
110
120
|
let _profileWindowMissing = false; // True when profile window is not found
|
|
111
121
|
|
|
112
122
|
// Get the target window ref, falling back to 'front window' ONLY when no profile is configured
|
|
@@ -125,14 +135,16 @@ async function refreshTargetWindow(force = false) {
|
|
|
125
135
|
const now = Date.now();
|
|
126
136
|
if (!force && _targetWindowRef && (now - _targetWindowCacheTime) < TARGET_WINDOW_CACHE_MS) return;
|
|
127
137
|
const safeProfile = SAFARI_PROFILE.replace(/"/g, '\\"');
|
|
138
|
+
// Find profile window by name AND verify the window ID still matches
|
|
128
139
|
const result = await osascriptFast(
|
|
129
|
-
`tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return id of w\n end repeat\n return 0\nend tell`
|
|
130
|
-
).catch(() => '0');
|
|
131
|
-
const
|
|
140
|
+
`tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return (id of w as text) & "|" & name of w\n end repeat\n return "0|"\nend tell`
|
|
141
|
+
).catch(() => '0|');
|
|
142
|
+
const [idStr, windowName] = String(result).split('|');
|
|
143
|
+
const id = Number(idStr);
|
|
132
144
|
if (id > 0) {
|
|
133
145
|
const newRef = `window id ${id}`;
|
|
134
146
|
if (_targetWindowRef && _targetWindowRef !== newRef) {
|
|
135
|
-
|
|
147
|
+
_logProfile(`Profile window changed: ${_targetWindowRef} → ${newRef} ("${windowName}")`);
|
|
136
148
|
}
|
|
137
149
|
_targetWindowRef = newRef;
|
|
138
150
|
_targetWindowId = id;
|
|
@@ -144,18 +156,46 @@ async function refreshTargetWindow(force = false) {
|
|
|
144
156
|
_targetWindowId = null;
|
|
145
157
|
_targetWindowCacheTime = 0;
|
|
146
158
|
_profileWindowMissing = true;
|
|
147
|
-
|
|
159
|
+
_logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window not found — refusing to use front window`);
|
|
148
160
|
}
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
// Background verification: periodically check that cached window ID still belongs to profile
|
|
164
|
+
if (SAFARI_PROFILE) {
|
|
165
|
+
setInterval(async () => {
|
|
166
|
+
if (!_targetWindowRef || !_targetWindowId) return;
|
|
167
|
+
try {
|
|
168
|
+
const name = await osascriptFast(
|
|
169
|
+
`tell application "Safari" to return name of ${_targetWindowRef}`
|
|
170
|
+
).catch(() => '');
|
|
171
|
+
if (name && !name.startsWith(`${SAFARI_PROFILE} \u2014`)) {
|
|
172
|
+
// Window ID no longer belongs to profile — invalidate cache immediately
|
|
173
|
+
_logProfile(`SAFETY: Window ${_targetWindowRef} no longer belongs to profile "${SAFARI_PROFILE}" (name: "${name}") — invalidating`);
|
|
174
|
+
_targetWindowRef = null;
|
|
175
|
+
_targetWindowId = null;
|
|
176
|
+
_targetWindowCacheTime = 0;
|
|
177
|
+
_profileWindowMissing = true;
|
|
178
|
+
// Try to rediscover immediately
|
|
179
|
+
await refreshTargetWindow(true);
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Window might have been closed — invalidate and rediscover
|
|
183
|
+
_targetWindowRef = null;
|
|
184
|
+
_targetWindowId = null;
|
|
185
|
+
_targetWindowCacheTime = 0;
|
|
186
|
+
await refreshTargetWindow(true);
|
|
187
|
+
}
|
|
188
|
+
}, 3000); // Check every 3 seconds
|
|
189
|
+
}
|
|
190
|
+
|
|
151
191
|
// Initialize profile window at startup (ES module top-level await)
|
|
152
192
|
if (SAFARI_PROFILE) {
|
|
153
193
|
await new Promise(r => setTimeout(r, 50)); // Let helper process initialize
|
|
154
194
|
await refreshTargetWindow(true);
|
|
155
195
|
if (_targetWindowRef) {
|
|
156
|
-
|
|
196
|
+
_logProfile(`Startup: Profile "${SAFARI_PROFILE}" → targeting ${_targetWindowRef}`);
|
|
157
197
|
} else {
|
|
158
|
-
|
|
198
|
+
_logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
|
|
159
199
|
}
|
|
160
200
|
}
|
|
161
201
|
|
|
@@ -811,11 +851,21 @@ const INJECT_MCP_HELPERS = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
|
|
|
811
851
|
clientX:r.left+r.width/2,clientY:r.top+r.height/2,pageX:r.left+r.width/2+window.scrollX,pageY:r.top+r.height/2+window.scrollY,
|
|
812
852
|
preventDefault:function(){},stopPropagation:function(){},nativeEvent:new MouseEvent(type||'click'),persist:function(){},bubbles:true,cancelable:true};
|
|
813
853
|
}
|
|
854
|
+
// For React radio/checkbox inputs, onChange is the primary handler (not onClick)
|
|
855
|
+
var isToggle=startNode.tagName==='INPUT'&&(startNode.type==='radio'||startNode.type==='checkbox');
|
|
856
|
+
function tryOnChange(propsObj,targetNode){
|
|
857
|
+
if(!isToggle||!propsObj.onChange)return false;
|
|
858
|
+
var newChecked=startNode.type==='radio'?true:!startNode.checked;
|
|
859
|
+
propsObj.onChange({target:{value:startNode.value,checked:newChecked,type:startNode.type,name:startNode.name,id:startNode.id,tagName:'INPUT'},
|
|
860
|
+
currentTarget:targetNode,preventDefault:function(){},stopPropagation:function(){},nativeEvent:new Event('change'),persist:function(){},bubbles:true,cancelable:true,type:'change'});
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
814
863
|
var node=startNode;
|
|
815
864
|
for(var depth=0;depth<15&&node;depth++){
|
|
816
865
|
var pk=Object.keys(node).find(function(k){return k.startsWith('__reactProps$')});
|
|
817
866
|
if(pk&&node[pk]){
|
|
818
867
|
var props=node[pk];
|
|
868
|
+
if(tryOnChange(props,node))return true;
|
|
819
869
|
if(props.onClick){props.onClick(makeSynth(node,'click'));return true}
|
|
820
870
|
if(props.onMouseDown){props.onMouseDown(makeSynth(node,'mousedown'));return true}
|
|
821
871
|
if(props.onPointerDown){props.onPointerDown(makeSynth(node,'pointerdown'));return true}
|
|
@@ -825,6 +875,7 @@ const INJECT_MCP_HELPERS = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
|
|
|
825
875
|
var f=node[fk];
|
|
826
876
|
while(f){
|
|
827
877
|
if(f.memoizedProps){
|
|
878
|
+
if(tryOnChange(f.memoizedProps,node))return true;
|
|
828
879
|
if(f.memoizedProps.onClick){f.memoizedProps.onClick(makeSynth(node,'click'));return true}
|
|
829
880
|
if(f.memoizedProps.onMouseDown){f.memoizedProps.onMouseDown(makeSynth(node,'mousedown'));return true}
|
|
830
881
|
if(f.memoizedProps.onPointerDown){f.memoizedProps.onPointerDown(makeSynth(node,'pointerdown'));return true}
|
|
@@ -1527,30 +1578,35 @@ export async function replaceEditorContent({ text }) {
|
|
|
1527
1578
|
export async function screenshot({ fullPage = false } = {}) {
|
|
1528
1579
|
await refreshTargetWindow();
|
|
1529
1580
|
const tmpFile = join(tmpdir(), `safari-screenshot-${Date.now()}.png`);
|
|
1530
|
-
let savedTabIdx = null;
|
|
1531
1581
|
try {
|
|
1532
|
-
//
|
|
1582
|
+
// Check if target tab is a background tab — if so, use JS screenshot to avoid tab jumping
|
|
1583
|
+
let isBackgroundTab = false;
|
|
1533
1584
|
if (_activeTabIndex) {
|
|
1534
1585
|
try {
|
|
1535
1586
|
const currentIdx = await osascriptFast(
|
|
1536
1587
|
`tell application "Safari" to return index of current tab of ${getTargetWindowRef()}`
|
|
1537
1588
|
);
|
|
1538
|
-
|
|
1539
|
-
savedTabIdx = Number(currentIdx);
|
|
1540
|
-
await osascriptFast(
|
|
1541
|
-
`tell application "Safari" to set current tab of ${getTargetWindowRef()} to tab ${_activeTabIndex} of ${getTargetWindowRef()}`
|
|
1542
|
-
);
|
|
1543
|
-
await new Promise(r => setTimeout(r, 200)); // Let Safari render the tab
|
|
1544
|
-
}
|
|
1589
|
+
isBackgroundTab = Number(currentIdx) !== _activeTabIndex;
|
|
1545
1590
|
} catch (_) {}
|
|
1546
1591
|
}
|
|
1547
|
-
|
|
1592
|
+
// When on a background tab, go straight to JS-based screenshot (no tab switch, no focus steal)
|
|
1593
|
+
const skipScreencapture = isBackgroundTab;
|
|
1548
1594
|
|
|
1549
1595
|
// Try screencapture — use osascript's do shell script to bypass VS Code permission issue
|
|
1550
1596
|
const windowId = !skipScreencapture ? await osascript(
|
|
1551
1597
|
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
1552
1598
|
).catch(() => null) : null;
|
|
1553
1599
|
|
|
1600
|
+
// Save frontmost app BEFORE screencapture (it may steal focus on macOS Tahoe)
|
|
1601
|
+
let previousApp = null;
|
|
1602
|
+
if (windowId) {
|
|
1603
|
+
try {
|
|
1604
|
+
previousApp = (await osascriptFast(
|
|
1605
|
+
`tell application "System Events" to return name of first application process whose frontmost is true`
|
|
1606
|
+
)).trim();
|
|
1607
|
+
} catch (_) {}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1554
1610
|
if (windowId) {
|
|
1555
1611
|
try {
|
|
1556
1612
|
if (fullPage) {
|
|
@@ -1585,6 +1641,12 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1585
1641
|
);
|
|
1586
1642
|
}
|
|
1587
1643
|
}
|
|
1644
|
+
// Restore focus if screencapture stole it (common on macOS Tahoe)
|
|
1645
|
+
if (previousApp && previousApp !== "Safari") {
|
|
1646
|
+
await osascriptFast(
|
|
1647
|
+
`tell application "${previousApp}" to activate`
|
|
1648
|
+
).catch(() => {});
|
|
1649
|
+
}
|
|
1588
1650
|
// Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
|
|
1589
1651
|
// Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit
|
|
1590
1652
|
const jpgFile = tmpFile.replace('.png', '.jpg');
|
|
@@ -1638,14 +1700,6 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1638
1700
|
throw new Error("screencapture failed — Screen Recording permission may have been lost. Grant permission in System Settings → Privacy & Security → Screen Recording, then restart Safari.");
|
|
1639
1701
|
} finally {
|
|
1640
1702
|
await unlink(tmpFile).catch(() => {});
|
|
1641
|
-
// Restore user's tab if we switched
|
|
1642
|
-
if (savedTabIdx) {
|
|
1643
|
-
try {
|
|
1644
|
-
await osascriptFast(
|
|
1645
|
-
`tell application "Safari" to set current tab of ${getTargetWindowRef()} to tab ${savedTabIdx} of ${getTargetWindowRef()}`
|
|
1646
|
-
);
|
|
1647
|
-
} catch (_) {}
|
|
1648
|
-
}
|
|
1649
1703
|
}
|
|
1650
1704
|
}
|
|
1651
1705
|
|