// lib/mac.ts — run AppleScript (osascript) for local macOS app integrations // (Calendar.app, Things3). Server-only. The host process needs Automation // permission for the target app; macOS prompts once on first use. import { spawn } from "node:child_process"; // Unit/record separators — safe field/row delimiters that won't appear in user // text. In AppleScript these are (ASCII character 31) and (ASCII character 30). export const FS = String.fromCharCode(31); export const RS = String.fromCharCode(30); // Run an AppleScript by piping it to osascript's stdin. Passing multi-line // scripts via a single -e arg is unreliable; stdin always compiles cleanly. export function runOsa(script: string, timeoutMs = 20_000): Promise { return new Promise((resolve, reject) => { const child = spawn("osascript", [], { stdio: ["pipe", "pipe", "pipe"] }); let out = ""; let errOut = ""; let settled = false; const finish = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); fn(); }; const timer = setTimeout(() => { child.kill("SIGKILL"); finish(() => reject(new Error("AppleScript timed out"))); }, timeoutMs); child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (errOut += d)); child.on("error", (e) => finish(() => reject(new Error(e.message)))); child.on("close", (code) => finish(() => { if (code === 0) return resolve(out); const msg = errOut.trim() || `osascript exited ${code}`; if (/-1743|not authori|not allowed/i.test(msg)) { reject(new Error("Permission needed — grant Automation access in System Settings.")); } else if (/isn.?t running|can.?t find|-600|-10814|-1728/i.test(msg)) { reject(new Error("App not running or not installed.")); } else { reject(new Error(msg)); } }), ); child.stdin.write(script); child.stdin.end(); }); } // Run an arbitrary local binary, capturing stdout. Used for icalBuddy (reads // EventKit directly — far faster than Calendar.app AppleScript on big calendars). // Resolves stdout on exit 0; rejects on non-zero, spawn error, or timeout. export function runCmd(file: string, args: string[], timeoutMs = 20_000): Promise { return new Promise((resolve, reject) => { const child = spawn(file, args, { stdio: ["ignore", "pipe", "pipe"] }); let out = ""; let errOut = ""; let settled = false; const finish = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); fn(); }; const timer = setTimeout(() => { child.kill("SIGKILL"); finish(() => reject(new Error(`${file} timed out`))); }, timeoutMs); child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (errOut += d)); child.on("error", (e) => finish(() => reject(new Error(e.message)))); child.on("close", (code) => finish(() => (code === 0 ? resolve(out) : reject(new Error(errOut.trim() || `${file} exited ${code}`)))), ); }); } // Parse FS/RS-delimited osascript output into rows of fields. export function parseRows(out: string): string[][] { return out .split(RS) .map((r) => r.replace(/[\r\n]+$/, "")) .filter((r) => r.length > 0) .map((r) => r.split(FS)); } // Escape a string for safe embedding inside an AppleScript double-quoted literal. export function osaQuote(s: string): string { return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; }