applescript.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. /**
  2. * AppleScript Execution Utilities
  3. *
  4. * This module provides a safe interface for executing AppleScript commands
  5. * on macOS. It handles script execution, error capture, and result parsing.
  6. *
  7. * @module utils/applescript
  8. */
  9. import { execSync, spawnSync } from "child_process";
  10. /**
  11. * Default execution timeout for AppleScript commands in milliseconds.
  12. * 30 seconds is sufficient for most operations, including complex
  13. * searches on large mailboxes. Can be overridden per-call.
  14. */
  15. const DEFAULT_TIMEOUT_MS = 30000;
  16. /**
  17. * Default retry configuration.
  18. * - 1 attempt means no retries (default behavior)
  19. * - Use maxRetries: 3 for exponential backoff with 1s/2s delays
  20. */
  21. const DEFAULT_MAX_RETRIES = 1;
  22. const DEFAULT_RETRY_DELAY_MS = 1000;
  23. /**
  24. * Check if debug/verbose logging is enabled.
  25. * Set DEBUG=1 or DEBUG=true or VERBOSE=1 to enable.
  26. */
  27. const isDebugEnabled = () => {
  28. const debug = process.env.DEBUG;
  29. const verbose = process.env.VERBOSE;
  30. return debug === "1" || debug === "true" || verbose === "1" || verbose === "true";
  31. };
  32. /**
  33. * Log a debug message if debug mode is enabled.
  34. *
  35. * @param message - The message to log
  36. * @param data - Optional additional data to log
  37. */
  38. function debugLog(message, data) {
  39. if (!isDebugEnabled())
  40. return;
  41. const timestamp = new Date().toISOString();
  42. if (data !== undefined) {
  43. console.error(`[DEBUG ${timestamp}] ${message}`, data);
  44. }
  45. else {
  46. console.error(`[DEBUG ${timestamp}] ${message}`);
  47. }
  48. }
  49. /**
  50. * Escapes a string for safe inclusion in a shell command.
  51. *
  52. * When passing AppleScript to osascript via shell, we need to handle
  53. * the interaction between shell quoting and AppleScript string literals.
  54. * This function escapes single quotes since we wrap the script in single quotes.
  55. *
  56. * @param script - The raw AppleScript code
  57. * @returns Shell-safe version of the script
  58. *
  59. * @example
  60. * // Input: tell app "Notes" to get note "Rob's Note"
  61. * // Output: tell app "Notes" to get note "Rob'\''s Note"
  62. */
  63. function escapeForShell(script) {
  64. // Replace single quotes with: end quote, escaped quote, start quote
  65. // This is the standard shell escaping pattern for single-quoted strings
  66. return script.replace(/'/g, "'\\''");
  67. }
  68. /**
  69. * Checks if an error is a timeout error from execSync.
  70. *
  71. * Node.js throws errors with specific properties when a child process
  72. * is killed due to timeout.
  73. *
  74. * @param error - The caught error object
  75. * @returns True if this was a timeout error
  76. */
  77. function isTimeoutError(error) {
  78. if (error instanceof Error) {
  79. const execError = error;
  80. // execSync kills the process with SIGTERM on timeout
  81. return execError.killed === true || execError.signal === "SIGTERM";
  82. }
  83. return false;
  84. }
  85. /**
  86. * Error patterns that indicate transient failures worth retrying.
  87. * These typically occur when Mail.app is busy or temporarily unresponsive.
  88. */
  89. const RETRYABLE_ERROR_PATTERNS = [
  90. /timed? out/i,
  91. /not responding/i,
  92. /connection.*invalid/i,
  93. /lost connection/i,
  94. /busy/i,
  95. ];
  96. /**
  97. * Checks if an error message indicates a transient failure that should be retried.
  98. *
  99. * @param errorMessage - The error message to check
  100. * @returns True if this error is worth retrying
  101. */
  102. function isRetryableError(errorMessage) {
  103. return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
  104. }
  105. /**
  106. * Synchronous sleep using the system's sleep command.
  107. * Used between retry attempts for exponential backoff.
  108. *
  109. * This is more efficient than a busy-wait loop as it doesn't
  110. * consume CPU cycles during the delay.
  111. *
  112. * Uses spawnSync instead of execSync to avoid interference with
  113. * execSync mocks in tests.
  114. *
  115. * @param ms - Milliseconds to sleep
  116. */
  117. function sleep(ms) {
  118. // Use system sleep command with fractional seconds support
  119. // This avoids CPU-spinning busy wait while keeping the code synchronous
  120. const seconds = ms / 1000;
  121. const result = spawnSync("sleep", [seconds.toString()], { stdio: "ignore" });
  122. if (result.error) {
  123. // Fallback to busy-wait if sleep command fails (shouldn't happen on macOS)
  124. const end = Date.now() + ms;
  125. while (Date.now() < end) {
  126. // Busy wait fallback
  127. }
  128. }
  129. }
  130. /**
  131. * User-friendly error messages mapped from common AppleScript errors.
  132. * Each entry maps a pattern (regex or string) to a user-friendly message.
  133. */
  134. const ERROR_MAPPINGS = [
  135. // Permission errors
  136. {
  137. pattern: /not authorized|not permitted|access.*denied/i,
  138. message: "Permission denied. Grant automation access in System Preferences > Privacy & Security > Automation.",
  139. },
  140. // Application not running
  141. {
  142. pattern: /application isn't running|not running/i,
  143. message: "Mail.app is not responding. Try opening Mail.app manually.",
  144. },
  145. // Connection errors
  146. {
  147. pattern: /connection is invalid|lost connection/i,
  148. message: "Lost connection to Mail.app. The app may have crashed or been restarted.",
  149. },
  150. // Message not found
  151. {
  152. pattern: /can't get message/i,
  153. message: "Message not found. The message may have been deleted or moved.",
  154. },
  155. // Mailbox not found
  156. {
  157. pattern: /can't get mailbox "([^"]+)"/i,
  158. message: 'Mailbox "$1" not found. Use list-mailboxes to see available mailboxes.',
  159. },
  160. // Account not found
  161. {
  162. pattern: /can't get account "([^"]+)"/i,
  163. message: 'Account "$1" not found. Use list-accounts to see available accounts.',
  164. },
  165. // Send failed
  166. {
  167. pattern: /couldn't send|send failed|cannot send/i,
  168. message: "Failed to send email. Check your network connection and Mail.app settings.",
  169. },
  170. // Offline
  171. {
  172. pattern: /offline|no connection/i,
  173. message: "Mail.app is offline. Check your network connection.",
  174. },
  175. // Cannot delete (various reasons)
  176. {
  177. pattern: /can't delete|cannot delete/i,
  178. message: "Cannot delete. The message may be locked or in use.",
  179. },
  180. // Syntax/script errors (usually programming bugs)
  181. {
  182. pattern: /syntax error|expected/i,
  183. message: "Internal error. Please report this issue.",
  184. },
  185. ];
  186. /**
  187. * Parses error output from osascript to extract meaningful error messages.
  188. *
  189. * osascript errors typically include execution error numbers and descriptions.
  190. * This function attempts to extract the human-readable portion and map it
  191. * to a user-friendly message with helpful suggestions.
  192. *
  193. * @param errorOutput - Raw error string from execSync
  194. * @returns User-friendly error message with suggested action
  195. */
  196. function parseErrorMessage(errorOutput) {
  197. // First, extract the core error message from AppleScript format
  198. let coreError = errorOutput;
  199. // Check for execution error format: "execution error: Message (-1234)"
  200. const executionError = errorOutput.match(/execution error: (.+?)(?:\s*\(-?\d+\))?$/m);
  201. if (executionError) {
  202. coreError = executionError[1].trim();
  203. }
  204. // Try to match against known error patterns for user-friendly messages
  205. for (const { pattern, message } of ERROR_MAPPINGS) {
  206. const match = coreError.match(pattern);
  207. if (match) {
  208. // Replace $1, $2, etc. with captured groups
  209. let result = message;
  210. for (let i = 1; i < match.length; i++) {
  211. result = result.replace(`$${i}`, match[i] || "");
  212. }
  213. return result;
  214. }
  215. }
  216. // Fall back to basic "Can't get X" parsing
  217. const notFoundError = coreError.match(/Can't get (.+?)\./);
  218. if (notFoundError) {
  219. return `Not found: ${notFoundError[1]}`;
  220. }
  221. // Return cleaned version of original error
  222. return coreError.trim() || "Unknown AppleScript error";
  223. }
  224. /**
  225. * Executes an AppleScript command and returns a structured result.
  226. *
  227. * This function serves as the bridge between TypeScript and macOS AppleScript.
  228. * It handles the complexity of shell escaping, execution, and error handling
  229. * so that calling code can work with clean TypeScript interfaces.
  230. *
  231. * The script is executed synchronously via the `osascript` command-line tool.
  232. * Multi-line scripts are supported and preserved (important for AppleScript
  233. * tell blocks and repeat loops).
  234. *
  235. * @param script - The AppleScript code to execute
  236. * @param options - Optional execution settings (timeout, etc.)
  237. * @returns A result object with success status and output or error message
  238. *
  239. * @example
  240. * ```typescript
  241. * // Basic usage with default timeout (30 seconds)
  242. * const result = executeAppleScript(`
  243. * tell application "Notes"
  244. * get name of every note
  245. * end tell
  246. * `);
  247. *
  248. * // With custom timeout for complex operations
  249. * const result = executeAppleScript(complexScript, { timeoutMs: 60000 });
  250. *
  251. * if (result.success) {
  252. * console.log("Notes:", result.output);
  253. * } else {
  254. * console.error("Failed:", result.error);
  255. * }
  256. * ```
  257. */
  258. export function executeAppleScript(script, options = {}) {
  259. const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  260. const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
  261. const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
  262. // Validate input - empty scripts are likely programmer errors
  263. if (!script || !script.trim()) {
  264. return {
  265. success: false,
  266. output: "",
  267. error: "Cannot execute empty AppleScript",
  268. };
  269. }
  270. // Prepare the script:
  271. // 1. Trim leading/trailing whitespace (cosmetic)
  272. // 2. Preserve internal newlines (required for AppleScript syntax)
  273. // 3. Escape for shell execution
  274. const preparedScript = escapeForShell(script.trim());
  275. // Build the osascript command
  276. // We use single quotes to wrap the script, which is why we escape
  277. // single quotes within the script itself
  278. const command = `osascript -e '${preparedScript}'`;
  279. // Debug: Log the script being executed
  280. debugLog("Executing AppleScript", {
  281. scriptPreview: script.trim().substring(0, 200) + (script.length > 200 ? "..." : ""),
  282. timeout: timeoutMs,
  283. maxRetries,
  284. });
  285. let lastError = null;
  286. const startTime = Date.now();
  287. for (let attempt = 1; attempt <= maxRetries; attempt++) {
  288. const attemptStart = Date.now();
  289. try {
  290. // Execute synchronously - MCP tools are inherently synchronous
  291. // and Apple Notes operations are fast enough that async isn't needed
  292. const output = execSync(command, {
  293. encoding: "utf8",
  294. timeout: timeoutMs,
  295. // Capture stderr separately to get error details
  296. stdio: ["pipe", "pipe", "pipe"],
  297. });
  298. const duration = Date.now() - attemptStart;
  299. debugLog("AppleScript succeeded", {
  300. attempt,
  301. duration: `${duration}ms`,
  302. outputLength: output.length,
  303. outputPreview: output.substring(0, 100) + (output.length > 100 ? "..." : ""),
  304. });
  305. return {
  306. success: true,
  307. output: output.trim(),
  308. };
  309. }
  310. catch (error) {
  311. // execSync throws on non-zero exit codes
  312. // The error object contains stderr output with AppleScript error details
  313. const attemptDuration = Date.now() - attemptStart;
  314. let errorMessage;
  315. let isTimeout = false;
  316. let rawError;
  317. // Check for timeout first - provide specific message
  318. if (isTimeoutError(error)) {
  319. isTimeout = true;
  320. const timeoutSecs = Math.round(timeoutMs / 1000);
  321. errorMessage = `Operation timed out after ${timeoutSecs} seconds. Mail.app may be unresponsive or the operation involves too many messages.`;
  322. }
  323. else if (error instanceof Error) {
  324. rawError = error.message;
  325. // Node's ExecException includes stderr in the message
  326. errorMessage = parseErrorMessage(error.message);
  327. }
  328. else if (typeof error === "string") {
  329. rawError = error;
  330. errorMessage = parseErrorMessage(error);
  331. }
  332. else {
  333. errorMessage = "AppleScript execution failed with unknown error";
  334. }
  335. // Debug: Log error details
  336. debugLog("AppleScript failed", {
  337. attempt,
  338. duration: `${attemptDuration}ms`,
  339. totalElapsed: `${Date.now() - startTime}ms`,
  340. isTimeout,
  341. errorMessage,
  342. rawError: rawError?.substring(0, 500),
  343. });
  344. lastError = {
  345. success: false,
  346. output: "",
  347. error: errorMessage,
  348. };
  349. // Check if we should retry
  350. const canRetry = isTimeout || isRetryableError(errorMessage);
  351. const hasAttemptsLeft = attempt < maxRetries;
  352. if (canRetry && hasAttemptsLeft) {
  353. const delayMs = retryDelayMs * Math.pow(2, attempt - 1);
  354. console.error(`AppleScript retry: Attempt ${attempt}/${maxRetries} failed with "${errorMessage}". Retrying in ${delayMs}ms...`);
  355. sleep(delayMs);
  356. // Continue to next attempt
  357. }
  358. else {
  359. // Log final error and return
  360. if (isTimeout) {
  361. console.error(`AppleScript timeout: ${errorMessage}`);
  362. }
  363. else {
  364. console.error(`AppleScript error: ${errorMessage}`);
  365. }
  366. return lastError;
  367. }
  368. }
  369. }
  370. // Return the last error (all retries exhausted - shouldn't reach here normally)
  371. return lastError;
  372. }