mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-08 11:45:58 +00:00
274 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|