safari-mcp 2.22.1 → 2.22.2

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 +64 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.22.1",
3
+ "version": "2.22.2",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents \u2014 native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -1060,6 +1060,26 @@ function _helperPreflight(timeout = 3000) {
1060
1060
  }));
1061
1061
  }
1062
1062
 
1063
+ // ========== SCREEN LOCK GATE ==========
1064
+ // A locked screen (the lid shut on an awake Mac, or the lock screen up) routes every OS-level
1065
+ // event to loginwindow: CGEvent clicks and keys land on the lock screen — keystrokes even in its
1066
+ // password field — while the native tools used to report "clicked"/"typed". In-page tools keep
1067
+ // working while locked, so every native path fails fast here and points there instead.
1068
+ export const SCREEN_LOCKED_MSG =
1069
+ "SCREEN_LOCKED: the Mac's screen is locked, so OS-level (native) input would land on the lock screen " +
1070
+ "instead of Safari. In-page tools still work while locked: use safari_click / safari_fill / " +
1071
+ "safari_type_text / safari_press_key, and safari_upload_file without forceNative.";
1072
+
1073
+ export async function isScreenLocked() {
1074
+ return execFileAsync("/bin/sh", ["-c",
1075
+ "ioreg -n Root -d1 -r -a 2>/dev/null | grep -c CGSSessionScreenIsLocked || true"])
1076
+ .then((r) => String(r.stdout).trim() !== "0").catch(() => false);
1077
+ }
1078
+
1079
+ export async function assertScreenUnlocked(probe = isScreenLocked) {
1080
+ if (await probe()) throw new Error(SCREEN_LOCKED_MSG);
1081
+ }
1082
+
1063
1083
  // ========== NATIVE CLICK VIA CGEVENT ==========
1064
1084
  // Sends a CGEvent click command to the Swift helper daemon.
1065
1085
  // This produces isTrusted: true events — bypasses WAF protection (G2, etc.)
@@ -1071,7 +1091,8 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
1071
1091
  return _withTargetTabFronted(() => _helperNativeClickRaw(x, y, doubleClick, windowId, timeout));
1072
1092
  }
1073
1093
 
