| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- /**
- * AppleScript Execution Utilities
- *
- * This module provides a safe interface for executing AppleScript commands
- * on macOS. It handles script execution, error capture, and result parsing.
- *
- * @module utils/applescript
- */
- import { execSync, spawnSync } from "child_process";
- /**
- * Default execution timeout for AppleScript commands in milliseconds.
- * 30 seconds is sufficient for most operations, including complex
- * searches on large mailboxes. Can be overridden per-call.
- */
- const DEFAULT_TIMEOUT_MS = 30000;
- /**
- * Default retry configuration.
- * - 1 attempt means no retries (default behavior)
- * - Use maxRetries: 3 for exponential backoff with 1s/2s delays
- */
- const DEFAULT_MAX_RETRIES = 1;
- const DEFAULT_RETRY_DELAY_MS = 1000;
- /**
- * Check if debug/verbose logging is enabled.
- * Set DEBUG=1 or DEBUG=true or VERBOSE=1 to enable.
- */
- const isDebugEnabled = () => {
- const debug = process.env.DEBUG;
- const verbose = process.env.VERBOSE;
- return debug === "1" || debug === "true" || verbose === "1" || verbose === "true";
- };
- /**
- * Log a debug message if debug mode is enabled.
- *
- * @param message - The message to log
- * @param data - Optional additional data to log
- */
- function debugLog(message, data) {
- if (!isDebugEnabled())
- return;
- const timestamp = new Date().toISOString();
- if (data !== undefined) {
- console.error(`[DEBUG ${timestamp}] ${message}`, data);
- }
- else {
- console.error(`[DEBUG ${timestamp}] ${message}`);
- }
- }
- /**
- * Escapes a string for safe inclusion in a shell command.
- *
- * When passing AppleScript to osascript via shell, we need to handle
- * the interaction between shell quoting and AppleScript string literals.
- * This function escapes single quotes since we wrap the script in single quotes.
- *
- * @param script - The raw AppleScript code
- * @returns Shell-safe version of the script
- *
- * @example
- * // Input: tell app "Notes" to get note "Rob's Note"
- * // Output: tell app "Notes" to get note "Rob'\''s Note"
- */
- function escapeForShell(script) {
- // Replace single quotes with: end quote, escaped quote, start quote
- // This is the standard shell escaping pattern for single-quoted strings
- return script.replace(/'/g, "'\\''");
- }
- /**
- * Checks if an error is a timeout error from execSync.
- *
- * Node.js throws errors with specific properties when a child process
- * is killed due to timeout.
- *
- * @param error - The caught error object
- * @returns True if this was a timeout error
- */
- function isTimeoutError(error) {
- if (error instanceof Error) {
- const execError = error;
- // execSync kills the process with SIGTERM on timeout
- return execError.killed === true || execError.signal === "SIGTERM";
- }
- return false;
- }
- /**
- * Error patterns that indicate transient failures worth retrying.
- * These typically occur when Mail.app is busy or temporarily unresponsive.
- */
- const RETRYABLE_ERROR_PATTERNS = [
- /timed? out/i,
- /not responding/i,
- /connection.*invalid/i,
- /lost connection/i,
- /busy/i,
- ];
- /**
- * Checks if an error message indicates a transient failure that should be retried.
- *
- * @param errorMessage - The error message to check
- * @returns True if this error is worth retrying
- */
- function isRetryableError(errorMessage) {
- return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
- }
- /**
- * Synchronous sleep using the system's sleep command.
- * Used between retry attempts for exponential backoff.
- *
- * This is more efficient than a busy-wait loop as it doesn't
- * consume CPU cycles during the delay.
- *
- * Uses spawnSync instead of execSync to avoid interference with
- * execSync mocks in tests.
- *
- * @param ms - Milliseconds to sleep
- */
- function sleep(ms) {
- // Use system sleep command with fractional seconds support
- // This avoids CPU-spinning busy wait while keeping the code synchronous
- const seconds = ms / 1000;
- const result = spawnSync("sleep", [seconds.toString()], { stdio: "ignore" });
- if (result.error) {
- // Fallback to busy-wait if sleep command fails (shouldn't happen on macOS)
- const end = Date.now() + ms;
- while (Date.now() < end) {
- // Busy wait fallback
- }
- }
- }
- /**
- * User-friendly error messages mapped from common AppleScript errors.
- * Each entry maps a pattern (regex or string) to a user-friendly message.
- */
- const ERROR_MAPPINGS = [
- // Permission errors
- {
- pattern: /not authorized|not permitted|access.*denied/i,
- message: "Permission denied. Grant automation access in System Preferences > Privacy & Security > Automation.",
- },
- // Application not running
- {
- pattern: /application isn't running|not running/i,
- message: "Mail.app is not responding. Try opening Mail.app manually.",
- },
- // Connection errors
- {
- pattern: /connection is invalid|lost connection/i,
- message: "Lost connection to Mail.app. The app may have crashed or been restarted.",
- },
- // Message not found
- {
- pattern: /can't get message/i,
- message: "Message not found. The message may have been deleted or moved.",
- },
- // Mailbox not found
- {
- pattern: /can't get mailbox "([^"]+)"/i,
- message: 'Mailbox "$1" not found. Use list-mailboxes to see available mailboxes.',
- },
- // Account not found
- {
- pattern: /can't get account "([^"]+)"/i,
- message: 'Account "$1" not found. Use list-accounts to see available accounts.',
- },
- // Send failed
- {
- pattern: /couldn't send|send failed|cannot send/i,
- message: "Failed to send email. Check your network connection and Mail.app settings.",
- },
- // Offline
- {
- pattern: /offline|no connection/i,
- message: "Mail.app is offline. Check your network connection.",
- },
- // Cannot delete (various reasons)
- {
- pattern: /can't delete|cannot delete/i,
- message: "Cannot delete. The message may be locked or in use.",
- },
- // Syntax/script errors (usually programming bugs)
- {
- pattern: /syntax error|expected/i,
- message: "Internal error. Please report this issue.",
- },
- ];
- /**
- * Parses error output from osascript to extract meaningful error messages.
- *
- * osascript errors typically include execution error numbers and descriptions.
- * This function attempts to extract the human-readable portion and map it
- * to a user-friendly message with helpful suggestions.
- *
- * @param errorOutput - Raw error string from execSync
- * @returns User-friendly error message with suggested action
- */
- function parseErrorMessage(errorOutput) {
- // First, extract the core error message from AppleScript format
- let coreError = errorOutput;
- // Check for execution error format: "execution error: Message (-1234)"
- const executionError = errorOutput.match(/execution error: (.+?)(?:\s*\(-?\d+\))?$/m);
- if (executionError) {
- coreError = executionError[1].trim();
- }
- // Try to match against known error patterns for user-friendly messages
- for (const { pattern, message } of ERROR_MAPPINGS) {
- const match = coreError.match(pattern);
- if (match) {
- // Replace $1, $2, etc. with captured groups
- let result = message;
- for (let i = 1; i < match.length; i++) {
- result = result.replace(`$${i}`, match[i] || "");
- }
- return result;
- }
- }
- // Fall back to basic "Can't get X" parsing
- const notFoundError = coreError.match(/Can't get (.+?)\./);
- if (notFoundError) {
- return `Not found: ${notFoundError[1]}`;
- }
- // Return cleaned version of original error
- return coreError.trim() || "Unknown AppleScript error";
- }
- /**
- * Executes an AppleScript command and returns a structured result.
- *
- * This function serves as the bridge between TypeScript and macOS AppleScript.
- * It handles the complexity of shell escaping, execution, and error handling
- * so that calling code can work with clean TypeScript interfaces.
- *
- * The script is executed synchronously via the `osascript` command-line tool.
- * Multi-line scripts are supported and preserved (important for AppleScript
- * tell blocks and repeat loops).
- *
- * @param script - The AppleScript code to execute
- * @param options - Optional execution settings (timeout, etc.)
- * @returns A result object with success status and output or error message
- *
- * @example
- * ```typescript
- * // Basic usage with default timeout (30 seconds)
- * const result = executeAppleScript(`
- * tell application "Notes"
- * get name of every note
- * end tell
- * `);
- *
- * // With custom timeout for complex operations
- * const result = executeAppleScript(complexScript, { timeoutMs: 60000 });
- *
- * if (result.success) {
- * console.log("Notes:", result.output);
- * } else {
- * console.error("Failed:", result.error);
- * }
- * ```
- */
- export function executeAppleScript(script, options = {}) {
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
- const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
- const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
- // Validate input - empty scripts are likely programmer errors
- if (!script || !script.trim()) {
- return {
- success: false,
- output: "",
- error: "Cannot execute empty AppleScript",
- };
- }
- // Prepare the script:
- // 1. Trim leading/trailing whitespace (cosmetic)
- // 2. Preserve internal newlines (required for AppleScript syntax)
- // 3. Escape for shell execution
- const preparedScript = escapeForShell(script.trim());
- // Build the osascript command
- // We use single quotes to wrap the script, which is why we escape
- // single quotes within the script itself
- const command = `osascript -e '${preparedScript}'`;
- // Debug: Log the script being executed
- debugLog("Executing AppleScript", {
- scriptPreview: script.trim().substring(0, 200) + (script.length > 200 ? "..." : ""),
- timeout: timeoutMs,
- maxRetries,
- });
- let lastError = null;
- const startTime = Date.now();
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
- const attemptStart = Date.now();
- try {
- // Execute synchronously - MCP tools are inherently synchronous
- // and Apple Notes operations are fast enough that async isn't needed
- const output = execSync(command, {
- encoding: "utf8",
- timeout: timeoutMs,
- // Capture stderr separately to get error details
- stdio: ["pipe", "pipe", "pipe"],
- });
- const duration = Date.now() - attemptStart;
- debugLog("AppleScript succeeded", {
- attempt,
- duration: `${duration}ms`,
- outputLength: output.length,
- outputPreview: output.substring(0, 100) + (output.length > 100 ? "..." : ""),
- });
- return {
- success: true,
- output: output.trim(),
- };
- }
- catch (error) {
- // execSync throws on non-zero exit codes
- // The error object contains stderr output with AppleScript error details
- const attemptDuration = Date.now() - attemptStart;
- let errorMessage;
- let isTimeout = false;
- let rawError;
- // Check for timeout first - provide specific message
- if (isTimeoutError(error)) {
- isTimeout = true;
- const timeoutSecs = Math.round(timeoutMs / 1000);
- errorMessage = `Operation timed out after ${timeoutSecs} seconds. Mail.app may be unresponsive or the operation involves too many messages.`;
- }
- else if (error instanceof Error) {
- rawError = error.message;
- // Node's ExecException includes stderr in the message
- errorMessage = parseErrorMessage(error.message);
- }
- else if (typeof error === "string") {
- rawError = error;
- errorMessage = parseErrorMessage(error);
- }
- else {
- errorMessage = "AppleScript execution failed with unknown error";
- }
- // Debug: Log error details
- debugLog("AppleScript failed", {
- attempt,
- duration: `${attemptDuration}ms`,
- totalElapsed: `${Date.now() - startTime}ms`,
- isTimeout,
- errorMessage,
- rawError: rawError?.substring(0, 500),
- });
- lastError = {
- success: false,
- output: "",
- error: errorMessage,
- };
- // Check if we should retry
- const canRetry = isTimeout || isRetryableError(errorMessage);
- const hasAttemptsLeft = attempt < maxRetries;
- if (canRetry && hasAttemptsLeft) {
- const delayMs = retryDelayMs * Math.pow(2, attempt - 1);
- console.error(`AppleScript retry: Attempt ${attempt}/${maxRetries} failed with "${errorMessage}". Retrying in ${delayMs}ms...`);
- sleep(delayMs);
- // Continue to next attempt
- }
- else {
- // Log final error and return
- if (isTimeout) {
- console.error(`AppleScript timeout: ${errorMessage}`);
- }
- else {
- console.error(`AppleScript error: ${errorMessage}`);
- }
- return lastError;
- }
- }
- }
- // Return the last error (all retries exhausted - shouldn't reach here normally)
- return lastError;
- }
|