UNPKG

safari-mcp

Version:

Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.

134 lines (128 loc) • 9.29 kB
// Injected-JS source for the iOS/WebKit validation tools. These IIFE strings are run in // the page via `do JavaScript` (safari.js) and are also imported directly by the test // suite so the exact same code is exercised under jsdom — no drift between what ships and // what's tested. Each is a SYNCHRONOUS IIFE returning JSON.stringify(...) (AppleScript // `do JavaScript` can't await), with no `//` comments inside (runJS flattens to one line). export const VIEWPORT_SCRIPT = `(function(){ var meta = document.querySelector('meta[name="viewport"]'); var content = meta ? (meta.getAttribute('content') || '') : null; var attrs = {}; if (content) { content.split(',').forEach(function(part){ var kv = part.split('='); var key = (kv[0]||'').trim(); var val = (kv[1]||'').trim(); if (key) attrs[key] = val; }); } var issues = []; if (!content) { issues.push({severity:'error', message:'No viewport meta tag found. iOS Safari renders at 980px width and scales down.'}); } else { if (!attrs.width) issues.push({severity:'error', message:'Missing width=device-width. Safari falls back to a 980px layout width.'}); else if (attrs.width !== 'device-width') issues.push({severity:'warning', message:'width=' + attrs.width + ' — prefer device-width for responsive layouts.'}); if (!attrs['initial-scale']) issues.push({severity:'warning', message:'Missing initial-scale=1. Some iOS Safari versions may not zoom correctly without it.'}); if (attrs['user-scalable'] === 'no' || attrs['maximum-scale'] === '1') issues.push({severity:'error', message:'Zoom disabled (user-scalable=no / maximum-scale=1) — WCAG 1.4.4 violation, and Safari 10+ ignores it anyway.'}); if (attrs['viewport-fit'] !== 'cover') issues.push({severity:'warning', message:'Missing viewport-fit=cover. Required for safe-area-inset env() values on notched devices.'}); if (attrs['minimum-scale'] && parseFloat(attrs['minimum-scale']) < 1) issues.push({severity:'info', message:'minimum-scale=' + attrs['minimum-scale'] + ' allows zoom-out on iOS, which can cause layout issues.'}); } var errors = issues.filter(function(i){return i.severity==='error';}).length; var warnings = issues.filter(function(i){return i.severity==='warning';}).length; return JSON.stringify({viewport: content, attrs: attrs, ok: issues.length===0, errors: errors, warnings: warnings, issues: issues}); })()`; // Safari 27 stopped listing constructable (adopted) style sheets in document.styleSheets, per // the CSSOM spec (Safari 27.0 release notes, 174583340). Stylesheet scans append them // explicitly, skipping any an older engine still lists, so they see the same rules on both. export const ALL_SHEETS_FN = `function allSheets(){var out=[].slice.call(document.styleSheets);var ad=document.adoptedStyleSheets||[];for(var a=0;a<ad.length;a++){if(out.indexOf(ad[a])===-1)out.push(ad[a]);}return out;}`; export const SAFE_AREA_SCRIPT = `(function(){ ${ALL_SHEETS_FN} if (!document.body) return JSON.stringify({error:'No document.body yet'}); var probe = document.createElement('div'); probe.style.cssText = 'position:fixed;top:env(safe-area-inset-top,0px);right:env(safe-area-inset-right,0px);bottom:env(safe-area-inset-bottom,0px);left:env(safe-area-inset-left,0px);pointer-events:none;visibility:hidden;'; document.body.appendChild(probe); var cs = getComputedStyle(probe); var insets = {top: cs.top, right: cs.right, bottom: cs.bottom, left: cs.left}; probe.remove(); var meta = document.querySelector('meta[name="viewport"]'); var viewportFitCover = meta ? ((meta.getAttribute('content')||'').indexOf('viewport-fit=cover') !== -1) : false; var usedInCSS = false; try { var sheets = allSheets(); for (var i=0; i<sheets.length; i++) { try { var rules = sheets[i].cssRules; var text = ''; for (var j=0; j<rules.length; j++) text += rules[j].cssText + ' '; if (text.indexOf('safe-area-inset') !== -1) { usedInCSS = true; break; } } catch(e) {} } } catch(e) {} return JSON.stringify({insets: insets, viewportFitCover: viewportFitCover, usedInCSS: usedInCSS}); })()`; export const PWA_SCRIPT = `(function(){ function getMeta(name){ var el = document.querySelector('meta[name="'+name+'"]'); return el ? el.getAttribute('content') : null; } function getLink(rel){ var el = document.querySelector('link[rel="'+rel+'"]'); return el ? el.getAttribute('href') : null; } var touchIcons = [].slice.call(document.querySelectorAll('link[rel="apple-touch-icon"]')).map(function(el){ return {sizes: el.getAttribute('sizes')||'unspecified', href: el.getAttribute('href')||''}; }); var splash = document.querySelectorAll('link[rel="apple-touch-startup-image"]').length; var capable = getMeta('apple-mobile-web-app-capable'); var themeColor = getMeta('theme-color'); var statusBar = getMeta('apple-mobile-web-app-status-bar-style'); var manifest = getLink('manifest'); var has180 = touchIcons.some(function(i){return i.sizes==='180x180';}); var checks = []; checks.push({pass: capable==='yes' || manifest!==null, label:'standalone launch (apple-mobile-web-app-capable or manifest)', detail: capable==='yes' ? 'apple-mobile-web-app-capable=yes' : (manifest!==null ? 'manifest linked — its display member sets standalone' : 'neither set — iOS/iPadOS 26+ still open any Home Screen site as a web app by default; iOS 18 and older open it in Safari')}); checks.push({pass: touchIcons.length>0, label:'apple-touch-icon', detail: touchIcons.length>0 ? (touchIcons.length+' icon(s): '+touchIcons.map(function(i){return i.sizes;}).join(', ')+(has180?'':' (missing 180x180 for iPhone)')) : 'none — iOS uses a screenshot as the icon'}); checks.push({pass: themeColor!==null, label:'theme-color', detail: (themeColor!==null ? ('set to '+themeColor) : 'not set') + ' — Chrome and Android color their UI with it; Safari 26+ ignores it and tints its bars from the page background'}); checks.push({pass: statusBar!==null, label:'apple-mobile-web-app-status-bar-style', detail: statusBar!==null ? ('set to '+statusBar) : 'not set — defaults to black-on-white'}); checks.push({pass: manifest!==null, label:'web app manifest', detail: manifest!==null ? ('found: '+manifest) : 'no manifest link — the Home Screen web app gets no name, id, scope or display mode of its own (Safari has no install prompt to trigger)'}); checks.push({pass: splash>0, label:'apple-touch-startup-image', detail: splash>0 ? (splash+' splash screen(s)') : 'none — white screen while the app loads'}); var passed = checks.filter(function(c){return c.pass;}).length; return JSON.stringify({passed: passed, total: checks.length, checks: checks}); })()`; export const WEBKIT_COMPAT_SCRIPT = `(function(){ ${ALL_SHEETS_FN} var seen = {}; function collectFromStyle(style){ for (var k=0; k<style.length; k++){ var prop = style[k]; if (prop.indexOf('--')===0) continue; if (seen.hasOwnProperty(prop)) continue; seen[prop] = (style.getPropertyValue(prop)||'').trim(); } } function processRules(rules){ for (var r=0; r<rules.length; r++){ var rule = rules[r]; if (rule.constructor && rule.constructor.name === 'CSSFontFaceRule') continue; if (rule.style) collectFromStyle(rule.style); if (rule.cssRules){ try { processRules(rule.cssRules); } catch(e){} } } } var sheets = allSheets(); for (var i=0; i<sheets.length; i++){ try { processRules(sheets[i].cssRules); } catch(e){} } var inline = document.querySelectorAll('[style]'); for (var n=0; n<inline.length; n++) collectFromStyle(inline[n].style); var unsupported = []; var needsPrefix = []; var total = 0; for (var prop in seen){ if (!seen.hasOwnProperty(prop)) continue; total++; var value = seen[prop]; var supported = false; try { supported = CSS.supports(prop, value); } catch(e){} if (!supported){ try { supported = CSS.supports(prop, 'initial'); } catch(e){} } if (supported) continue; var wk = '-webkit-' + prop; var wkOk = false; try { wkOk = CSS.supports(wk, value); } catch(e){} if (!wkOk){ try { wkOk = CSS.supports(wk, 'initial'); } catch(e){} } if (wkOk) needsPrefix.push({property: prop, value: value.slice(0,60)}); else unsupported.push({property: prop, value: value.slice(0,60)}); } var quirks = []; if (seen.hasOwnProperty('position') && (seen['position']||'').indexOf('sticky') !== -1) { quirks.push('position:sticky silently fails inside an overflow:hidden/auto ancestor in Safari — use overflow:clip on the ancestor.'); } return JSON.stringify({totalProperties: total, ok: (unsupported.length+needsPrefix.length+quirks.length)===0, unsupported: unsupported, needsPrefix: needsPrefix, quirks: quirks}); })()`;