@preconcurrency import AppKit import Foundation import OSLog /// Sendable wrapper for NSAppleEventDescriptor private struct SendableDescriptor: @unchecked Sendable { let descriptor: NSAppleEventDescriptor? } /// Safely executes AppleScript commands with proper error handling and crash prevention. /// /// This class ensures AppleScript execution is deferred to the next run loop to avoid /// crashes when called directly from SwiftUI actions. It provides centralized error /// handling and logging for all AppleScript operations in the app. @MainActor final class AppleScriptExecutor { private let logger = Logger( subsystem: "sh.vibetunnel.vibetunnel", category: "AppleScriptExecutor" ) /// Shared instance for app-wide AppleScript execution static let shared = AppleScriptExecutor() private init() {} /// Core AppleScript execution logic shared between sync and async methods. /// /// - Parameter script: The AppleScript source code to execute /// - Returns: The result of the AppleScript execution, if any /// - Throws: `AppleScriptError` if execution fails /// - Note: This method must be called on the main thread @MainActor private func executeCore(_ script: String) throws -> NSAppleEventDescriptor? { var error: NSDictionary? guard let scriptObject = NSAppleScript(source: script) else { logger.error("Failed to create NSAppleScript object") throw AppleScriptError.scriptCreationFailed } let result = scriptObject.executeAndReturnError(&error) if let error { let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error" let errorNumber = error["NSAppleScriptErrorNumber"] as? Int // Log error details logger.error("AppleScript execution failed:") logger.error(" Error code: \(errorNumber ?? -1)") logger.error(" Error message: \(errorMessage)") if let errorRange = error["NSAppleScriptErrorRange"] as? NSRange { logger.error(" Error range: \(errorRange)") } if let errorBriefMessage = error["NSAppleScriptErrorBriefMessage"] as? String { logger.error(" Brief message: \(errorBriefMessage)") } throw AppleScriptError.executionFailed( message: errorMessage, errorCode: errorNumber ) } logger.debug("AppleScript \(script) executed successfully") return result } /// Executes an AppleScript synchronously with proper error handling. /// /// This method runs on the main thread and is suitable for use in /// synchronous contexts where async/await is not available. /// /// - Parameters: /// - script: The AppleScript source code to execute /// - timeout: The timeout in seconds (default: 5.0, max: 30.0) /// - Throws: `AppleScriptError` if execution fails /// - Returns: The result of the AppleScript execution, if any @discardableResult func execute(_ script: String, timeout: TimeInterval = 5.0) throws -> NSAppleEventDescriptor? { // If we're already on the main thread, execute directly if Thread.isMainThread { // Add a small delay to avoid crashes from SwiftUI actions RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) return try executeCore(script) } else { // If on background thread, dispatch to main and wait var result: Result? DispatchQueue.main.sync { do { result = try .success(execute(script, timeout: timeout)) } catch { result = .failure(error) } } switch result { case .success(let value): return value case .failure(let error): throw error case .none: throw AppleScriptError.executionFailed(message: "Script execution result was nil", errorCode: nil) } } } /// Executes an AppleScript asynchronously. /// /// This method ensures AppleScript runs on the main thread with proper /// timeout handling using Swift's modern concurrency features. /// /// - Parameters: /// - script: The AppleScript source code to execute /// - timeout: The timeout in seconds (default: 5.0, max: 30.0) /// - Returns: The result of the AppleScript execution, if any func executeAsync(_ script: String, timeout: TimeInterval = 5.0) async throws -> NSAppleEventDescriptor? { let timeoutDuration = min(timeout, 30.0) return try await withTaskCancellationHandler { let sendableResult: SendableDescriptor = try await withCheckedThrowingContinuation { continuation in let wrapper = ContinuationWrapper(continuation: continuation) Task { @MainActor in // Small delay to ensure we're not in a SwiftUI action context do { try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds } catch { wrapper.resume(throwing: error) return } do { let result = try executeCore(script) wrapper.resume(returning: SendableDescriptor(descriptor: result)) } catch { wrapper.resume(throwing: error) } } // Set up timeout Task { do { try await Task.sleep(nanoseconds: UInt64(timeoutDuration * 1_000_000_000)) logger.error("AppleScript execution timed out after \(timeoutDuration) seconds") wrapper.resume(throwing: AppleScriptError.timeout) } catch { // Task was cancelled, do nothing } } } return sendableResult.descriptor } onCancel: { // Handle cancellation if needed } } /// Executes an AppleScript and returns its string result. /// /// This method is useful when you need to get a string result from AppleScript, /// such as window IDs or other identifiers. /// /// - Parameters: /// - script: The AppleScript source code to execute /// - timeout: The timeout in seconds (default: 5.0, max: 30.0) /// - Returns: The string result of the AppleScript execution /// - Throws: `AppleScriptError` if execution fails func executeWithResult(_ script: String, timeout: TimeInterval = 5.0) throws -> String { let descriptor = try execute(script, timeout: timeout) return descriptor?.stringValue ?? "" } /// Checks if AppleScript permission is granted by executing a simple test script. /// /// - Returns: true if permission is granted, false otherwise func checkPermission() async -> Bool { let testScript = """ tell application "System Events" return name of first process whose frontmost is true end tell """ do { _ = try await executeAsync(testScript) return true } catch let error as AppleScriptError { if error.isPermissionError { logger.info("AppleScript permission check: Permission denied") return false } logger.error("AppleScript permission check failed with error: \(error)") return false } catch { logger.error("AppleScript permission check failed with unexpected error: \(error)") return false } } } /// Errors that can occur during AppleScript execution. enum AppleScriptError: LocalizedError { case scriptCreationFailed case executionFailed(message: String, errorCode: Int?) case permissionDenied case timeout var errorDescription: String? { switch self { case .scriptCreationFailed: "Failed to create AppleScript object" case .executionFailed(let message, let errorCode): if let code = errorCode { "AppleScript error \(code): \(message)" } else { "AppleScript error: \(message)" } case .permissionDenied: "AppleScript permission denied. Please grant permission in System Settings." case .timeout: "AppleScript execution timed out" } } var failureReason: String? { switch self { case .permissionDenied: return "VibeTunnel needs Automation permission to control other applications." case .executionFailed(_, let errorCode): if let code = errorCode { switch code { case -1_743: return "User permission is required to control other applications." case -1_728: return "The application is not running or cannot be controlled." case -1_708: return "The event was not handled by the target application." case -2_741: return "AppleScript syntax error - check for unescaped quotes or invalid identifiers." default: return nil } } return nil default: return nil } } /// Checks if this error represents a permission denial var isPermissionError: Bool { switch self { case .permissionDenied: true case .executionFailed(_, let errorCode): errorCode == -1_743 default: false } } /// Converts this error to a TerminalLauncherError if appropriate func toTerminalLauncherError() -> TerminalLauncherError { if isPermissionError { return .appleScriptPermissionDenied } switch self { case .executionFailed(let message, let errorCode): return .appleScriptExecutionFailed(message, errorCode: errorCode) default: return .appleScriptExecutionFailed(self.localizedDescription, errorCode: nil) } } }