vibetunnel/mac/VibeTunnel/Core/Services/AppleScriptExecutor.swift
Peter Steinberger 954b6712aa improve logging
2025-06-22 07:16:38 +02:00

274 lines
10 KiB
Swift

@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<NSAppleEventDescriptor?, Error>?
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<SendableDescriptor>(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)
}
}
}