safari-mcp 2.7.3 → 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 CHANGED
@@ -31,6 +31,8 @@
31
31
 
32
32
  </div>
33
33
 
34
+ > **If Safari MCP saves you from running Chrome, consider giving it a star!** It helps others discover this project.
35
+
34
36
  ---
35
37
 
36
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.
@@ -545,9 +547,26 @@ Your support funds:
545
547
 
546
548
  ---
547
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
+
548
567
  ## Star History
549
568
 
550
- 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!
551
570
 
552
571
  [![Star History Chart](https://api.star-history.com/svg?repos=achiya-automation/safari-mcp&type=Date)](https://star-history.com/#achiya-automation/safari-mcp&Date)
553
572
 
@@ -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
@@ -526,11 +526,11 @@ async function _proxyToExtension(type, payload, timeoutMs = 30000) {
526
526
  }
527
527
 
528
528
  let _extensionLastPollTime = 0;
529
- // Detect stale HTTP connection (no poll in 10s = disconnected)
529
+ // Detect stale HTTP connection (no poll in 30s = disconnected)
530
530
  // Only applies to primary instance (extension host) — not proxy mode
531
531
  setInterval(() => {
532
532
  if (_isExtensionHost && _extensionConnected && !_extensionWs && _extensionLastPollTime > 0) {
533
- if (Date.now() - _extensionLastPollTime > 10000) {
533
+ if (Date.now() - _extensionLastPollTime > 30000) {
534
534
  _extensionConnected = false;
535
535
  _drainOnDisconnect("HTTP poll timeout");
536
536
  console.error("[Safari MCP] Extension disconnected (HTTP poll timeout)");
@@ -665,33 +665,53 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
665
665
  }
666
666
  }
667
667
  }
668
- if (_extensionConnected && !_preferAppleScript) {
669
- try {
670
- const t0 = Date.now();
671
- const tabUrl = safari.getActiveTabURL();
672
- const payload = { ...extensionPayload, sessionId: SESSION_ID, ...(tabUrl ? { tabUrl } : {}) };
673
- const timeout = _commandTimeouts[extensionType] || 30000;
674
- const result = await sendToExtension(extensionType, payload, timeout);
675
- const isCspError = typeof result === 'string' && (result.includes('unsafe-eval') || result.includes('trusted-types') || result.includes('Trusted Type') || result.includes('Content Security Policy'));
676
- const isPermissionDenied = typeof result === 'string' && result.includes('__SCREENSHOT_PERMISSION_DENIED__');
677
- const isFailed = result === null || (typeof result === 'string' && result.startsWith('Element not found'));
678
- if (isPermissionDenied) {
679
- console.error(`[Safari MCP] ${extensionType} permission denied (${Date.now() - t0}ms) — falling back to AppleScript`);
680
- } else if (isCspError) {
681
- console.error(`[Safari MCP] ${extensionType} CSP blocked: ${result?.substring(0, 100)} (${Date.now() - t0}ms) — falling back to AppleScript`);
682
- } else if (isFailed && _nullMeansFailure.has(extensionType)) {
683
- console.error(`[Safari MCP] ${extensionType} extension failed: ${result} (${Date.now() - t0}ms) — falling back to AppleScript`);
684
- } else {
685
- console.error(`[Safari MCP] ${extensionType} via extension (${Date.now() - t0}ms)`);
686
- return result;
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`);
687
701
  }
688
- } catch (err) {
689
- console.error(`[Safari MCP] ${extensionType} extension failed: ${err.message} — falling back to AppleScript`);
690
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);
691
710
  }
692
- const t0 = Date.now();
693
- const result = await fallbackFn();
694
- console.error(`[Safari MCP] ${extensionType} via AppleScript (${Date.now() - t0}ms)`);
711
+
712
+ // Restore focus if Safari stole it
713
+ await safari.restoreFocusIfStolen(savedApp);
714
+
695
715
  return result;
696
716
  }
697
717
 
@@ -1094,7 +1114,10 @@ server.tool(
1094
1114
  "Take a screenshot of a specific element (by CSS selector). Returns base64 PNG image.",
1095
1115
  { selector: z.string().describe("CSS selector of the element to capture") },
1096
1116
  async ({ selector }) => {
1097
- const base64 = await safari.screenshotElement({ selector });
1117
+ const base64 = await extensionOrFallback(
1118
+ "screenshot_element", { selector },
1119
+ () => safari.screenshotElement({ selector })
1120
+ );
1098
1121
  return {
1099
1122
  content: [{ type: "image", data: base64, mimeType: "image/jpeg" }],
1100
1123
  };
@@ -1582,7 +1605,7 @@ server.tool(
1582
1605
 
1583
1606
  server.tool(
1584
1607
  "safari_save_pdf",
1585
- "Save the current page as a PDF file. Uses Safari's native Export as PDF. WARNING: This STEALS FOCUS — Safari comes to foreground briefly for the Export menu.",
1608
+ "Save the current page as a PDF file. Uses screencapture + PDF rendering (no Safari UI interaction needed).",
1586
1609
  { path: z.string().describe("Absolute file path to save the PDF (e.g. /Users/am/Downloads/page.pdf)") },
1587
1610
  async (args) => {
1588
1611
  const result = await safari.savePDF(args);
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.7.3",
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"
@@ -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
@@ -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 'keyboard'"])
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
@@ -177,6 +177,7 @@ let _targetWindowId = null; // Numeric window ID (for CGEvent window targeting)
177
177
  let _targetWindowCacheTime = 0;
178
178
  const TARGET_WINDOW_CACHE_MS = 1000; // Short cache — fast detection of window changes
179
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
180
181
 
181
182
  // Get the target window ref, falling back to 'front window' ONLY when no profile is configured
182
183
  function getTargetWindowRef() {
@@ -273,6 +274,56 @@ function getFallbackTarget() {
273
274
  // Quick JS execution — exposed for smart-wait checks in index.js
274
275
  export async function runJSQuick(js) { return runJS(js); }
275
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; }
276
327
  export function getActiveTabIndex() { return _activeTabIndex; }
277
328
  export function setActiveTabIndex(idx) { _activeTabIndex = idx; }
278
329
  export function getActiveTabURL() { return _activeTabURL; }
@@ -342,6 +393,10 @@ async function resolveActiveTab() {
342
393
  // Run AppleScript — uses execFile (safe, isolated, for complex scripts)
343
394
  async function osascript(script, { timeout = 10000 } = {}) {
344
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;
345
400
  try {
346
401
  const { stdout } = await execFileAsync("osascript", ["-e", script], {
347
402
  timeout,
@@ -360,6 +415,14 @@ async function osascript(script, { timeout = 10000 } = {}) {
360
415
  }
361
416
  }
362
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
+ }
363
426
  }
364
427
  }
365
428
 
@@ -520,6 +583,26 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
520
583
  });
521
584
  }
522
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
+
523
606
  // Get Safari window bounds, toolbar height, and window ID for coordinate calculation
524
607
  async function _getSafariWindowGeometry() {
525
608
  await refreshTargetWindow();
@@ -632,6 +715,10 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
632
715
  const appleScript = `tell application "Safari" to do JavaScript "${escaped}" in ${target}`;
633
716
  const tmpFile = join(tmpdir(), `safari-mcp-${Date.now()}.scpt`);
634
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;
635
722
  try {
636
723
  const { stdout } = await execFileAsync("osascript", [tmpFile], {
637
724
  timeout,
@@ -640,6 +727,13 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
640
727
  return stdout.trim();
641
728
  } finally {
642
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
+ }
643
737
  }
644
738
  }
645
739
 
@@ -1504,15 +1598,11 @@ export async function screenshot({ fullPage = false } = {}) {
1504
1598
  ).catch(() => null) : null;
1505
1599
 
1506
1600
  // On macOS Tahoe, screencapture -l may briefly steal focus.
1507
- // Save frontmost app so we can detect if focus was stolen and restore it.
1508
- let previousApp = null;
1601
+ // Save frontmost app via daemon so we can hide Safari if it stole focus.
1602
+ let previousBundleId = null;
1509
1603
  if (windowId) {
1510
- try {
1511
- // Use NSWorkspace via AppleScript (lighter than System Events — no AX needed)
1512
- previousApp = (await osascriptFast(
1513
- `tell application "System Events" to return name of first application process whose frontmost is true`
1514
- )).trim();
1515
- } catch (_) {}
1604
+ const fa = await _helperGetFrontApp();
1605
+ previousBundleId = fa?.bundleId || null;
1516
1606
  }
1517
1607
 
1518
1608
  if (windowId) {
@@ -1553,11 +1643,12 @@ export async function screenshot({ fullPage = false } = {}) {
1553
1643
  );
1554
1644
  }
1555
1645
  }
1556
- // Restore focus if screencapture stole it (common on macOS Tahoe)
1557
- if (previousApp && previousApp !== "Safari") {
1558
- await osascriptFast(
1559
- `tell application "${previousApp}" to activate`
1560
- ).catch(() => {});
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
+ }
1561
1652
  }
1562
1653
  // Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
1563
1654
  // Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit