safari-mcp 2.22.13 → 2.22.15

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/safari.js +42 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.22.13",
3
+ "version": "2.22.15",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
8
8
  import { tmpdir, homedir } from "node:os";
9
9
  import { join, dirname, resolve as resolvePath } from "node:path";
10
10
  import { readFile, writeFile, unlink, appendFile, mkdir } from "node:fs/promises";
11
- import { readFileSync, realpathSync } from "node:fs";
11
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { randomUUID } from "node:crypto";
14
14
  import { VIEWPORT_SCRIPT, SAFE_AREA_SCRIPT, PWA_SCRIPT, WEBKIT_COMPAT_SCRIPT, ALL_SHEETS_FN } from "./injected-validators.js";
@@ -998,7 +998,7 @@ function _windowById(id) {
998
998
  // Page JavaScript that answers "1" when the page carries exactly `marker`: in window.name, or in
999
999
  // window.__mcpTabMarker once a page has taken window.name over.
1000
1000
  function _markerCheckJS(marker) {
1001
- const safeMarker = String(marker).replace(/'/g, "\\'");
1001
+ const safeMarker = String(marker).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1002
1002
  return `(function(){try{return (window.name==='${safeMarker}'||window.__mcpTabMarker==='${safeMarker}')?'1':'0'}catch(e){return '0'}})()`;
1003
1003
  }
1004
1004
 
@@ -1426,9 +1426,16 @@ export const SCREEN_LOCKED_MSG =
1426
1426
  "safari_type_text / safari_press_key, and safari_upload_file without forceNative.";
1427
1427
 
1428
1428
  export async function isScreenLocked() {
1429
- return execFileAsync("/bin/sh", ["-c",
1430
- "ioreg -n Root -d1 -r -a 2>/dev/null | grep -c CGSSessionScreenIsLocked || true"])
1431
- .then((r) => String(r.stdout).trim() !== "0").catch(() => false);
1429
+ try {
1430
+ const { stdout } = await execFileAsync("/usr/sbin/ioreg", ["-n", "Root", "-d1", "-r", "-a"], { timeout: 2000 });
1431
+ const matches = [...String(stdout).matchAll(/<key>\s*(?:CGSSessionScreenIsLocked|IOConsoleLocked)\s*<\/key>\s*<(true|false)\s*\/>/g)];
1432
+ if (matches.length) return matches.some(match => match[1] === "true");
1433
+ // ioreg omits the key on an unlocked session. A present but unreadable value or
1434
+ // a broken probe must fail toward blocked input, never toward CGEvent.
1435
+ return /<key>\s*(?:CGSSessionScreenIsLocked|IOConsoleLocked)\s*<\/key>/.test(String(stdout));
1436
+ } catch {
1437
+ return true;
1438
+ }
1432
1439
  }
1433
1440
 
1434
1441
  export async function assertScreenUnlocked(probe = isScreenLocked) {
@@ -3235,7 +3242,7 @@ export async function selectOption({ selector, value, ref }) {
3235
3242
  const val = String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3236
3243
  let finder;
3237
3244
  if (ref) {
3238
- finder = `mcpFindRef('${String(ref).replace(/'/g, "\\'")}')`;
3245
+ finder = `mcpFindRef('${escJsSingleQuote(ref)}')`;
3239
3246
  } else if (selector) {
3240
3247
  const sel = escJsSingleQuote(selector);
3241
3248
  finder = `(document.querySelector('${sel}')||mcpQuerySelectorDeep('${sel}'))`;
@@ -3257,7 +3264,7 @@ export async function reactSelectSet({ selector, ref, value }) {
3257
3264
  if (value === undefined || value === null) throw new Error("reactSelectSet requires 'value' (option label)");
3258
3265
  let finder;
3259
3266
  if (ref) {
3260
- const safeRef = String(ref).replace(/'/g, "\\'");
3267
+ const safeRef = escJsSingleQuote(ref);
3261
3268
  finder = `mcpFindRef('${safeRef}')`;
3262
3269
  } else if (selector) {
3263
3270
  const sel = escJsSingleQuote(selector);
@@ -3274,7 +3281,7 @@ export async function reactSelectListOptions({ selector, ref }) {
3274
3281
  await ensureHelpers();
3275
3282
  let finder;
3276
3283
  if (ref) {
3277
- const safeRef = String(ref).replace(/'/g, "\\'");
3284
+ const safeRef = escJsSingleQuote(ref);
3278
3285
  finder = `mcpFindRef('${safeRef}')`;
3279
3286
  } else if (selector) {
3280
3287
  const sel = escJsSingleQuote(selector);
@@ -3511,7 +3518,7 @@ export async function pressKey({ key, modifiers = [] }) {
3511
3518
 
3512
3519
  // Non-modifier keys: pure JavaScript (no System Events)
3513
3520
  const jsKey = jsKeyMap[k] || key;
3514
- const safeKey = jsKey.replace(/'/g, "\\'");
3521
+ const safeKey = escJsSingleQuote(jsKey);
3515
3522
  // W3C `code` values: special keys are NOT "Key"-prefixed ("Enter", "ArrowUp", ...);
3516
3523
  // only letters are ("KeyA"), digits are "Digit1". Apps that route on event.code
3517
3524
  // (Notion, Monaco, Google Docs) ignore a bogus "KeyEnter".
@@ -3526,7 +3533,7 @@ export async function pressKey({ key, modifiers = [] }) {
3526
3533
  || (/^[a-z]$/i.test(jsKey) ? "Key" + jsKey.toUpperCase()
3527
3534
  : /^[0-9]$/.test(jsKey) ? "Digit" + jsKey
3528
3535
  : jsKey);
3529
- const safeCode = jsCode.replace(/'/g, "\\'");
3536
+ const safeCode = escJsSingleQuote(jsCode);
3530
3537
  const shiftKey = hasShift;
3531
3538
  const altKey = modifiers.some((m) => m.toLowerCase() === "alt");
3532
3539
 
@@ -3687,7 +3694,7 @@ export async function replaceEditorContent({ text }) {
3687
3694
  // then call onChange(text) to sync React. Works for Airtable-style embeds
3688
3695
  // and is a no-op on plain Monaco (VS Code web, GitHub) where setValue
3689
3696
  // already covers the state.
3690
- const preflightFirstLine = text.split('\n')[0].slice(0, 20).replace(/'/g, "\\'");
3697
+ const preflightFirstLine = escJsSingleQuote(text.split('\n')[0].slice(0, 20));
3691
3698
  const monacoPreflight = await runJS(
3692
3699
  `(function(){
3693
3700
  var m = (typeof monaco !== 'undefined') ? monaco : window.monaco;
@@ -5174,7 +5181,7 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
5174
5181
  xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5175
5182
  };
5176
5183
  const mime = mimeMap[ext] || "application/octet-stream";
5177
- const safeName = fileName.replace(/'/g, "\\'");
5184
+ const safeName = escJsSingleQuote(fileName);
5178
5185
 
5179
5186
  // forceNative: skip synthetic injection entirely. Attempting it first would leave a stray
5180
5187
  // media item behind on sites that register the pickup in their UI without ingesting it
@@ -5406,7 +5413,7 @@ export async function pasteImageFromFile({ filePath, locate = null }) {
5406
5413
  // Read image as base64
5407
5414
  const fileData = await readFile(filePath);
5408
5415
  const base64 = fileData.toString("base64");
5409
- const fileName = filePath.split("/").pop().replace(/'/g, "\\'");
5416
+ const fileName = escJsSingleQuote(filePath.split("/").pop());
5410
5417
 
5411
5418
  // Use runJSLarge — images are often >260KB as base64
5412
5419
  const result = await runJSLarge(
@@ -5658,7 +5665,7 @@ export function getNextSnapshotGen() { return _snapshotGen++; }
5658
5665
  export async function takeSnapshot({ selector, _gen } = {}) {
5659
5666
  // Use provided gen (from tool path) or allocate a new one (direct call)
5660
5667
  const gen = _gen != null ? _gen : _snapshotGen++;
5661
- const root = selector ? `document.querySelector('${selector.replace(/'/g, "\\'")}')` : "document.body";
5668
+ const root = selector ? `document.querySelector('${escJsSingleQuote(selector)}')` : "document.body";
5662
5669
 
5663
5670
  const result = await runJS(
5664
5671
  `(function(){
@@ -5890,7 +5897,7 @@ export async function runScript({ steps, onStep, actions: overrides = {} }) {
5890
5897
  // ========== ACCESSIBILITY SNAPSHOT ==========
5891
5898
 
5892
5899
  export async function getAccessibilityTree({ selector, maxDepth = 5 }) {
5893
- const sel = selector ? `'${selector.replace(/'/g, "\\'")}'` : "null";
5900
+ const sel = selector ? `'${escJsSingleQuote(selector)}'` : "null";
5894
5901
  return runJS(
5895
5902
  `(function(){
5896
5903
  function buildTree(el, depth) {
@@ -6235,7 +6242,7 @@ export async function clearNetworkCapture() {
6235
6242
  }
6236
6243
 
6237
6244
  export async function getNetworkDetails({ limit = 50, filter } = {}) {
6238
- const filterStr = filter ? `.filter(function(r){return r.url.includes('${filter.replace(/'/g, "\\'")}')})` : "";
6245
+ const filterStr = filter ? `.filter(function(r){return r.url.includes('${escJsSingleQuote(filter)}')})` : "";
6239
6246
  return runJS(
6240
6247
  `JSON.stringify((window.__mcp_network||[])${filterStr}.slice(-${Number(limit)}))`
6241
6248
  );
@@ -6343,7 +6350,7 @@ export async function throttleNetwork({ profile, latency, downloadKbps, uploadKb
6343
6350
  // ========== CONSOLE FILTER ==========
6344
6351
 
6345
6352
  export async function getConsoleByLevel({ level }) {
6346
- const safeLevel = level.replace(/'/g, "\\'");
6353
+ const safeLevel = escJsSingleQuote(level);
6347
6354
  return runJS(
6348
6355
  `JSON.stringify((window.__mcp_console||[]).filter(function(m){return m.level==='${safeLevel}'}))`
6349
6356
  );
@@ -6352,7 +6359,7 @@ export async function getConsoleByLevel({ level }) {
6352
6359
  // ========== DATA EXTRACTION ==========
6353
6360
 
6354
6361
  export async function extractTables({ selector, limit = 10 }) {
6355
- const sel = selector ? `'${selector.replace(/'/g, "\\'")}'` : "'table'";
6362
+ const sel = selector ? `'${escJsSingleQuote(selector)}'` : "'table'";
6356
6363
  return runJS(
6357
6364
  `(function(){
6358
6365
  var tables = [...document.querySelectorAll(${sel})].slice(0, ${Number(limit)});
@@ -6417,7 +6424,7 @@ export async function extractImages({ limit = 50 }) {
6417
6424
 
6418
6425
  export async function extractLinks({ limit = 100, filter }) {
6419
6426
  const filterStr = filter
6420
- ? `.filter(function(a){return a.href.includes('${filter.replace(/'/g, "\\'")}')||a.textContent.includes('${filter.replace(/'/g, "\\'")}')})`
6427
+ ? `.filter(function(a){return a.href.includes('${escJsSingleQuote(filter)}')||a.textContent.includes('${escJsSingleQuote(filter)}')})`
6421
6428
  : "";
6422
6429
  return runJS(
6423
6430
  `JSON.stringify([...document.querySelectorAll('a[href]')]${filterStr}.slice(0,${Number(limit)}).map(function(a){
@@ -6469,8 +6476,8 @@ export async function getComputedStyles({ selector, properties }) {
6469
6476
  // ========== INDEXEDDB ==========
6470
6477
 
6471
6478
  export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
6472
- const safeDb = dbName.replace(/'/g, "\\'");
6473
- const safeStore = storeName.replace(/'/g, "\\'");
6479
+ const safeDb = escJsSingleQuote(dbName);
6480
+ const safeStore = escJsSingleQuote(storeName);
6474
6481
  // `do JavaScript` can't await a Promise — route async work through the Node-side poller.
6475
6482
  return _evaluateAsync(
6476
6483
  `(async function(){
@@ -6610,6 +6617,17 @@ export function macosCompatNote(productVersion) {
6610
6617
  return { version: raw, major, risky, line };
6611
6618
  }
6612
6619
 
6620
+ // A Homebrew upgrade deletes the node binary a long-running server still executes, and System
6621
+ // Settings cannot grant anything to a file that is gone. Seen on 9.10.26: node 26.9.0 was replaced
6622
+ // by 26.11.0 under a LaunchAgent daemon started two days earlier, and doctor went from 6/6 to 3/6
6623
+ // (Apple Events -1743, both helper permissions false). Only a restart onto the current node helps.
6624
+ export function staleNodeNote(execPath = process.execPath, exists = existsSync) {
6625
+ if (exists(execPath)) return null;
6626
+ return `⚠ This server still runs node from ${execPath}, which no longer exists: node was upgraded or removed while the server kept running. ` +
6627
+ "macOS permission checks can fail for a binary that is gone, and System Settings cannot grant anything to it. " +
6628
+ "Restart the server on the current node, then grant the permissions to the new binary if macOS asks.";
6629
+ }
6630
+
6613
6631
  export async function doctor() {
6614
6632
  const checks = [];
6615
6633
  const add = (ok, label, detail, fix) => checks.push({ ok, label, detail, fix: ok ? null : fix });
@@ -6657,7 +6675,7 @@ export async function doctor() {
6657
6675
  // Homebrew node upgrade changes that path, so the grant has to follow the new binary.
6658
6676
  add(!!pf && pf.screenRecording === true, "Screen Recording (screenshots)",
6659
6677
  pf ? (pf.screenRecording ? "permitted" : "NOT permitted — screenshots taken through AppleScript and safari_save_pdf fail (extension screenshots don't need it)") : "unknown (helper not responding)",
6660
- `System Settings > Privacy & Security > Screen & System Audio Recording → enable ${process.ppid === 1 ? `node at ${process.execPath} (this server runs under launchd)` : "the terminal/IDE that launched this server"}.`);
6678
+ `System Settings > Privacy & Security > Screen & System Audio Recording → enable ${process.ppid !== 1 ? "the terminal/IDE that launched this server" : staleNodeNote() ? "the node this server runs on after a restart (see the warning above)" : `node at ${process.execPath} (this server runs under launchd)`}.`);
6661
6679
 
6662
6680
  // 6. Helper codesign identity — a stale/ad-hoc id breaks the Accessibility grant on reinstall
6663
6681
  let idOk = false, idDetail = "";
@@ -6685,6 +6703,8 @@ export async function doctor() {
6685
6703
  const passed = checks.filter((c) => c.ok).length;
6686
6704
  const lines = [`Safari MCP doctor — ${passed}/${checks.length} checks passed`, ""];
6687
6705
  if (osLine) lines.push(osLine, "");
6706
+ const nodeNote = staleNodeNote();
6707
+ if (nodeNote) lines.push(nodeNote, "");
6688
6708
  // Not a pass/fail check — a state the user has to be able to see here rather than dig out
6689
6709
  // of the host's env, so "why did it touch my tab" has an answer in the same report (#92).
6690
6710
  lines.push(