safari-mcp 2.7.0 → 2.7.1
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/extension/background.js +14 -2
- package/package.json +1 -1
- package/safari.js +20 -13
package/extension/background.js
CHANGED
|
@@ -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,6 +1727,10 @@ 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.
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.1",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -378,10 +378,11 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
378
378
|
const timer = setTimeout(() => {
|
|
379
379
|
if (resolved) return;
|
|
380
380
|
resolved = true;
|
|
381
|
+
// DON'T remove from queue — replace with no-op consumer to maintain FIFO order.
|
|
382
|
+
// Without this, the late response would be consumed by the NEXT callback in the queue.
|
|
381
383
|
const idx = _helperQueue.indexOf(cb);
|
|
382
|
-
if (idx >= 0) _helperQueue
|
|
384
|
+
if (idx >= 0) _helperQueue[idx] = () => {}; // Consume and discard late response
|
|
383
385
|
_helperConsecutiveTimeouts++;
|
|
384
|
-
// Only kill the daemon after 3 consecutive timeouts (single slow script shouldn't kill concurrent ops)
|
|
385
386
|
if (_helperConsecutiveTimeouts >= 3) {
|
|
386
387
|
console.error(`[Safari MCP] safari-helper: ${_helperConsecutiveTimeouts} consecutive timeouts — killing daemon`);
|
|
387
388
|
_helperProc?.kill();
|
|
@@ -396,7 +397,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
396
397
|
if (resolved) return;
|
|
397
398
|
resolved = true;
|
|
398
399
|
clearTimeout(timer);
|
|
399
|
-
_helperConsecutiveTimeouts = 0;
|
|
400
|
+
_helperConsecutiveTimeouts = 0;
|
|
400
401
|
try {
|
|
401
402
|
const parsed = JSON.parse(line);
|
|
402
403
|
if (parsed.error) reject(new Error(parsed.error));
|
|
@@ -406,7 +407,6 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
406
407
|
}
|
|
407
408
|
}
|
|
408
409
|
|
|
409
|
-
// Check process availability BEFORE pushing to queue (prevents dangling callbacks)
|
|
410
410
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
411
411
|
clearTimeout(timer);
|
|
412
412
|
reject(new Error("safari-helper not available"));
|
|
@@ -440,7 +440,7 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
|
|
|
440
440
|
if (resolved) return;
|
|
441
441
|
resolved = true;
|
|
442
442
|
const idx = _helperQueue.indexOf(cb);
|
|
443
|
-
if (idx >= 0) _helperQueue
|
|
443
|
+
if (idx >= 0) _helperQueue[idx] = () => {}; // No-op consumer for late response
|
|
444
444
|
reject(new Error("native click timeout"));
|
|
445
445
|
}, timeout);
|
|
446
446
|
|
|
@@ -479,7 +479,7 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
|
|
|
479
479
|
if (resolved) return;
|
|
480
480
|
resolved = true;
|
|
481
481
|
const idx = _helperQueue.indexOf(cb);
|
|
482
|
-
if (idx >= 0) _helperQueue
|
|
482
|
+
if (idx >= 0) _helperQueue[idx] = () => {}; // No-op consumer for late response
|
|
483
483
|
reject(new Error("native keyboard timeout"));
|
|
484
484
|
}, timeout);
|
|
485
485
|
|
|
@@ -1164,7 +1164,7 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
1164
1164
|
const geo = await _getSafariWindowGeometry();
|
|
1165
1165
|
if (!geo.windowId) throw new Error("Cannot paste into iframe without Safari window ID — would steal focus");
|
|
1166
1166
|
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
|
|
1167
|
-
await new Promise(r => setTimeout(r,
|
|
1167
|
+
await new Promise(r => setTimeout(r, 100)); // 100ms is enough for Cmd+V to process
|
|
1168
1168
|
return `Pressed: ${modifiers.join("+")}+v (CGEvent Cmd+V into iframe, no focus steal)`;
|
|
1169
1169
|
}
|
|
1170
1170
|
|
|
@@ -1277,22 +1277,22 @@ async function _nativeTypeViaClipboard(text) {
|
|
|
1277
1277
|
// MUST have windowId — global CGEvent (windowId=0) would steal focus and move mouse
|
|
1278
1278
|
const geo = await _getSafariWindowGeometry();
|
|
1279
1279
|
if (!geo.windowId) {
|
|
1280
|
-
// Can't get window ID — restore clipboard and
|
|
1280
|
+
// Can't get window ID — restore clipboard and let catch block release lock
|
|
1281
1281
|
await _restoreClipboard(savedClipboard);
|
|
1282
|
-
_releaseClipboardLock();
|
|
1283
1282
|
throw new Error("Cannot native-paste without Safari window ID — would steal focus");
|
|
1284
1283
|
}
|
|
1285
1284
|
// keyCode 9 = V key, flags: ["cmd"]
|
|
1286
1285
|
await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
|
|
1287
1286
|
|
|
1288
1287
|
// Wait for paste to settle, then restore clipboard immediately
|
|
1289
|
-
await new Promise(r => setTimeout(r,
|
|
1288
|
+
await new Promise(r => setTimeout(r, 100)); // 100ms is enough for Cmd+V to process
|
|
1290
1289
|
await _restoreClipboard(savedClipboard);
|
|
1291
1290
|
_releaseClipboardLock();
|
|
1292
1291
|
|
|
1293
1292
|
return `Typed ${text.length} chars (native paste into iframe)`;
|
|
1294
1293
|
} catch (err) {
|
|
1295
|
-
|
|
1294
|
+
// Ensure lock is released on ANY error (including the windowId check above)
|
|
1295
|
+
if (_clipboardLocked) _releaseClipboardLock();
|
|
1296
1296
|
throw err;
|
|
1297
1297
|
}
|
|
1298
1298
|
}
|
|
@@ -1486,11 +1486,12 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1486
1486
|
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
1487
1487
|
).catch(() => null) : null;
|
|
1488
1488
|
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1489
|
+
// On macOS Tahoe, screencapture -l may briefly steal focus.
|
|
1490
|
+
// Save frontmost app so we can detect if focus was stolen and restore it.
|
|
1491
1491
|
let previousApp = null;
|
|
1492
1492
|
if (windowId) {
|
|
1493
1493
|
try {
|
|
1494
|
+
// Use NSWorkspace via AppleScript (lighter than System Events — no AX needed)
|
|
1494
1495
|
previousApp = (await osascriptFast(
|
|
1495
1496
|
`tell application "System Events" to return name of first application process whose frontmost is true`
|
|
1496
1497
|
)).trim();
|
|
@@ -1680,6 +1681,8 @@ export async function screenshotElement({ selector }) {
|
|
|
1680
1681
|
if (data.length > 100) return data.toString("base64");
|
|
1681
1682
|
} catch (e) {
|
|
1682
1683
|
await unlink(tmpFile).catch(() => {});
|
|
1684
|
+
// Also clean cropFile if it was created before the error
|
|
1685
|
+
await unlink(tmpFile.replace('safari-el-', 'safari-el-crop-')).catch(() => {});
|
|
1683
1686
|
throw new Error(`Element screenshot failed: ${e.message}`);
|
|
1684
1687
|
}
|
|
1685
1688
|
}
|
|
@@ -2776,11 +2779,15 @@ export async function importStorageState({ state }) {
|
|
|
2776
2779
|
// ========== CLIPBOARD ==========
|
|
2777
2780
|
|
|
2778
2781
|
export async function clipboardRead() {
|
|
2782
|
+
// Acquire lock to avoid reading during a write/restore cycle
|
|
2783
|
+
await _acquireClipboardLock(3000); // Short timeout — reads are fast
|
|
2779
2784
|
try {
|
|
2780
2785
|
const text = await execFileAsync("pbpaste", []);
|
|
2781
2786
|
return text.stdout;
|
|
2782
2787
|
} catch {
|
|
2783
2788
|
return "(clipboard empty or contains non-text data)";
|
|
2789
|
+
} finally {
|
|
2790
|
+
_releaseClipboardLock();
|
|
2784
2791
|
}
|
|
2785
2792
|
}
|
|
2786
2793
|
|