safari-mcp 2.7.0 → 2.7.3
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 +17 -5
- package/extension/background.js +16 -4
- package/extension/content.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/popup/popup.html +1 -1
- package/index.js +31 -8
- package/mcp-helpers.js +1 -1
- package/package.json +3 -3
- package/safari.js +39 -15
- package/extension/popup/popup.js +0 -57
package/README.md
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<
|
|
4
|
-
<source media="(prefers-color-scheme: dark)" srcset="assets/banner.svg">
|
|
5
|
-
<source media="(prefers-color-scheme: light)" srcset="assets/banner.svg">
|
|
6
|
-
<img src="assets/banner.svg" alt="Safari MCP — Native browser automation for AI agents" width="100%">
|
|
7
|
-
</picture>
|
|
3
|
+
<img src="social-preview.png" alt="Safari MCP Server — 80 native browser automation tools for AI agents on macOS" width="100%">
|
|
8
4
|
|
|
9
5
|
<br/>
|
|
10
6
|
|
|
@@ -25,6 +21,7 @@
|
|
|
25
21
|
|
|
26
22
|
<a href="vscode:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code-Install_MCP_Server-0078d4?logo=visual-studio-code&logoColor=white" alt="Install in VS Code"></a>
|
|
27
23
|
<a href="https://insiders.vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?logo=visual-studio-code&logoColor=white" alt="Install in VS Code Insiders"></a>
|
|
24
|
+
<a href="https://paypal.me/achiyaC123"><img src="https://img.shields.io/badge/Sponsor-PayPal-blue?logo=paypal" alt="Sponsor"></a>
|
|
28
25
|
|
|
29
26
|
**80 tools** · **Zero dependencies** · **~5ms per command** · **60% less CPU than Chrome**
|
|
30
27
|
|
|
@@ -533,6 +530,21 @@ The codebase is two files:
|
|
|
533
530
|
|
|
534
531
|
---
|
|
535
532
|
|
|
533
|
+
## Sponsors
|
|
534
|
+
|
|
535
|
+
Safari MCP is free and open source. If it saves you time or CPU cycles, consider supporting its development:
|
|
536
|
+
|
|
537
|
+
<a href="https://paypal.me/achiyaC123"><img src="https://img.shields.io/badge/Sponsor-PayPal-blue?logo=paypal" alt="Sponsor via PayPal"></a>
|
|
538
|
+
|
|
539
|
+
Your support funds:
|
|
540
|
+
- 🧪 Testing across macOS versions and Safari releases
|
|
541
|
+
- 🛠️ New tools and features
|
|
542
|
+
- 📖 Documentation and examples
|
|
543
|
+
|
|
544
|
+
[Become the first sponsor!](https://paypal.me/achiyaC123)
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
536
548
|
## Star History
|
|
537
549
|
|
|
538
550
|
If Safari MCP saved you from Chrome overhead, consider giving it a ⭐
|
package/extension/background.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const HTTP_URL = "http://127.0.0.1:9224";
|
|
6
6
|
let isConnected = false;
|
|
7
7
|
let pollAbort = null;
|
|
8
|
-
let _targetProfile = null; // Profile name from server (e.g. "
|
|
8
|
+
let _targetProfile = null; // Profile name from server (e.g. "Automations")
|
|
9
9
|
let _profileWindowId = null; // Discovered windowId for the profile
|
|
10
10
|
let _enabled = true; // Toggle from popup — when false, stops polling and rejects commands
|
|
11
11
|
let _reconnectTimer = null; // Single reconnect timer — prevents exponential growth
|
|
@@ -185,7 +185,9 @@ async function executeAndReply(msg) {
|
|
|
185
185
|
body: JSON.stringify(response),
|
|
186
186
|
signal: AbortSignal.timeout(5000),
|
|
187
187
|
});
|
|
188
|
-
} catch {
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.warn("Safari MCP Bridge: failed to send result to server:", err.message);
|
|
190
|
+
}
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
// ========== COMMAND HANDLERS ==========
|
|
@@ -1725,11 +1727,15 @@ browser.tabs.onRemoved.addListener((tabId) => {
|
|
|
1725
1727
|
cache.tabUrl = null;
|
|
1726
1728
|
}
|
|
1727
1729
|
}
|
|
1730
|
+
// Also remove from owned tabs — prevents stale ownership on externally closed tabs
|
|
1731
|
+
for (const [sid, ownedSet] of _sessionOwnedTabs) {
|
|
1732
|
+
ownedSet.delete(tabId);
|
|
1733
|
+
}
|
|
1728
1734
|
});
|
|
1729
1735
|
|
|
1730
1736
|
// Verify this extension instance is running in the expected profile.
|
|
1731
1737
|
// Safari extensions see only their own profile's windows/tabs.
|
|
1732
|
-
// If the server expects
|
|
1738
|
+
// If the server expects a specific profile but this worker's windows don't match, we're in the wrong profile.
|
|
1733
1739
|
async function _verifyProfileMatch(expectedProfile) {
|
|
1734
1740
|
try {
|
|
1735
1741
|
const allWindows = await browser.windows.getAll({ populate: true });
|
|
@@ -1774,7 +1780,13 @@ async function _verifyProfileMatch(expectedProfile) {
|
|
|
1774
1780
|
}
|
|
1775
1781
|
|
|
1776
1782
|
if (verifyRes && verifyRes.ok) {
|
|
1777
|
-
|
|
1783
|
+
let result;
|
|
1784
|
+
try {
|
|
1785
|
+
result = await verifyRes.json();
|
|
1786
|
+
} catch {
|
|
1787
|
+
console.warn("Safari MCP: profile verification response invalid JSON — rejecting");
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1778
1790
|
if (result.match) {
|
|
1779
1791
|
await browser.storage.local.set({ mcpVerifiedProfile: expectedProfile });
|
|
1780
1792
|
return true;
|
package/extension/content.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Content script — runs at document_start in MAIN world (before page scripts)
|
|
2
2
|
// Purpose: Monkey-patch attachShadow to capture CLOSED shadow roots.
|
|
3
|
-
// Without this, Reddit
|
|
3
|
+
// Without this, Reddit and other sites using closed shadow DOM
|
|
4
4
|
// are invisible to snapshot/click/fill.
|
|
5
5
|
// Runs in MAIN world via manifest "world": "MAIN" — no script injection needed,
|
|
6
6
|
// so CSP cannot block it.
|
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.
|
|
5
|
+
"version": "2.7.2",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
package/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 1. Safari Web Extension (fast, ~5-20ms, keeps logins) — when extension is connected
|
|
4
4
|
// 2. AppleScript + Swift daemon (~5ms, keeps logins) — always available
|
|
5
5
|
//
|
|
6
|
-
// Extension transport:
|
|
6
|
+
// Extension transport: HTTP polling (Safari — WebSocket blocked by Apple)
|
|
7
7
|
|
|
8
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -84,6 +84,13 @@ function _isURLOwned(url) {
|
|
|
84
84
|
if (new URL(owned).origin === urlOrigin && urlBase.startsWith(normalize(owned))) return true;
|
|
85
85
|
} catch {}
|
|
86
86
|
}
|
|
87
|
+
// Broader same-origin check: if we own ANY URL on this origin, treat all same-origin URLs as owned.
|
|
88
|
+
// This handles redirects like /article/new → /article/edit/ID where path prefix doesn't match.
|
|
89
|
+
for (const owned of _ownedTabURLs) {
|
|
90
|
+
try {
|
|
91
|
+
if (new URL(owned).origin === urlOrigin) return true;
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
87
94
|
} catch {}
|
|
88
95
|
return false;
|
|
89
96
|
}
|
|
@@ -386,6 +393,14 @@ try {
|
|
|
386
393
|
const { execFile: execFileCb } = await import("node:child_process");
|
|
387
394
|
const { promisify: pfy } = await import("node:util");
|
|
388
395
|
const execFileAsync = pfy(execFileCb);
|
|
396
|
+
// Don't launch Safari if it's not running
|
|
397
|
+
try {
|
|
398
|
+
await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 2000 });
|
|
399
|
+
} catch {
|
|
400
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
401
|
+
res.end(JSON.stringify({ match: false, error: "Safari is not running" }));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
389
404
|
const script = `tell application "Safari"
|
|
390
405
|
repeat with w in every window
|
|
391
406
|
repeat with t in every tab of w
|
|
@@ -635,15 +650,19 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
635
650
|
// must target an owned tab. This prevents navigating/clicking in user's tabs.
|
|
636
651
|
if (!_noOwnershipCheck.has(extensionType)) {
|
|
637
652
|
const currentUrl = safari.getActiveTabURL();
|
|
638
|
-
if (_ownedTabURLs.size === 0) {
|
|
653
|
+
if (_ownedTabURLs.size === 0 && _openedTabs.size === 0) {
|
|
639
654
|
// No tabs opened yet — block everything except read-only ops
|
|
640
655
|
const msg = `⚠️ Tab safety: no tabs opened yet. Call safari_new_tab first before "${extensionType}".`;
|
|
641
656
|
console.error(`[Safari MCP] ${msg}`);
|
|
642
657
|
throw new Error(msg);
|
|
643
658
|
} else if (currentUrl && !_isURLOwned(currentUrl)) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
659
|
+
// about:blank tabs are owned if we have any tracked tabs (new_tab creates them at about:blank)
|
|
660
|
+
const isBlankOwned = (currentUrl === 'about:blank' || currentUrl === 'missing value') && _openedTabs.size > 0;
|
|
661
|
+
if (!isBlankOwned) {
|
|
662
|
+
const msg = `⚠️ Tab safety: refusing "${extensionType}" — current tab (${currentUrl}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
|
|
663
|
+
console.error(`[Safari MCP] ${msg}`);
|
|
664
|
+
throw new Error(msg);
|
|
665
|
+
}
|
|
647
666
|
}
|
|
648
667
|
}
|
|
649
668
|
if (_extensionConnected && !_preferAppleScript) {
|
|
@@ -1203,9 +1222,13 @@ server.tool(
|
|
|
1203
1222
|
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
1204
1223
|
const target = parsed.find(t => t.index === index);
|
|
1205
1224
|
if (target && target.url && !_isURLOwned(target.url)) {
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1225
|
+
// about:blank / missing value tabs are owned if tracked in _openedTabs
|
|
1226
|
+
const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && _openedTabs.has(index);
|
|
1227
|
+
if (!isBlankOwned) {
|
|
1228
|
+
const msg = `⚠️ Tab safety: refusing switch_tab to index ${index} (${target.url}) — not opened by this MCP session. Use safari_new_tab to open your own tab.`;
|
|
1229
|
+
console.error(`[Safari MCP] ${msg}`);
|
|
1230
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
1231
|
+
}
|
|
1209
1232
|
}
|
|
1210
1233
|
} catch {}
|
|
1211
1234
|
}
|
package/mcp-helpers.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// MCP Click/Interaction Helpers — injected into every page
|
|
2
2
|
// This file is loaded by safari.js at startup, escaped, and injected via AppleScript `do JavaScript`.
|
|
3
3
|
// It is also used by the extension's execInTab for the AppleScript fallback path.
|
|
4
|
-
// IMPORTANT: Keep
|
|
4
|
+
// IMPORTANT: Keep compatible with all browsers — no ES6+ modules, use var where possible.
|
|
5
5
|
|
|
6
6
|
if (window.__mcpVersion !== 5) {
|
|
7
7
|
window.__mcpVersion = 5;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.3",
|
|
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",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"homepage": "https://achiya-automation.com",
|
|
41
41
|
"funding": {
|
|
42
|
-
"type": "
|
|
43
|
-
"url": "https://
|
|
42
|
+
"type": "individual",
|
|
43
|
+
"url": "https://paypal.me/achiyaC123"
|
|
44
44
|
},
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
package/safari.js
CHANGED
|
@@ -89,6 +89,21 @@ function cleanupHelper() {
|
|
|
89
89
|
process.on("exit", cleanupHelper);
|
|
90
90
|
process.on("uncaughtException", (err) => { console.error("Uncaught:", err); cleanupHelper(); process.exit(1); });
|
|
91
91
|
|
|
92
|
+
// ========== SAFARI RUNNING CHECK ==========
|
|
93
|
+
// Prevent AppleScript from auto-launching Safari when it's closed
|
|
94
|
+
async function isSafariRunning() {
|
|
95
|
+
try {
|
|
96
|
+
const { stdout } = await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 2000 });
|
|
97
|
+
return stdout.trim().length > 0;
|
|
98
|
+
} catch {
|
|
99
|
+
return false; // pgrep exits 1 when no match
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function safariNotRunningError() {
|
|
104
|
+
return new Error("Safari is not running. Open Safari manually before using Safari MCP tools.");
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
// ========== CLIPBOARD LOCK ==========
|
|
93
108
|
// Prevents concurrent clipboard operations from clobbering the user's clipboard.
|
|
94
109
|
// While locked, any new clipboard operation waits until the current one completes.
|
|
@@ -326,6 +341,7 @@ async function resolveActiveTab() {
|
|
|
326
341
|
|
|
327
342
|
// Run AppleScript — uses execFile (safe, isolated, for complex scripts)
|
|
328
343
|
async function osascript(script, { timeout = 10000 } = {}) {
|
|
344
|
+
if (!(await isSafariRunning())) throw safariNotRunningError();
|
|
329
345
|
try {
|
|
330
346
|
const { stdout } = await execFileAsync("osascript", ["-e", script], {
|
|
331
347
|
timeout,
|
|
@@ -349,6 +365,7 @@ async function osascript(script, { timeout = 10000 } = {}) {
|
|
|
349
365
|
|
|
350
366
|
// osascriptFast: uses persistent Swift daemon (~5ms) — 18x faster than subprocess (~90ms)
|
|
351
367
|
async function osascriptFast(script, { timeout = 10000 } = {}) {
|
|
368
|
+
if (!(await isSafariRunning())) throw safariNotRunningError();
|
|
352
369
|
if (!_helperProc) startHelper();
|
|
353
370
|
if (_helperProc) {
|
|
354
371
|
try {
|
|
@@ -378,10 +395,11 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
378
395
|
const timer = setTimeout(() => {
|
|
379
396
|
if (resolved) return;
|
|
380
397
|
resolved = true;
|
|
398
|
+
// DON'T remove from queue — replace with no-op consumer to maintain FIFO order.
|
|
399
|
+
// Without this, the late response would be consumed by the NEXT callback in the queue.
|
|
381
400
|
const idx = _helperQueue.indexOf(cb);
|
|
382
|
-
if (idx >= 0) _helperQueue
|
|
401
|
+
if (idx >= 0) _helperQueue[idx] = () => {}; // Consume and discard late response
|
|
383
402
|
_helperConsecutiveTimeouts++;
|
|
384
|
-
// Only kill the daemon after 3 consecutive timeouts (single slow script shouldn't kill concurrent ops)
|
|
385
403
|
if (_helperConsecutiveTimeouts >= 3) {
|
|
386
404
|
console.error(`[Safari MCP] safari-helper: ${_helperConsecutiveTimeouts} consecutive timeouts — killing daemon`);
|
|
387
405
|
_helperProc?.kill();
|
|
@@ -396,7 +414,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
396
414
|
if (resolved) return;
|
|
397
415
|
resolved = true;
|
|
398
416
|
clearTimeout(timer);
|
|
399
|
-
_helperConsecutiveTimeouts = 0;
|
|
417
|
+
_helperConsecutiveTimeouts = 0;
|
|
400
418
|
try {
|
|
401
419
|
const parsed = JSON.parse(line);
|
|
402
420
|
if (parsed.error) reject(new Error(parsed.error));
|
|
@@ -406,7 +424,6 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
406
424
|
}
|
|
407
425
|
}
|
|
408
426
|
|
|
409
|
-
// Check process availability BEFORE pushing to queue (prevents dangling callbacks)
|
|
410
427
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
411
428
|
clearTimeout(timer);
|
|
412
429
|
reject(new Error("safari-helper not available"));
|
|
@@ -440,7 +457,7 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
|
|
|
440
457
|
if (resolved) return;
|
|
441
458
|
resolved = true;
|
|
442
459
|
const idx = _helperQueue.indexOf(cb);
|
|
443
|
-
if (idx >= 0) _helperQueue
|
|
460
|
+
if (idx >= 0) _helperQueue[idx] = () => {}; // No-op consumer for late response
|
|
444
461
|
reject(new Error("native click timeout"));
|
|
445
462
|
}, timeout);
|
|
446
463
|
|
|
@@ -479,7 +496,7 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
|
|
|
479
496
|
if (resolved) return;
|
|
480
497
|
resolved = true;
|
|
481
498
|
const idx = _helperQueue.indexOf(cb);
|
|
482
|
-
if (idx >= 0) _helperQueue
|
|
499
|
+
if (idx >= 0) _helperQueue[idx] = () => {}; // No-op consumer for late response
|
|
483
500
|
reject(new Error("native keyboard timeout"));
|
|
484
501
|
}, timeout);
|
|
485
502
|
|
|
@@ -659,7 +676,7 @@ export async function navigate(url) {
|
|
|
659
676
|
const state = await runJS('document.readyState', { timeout: 5000 });
|
|
660
677
|
if (state === 'complete' || state === 'interactive') {
|
|
661
678
|
result = await runJS(
|
|
662
|
-
`JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('
|
|
679
|
+
`JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('\u05D0\u05D9\u05DF \u05D0\u05E4\u05E9\u05E8\u05D5\u05EA')})`,
|
|
663
680
|
{ timeout: 5000 }
|
|
664
681
|
);
|
|
665
682
|
if (state === 'complete') break;
|
|
@@ -1164,7 +1181,7 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
1164
1181
|
const geo = await _getSafariWindowGeometry();
|
|
1165
1182
|
if (!geo.windowId) throw new Error("Cannot paste into iframe without Safari window ID — would steal focus");
|
|
1166
1183
|
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
|
|
1167
|
-
await new Promise(r => setTimeout(r,
|
|
1184
|
+
await new Promise(r => setTimeout(r, 100)); // 100ms is enough for Cmd+V to process
|
|
1168
1185
|
return `Pressed: ${modifiers.join("+")}+v (CGEvent Cmd+V into iframe, no focus steal)`;
|
|
1169
1186
|
}
|
|
1170
1187
|
|
|
@@ -1277,22 +1294,22 @@ async function _nativeTypeViaClipboard(text) {
|
|
|
1277
1294
|
// MUST have windowId — global CGEvent (windowId=0) would steal focus and move mouse
|
|
1278
1295
|
const geo = await _getSafariWindowGeometry();
|
|
1279
1296
|
if (!geo.windowId) {
|
|
1280
|
-
// Can't get window ID — restore clipboard and
|
|
1297
|
+
// Can't get window ID — restore clipboard and let catch block release lock
|
|
1281
1298
|
await _restoreClipboard(savedClipboard);
|
|
1282
|
-
_releaseClipboardLock();
|
|
1283
1299
|
throw new Error("Cannot native-paste without Safari window ID — would steal focus");
|
|
1284
1300
|
}
|
|
1285
1301
|
// keyCode 9 = V key, flags: ["cmd"]
|
|
1286
1302
|
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
|
|
1287
1303
|
|
|
1288
1304
|
// Wait for paste to settle, then restore clipboard immediately
|
|
1289
|
-
await new Promise(r => setTimeout(r,
|
|
1305
|
+
await new Promise(r => setTimeout(r, 100)); // 100ms is enough for Cmd+V to process
|
|
1290
1306
|
await _restoreClipboard(savedClipboard);
|
|
1291
1307
|
_releaseClipboardLock();
|
|
1292
1308
|
|
|
1293
1309
|
return `Typed ${text.length} chars (native paste into iframe)`;
|
|
1294
1310
|
} catch (err) {
|
|
1295
|
-
|
|
1311
|
+
// Ensure lock is released on ANY error (including the windowId check above)
|
|
1312
|
+
if (_clipboardLocked) _releaseClipboardLock();
|
|
1296
1313
|
throw err;
|
|
1297
1314
|
}
|
|
1298
1315
|
}
|
|
@@ -1486,11 +1503,12 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1486
1503
|
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
1487
1504
|
).catch(() => null) : null;
|
|
1488
1505
|
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1506
|
+
// On macOS Tahoe, screencapture -l may briefly steal focus.
|
|
1507
|
+
// Save frontmost app so we can detect if focus was stolen and restore it.
|
|
1491
1508
|
let previousApp = null;
|
|
1492
1509
|
if (windowId) {
|
|
1493
1510
|
try {
|
|
1511
|
+
// Use NSWorkspace via AppleScript (lighter than System Events — no AX needed)
|
|
1494
1512
|
previousApp = (await osascriptFast(
|
|
1495
1513
|
`tell application "System Events" to return name of first application process whose frontmost is true`
|
|
1496
1514
|
)).trim();
|
|
@@ -1680,6 +1698,8 @@ export async function screenshotElement({ selector }) {
|
|
|
1680
1698
|
if (data.length > 100) return data.toString("base64");
|
|
1681
1699
|
} catch (e) {
|
|
1682
1700
|
await unlink(tmpFile).catch(() => {});
|
|
1701
|
+
// Also clean cropFile if it was created before the error
|
|
1702
|
+
await unlink(tmpFile.replace('safari-el-', 'safari-el-crop-')).catch(() => {});
|
|
1683
1703
|
throw new Error(`Element screenshot failed: ${e.message}`);
|
|
1684
1704
|
}
|
|
1685
1705
|
}
|
|
@@ -2066,7 +2086,7 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
2066
2086
|
click button "Cancel" of sheet 1 of w
|
|
2067
2087
|
on error
|
|
2068
2088
|
try
|
|
2069
|
-
click button "
|
|
2089
|
+
click button "\u05D1\u05D9\u05D8\u05D5\u05DC" of sheet 1 of w -- "Cancel" in Hebrew locale
|
|
2070
2090
|
on error
|
|
2071
2091
|
key code 53
|
|
2072
2092
|
end try
|
|
@@ -2776,11 +2796,15 @@ export async function importStorageState({ state }) {
|
|
|
2776
2796
|
// ========== CLIPBOARD ==========
|
|
2777
2797
|
|
|
2778
2798
|
export async function clipboardRead() {
|
|
2799
|
+
// Acquire lock to avoid reading during a write/restore cycle
|
|
2800
|
+
await _acquireClipboardLock(3000); // Short timeout — reads are fast
|
|
2779
2801
|
try {
|
|
2780
2802
|
const text = await execFileAsync("pbpaste", []);
|
|
2781
2803
|
return text.stdout;
|
|
2782
2804
|
} catch {
|
|
2783
2805
|
return "(clipboard empty or contains non-text data)";
|
|
2806
|
+
} finally {
|
|
2807
|
+
_releaseClipboardLock();
|
|
2784
2808
|
}
|
|
2785
2809
|
}
|
|
2786
2810
|
|
package/extension/popup/popup.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// v6 — asks background script for real-time status, falls back to storage
|
|
2
|
-
async function checkStatus() {
|
|
3
|
-
const enabled = await getVal("mcpEnabled", true);
|
|
4
|
-
document.getElementById("enableToggle").checked = enabled;
|
|
5
|
-
|
|
6
|
-
if (!enabled) {
|
|
7
|
-
setStatus("paused", "Paused", "Toggle to resume");
|
|
8
|
-
return;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Ask background script for real-time status
|
|
12
|
-
try {
|
|
13
|
-
const resp = await browser.runtime.sendMessage({ action: "getStatus" });
|
|
14
|
-
if (resp) {
|
|
15
|
-
if (resp.connected) { setStatus("connected", "Connected", "Port 9224"); return; }
|
|
16
|
-
if (!resp.enabled) { setStatus("paused", "Paused", "Toggle to resume"); return; }
|
|
17
|
-
}
|
|
18
|
-
} catch {}
|
|
19
|
-
|
|
20
|
-
// Fallback: read from storage
|
|
21
|
-
const status = await getVal("mcpStatus", "checking");
|
|
22
|
-
switch (status) {
|
|
23
|
-
case "connected":
|
|
24
|
-
setStatus("connected", "Connected", "Port 9224");
|
|
25
|
-
break;
|
|
26
|
-
case "paused":
|
|
27
|
-
setStatus("paused", "Paused", "Toggle to resume");
|
|
28
|
-
break;
|
|
29
|
-
case "checking":
|
|
30
|
-
setStatus("checking", "Connecting...", "Trying port 9224...");
|
|
31
|
-
break;
|
|
32
|
-
default:
|
|
33
|
-
setStatus("disconnected", "Not connected", "Start the MCP server to connect");
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function setStatus(dotClass, text, info) {
|
|
38
|
-
document.getElementById("statusDot").className = "dot " + dotClass;
|
|
39
|
-
document.getElementById("statusText").textContent = text;
|
|
40
|
-
document.getElementById("info").textContent = info;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
document.getElementById("enableToggle").addEventListener("change", async (e) => {
|
|
44
|
-
const enabled = e.target.checked;
|
|
45
|
-
await browser.storage.local.set({ mcpEnabled: enabled });
|
|
46
|
-
try { browser.runtime.sendMessage({ action: "setEnabled", enabled }); } catch {}
|
|
47
|
-
setTimeout(checkStatus, 500);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
async function getVal(key, defaultVal) {
|
|
51
|
-
try {
|
|
52
|
-
const data = await browser.storage.local.get(key);
|
|
53
|
-
return data[key] !== undefined ? data[key] : defaultVal;
|
|
54
|
-
} catch { return defaultVal; }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
checkStatus();
|