1074
- function _helperNativeClickRaw(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
1094
+ async function _helperNativeClickRaw(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
1095
+ await assertScreenUnlocked();
1075
1096
  return _withHelperLock(() => new Promise((resolve, reject) => {
1076
1097
  if (!_helperProc) startHelper();
1077
1098
  if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
@@ -1118,7 +1139,8 @@ function _helperNativeClickRaw(x, y, doubleClick = false, windowId = 0, timeout
1118
1139
 
1119
1140
  // Sends a CGEvent hover command to the Swift helper daemon.
1120
1141
  // Moves the cursor to (x, y), dwells to let tooltips render, optionally restores cursor.
1121
- function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
1142
+ async function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
1143
+ await assertScreenUnlocked();
1122
1144
  return _withHelperLock(() => new Promise((resolve, reject) => {
1123
1145
  if (!_helperProc) startHelper();
1124
1146
  if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
@@ -1168,7 +1190,8 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
1168
1190
  return _withTargetTabFronted(() => _helperNativeKeyboardRaw(keyCode, flags, windowId, timeout));
1169
1191
  }
1170
1192
 
1171
- function _helperNativeKeyboardRaw(keyCode, flags = [], windowId = 0, timeout = 5000) {
1193
+ async function _helperNativeKeyboardRaw(keyCode, flags = [], windowId = 0, timeout = 5000) {
1194
+ await assertScreenUnlocked();
1172
1195
  return _withHelperLock(() => new Promise((resolve, reject) => {
1173
1196
  if (!_helperProc) startHelper();
1174
1197
  if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
@@ -3620,10 +3643,7 @@ async function _screenshotFronted({ fullPage }) {
3620
3643
  try {
3621
3644
  // A locked screen captures as solid black. Say so instead of returning a
3622
3645
  // black rectangle the caller has to guess at.
3623
- const locked = await execFileAsync("/bin/sh", ["-c",
3624
- "ioreg -n Root -d1 -r -a 2>/dev/null | grep -c CGSSessionScreenIsLocked || true"])
3625
- .then((r) => String(r.stdout).trim() !== "0").catch(() => false);
3626
- if (locked) {
3646
+ if (await isScreenLocked()) {
3627
3647
  throw new Error("SCREEN_LOCKED");
3628
3648
  }
3629
3649
  const boundsRaw = await osascript(
@@ -4568,12 +4588,21 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
4568
4588
  let ghostPickup = false;
4569
4589
  if (verifyPreview && !forceNative && /verified [1-9]\d* file/i.test(result)) {
4570
4590
  const before = Number((result.match(/\[previews=(-?\d+)\]/) || [])[1] ?? -1);
4571
- await new Promise((r) => setTimeout(r, 1400));
4572
- const after = Number(
4591
+ const countPreviews = async () => Number(
4573
4592
  await runJS(
4574
4593
  `(function(){try{var n=document.querySelectorAll('img[src^="blob:"], img[src^="data:image"], video[src^="blob:"]').length;var all=document.querySelectorAll('div,span,a,figure');for(var i=0;i<all.length;i++){var b=all[i].style&&all[i].style.backgroundImage||'';if(b.indexOf('blob:')>-1||b.indexOf('data:image')>-1)n++;}return n;}catch(_){return -1;}})()`
4575
4594
  ).catch(() => -1)
4576
4595
  );
4596
+ // Locked screen: every window is occluded, Safari throttles the page, and the site can take
4597
+ // 30–90 s to render the preview (GBP, 26.9.2026) — while the native-dialog escalation cannot
4598
+ // run at all. So poll for up to 90 s instead of judging after 1.4 s.
4599
+ const locked = await isScreenLocked();
4600
+ const deadline = Date.now() + (locked && before >= 0 ? 90000 : 0);
4601
+ let after;
4602
+ do {
4603
+ await new Promise((r) => setTimeout(r, locked ? 2000 : 1400));
4604
+ after = await countPreviews();
4605
+ } while (Date.now() < deadline && !(before >= 0 && after > before));
4577
4606
  if (before >= 0 && after >= 0 && after <= before) {
4578
4607
  ghostPickup = true;
4579
4608
  console.error(`[Safari MCP] upload_file: ghost pickup on ${sel} (previews ${before}→${after}) — escalating to native dialog`);
@@ -4585,7 +4614,7 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
4585
4614
  finalResult = await _nativeFileUpload(sel, resolvedPath, safeName);
4586
4615
  if (ghostPickup) finalResult = `${finalResult} [escalated: synthetic pickup produced no preview]`;
4587
4616
  } catch (nativeErr) {
4588
- finalResult = `${result} | Native file-dialog ${forceNative ? '(forced)' : '(ghost-pickup escalation)'} failed: ${nativeErr.message}`;
4617
+ finalResult = `${ghostPickup ? "NO PREVIEW — the page never showed the file. " : ""}${result} | Native file-dialog ${forceNative ? '(forced)' : '(ghost-pickup escalation)'} failed: ${nativeErr.message}`;
4589
4618
  }
4590
4619
  } else if (/verified 0 file|Upload attempted|el\.files is empty/i.test(result)) {
4591
4620
  try {
@@ -4607,6 +4636,8 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
4607
4636
  // drives the dialog via System Events (Cmd+Shift+G → path → Return → Return). `sel` is
4608
4637
  // already escJsSingleQuote-escaped by the caller.
4609
4638
  async function _nativeFileUpload(sel, absPath, safeName) {
4639
+ // Gate first: step 1 restyles the input into an invisible full-size overlay.
4640
+ await assertScreenUnlocked();
4610
4641
  // 1. Make the input clickable (hidden inputs have no hit-box) and read its viewport centre.
4611
4642
  const coordsJson = await runJS(
4612
4643
  `(function(){var el=document.querySelector('${sel}');if(!el){var all=document.querySelectorAll('*');for(var i=0;i<all.length;i++){var sr=all[i].shadowRoot;if(sr){el=sr.querySelector('${sel}');if(el)break;}}}if(!el)return JSON.stringify({error:'Element not found: ${sel}'});el.setAttribute('data-mcp-oldstyle',el.getAttribute('style')||'');el.style.cssText='position:fixed !important;top:42% !important;left:38% !important;width:320px !important;height:120px !important;opacity:0.02 !important;z-index:2147483647 !important;display:block !important;visibility:visible !important;pointer-events:auto !important';var r=el.getBoundingClientRect();return JSON.stringify({x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)});})()`
@@ -4615,6 +4646,27 @@ async function _nativeFileUpload(sel, absPath, safeName) {
4615
4646
  try { c = JSON.parse(coordsJson); } catch { throw new Error("coord parse failed: " + coordsJson); }
4616
4647
  if (c.error) throw new Error(c.error);
4617
4648
 
4649
+ let dlgStatus;
4650
+ let filesLen;
4651
+ try {
4652
+ dlgStatus = await _clickInputAndDriveDialog(c, absPath);
4653
+ } finally {
4654
+ // 4. Restore the input's original style — also when the click or the dialog threw, or the
4655
+ // page keeps an invisible full-size overlay that swallows clicks.
4656
+ filesLen = await runJS(
4657
+ `(function(){var el=document.querySelector('${sel}');if(!el)return 'gone';var old=el.getAttribute('data-mcp-oldstyle');if(old!==null){if(old){el.setAttribute('style',old);}else{el.removeAttribute('style');}el.removeAttribute('data-mcp-oldstyle');}return String(el.files?el.files.length:0);})()`
4658
+ ).catch(() => "?");
4659
+ }
4660
+ // A dialog that never opened selected nothing; reporting "Uploaded" here sent callers on as if it had.
4661
+ if (/sheet=false/.test(dlgStatus)) {
4662
+ throw new Error(`the OS file dialog never opened (${dlgStatus}, input.files=${filesLen}) — nothing was selected`);
4663
+ }
4664
+ return `Uploaded via native file dialog (isTrusted): ${safeName} — input.files=${filesLen}, ${dlgStatus}. Real OS selection; accepted by isTrusted-gated handlers (GitHub etc.). Verify with safari_snapshot.`;
4665
+ }
4666
+
4667
+ // Steps 2–3 of _nativeFileUpload: a native click on the restyled input opens the NSOpenPanel,
4668
+ // then System Events drives it. Returns the dialog status ("sheet=true|false" or "osaerr:…").
4669
+ async function _clickInputAndDriveDialog(c, absPath) {
4618
4670
  // 2. Native (CGEvent, isTrusted:true) click on the input → opens the OS file dialog.
4619
4671
  const geo = await _getSafariWindowGeometry();
4620
4672
  if (!geo.windowId) throw new Error("no Safari window id (cannot native-click without focus steal)");
@@ -4635,7 +4687,7 @@ async function _nativeFileUpload(sel, absPath, safeName) {
4635
4687
  // Activate + drive the dialog in ONE osascript so Safari stays frontmost throughout (a
4636
4688
  // separate activate call would be undone by osascript()'s focus-guard restore). Generous
4637
4689
  // timeout: the sheet-wait + Cmd+Shift+G + typing a long path can exceed the 10s default.
4638
- const dlgStatus = await osascript(
4690
+ const status = await osascript(
4639
4691
  `tell application "Safari" to activate
4640
4692
  delay 0.3
4641
4693
  tell application "System Events"
@@ -4659,13 +4711,8 @@ async function _nativeFileUpload(sel, absPath, safeName) {
4659
4711
  end tell`,
4660
4712
  { timeout: 30000 }
4661
4713
  ).catch((e) => "osaerr:" + e.message);
4662
-
4663
- // 4. Restore the input's original style, then report the resulting file count.
4664
4714
  await new Promise((r) => setTimeout(r, 900));
4665
- const filesLen = await runJS(
4666
- `(function(){var el=document.querySelector('${sel}');if(!el)return 'gone';var old=el.getAttribute('data-mcp-oldstyle');if(old!==null){if(old){el.setAttribute('style',old);}else{el.removeAttribute('style');}el.removeAttribute('data-mcp-oldstyle');}return String(el.files?el.files.length:0);})()`
4667
- ).catch(() => "?");
4668
- return `Uploaded via native file dialog (isTrusted): ${safeName} — input.files=${filesLen}, ${dlgStatus}. Real OS selection; accepted by isTrusted-gated handlers (GitHub etc.). Verify with safari_snapshot.`;
4715
+ return status;
4669
4716
  }
4670
4717
 
4671
4718
  // ========== PASTE IMAGE FROM FILE ==========