safari-mcp 2.15.7 → 2.15.9
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/README.md +32 -0
- package/extension/background.js +17 -1
- package/index.js +11 -12
- package/package.json +1 -1
- package/safari.js +108 -87
package/README.md
CHANGED
|
@@ -304,6 +304,38 @@ The recommended pattern for AI agents using Safari MCP:
|
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|
|
307
|
+
## Running several agents at once
|
|
308
|
+
|
|
309
|
+
Multiple agents or subagents driving one Safari at the same time will fight over the active tab — unless you run them against a **shared HTTP daemon** instead of one process per client:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
SAFARI_MCP_HTTP=1 SAFARI_MCP_HTTP_PORT=9225 npx safari-mcp
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Then point every client at it:
|
|
316
|
+
|
|
317
|
+
```json
|
|
318
|
+
{ "mcpServers": { "safari-mcp": { "type": "http", "url": "http://127.0.0.1:9225/mcp" } } }
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
One daemon, many sessions — and each session gets its **own tab state**. The server keys `activeTabIndex`, the ownership flag and a unique tab marker off the MCP session id, so session A physically cannot read or steer session B's tab.
|
|
322
|
+
|
|
323
|
+
Two properties make this safe rather than merely tidy:
|
|
324
|
+
|
|
325
|
+
- **Tab identity is a marker, not an index.** Each session stamps a unique id into the page it opens, so ownership survives navigation *and* survives the user reordering or closing other tabs. An index alone would silently drift onto the wrong tab.
|
|
326
|
+
- **It fails closed.** If a session's marked tab can't be re-found, every tool refuses instead of falling back to whatever tab is in front — because that tab is usually yours:
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
Tab tracking lost — refusing to fall back to "current tab of window"
|
|
330
|
+
(would target the user's active tab). Call safari_new_tab to reopen.
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
This also drops process count sharply: ~17 node processes for 17 concurrent sessions becomes 1.
|
|
334
|
+
|
|
335
|
+
`SAFARI_PROFILE` stays optional — leave it unset and sessions bind to your ordinary Safari windows, cookies and logins intact. Details in [docs/http-transport-design.md](docs/http-transport-design.md).
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
307
339
|
## Tools (97)
|
|
308
340
|
|
|
309
341
|
<details>
|
package/extension/background.js
CHANGED
|
@@ -226,7 +226,14 @@ async function handleCommand(type, payload) {
|
|
|
226
226
|
if (anyOwned) {
|
|
227
227
|
throw new Error(`⚠️ Tab safety: refusing "${type}" on tab ${tabId} (${targetTab.url || 'unknown'}) — not opened by this MCP session. Use safari_new_tab first.`);
|
|
228
228
|
}
|
|
229
|
-
//
|
|
229
|
+
// No tabs owned yet → allow, for sessions that never call new_tab. That leniency is
|
|
230
|
+
// survivable for a write (worst case: it edits the page the user is already on) but not
|
|
231
|
+
// for a close, which throws their tab away. It also fires for a session re-initialised
|
|
232
|
+
// after a transport drop, which reports owning nothing while its tab is still open —
|
|
233
|
+
// the state in which close_tab destroyed a user's tab (#68).
|
|
234
|
+
if (_destructiveTabCommands.has(type)) {
|
|
235
|
+
throw new Error(`⚠️ Tab safety: refusing "${type}" on tab ${tabId} (${targetTab.url || 'unknown'}) — this session owns no tabs, so it has none to close. Use safari_new_tab first.`);
|
|
236
|
+
}
|
|
230
237
|
}
|
|
231
238
|
|
|
232
239
|
switch (type) {
|
|
@@ -1117,6 +1124,11 @@ async function handleCommand(type, payload) {
|
|
|
1117
1124
|
// could resolve the wrong tab or leave a stale last-tab verdict).
|
|
1118
1125
|
const target = _winTabs[payload.index - 1];
|
|
1119
1126
|
if (target) {
|
|
1127
|
+
// The ownership guard above checked `tabId` — this branch closes a tab chosen by
|
|
1128
|
+
// index instead, so it must be checked on its own or the guard is decorative.
|
|
1129
|
+
if (!_isTabOwnedBySession(sessionId, target.id)) {
|
|
1130
|
+
throw new Error(`⚠️ Tab safety: refusing to close tab ${payload.index} (${target.url || 'unknown'}) — not opened by this MCP session.`);
|
|
1131
|
+
}
|
|
1120
1132
|
_removeOwnedTab(sessionId, target.id);
|
|
1121
1133
|
if (_isLastTab) {
|
|
1122
1134
|
await browser.tabs.update(target.id, { url: "about:blank" });
|
|
@@ -1828,6 +1840,10 @@ function _isTabOwnedBySession(sessionId, tabId) {
|
|
|
1828
1840
|
return set ? set.has(tabId) : false;
|
|
1829
1841
|
}
|
|
1830
1842
|
|
|
1843
|
+
// Commands that destroy a tab rather than change a page. They get no "no tabs owned yet"
|
|
1844
|
+
// leniency: a session that owns nothing has nothing to close (#68).
|
|
1845
|
+
const _destructiveTabCommands = new Set(["close_tab"]);
|
|
1846
|
+
|
|
1831
1847
|
// Read-only commands that don't modify the page — allowed on any tab
|
|
1832
1848
|
const _readOnlyCommands = new Set([
|
|
1833
1849
|
"list_tabs", "read_page", "get_source", "snapshot", "accessibility_snapshot",
|
package/index.js
CHANGED
|
@@ -81,10 +81,9 @@ async function _cleanupTabs() {
|
|
|
81
81
|
const tabs = await safari.listTabs();
|
|
82
82
|
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
83
83
|
const match = parsed.find(t => t.url === url);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
84
|
+
// Name the tab explicitly: it was just resolved out of our own opened-tab table, and
|
|
85
|
+
// closeTab refuses anything it cannot prove it owns (#68).
|
|
86
|
+
if (match) await safari.closeTab(match.index);
|
|
88
87
|
} catch {}
|
|
89
88
|
}
|
|
90
89
|
_openedTabs.clear();
|
|
@@ -153,10 +152,7 @@ async function _closeOldestMCPTab() {
|
|
|
153
152
|
const tabs = await safari.listTabs();
|
|
154
153
|
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
155
154
|
const match = parsed.find(t => t.url === info.url);
|
|
156
|
-
if (match)
|
|
157
|
-
safari.setActiveTabIndex(match.index);
|
|
158
|
-
await safari.closeTab();
|
|
159
|
-
}
|
|
155
|
+
if (match) await safari.closeTab(match.index);
|
|
160
156
|
}
|
|
161
157
|
} catch {}
|
|
162
158
|
_untrackTab(oldestIdx);
|
|
@@ -657,8 +653,12 @@ const _nullMeansFailure = new Set([
|
|
|
657
653
|
|
|
658
654
|
// Operations that don't need tab ownership (read-only or tab management)
|
|
659
655
|
const _noOwnershipCheck = new Set([
|
|
660
|
-
// Tab management
|
|
661
|
-
"
|
|
656
|
+
// Tab management. NOTE: "close_tab" is deliberately NOT here. It sat in this set as
|
|
657
|
+
// "tab management" next to new_tab/list_tabs/switch_tab, but it is the only member that
|
|
658
|
+
// destroys a user tab: new_tab creates one, list_tabs reads, and switch_tab carries its
|
|
659
|
+
// own ownership check at the tool. close_tab had none — exempt from the shared guard and
|
|
660
|
+
// unguarded underneath, which is how it closed a user's tab in #68.
|
|
661
|
+
"new_tab", "list_tabs", "switch_tab",
|
|
662
662
|
// Extension self-management (doesn't touch tabs)
|
|
663
663
|
"reload_extension",
|
|
664
664
|
// Read-only — don't modify the page
|
|
@@ -1378,8 +1378,7 @@ server.tool(
|
|
|
1378
1378
|
if (oldestIdx !== null) {
|
|
1379
1379
|
console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached — closing oldest tab #${oldestIdx}`);
|
|
1380
1380
|
try {
|
|
1381
|
-
safari.
|
|
1382
|
-
await safari.closeTab();
|
|
1381
|
+
await safari.closeTab(oldestIdx);
|
|
1383
1382
|
} catch {}
|
|
1384
1383
|
_untrackTab(oldestIdx);
|
|
1385
1384
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.9",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 96 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -508,7 +508,7 @@ export async function restoreFocusIfStolen(savedBundleId) {
|
|
|
508
508
|
// callers fail toward "user is idle" (preserving the pre-existing hide behavior).
|
|
509
509
|
async function _userIdleSeconds() {
|
|
510
510
|
try {
|
|
511
|
-
const { stdout } = await execFileAsync("ioreg", ["-c", "IOHIDSystem", "-r", "-d", "1"], { timeout: 1500 });
|
|
511
|
+
const { stdout } = await execFileAsync("/usr/sbin/ioreg", ["-c", "IOHIDSystem", "-r", "-d", "1"], { timeout: 1500 });
|
|
512
512
|
const m = stdout.match(/"HIDIdleTime"\s*=\s*(\d+)/);
|
|
513
513
|
if (!m) return Infinity;
|
|
514
514
|
return parseInt(m[1], 10) / 1e9; // nanoseconds → seconds
|
|
@@ -3154,7 +3154,7 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
3154
3154
|
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
3155
3155
|
).catch(() => null) : null;
|
|
3156
3156
|
// Window IDs are OS-assigned integers — reject anything non-numeric before it reaches
|
|
3157
|
-
// `do shell script "screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
|
|
3157
|
+
// `do shell script "/usr/sbin/screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
|
|
3158
3158
|
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
3159
3159
|
|
|
3160
3160
|
// On macOS Tahoe, screencapture -l may briefly steal focus.
|
|
@@ -3178,9 +3178,10 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
3178
3178
|
);
|
|
3179
3179
|
try {
|
|
3180
3180
|
await new Promise((r) => setTimeout(r, 500));
|
|
3181
|
-
//
|
|
3182
|
-
|
|
3183
|
-
|
|
3181
|
+
// Route capture through the TCC-granted helper (NSAppleScript → do shell script):
|
|
3182
|
+
// under the launchd daemon, node has no Screen Recording grant — the helper does.
|
|
3183
|
+
await osascriptFast(
|
|
3184
|
+
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
3184
3185
|
{ timeout: 15000 }
|
|
3185
3186
|
);
|
|
3186
3187
|
} finally {
|
|
@@ -3192,13 +3193,13 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
3192
3193
|
} else {
|
|
3193
3194
|
// Try direct execFile first (works if VS Code has Screen Recording permission)
|
|
3194
3195
|
try {
|
|
3195
|
-
await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
3196
|
+
await execFileAsync("/usr/sbin/screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
3196
3197
|
const testData = await readFile(tmpFile);
|
|
3197
3198
|
if (testData.length < 100) throw new Error("empty");
|
|
3198
3199
|
} catch (_) {
|
|
3199
|
-
// Fallback:
|
|
3200
|
-
await
|
|
3201
|
-
`do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
3200
|
+
// Fallback: do shell script via the TCC-granted helper (node lacks Screen Recording under launchd)
|
|
3201
|
+
await osascriptFast(
|
|
3202
|
+
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
3202
3203
|
{ timeout: 15000 }
|
|
3203
3204
|
);
|
|
3204
3205
|
}
|
|
@@ -3340,8 +3341,17 @@ export async function screenshotElement({ selector }) {
|
|
|
3340
3341
|
);
|
|
3341
3342
|
if (!bounds) throw new Error(typeof result === 'string' && result.startsWith('Element') ? result : 'Element not found for screenshot');
|
|
3342
3343
|
|
|
3343
|
-
// Full window screenshot then crop with sips
|
|
3344
|
-
|
|
3344
|
+
// Full window screenshot then crop with sips. Direct execFile works when this process
|
|
3345
|
+
// has Screen Recording (stdio mode); under the launchd daemon it doesn't — fall back
|
|
3346
|
+
// to the TCC-granted helper.
|
|
3347
|
+
try {
|
|
3348
|
+
await execFileAsync("/usr/sbin/screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
3349
|
+
} catch {
|
|
3350
|
+
await osascriptFast(
|
|
3351
|
+
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
3352
|
+
{ timeout: 15000 }
|
|
3353
|
+
);
|
|
3354
|
+
}
|
|
3345
3355
|
const { x, y, w, h, dpr = 1 } = JSON.parse(bounds);
|
|
3346
3356
|
// Use sips to crop (macOS built-in). Use the DYNAMIC toolbar height — Sequoia+ chrome is
|
|
3347
3357
|
// ~90px, not 74, so a hardcoded 74 left element screenshots vertically offset. Fall back
|
|
@@ -3484,8 +3494,37 @@ export async function newTab(url = "") {
|
|
|
3484
3494
|
return info;
|
|
3485
3495
|
}
|
|
3486
3496
|
|
|
3487
|
-
|
|
3497
|
+
// A tab index this session can prove it owns, or null. Destructive paths only: they may
|
|
3498
|
+
// never guess, so "can't prove it" has to read as null rather than as the front document.
|
|
3499
|
+
// `resolveActiveTab()` re-finds the tab through its identity marker (surviving the index
|
|
3500
|
+
// shifts of #54) and fails closed to null when the marker is on no tab at all.
|
|
3501
|
+
async function _provenOwnTabIndex() {
|
|
3502
|
+
return (await resolveActiveTab()) || null;
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
// `explicitIndex` — the caller naming the tab, the same opt-out convention the identity
|
|
3506
|
+
// guard uses. Internal cleanup resolves its own indices out of the opened-tab table and
|
|
3507
|
+
// passes them here; everything else must prove ownership.
|
|
3508
|
+
export async function closeTab(explicitIndex) {
|
|
3488
3509
|
await refreshTargetWindow();
|
|
3510
|
+
|
|
3511
|
+
// ── Guard: close nothing this session cannot prove it owns. There is deliberately no
|
|
3512
|
+
// front-document fallback here, unlike the read paths: `close current tab of window` is
|
|
3513
|
+
// the tab the USER is looking at whenever our index is unknown — and "unknown" is exactly
|
|
3514
|
+
// what a session re-initialised after a transport drop reports, mid-task, while a tab of
|
|
3515
|
+
// its own is still open. That fail-open destroyed a user's tab (#68, the destructive
|
|
3516
|
+
// sibling of #64). An unmarked front document stays readable for a genuinely fresh
|
|
3517
|
+
// session, because a bad read costs information; a bad close costs their work.
|
|
3518
|
+
const idx = explicitIndex || (await _provenOwnTabIndex());
|
|
3519
|
+
if (!idx) {
|
|
3520
|
+
throw new Error(
|
|
3521
|
+
`Tab tracking lost — refusing to close a tab this session cannot prove it opened ` +
|
|
3522
|
+
`(closing "current tab of window" would close the user's active tab). ` +
|
|
3523
|
+
`Call safari_new_tab to open a tab this session owns, or safari_list_tabs and ` +
|
|
3524
|
+
`safari_switch_tab to re-anchor to a known one.`
|
|
3525
|
+
);
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3489
3528
|
// ── Guard: never close a window's LAST tab. Closing it shuts the window —
|
|
3490
3529
|
// which quits Safari if it's the only window, AND (for profile-targeted
|
|
3491
3530
|
// instances) makes the target window vanish so every later op throws
|
|
@@ -3500,10 +3539,11 @@ export async function closeTab() {
|
|
|
3500
3539
|
10
|
|
3501
3540
|
);
|
|
3502
3541
|
if (Number.isFinite(_winTabs) && _winTabs <= 1) {
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3542
|
+
// Blanking is destructive too — it throws away whatever page is loaded — so it
|
|
3543
|
+
// targets the proven index, never the front document.
|
|
3544
|
+
await osascript(
|
|
3545
|
+
`tell application "Safari" to set URL of tab ${idx} of ${getTargetWindowRef()} to "about:blank"`
|
|
3546
|
+
);
|
|
3507
3547
|
_st().activeTabIndex = null;
|
|
3508
3548
|
_st().activeTabURL = null;
|
|
3509
3549
|
_st().lastTabCount = null;
|
|
@@ -3512,16 +3552,15 @@ export async function closeTab() {
|
|
|
3512
3552
|
}
|
|
3513
3553
|
} catch { /* count check failed — fall through to normal close */ }
|
|
3514
3554
|
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3555
|
+
await osascript(
|
|
3556
|
+
`tell application "Safari" to close tab ${idx} of ${getTargetWindowRef()}`
|
|
3557
|
+
);
|
|
3558
|
+
if (!explicitIndex || idx === _st().activeTabIndex) {
|
|
3519
3559
|
_st().activeTabIndex = null;
|
|
3520
3560
|
_st().activeTabURL = null;
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
);
|
|
3561
|
+
// The tab is gone, so its marker is too — keeping it would let a later resolve
|
|
3562
|
+
// match a stale identity.
|
|
3563
|
+
_st().activeTabMarker = null;
|
|
3525
3564
|
}
|
|
3526
3565
|
_st().lastTabCount = null; // Invalidate — tab count changed
|
|
3527
3566
|
_st().lastResolveTime = 0; // Force re-resolve on next operation
|
|
@@ -4262,76 +4301,58 @@ export async function clearConsoleCapture() {
|
|
|
4262
4301
|
export async function savePDF({ path: pdfPath }) {
|
|
4263
4302
|
await refreshTargetWindow();
|
|
4264
4303
|
_validateFilePath(pdfPath); // allowlist (/Users//tmp//var-folders) + block sensitive paths — prevents arbitrary overwrite
|
|
4265
|
-
// NO focus stealing —
|
|
4304
|
+
// NO app focus stealing — tab selection, window resize and screencapture all stay
|
|
4305
|
+
// inside our window; the user's frontmost app is never activated.
|
|
4266
4306
|
|
|
4267
|
-
// Step 1: Get full page dimensions
|
|
4268
|
-
const dims = await runJS("JSON.stringify({h:document.documentElement.scrollHeight,w:document.documentElement.scrollWidth})");
|
|
4269
|
-
const { h, w } = JSON.parse(dims);
|
|
4270
|
-
|
|
4271
|
-
// Step 2: Save current bounds and resize to capture full page
|
|
4272
|
-
const origBounds = await osascript(
|
|
4273
|
-
`tell application "Safari" to return bounds of ${getTargetWindowRef()}`
|
|
4274
|
-
);
|
|
4275
|
-
const captureHeight = Math.min(Number(h) + 100, 16000);
|
|
4276
|
-
await osascript(
|
|
4277
|
-
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${captureHeight}}`
|
|
4278
|
-
);
|
|
4279
|
-
await new Promise(r => setTimeout(r, 500)); // Let page reflow
|
|
4280
|
-
|
|
4281
|
-
// Step 3: Take screenshot via screencapture -l (window-targeted, NO focus steal)
|
|
4282
|
-
const windowIdRaw = await osascript(
|
|
4283
|
-
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
4284
|
-
);
|
|
4285
|
-
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
4286
|
-
if (!windowId) throw new Error("Cannot get Safari window ID for PDF capture");
|
|
4287
4307
|
const tmpPng = join(tmpdir(), `safari-mcp-pdf-${Date.now()}.png`);
|
|
4288
|
-
|
|
4289
|
-
|
|
4308
|
+
// screencapture -l grabs the window's SELECTED tab — front ours for the duration
|
|
4309
|
+
// (selection restored by _withTargetTabFronted), otherwise the PDF shows whatever
|
|
4310
|
+
// tab the user last left selected in that window.
|
|
4311
|
+
await _withTargetTabFronted(async () => {
|
|
4312
|
+
// Step 1: Get full page dimensions
|
|
4313
|
+
const dims = await runJS("JSON.stringify({h:document.documentElement.scrollHeight,w:document.documentElement.scrollWidth})");
|
|
4314
|
+
const { h, w } = JSON.parse(dims);
|
|
4315
|
+
|
|
4316
|
+
// Step 2: Save current bounds and resize to capture full page
|
|
4317
|
+
const origBounds = await osascript(
|
|
4318
|
+
`tell application "Safari" to return bounds of ${getTargetWindowRef()}`
|
|
4319
|
+
);
|
|
4320
|
+
const captureHeight = Math.min(Number(h) + 100, 16000);
|
|
4290
4321
|
await osascript(
|
|
4291
|
-
`
|
|
4292
|
-
{ timeout: 15000 }
|
|
4322
|
+
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${captureHeight}}`
|
|
4293
4323
|
);
|
|
4294
|
-
|
|
4295
|
-
// Restore bounds on failure
|
|
4296
|
-
await osascript(`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`).catch(() => {});
|
|
4297
|
-
throw new Error(`PDF screenshot capture failed: ${err.message}`);
|
|
4298
|
-
}
|
|
4324
|
+
await new Promise(r => setTimeout(r, 500)); // Let page reflow
|
|
4299
4325
|
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4326
|
+
// Step 3: Take screenshot via screencapture -l (window-targeted, NO focus steal)
|
|
4327
|
+
const windowIdRaw = await osascript(
|
|
4328
|
+
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
4329
|
+
);
|
|
4330
|
+
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
4331
|
+
if (!windowId) throw new Error("Cannot get Safari window ID for PDF capture");
|
|
4332
|
+
try {
|
|
4333
|
+
// Route capture through the TCC-granted helper (NSAppleScript → do shell script):
|
|
4334
|
+
// under the launchd daemon, node has no Screen Recording grant — the helper does.
|
|
4335
|
+
await osascriptFast(
|
|
4336
|
+
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpPng}'"`,
|
|
4337
|
+
{ timeout: 15000 }
|
|
4338
|
+
);
|
|
4339
|
+
} catch (err) {
|
|
4340
|
+
// Restore bounds on failure
|
|
4341
|
+
await osascript(`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`).catch(() => {});
|
|
4342
|
+
throw new Error(`PDF screenshot capture failed: ${err.message}`);
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
// Step 4: Restore original bounds
|
|
4346
|
+
await osascript(
|
|
4347
|
+
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`
|
|
4348
|
+
).catch(() => {});
|
|
4349
|
+
});
|
|
4304
4350
|
|
|
4305
|
-
// Step 5: Convert screenshot to PDF
|
|
4306
|
-
//
|
|
4307
|
-
//
|
|
4351
|
+
// Step 5: Convert screenshot to PDF with sips (macOS built-in). Replaces the previous
|
|
4352
|
+
// python3+Quartz converter: current macOS ships pyobjc for neither the system nor the
|
|
4353
|
+
// homebrew python3, so any bare `python3` died with ModuleNotFoundError: Quartz.
|
|
4308
4354
|
try {
|
|
4309
|
-
await execFileAsync("
|
|
4310
|
-
import sys
|
|
4311
|
-
from Quartz import CGImageSourceCreateWithURL, CGImageSourceCreateImageAtIndex, CGImageGetWidth, CGImageGetHeight, CGPDFContextCreateWithURL, CGRectMake, CGPDFContextBeginPage, CGPDFContextEndPage, CGContextDrawImage
|
|
4312
|
-
from CoreFoundation import CFURLCreateFromFileSystemRepresentation
|
|
4313
|
-
|
|
4314
|
-
png_path = sys.argv[1].encode('utf-8')
|
|
4315
|
-
pdf_path = sys.argv[2].encode('utf-8')
|
|
4316
|
-
|
|
4317
|
-
src_url = CFURLCreateFromFileSystemRepresentation(None, png_path, len(png_path), False)
|
|
4318
|
-
img_src = CGImageSourceCreateWithURL(src_url, None)
|
|
4319
|
-
if not img_src:
|
|
4320
|
-
print("ERROR: failed to read screenshot", file=sys.stderr); sys.exit(1)
|
|
4321
|
-
img = CGImageSourceCreateImageAtIndex(img_src, 0, None)
|
|
4322
|
-
if not img:
|
|
4323
|
-
print("ERROR: failed to decode image", file=sys.stderr); sys.exit(1)
|
|
4324
|
-
w = CGImageGetWidth(img)
|
|
4325
|
-
h = CGImageGetHeight(img)
|
|
4326
|
-
|
|
4327
|
-
pdf_url = CFURLCreateFromFileSystemRepresentation(None, pdf_path, len(pdf_path), False)
|
|
4328
|
-
ctx = CGPDFContextCreateWithURL(pdf_url, CGRectMake(0, 0, w, h), None)
|
|
4329
|
-
CGPDFContextBeginPage(ctx, None)
|
|
4330
|
-
CGContextDrawImage(ctx, CGRectMake(0, 0, w, h), img)
|
|
4331
|
-
CGPDFContextEndPage(ctx)
|
|
4332
|
-
del ctx
|
|
4333
|
-
print(f"OK {w}x{h}")
|
|
4334
|
-
`.trim(), tmpPng, pdfPath], { timeout: 15000 });
|
|
4355
|
+
await execFileAsync("/usr/bin/sips", ["-s", "format", "pdf", tmpPng, "--out", pdfPath], { timeout: 15000 });
|
|
4335
4356
|
} catch (err) {
|
|
4336
4357
|
throw new Error(`PDF conversion failed: ${err.message}`);
|
|
4337
4358
|
} finally {
|