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 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>
@@ -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
- // If no tabs owned yet, allow operation (backward compatibility for sessions that don't use new_tab)
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
- if (match) {
85
- safari.setActiveTabIndex(match.index);
86
- await safari.closeTab();
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
- "new_tab", "list_tabs", "close_tab", "switch_tab",
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.setActiveTabIndex(oldestIdx);
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.7",
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
- // Use do shell script to inherit osascript's Screen Recording permission
3182
- await osascript(
3183
- `do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
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: use do shell script (osascript may have permission)
3200
- await osascript(
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
- await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
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
- export async function closeTab() {
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
- const _ref = _st().activeTabIndex
3504
- ? `tab ${_st().activeTabIndex} of ${getTargetWindowRef()}`
3505
- : `current tab of ${getTargetWindowRef()}`;
3506
- await osascript(`tell application "Safari" to set URL of ${_ref} to "about:blank"`);
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
- if (_st().activeTabIndex) {
3516
- await osascript(
3517
- `tell application "Safari" to close tab ${_st().activeTabIndex} of ${getTargetWindowRef()}`
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
- } else {
3522
- await osascript(
3523
- `tell application "Safari" to close current tab of ${getTargetWindowRef()}`
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 — uses screencapture + Python Quartz to generate PDF
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
- try {
4289
- // Use do shell script to inherit osascript's Screen Recording permission
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
- `do shell script "screencapture -l${windowId} -o -x '${tmpPng}'"`,
4292
- { timeout: 15000 }
4322
+ `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${captureHeight}}`
4293
4323
  );
4294
- } catch (err) {
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
- // Step 4: Restore original bounds
4301
- await osascript(
4302
- `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`
4303
- ).catch(() => {});
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 using Python3 + macOS Quartz (no external deps).
4306
- // Paths are passed as argv — NOT interpolated into the source — so there is no shell
4307
- // or Python string-escaping to get wrong, and no code-injection surface.
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("python3", ["-c", `
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 {