safari-mcp 2.7.1 → 2.7.4
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 +37 -6
- package/extension/background.js +2 -2
- package/extension/content.js +1 -1
- package/extension/manifest.json +7 -1
- package/extension/popup/popup.html +1 -1
- package/index.d.ts +218 -0
- package/index.js +82 -36
- package/mcp-helpers.js +1 -1
- package/package.json +6 -4
- package/safari-helper +0 -0
- package/safari-helper.swift +50 -1
- package/safari.js +123 -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
|
|
|
@@ -34,6 +31,8 @@
|
|
|
34
31
|
|
|
35
32
|
</div>
|
|
36
33
|
|
|
34
|
+
> **If Safari MCP saves you from running Chrome, consider giving it a star!** It helps others discover this project.
|
|
35
|
+
|
|
37
36
|
---
|
|
38
37
|
|
|
39
38
|
> **TL;DR:** Use your real Safari with all your logins, cookies, and sessions. No headless browsers, no Chrome, no Puppeteer. Just pure AppleScript + JavaScript running natively on macOS — 60% less CPU/heat on Apple Silicon.
|
|
@@ -533,9 +532,41 @@ The codebase is two files:
|
|
|
533
532
|
|
|
534
533
|
---
|
|
535
534
|
|
|
535
|
+
## Sponsors
|
|
536
|
+
|
|
537
|
+
Safari MCP is free and open source. If it saves you time or CPU cycles, consider supporting its development:
|
|
538
|
+
|
|
539
|
+
<a href="https://paypal.me/achiyaC123"><img src="https://img.shields.io/badge/Sponsor-PayPal-blue?logo=paypal" alt="Sponsor via PayPal"></a>
|
|
540
|
+
|
|
541
|
+
Your support funds:
|
|
542
|
+
- 🧪 Testing across macOS versions and Safari releases
|
|
543
|
+
- 🛠️ New tools and features
|
|
544
|
+
- 📖 Documentation and examples
|
|
545
|
+
|
|
546
|
+
[Become the first sponsor!](https://paypal.me/achiyaC123)
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
## Community
|
|
551
|
+
|
|
552
|
+
Safari MCP is used by developers building AI agents on macOS. [Share your use case in Discussions](https://github.com/achiya-automation/safari-mcp/discussions)
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
556
|
+
## Spread the Word
|
|
557
|
+
|
|
558
|
+
If you find Safari MCP useful, help others discover it:
|
|
559
|
+
|
|
560
|
+
- [Star this repo](https://github.com/achiya-automation/safari-mcp)
|
|
561
|
+
- [Share on Twitter/X](https://twitter.com/intent/tweet?text=Safari%20MCP%20%E2%80%94%20Stop%20running%20Chrome%20just%20so%20your%20AI%20agent%20can%20browse.%2080%20tools%2C%20native%20Safari%2C%2060%25%20less%20CPU.&url=https%3A%2F%2Fgithub.com%2Fachiya-automation%2Fsafari-mcp)
|
|
562
|
+
- [Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fgithub.com%2Fachiya-automation%2Fsafari-mcp)
|
|
563
|
+
- Write about it on dev.to or your blog
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
536
567
|
## Star History
|
|
537
568
|
|
|
538
|
-
If Safari MCP saved you from Chrome overhead, consider giving it a
|
|
569
|
+
If Safari MCP saved you from Chrome overhead, consider giving it a star!
|
|
539
570
|
|
|
540
571
|
[](https://star-history.com/#achiya-automation/safari-mcp&Date)
|
|
541
572
|
|
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
|
|
@@ -1735,7 +1735,7 @@ browser.tabs.onRemoved.addListener((tabId) => {
|
|
|
1735
1735
|
|
|
1736
1736
|
// Verify this extension instance is running in the expected profile.
|
|
1737
1737
|
// Safari extensions see only their own profile's windows/tabs.
|
|
1738
|
-
// 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.
|
|
1739
1739
|
async function _verifyProfileMatch(expectedProfile) {
|
|
1740
1740
|
try {
|
|
1741
1741
|
const allWindows = await browser.windows.getAll({ populate: 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",
|
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
"host_permissions": ["<all_urls>"],
|
|
21
21
|
"content_scripts": [{
|
|
22
22
|
"matches": ["<all_urls>"],
|
|
23
|
+
"exclude_matches": [
|
|
24
|
+
"*://*.linkedin.com/*",
|
|
25
|
+
"*://*.x.com/*",
|
|
26
|
+
"*://*.twitter.com/*",
|
|
27
|
+
"*://*.producthunt.com/*"
|
|
28
|
+
],
|
|
23
29
|
"js": ["content.js"],
|
|
24
30
|
"run_at": "document_start",
|
|
25
31
|
"world": "MAIN"
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safari MCP — TypeScript type declarations
|
|
3
|
+
* Covers all ~83 registered tools and the MCP server export.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ========== CORE TYPES ==========
|
|
7
|
+
|
|
8
|
+
export type MCPContent =
|
|
9
|
+
| { type: "text"; text: string }
|
|
10
|
+
| { type: "image"; data: string; mimeType: string };
|
|
11
|
+
|
|
12
|
+
export interface MCPToolResult {
|
|
13
|
+
content: MCPContent[];
|
|
14
|
+
isError?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MCPToolInputSchema {
|
|
18
|
+
type: "object";
|
|
19
|
+
properties: Record<string, { type: string; description?: string; enum?: string[] }>;
|
|
20
|
+
required?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MCPTool<TInput extends Record<string, unknown> = Record<string, unknown>> {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
inputSchema: MCPToolInputSchema;
|
|
27
|
+
handler: (input: TInput) => Promise<MCPToolResult>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
// ========== TOOL INPUT TYPES ==========
|
|
32
|
+
|
|
33
|
+
// Navigation
|
|
34
|
+
export interface SafariNavigateInput { url: string }
|
|
35
|
+
export interface SafariGoBackInput {}
|
|
36
|
+
export interface SafariGoForwardInput {}
|
|
37
|
+
export interface SafariReloadInput { hard?: boolean }
|
|
38
|
+
export interface SafariNavigateAndReadInput { url: string; maxLength?: number; timeout?: number }
|
|
39
|
+
|
|
40
|
+
// Page Info
|
|
41
|
+
export interface SafariReadPageInput { selector?: string; maxLength?: number }
|
|
42
|
+
export interface SafariGetSourceInput { maxLength?: number }
|
|
43
|
+
export interface SafariSnapshotInput { selector?: string }
|
|
44
|
+
|
|
45
|
+
// Click
|
|
46
|
+
export interface SafariClickInput { ref?: string; selector?: string; text?: string; x?: number; y?: number }
|
|
47
|
+
export interface SafariClickAndReadInput { text?: string; selector?: string; x?: number; y?: number; wait?: number; maxLength?: number }
|
|
48
|
+
export interface SafariDoubleClickInput { selector?: string; x?: number; y?: number }
|
|
49
|
+
export interface SafariRightClickInput { selector?: string; x?: number; y?: number }
|
|
50
|
+
export interface SafariNativeClickInput { ref?: string; selector?: string; text?: string; x?: number; y?: number; doubleClick?: boolean }
|
|
51
|
+
|
|
52
|
+
// Form Input
|
|
53
|
+
export interface SafariFillInput { ref?: string; selector?: string; value: string }
|
|
54
|
+
export interface SafariClearFieldInput { selector: string }
|
|
55
|
+
export interface SafariSelectOptionInput { selector: string; value: string }
|
|
56
|
+
export interface SafariFillFormField { selector: string; value: string }
|
|
57
|
+
export interface SafariFillFormInput { fields: SafariFillFormField[] }
|
|
58
|
+
|
|
59
|
+
// Keyboard
|
|
60
|
+
export interface SafariPressKeyInput { key: string; modifiers?: string[] }
|
|
61
|
+
export interface SafariTypeTextInput { text: string; ref?: string; selector?: string }
|
|
62
|
+
|
|
63
|
+
// Code Editor
|
|
64
|
+
export interface SafariReplaceEditorInput { text: string }
|
|
65
|
+
|
|
66
|
+
// Screenshot
|
|
67
|
+
export interface SafariScreenshotInput { fullPage?: boolean }
|
|
68
|
+
export interface SafariScreenshotElementInput { selector: string }
|
|
69
|
+
|
|
70
|
+
// Scroll
|
|
71
|
+
export interface SafariScrollInput { direction?: "up" | "down"; amount?: number }
|
|
72
|
+
export interface SafariScrollToInput { x?: number; y?: number }
|
|
73
|
+
export interface SafariScrollToElementInput {
|
|
74
|
+
selector?: string;
|
|
75
|
+
text?: string;
|
|
76
|
+
block?: "start" | "center" | "end" | "nearest";
|
|
77
|
+
timeout?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Tab Management
|
|
81
|
+
export interface SafariListTabsInput {}
|
|
82
|
+
export interface SafariNewTabInput { url?: string }
|
|
83
|
+
export interface SafariCloseTabInput {}
|
|
84
|
+
export interface SafariSwitchTabInput { index: number }
|
|
85
|
+
export interface SafariWaitForNewTabInput { timeout?: number; urlContains?: string }
|
|
86
|
+
|
|
87
|
+
// Wait
|
|
88
|
+
export interface SafariWaitForInput { selector?: string; text?: string; timeout?: number }
|
|
89
|
+
export interface SafariWaitInput { ms: number }
|
|
90
|
+
|
|
91
|
+
// Evaluate
|
|
92
|
+
export interface SafariEvaluateInput { script: string }
|
|
93
|
+
|
|
94
|
+
// Element Info
|
|
95
|
+
export interface SafariGetElementInput { selector: string }
|
|
96
|
+
export interface SafariQueryAllInput { selector: string; limit?: number }
|
|
97
|
+
|
|
98
|
+
// Hover
|
|
99
|
+
export interface SafariHoverInput { ref?: string; selector?: string; x?: number; y?: number }
|
|
100
|
+
|
|
101
|
+
// Dialog
|
|
102
|
+
export interface SafariHandleDialogInput { action?: "accept" | "dismiss"; text?: string }
|
|
103
|
+
|
|
104
|
+
// Window
|
|
105
|
+
export interface SafariResizeInput { width: number; height: number }
|
|
106
|
+
|
|
107
|
+
// Drag
|
|
108
|
+
export interface SafariDragInput {
|
|
109
|
+
sourceSelector?: string;
|
|
110
|
+
targetSelector?: string;
|
|
111
|
+
sourceX?: number;
|
|
112
|
+
sourceY?: number;
|
|
113
|
+
targetX?: number;
|
|
114
|
+
targetY?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// File / Image
|
|
118
|
+
export interface SafariUploadFileInput { selector: string; filePath: string }
|
|
119
|
+
export interface SafariPasteImageInput { filePath: string }
|
|
120
|
+
|
|
121
|
+
// Emulation
|
|
122
|
+
export interface SafariEmulateInput { device?: string; width?: number; height?: number; userAgent?: string; scale?: number }
|
|
123
|
+
export interface SafariResetEmulationInput {}
|
|
124
|
+
|
|
125
|
+
// Cookies & Storage
|
|
126
|
+
export interface SafariGetCookiesInput {}
|
|
127
|
+
export interface SafariLocalStorageInput { key?: string }
|
|
128
|
+
export interface SafariSetCookieInput {
|
|
129
|
+
name: string;
|
|
130
|
+
value: string;
|
|
131
|
+
domain?: string;
|
|
132
|
+
path?: string;
|
|
133
|
+
expires?: string;
|
|
134
|
+
secure?: boolean;
|
|
135
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
136
|
+
}
|
|
137
|
+
export interface SafariDeleteCookiesInput { name?: string; all?: boolean }
|
|
138
|
+
export interface SafariSessionStorageInput { key?: string }
|
|
139
|
+
export interface SafariSetSessionStorageInput { key: string; value: string }
|
|
140
|
+
export interface SafariSetLocalStorageInput { key: string; value: string }
|
|
141
|
+
export interface SafariDeleteLocalStorageInput { key?: string }
|
|
142
|
+
export interface SafariDeleteSessionStorageInput { key?: string }
|
|
143
|
+
export interface SafariExportStorageInput {}
|
|
144
|
+
export interface SafariImportStorageInput { state: string }
|
|
145
|
+
|
|
146
|
+
// Clipboard
|
|
147
|
+
export interface SafariClipboardReadInput {}
|
|
148
|
+
export interface SafariClipboardWriteInput { text: string }
|
|
149
|
+
|
|
150
|
+
// Network
|
|
151
|
+
export interface SafariNetworkInput { limit?: number }
|
|
152
|
+
export interface SafariMockRouteResponse { status?: number; body?: string; contentType?: string }
|
|
153
|
+
export interface SafariMockRouteInput { urlPattern: string; response: SafariMockRouteResponse }
|
|
154
|
+
export interface SafariClearMocksInput {}
|
|
155
|
+
export interface SafariStartNetworkCaptureInput {}
|
|
156
|
+
export interface SafariNetworkDetailsInput { limit?: number; filter?: string }
|
|
157
|
+
export interface SafariClearNetworkInput {}
|
|
158
|
+
export interface SafariThrottleNetworkInput { profile?: string; latency?: number; downloadKbps?: number }
|
|
159
|
+
|
|
160
|
+
// Console
|
|
161
|
+
export interface SafariStartConsoleInput {}
|
|
162
|
+
export interface SafariGetConsoleInput {}
|
|
163
|
+
export interface SafariClearConsoleInput {}
|
|
164
|
+
export interface SafariConsoleFilterInput { level: "log" | "warn" | "error" | "info" }
|
|
165
|
+
|
|
166
|
+
// PDF
|
|
167
|
+
export interface SafariSavePDFInput { path: string }
|
|
168
|
+
|
|
169
|
+
// Accessibility
|
|
170
|
+
export interface SafariAccessibilitySnapshotInput { selector?: string; maxDepth?: number }
|
|
171
|
+
|
|
172
|
+
// Performance
|
|
173
|
+
export interface SafariPerformanceMetricsInput {}
|
|
174
|
+
export interface SafariCSSCoverageInput {}
|
|
175
|
+
|
|
176
|
+
// Data Extraction
|
|
177
|
+
export interface SafariExtractTablesInput { selector?: string; limit?: number }
|
|
178
|
+
export interface SafariExtractMetaInput {}
|
|
179
|
+
export interface SafariExtractImagesInput { limit?: number }
|
|
180
|
+
export interface SafariExtractLinksInput { limit?: number; filter?: string }
|
|
181
|
+
|
|
182
|
+
// Geolocation
|
|
183
|
+
export interface SafariOverrideGeolocationInput { latitude: number; longitude: number; accuracy?: number }
|
|
184
|
+
|
|
185
|
+
// Computed Styles
|
|
186
|
+
export interface SafariGetComputedStyleInput { selector: string; properties?: string[] }
|
|
187
|
+
|
|
188
|
+
// IndexedDB
|
|
189
|
+
export interface SafariListIndexedDBsInput {}
|
|
190
|
+
export interface SafariGetIndexedDBInput { dbName: string; storeName: string; limit?: number }
|
|
191
|
+
|
|
192
|
+
// Form Detection
|
|
193
|
+
export interface SafariDetectFormsInput {}
|
|
194
|
+
|
|
195
|
+
// Batch / Combo Tools
|
|
196
|
+
export interface SafariRunScriptStep { action: string; args?: Record<string, unknown> }
|
|
197
|
+
export interface SafariRunScriptInput { steps: SafariRunScriptStep[] }
|
|
198
|
+
export interface SafariClickAndWaitInput { selector?: string; text?: string; waitFor?: string; timeout?: number }
|
|
199
|
+
export interface SafariFillAndSubmitInput { fields: SafariFillFormField[]; submitSelector?: string }
|
|
200
|
+
export interface SafariAnalyzePageInput {}
|
|
201
|
+
|
|
202
|
+
// ========== TAB INFO ==========
|
|
203
|
+
|
|
204
|
+
export interface SafariTab {
|
|
205
|
+
index: number;
|
|
206
|
+
title: string;
|
|
207
|
+
url: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ========== MODULE DECLARATION ==========
|
|
211
|
+
export interface McpServer {
|
|
212
|
+
connect(transport: unknown): Promise<void>;
|
|
213
|
+
tool(name: string, description: string, schema: unknown, handler: (input: unknown) => Promise<MCPToolResult>): unknown;
|
|
214
|
+
close(): Promise<void>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
declare const server: McpServer;
|
|
218
|
+
export default server;
|
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
|
|
@@ -511,11 +526,11 @@ async function _proxyToExtension(type, payload, timeoutMs = 30000) {
|
|
|
511
526
|
}
|
|
512
527
|
|
|
513
528
|
let _extensionLastPollTime = 0;
|
|
514
|
-
// Detect stale HTTP connection (no poll in
|
|
529
|
+
// Detect stale HTTP connection (no poll in 30s = disconnected)
|
|
515
530
|
// Only applies to primary instance (extension host) — not proxy mode
|
|
516
531
|
setInterval(() => {
|
|
517
532
|
if (_isExtensionHost && _extensionConnected && !_extensionWs && _extensionLastPollTime > 0) {
|
|
518
|
-
if (Date.now() - _extensionLastPollTime >
|
|
533
|
+
if (Date.now() - _extensionLastPollTime > 30000) {
|
|
519
534
|
_extensionConnected = false;
|
|
520
535
|
_drainOnDisconnect("HTTP poll timeout");
|
|
521
536
|
console.error("[Safari MCP] Extension disconnected (HTTP poll timeout)");
|
|
@@ -635,44 +650,68 @@ 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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
+
|
|
669
|
+
// ========== FOCUS PRESERVATION ==========
|
|
670
|
+
// Safari AppleScript/extension can steal focus (bring Safari window to front).
|
|
671
|
+
// Save the frontmost app before the operation and restore it after if Safari stole focus.
|
|
672
|
+
// Set focusGuard flag so inner osascript/runJSLarge calls skip their own focus logic.
|
|
673
|
+
const savedApp = await safari.saveFrontmostApp();
|
|
674
|
+
safari.setFocusGuard(true);
|
|
675
|
+
|
|
676
|
+
let result;
|
|
677
|
+
let usedExtension = false;
|
|
678
|
+
try {
|
|
679
|
+
if (_extensionConnected && !_preferAppleScript) {
|
|
680
|
+
try {
|
|
681
|
+
const t0 = Date.now();
|
|
682
|
+
const tabUrl = safari.getActiveTabURL();
|
|
683
|
+
const payload = { ...extensionPayload, sessionId: SESSION_ID, ...(tabUrl ? { tabUrl } : {}) };
|
|
684
|
+
const timeout = _commandTimeouts[extensionType] || 30000;
|
|
685
|
+
result = await sendToExtension(extensionType, payload, timeout);
|
|
686
|
+
const isCspError = typeof result === 'string' && (result.includes('unsafe-eval') || result.includes('trusted-types') || result.includes('Trusted Type') || result.includes('Content Security Policy'));
|
|
687
|
+
const isPermissionDenied = typeof result === 'string' && result.includes('__SCREENSHOT_PERMISSION_DENIED__');
|
|
688
|
+
const isFailed = result === null || (typeof result === 'string' && result.startsWith('Element not found'));
|
|
689
|
+
if (isPermissionDenied) {
|
|
690
|
+
console.error(`[Safari MCP] ${extensionType} permission denied (${Date.now() - t0}ms) — falling back to AppleScript`);
|
|
691
|
+
} else if (isCspError) {
|
|
692
|
+
console.error(`[Safari MCP] ${extensionType} CSP blocked: ${result?.substring(0, 100)} (${Date.now() - t0}ms) — falling back to AppleScript`);
|
|
693
|
+
} else if (isFailed && _nullMeansFailure.has(extensionType)) {
|
|
694
|
+
console.error(`[Safari MCP] ${extensionType} extension failed: ${result} (${Date.now() - t0}ms) — falling back to AppleScript`);
|
|
695
|
+
} else {
|
|
696
|
+
console.error(`[Safari MCP] ${extensionType} via extension (${Date.now() - t0}ms)`);
|
|
697
|
+
usedExtension = true;
|
|
698
|
+
}
|
|
699
|
+
} catch (err) {
|
|
700
|
+
console.error(`[Safari MCP] ${extensionType} extension failed: ${err.message} — falling back to AppleScript`);
|
|
668
701
|
}
|
|
669
|
-
} catch (err) {
|
|
670
|
-
console.error(`[Safari MCP] ${extensionType} extension failed: ${err.message} — falling back to AppleScript`);
|
|
671
702
|
}
|
|
703
|
+
if (!usedExtension) {
|
|
704
|
+
const t0 = Date.now();
|
|
705
|
+
result = await fallbackFn();
|
|
706
|
+
console.error(`[Safari MCP] ${extensionType} via AppleScript (${Date.now() - t0}ms)`);
|
|
707
|
+
}
|
|
708
|
+
} finally {
|
|
709
|
+
safari.setFocusGuard(false);
|
|
672
710
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
711
|
+
|
|
712
|
+
// Restore focus if Safari stole it
|
|
713
|
+
await safari.restoreFocusIfStolen(savedApp);
|
|
714
|
+
|
|
676
715
|
return result;
|
|
677
716
|
}
|
|
678
717
|
|
|
@@ -1075,7 +1114,10 @@ server.tool(
|
|
|
1075
1114
|
"Take a screenshot of a specific element (by CSS selector). Returns base64 PNG image.",
|
|
1076
1115
|
{ selector: z.string().describe("CSS selector of the element to capture") },
|
|
1077
1116
|
async ({ selector }) => {
|
|
1078
|
-
const base64 = await
|
|
1117
|
+
const base64 = await extensionOrFallback(
|
|
1118
|
+
"screenshot_element", { selector },
|
|
1119
|
+
() => safari.screenshotElement({ selector })
|
|
1120
|
+
);
|
|
1079
1121
|
return {
|
|
1080
1122
|
content: [{ type: "image", data: base64, mimeType: "image/jpeg" }],
|
|
1081
1123
|
};
|
|
@@ -1203,9 +1245,13 @@ server.tool(
|
|
|
1203
1245
|
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
1204
1246
|
const target = parsed.find(t => t.index === index);
|
|
1205
1247
|
if (target && target.url && !_isURLOwned(target.url)) {
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1248
|
+
// about:blank / missing value tabs are owned if tracked in _openedTabs
|
|
1249
|
+
const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && _openedTabs.has(index);
|
|
1250
|
+
if (!isBlankOwned) {
|
|
1251
|
+
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.`;
|
|
1252
|
+
console.error(`[Safari MCP] ${msg}`);
|
|
1253
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
1254
|
+
}
|
|
1209
1255
|
}
|
|
1210
1256
|
} catch {}
|
|
1211
1257
|
}
|
|
@@ -1559,7 +1605,7 @@ server.tool(
|
|
|
1559
1605
|
|
|
1560
1606
|
server.tool(
|
|
1561
1607
|
"safari_save_pdf",
|
|
1562
|
-
"Save the current page as a PDF file. Uses
|
|
1608
|
+
"Save the current page as a PDF file. Uses screencapture + PDF rendering (no Safari UI interaction needed).",
|
|
1563
1609
|
{ path: z.string().describe("Absolute file path to save the PDF (e.g. /Users/am/Downloads/page.pdf)") },
|
|
1564
1610
|
async (args) => {
|
|
1565
1611
|
const result = await safari.savePDF(args);
|
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,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.4",
|
|
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",
|
|
7
|
+
"types": "index.d.ts",
|
|
7
8
|
"main": "index.js",
|
|
8
9
|
"bin": {
|
|
9
10
|
"safari-mcp": "index.js"
|
|
@@ -39,8 +40,8 @@
|
|
|
39
40
|
"license": "MIT",
|
|
40
41
|
"homepage": "https://achiya-automation.com",
|
|
41
42
|
"funding": {
|
|
42
|
-
"type": "
|
|
43
|
-
"url": "https://
|
|
43
|
+
"type": "individual",
|
|
44
|
+
"url": "https://paypal.me/achiyaC123"
|
|
44
45
|
},
|
|
45
46
|
"repository": {
|
|
46
47
|
"type": "git",
|
|
@@ -64,7 +65,8 @@
|
|
|
64
65
|
"extension/",
|
|
65
66
|
"glama.json",
|
|
66
67
|
"LICENSE",
|
|
67
|
-
"README.md"
|
|
68
|
+
"README.md",
|
|
69
|
+
"index.d.ts"
|
|
68
70
|
],
|
|
69
71
|
"dependencies": {
|
|
70
72
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
package/safari-helper
CHANGED
|
Binary file
|
package/safari-helper.swift
CHANGED
|
@@ -278,12 +278,52 @@ while let line = readLine(strippingNewline: true) {
|
|
|
278
278
|
continue
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// Handle getFrontApp — returns frontmost application bundle ID (native, ~0.1ms)
|
|
282
|
+
if json["getFrontApp"] != nil {
|
|
283
|
+
let frontApp = NSWorkspace.shared.frontmostApplication
|
|
284
|
+
respond(["result": frontApp?.localizedName ?? "", "bundleId": frontApp?.bundleIdentifier ?? ""])
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Handle activateApp — activate a specific app by bundle ID (native, ~1ms)
|
|
289
|
+
// Used to restore focus to previous app instead of hiding Safari (less jarring)
|
|
290
|
+
if let bundleId = json["activateApp"] as? String {
|
|
291
|
+
if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId).first {
|
|
292
|
+
app.activate()
|
|
293
|
+
respond(["result": "activated"])
|
|
294
|
+
} else {
|
|
295
|
+
respond(["error": "app not found: \(bundleId)"])
|
|
296
|
+
}
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Handle hideSafari — hide Safari to prevent focus stealing (native, ~1ms)
|
|
301
|
+
if json["hideSafari"] != nil {
|
|
302
|
+
if let safariApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Safari").first {
|
|
303
|
+
safariApp.hide()
|
|
304
|
+
respond(["result": "hidden"])
|
|
305
|
+
} else {
|
|
306
|
+
respond(["error": "Safari not running"])
|
|
307
|
+
}
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
|
|
281
311
|
// Handle AppleScript command
|
|
282
312
|
guard let script = json["script"] as? String else {
|
|
283
|
-
respond(["error": "invalid input — expected 'script', 'click', or '
|
|
313
|
+
respond(["error": "invalid input — expected 'script', 'click', 'keyboard', 'getFrontApp', or 'hideSafari'"])
|
|
284
314
|
continue
|
|
285
315
|
}
|
|
286
316
|
|
|
317
|
+
// ========== FOCUS PRESERVATION ==========
|
|
318
|
+
// Save the frontmost app BEFORE executing AppleScript.
|
|
319
|
+
// Safari AppleScript can steal focus — we re-activate the previous app immediately after.
|
|
320
|
+
// Re-activating is better than hiding Safari because:
|
|
321
|
+
// 1. No jarring hide animation on Safari windows
|
|
322
|
+
// 2. User's Safari windows stay visible (just not in front)
|
|
323
|
+
// 3. Almost instantaneous — previous app just comes back to foreground
|
|
324
|
+
let savedBundleId = NSWorkspace.shared.frontmostApplication?.bundleIdentifier
|
|
325
|
+
let wasSafari = savedBundleId == "com.apple.Safari"
|
|
326
|
+
|
|
287
327
|
guard let nsScript = NSAppleScript(source: script) else {
|
|
288
328
|
respond(["error": "failed to compile AppleScript"])
|
|
289
329
|
continue
|
|
@@ -292,6 +332,15 @@ while let line = readLine(strippingNewline: true) {
|
|
|
292
332
|
var errorDict: NSDictionary?
|
|
293
333
|
let result = nsScript.executeAndReturnError(&errorDict)
|
|
294
334
|
|
|
335
|
+
// Restore focus to previous app if Safari stole it (only if caller wasn't already in Safari)
|
|
336
|
+
if !wasSafari,
|
|
337
|
+
NSWorkspace.shared.frontmostApplication?.bundleIdentifier == "com.apple.Safari" {
|
|
338
|
+
if let savedId = savedBundleId,
|
|
339
|
+
let previousApp = NSRunningApplication.runningApplications(withBundleIdentifier: savedId).first {
|
|
340
|
+
previousApp.activate()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
295
344
|
if let error = errorDict {
|
|
296
345
|
let msg = (error["NSAppleScriptErrorMessage"] as? String) ?? "AppleScript error"
|
|
297
346
|
respond(["error": msg])
|
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.
|
|
@@ -162,6 +177,7 @@ let _targetWindowId = null; // Numeric window ID (for CGEvent window targeting)
|
|
|
162
177
|
let _targetWindowCacheTime = 0;
|
|
163
178
|
const TARGET_WINDOW_CACHE_MS = 1000; // Short cache — fast detection of window changes
|
|
164
179
|
let _profileWindowMissing = false; // True when profile window is not found
|
|
180
|
+
let _focusGuardActive = false; // True when an outer caller already handles focus save/restore
|
|
165
181
|
|
|
166
182
|
// Get the target window ref, falling back to 'front window' ONLY when no profile is configured
|
|
167
183
|
function getTargetWindowRef() {
|
|
@@ -258,6 +274,56 @@ function getFallbackTarget() {
|
|
|
258
274
|
// Quick JS execution — exposed for smart-wait checks in index.js
|
|
259
275
|
export async function runJSQuick(js) { return runJS(js); }
|
|
260
276
|
|
|
277
|
+
// ========== FOCUS PRESERVATION ==========
|
|
278
|
+
// Safari AppleScript can steal focus (bring Safari window to front).
|
|
279
|
+
// We hide Safari instead of activating the previous app — prevents ANY Safari window from flashing.
|
|
280
|
+
export async function saveFrontmostApp() {
|
|
281
|
+
const app = await _helperGetFrontApp();
|
|
282
|
+
return app?.bundleId || null;
|
|
283
|
+
}
|
|
284
|
+
export async function restoreFocusIfStolen(savedBundleId) {
|
|
285
|
+
if (!savedBundleId || savedBundleId === "com.apple.Safari") return;
|
|
286
|
+
const current = await _helperGetFrontApp();
|
|
287
|
+
if (current?.bundleId === "com.apple.Safari") {
|
|
288
|
+
await _helperActivateApp(savedBundleId);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function _helperHideSafari(timeout = 2000) {
|
|
293
|
+
return new Promise((resolve) => {
|
|
294
|
+
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
|
|
295
|
+
let resolved = false;
|
|
296
|
+
const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
|
|
297
|
+
function cb() {
|
|
298
|
+
if (resolved) return;
|
|
299
|
+
resolved = true;
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
resolve();
|
|
302
|
+
}
|
|
303
|
+
_helperQueue.push(cb);
|
|
304
|
+
try { _helperProc.stdin.write('{"hideSafari":true}\n'); }
|
|
305
|
+
catch { clearTimeout(timer); resolve(); }
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function _helperActivateApp(bundleId, timeout = 2000) {
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
|
|
312
|
+
let resolved = false;
|
|
313
|
+
const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
|
|
314
|
+
function cb() {
|
|
315
|
+
if (resolved) return;
|
|
316
|
+
resolved = true;
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
resolve();
|
|
319
|
+
}
|
|
320
|
+
_helperQueue.push(cb);
|
|
321
|
+
try { _helperProc.stdin.write(JSON.stringify({ activateApp: bundleId }) + '\n'); }
|
|
322
|
+
catch { clearTimeout(timer); resolve(); }
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function setFocusGuard(active) { _focusGuardActive = active; }
|
|
261
327
|
export function getActiveTabIndex() { return _activeTabIndex; }
|
|
262
328
|
export function setActiveTabIndex(idx) { _activeTabIndex = idx; }
|
|
263
329
|
export function getActiveTabURL() { return _activeTabURL; }
|
|
@@ -326,6 +392,11 @@ async function resolveActiveTab() {
|
|
|
326
392
|
|
|
327
393
|
// Run AppleScript — uses execFile (safe, isolated, for complex scripts)
|
|
328
394
|
async function osascript(script, { timeout = 10000 } = {}) {
|
|
395
|
+
if (!(await isSafariRunning())) throw safariNotRunningError();
|
|
396
|
+
// Save frontmost app via daemon BEFORE subprocess (~0.1ms)
|
|
397
|
+
// Skip if an outer caller (extensionOrFallback) already handles focus
|
|
398
|
+
const shouldGuardFocus = !_focusGuardActive;
|
|
399
|
+
const frontApp = shouldGuardFocus ? await _helperGetFrontApp() : null;
|
|
329
400
|
try {
|
|
330
401
|
const { stdout } = await execFileAsync("osascript", ["-e", script], {
|
|
331
402
|
timeout,
|
|
@@ -344,11 +415,20 @@ async function osascript(script, { timeout = 10000 } = {}) {
|
|
|
344
415
|
}
|
|
345
416
|
}
|
|
346
417
|
throw new Error(`AppleScript error: ${err.stderr || err.message}`);
|
|
418
|
+
} finally {
|
|
419
|
+
// Re-activate previous app if Safari stole focus (~1ms)
|
|
420
|
+
if (shouldGuardFocus && frontApp?.bundleId && frontApp.bundleId !== 'com.apple.Safari') {
|
|
421
|
+
const current = await _helperGetFrontApp();
|
|
422
|
+
if (current?.bundleId === 'com.apple.Safari') {
|
|
423
|
+
_helperActivateApp(frontApp.bundleId).catch(() => {});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
347
426
|
}
|
|
348
427
|
}
|
|
349
428
|
|
|
350
429
|
// osascriptFast: uses persistent Swift daemon (~5ms) — 18x faster than subprocess (~90ms)
|
|
351
430
|
async function osascriptFast(script, { timeout = 10000 } = {}) {
|
|
431
|
+
if (!(await isSafariRunning())) throw safariNotRunningError();
|
|
352
432
|
if (!_helperProc) startHelper();
|
|
353
433
|
if (_helperProc) {
|
|
354
434
|
try {
|
|
@@ -503,6 +583,26 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
|
|
|
503
583
|
});
|
|
504
584
|
}
|
|
505
585
|
|
|
586
|
+
// ========== NATIVE FOCUS OPERATIONS VIA DAEMON ==========
|
|
587
|
+
// Uses NSRunningApplication — ~0.1ms for get, ~1ms for activate (vs ~90ms AppleScript)
|
|
588
|
+
|
|
589
|
+
function _helperGetFrontApp(timeout = 2000) {
|
|
590
|
+
return new Promise((resolve) => {
|
|
591
|
+
if (!_helperProc || !_helperProc.stdin?.writable) { resolve(null); return; }
|
|
592
|
+
let resolved = false;
|
|
593
|
+
const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(null); } }, timeout);
|
|
594
|
+
function cb(line) {
|
|
595
|
+
if (resolved) return;
|
|
596
|
+
resolved = true;
|
|
597
|
+
clearTimeout(timer);
|
|
598
|
+
try { resolve(JSON.parse(line)); } catch { resolve(null); }
|
|
599
|
+
}
|
|
600
|
+
_helperQueue.push(cb);
|
|
601
|
+
try { _helperProc.stdin.write('{"getFrontApp":true}\n'); }
|
|
602
|
+
catch { clearTimeout(timer); resolve(null); }
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
506
606
|
// Get Safari window bounds, toolbar height, and window ID for coordinate calculation
|
|
507
607
|
async function _getSafariWindowGeometry() {
|
|
508
608
|
await refreshTargetWindow();
|
|
@@ -615,6 +715,10 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
|
|
|
615
715
|
const appleScript = `tell application "Safari" to do JavaScript "${escaped}" in ${target}`;
|
|
616
716
|
const tmpFile = join(tmpdir(), `safari-mcp-${Date.now()}.scpt`);
|
|
617
717
|
await writeFile(tmpFile, appleScript, "utf8");
|
|
718
|
+
// Save frontmost app via daemon before subprocess execution
|
|
719
|
+
// Skip if outer caller already handles focus
|
|
720
|
+
const shouldGuard = !_focusGuardActive;
|
|
721
|
+
const frontApp = shouldGuard ? await _helperGetFrontApp() : null;
|
|
618
722
|
try {
|
|
619
723
|
const { stdout } = await execFileAsync("osascript", [tmpFile], {
|
|
620
724
|
timeout,
|
|
@@ -623,6 +727,13 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
|
|
|
623
727
|
return stdout.trim();
|
|
624
728
|
} finally {
|
|
625
729
|
unlink(tmpFile).catch(() => {});
|
|
730
|
+
// Re-activate previous app if Safari stole focus (~1ms)
|
|
731
|
+
if (shouldGuard && frontApp?.bundleId && frontApp.bundleId !== 'com.apple.Safari') {
|
|
732
|
+
const current = await _helperGetFrontApp();
|
|
733
|
+
if (current?.bundleId === 'com.apple.Safari') {
|
|
734
|
+
_helperActivateApp(frontApp.bundleId).catch(() => {});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
626
737
|
}
|
|
627
738
|
}
|
|
628
739
|
|
|
@@ -659,7 +770,7 @@ export async function navigate(url) {
|
|
|
659
770
|
const state = await runJS('document.readyState', { timeout: 5000 });
|
|
660
771
|
if (state === 'complete' || state === 'interactive') {
|
|
661
772
|
result = await runJS(
|
|
662
|
-
`JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('
|
|
773
|
+
`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
774
|
{ timeout: 5000 }
|
|
664
775
|
);
|
|
665
776
|
if (state === 'complete') break;
|
|
@@ -1487,15 +1598,11 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1487
1598
|
).catch(() => null) : null;
|
|
1488
1599
|
|
|
1489
1600
|
// On macOS Tahoe, screencapture -l may briefly steal focus.
|
|
1490
|
-
// Save frontmost app so we can
|
|
1491
|
-
let
|
|
1601
|
+
// Save frontmost app via daemon so we can hide Safari if it stole focus.
|
|
1602
|
+
let previousBundleId = null;
|
|
1492
1603
|
if (windowId) {
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
previousApp = (await osascriptFast(
|
|
1496
|
-
`tell application "System Events" to return name of first application process whose frontmost is true`
|
|
1497
|
-
)).trim();
|
|
1498
|
-
} catch (_) {}
|
|
1604
|
+
const fa = await _helperGetFrontApp();
|
|
1605
|
+
previousBundleId = fa?.bundleId || null;
|
|
1499
1606
|
}
|
|
1500
1607
|
|
|
1501
1608
|
if (windowId) {
|
|
@@ -1536,11 +1643,12 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1536
1643
|
);
|
|
1537
1644
|
}
|
|
1538
1645
|
}
|
|
1539
|
-
//
|
|
1540
|
-
if (
|
|
1541
|
-
await
|
|
1542
|
-
|
|
1543
|
-
|
|
1646
|
+
// Re-activate previous app if screencapture stole focus (common on macOS Tahoe)
|
|
1647
|
+
if (previousBundleId && previousBundleId !== "com.apple.Safari") {
|
|
1648
|
+
const cur = await _helperGetFrontApp();
|
|
1649
|
+
if (cur?.bundleId === "com.apple.Safari") {
|
|
1650
|
+
await _helperActivateApp(previousBundleId).catch(() => {});
|
|
1651
|
+
}
|
|
1544
1652
|
}
|
|
1545
1653
|
// Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
|
|
1546
1654
|
// Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit
|
|
@@ -2069,7 +2177,7 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
2069
2177
|
click button "Cancel" of sheet 1 of w
|
|
2070
2178
|
on error
|
|
2071
2179
|
try
|
|
2072
|
-
click button "
|
|
2180
|
+
click button "\u05D1\u05D9\u05D8\u05D5\u05DC" of sheet 1 of w -- "Cancel" in Hebrew locale
|
|
2073
2181
|
on error
|
|
2074
2182
|
key code 53
|
|
2075
2183
|
end try
|
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();
|