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.
@@ -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
- const result = await verifyRes.json();
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.0",
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.splice(idx, 1);
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; // Reset on success
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.splice(idx, 1);
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.splice(idx, 1);
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, 300));
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 throw
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, 300));
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
- _releaseClipboardLock();
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
- // Save frontmost app BEFORE screencapture (it may steal focus on macOS Tahoe)
1490
- // Restore it immediately after capture to minimize disruption
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