mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-21 13:55:54 +00:00
refactor apple script executor into separate file
This commit is contained in:
parent
c31d8c7250
commit
5ea5394e59
3 changed files with 234 additions and 77 deletions
225
VibeTunnel/Core/Services/AppleScriptExecutor.swift
Normal file
225
VibeTunnel/Core/Services/AppleScriptExecutor.swift
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import Foundation
|
||||
import AppKit
|
||||
import OSLog
|
||||
|
||||
/// 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: Bundle.main.bundleIdentifier ?? "VibeTunnel",
|
||||
category: "AppleScriptExecutor"
|
||||
)
|
||||
|
||||
/// Shared instance for app-wide AppleScript execution
|
||||
static let shared = AppleScriptExecutor()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Executes an AppleScript synchronously with proper error handling.
|
||||
///
|
||||
/// This method defers the actual AppleScript execution to the next run loop
|
||||
/// to prevent crashes when called from SwiftUI actions.
|
||||
///
|
||||
/// - Parameter script: The AppleScript source code to execute
|
||||
/// - Throws: `AppleScriptError` if execution fails
|
||||
/// - Returns: The result of the AppleScript execution, if any
|
||||
@discardableResult
|
||||
func execute(_ script: String) throws -> NSAppleEventDescriptor? {
|
||||
// Create a semaphore to wait for async execution
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var executionResult: NSAppleEventDescriptor?
|
||||
var executionError: Error?
|
||||
|
||||
// Defer AppleScript execution to next run loop to avoid crashes
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
var error: NSDictionary?
|
||||
if let scriptObject = NSAppleScript(source: script) {
|
||||
executionResult = scriptObject.executeAndReturnError(&error)
|
||||
|
||||
if let error = error {
|
||||
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
|
||||
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
|
||||
|
||||
// Log all error details
|
||||
self.logger.error("AppleScript execution failed:")
|
||||
self.logger.error(" Error code: \(errorNumber ?? -1)")
|
||||
self.logger.error(" Error message: \(errorMessage)")
|
||||
if let errorRange = error["NSAppleScriptErrorRange"] as? NSRange {
|
||||
self.logger.error(" Error range: \(errorRange)")
|
||||
}
|
||||
if let errorBriefMessage = error["NSAppleScriptErrorBriefMessage"] as? String {
|
||||
self.logger.error(" Brief message: \(errorBriefMessage)")
|
||||
}
|
||||
|
||||
// Create appropriate error
|
||||
executionError = AppleScriptError.executionFailed(
|
||||
message: errorMessage,
|
||||
errorCode: errorNumber
|
||||
)
|
||||
} else {
|
||||
// Log successful execution
|
||||
self.logger.debug("AppleScript executed successfully")
|
||||
}
|
||||
} else {
|
||||
self.logger.error("Failed to create NSAppleScript object")
|
||||
executionError = AppleScriptError.scriptCreationFailed
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
// Wait for execution to complete with timeout
|
||||
let waitResult = semaphore.wait(timeout: .now() + 5.0)
|
||||
|
||||
if waitResult == .timedOut {
|
||||
logger.error("AppleScript execution timed out after 5 seconds")
|
||||
throw AppleScriptError.timeout
|
||||
}
|
||||
|
||||
if let error = executionError {
|
||||
throw error
|
||||
}
|
||||
|
||||
return executionResult
|
||||
}
|
||||
|
||||
/// Executes an AppleScript asynchronously.
|
||||
///
|
||||
/// This method is useful when you don't need to wait for the result
|
||||
/// and want to avoid blocking the current thread.
|
||||
///
|
||||
/// - Parameter script: The AppleScript source code to execute
|
||||
/// - Returns: The result of the AppleScript execution, if any
|
||||
func executeAsync(_ script: String) async throws -> NSAppleEventDescriptor? {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
// Defer execution to next run loop to avoid crashes
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
var error: NSDictionary?
|
||||
if let scriptObject = NSAppleScript(source: script) {
|
||||
let result = scriptObject.executeAndReturnError(&error)
|
||||
|
||||
if let error = error {
|
||||
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
|
||||
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
|
||||
|
||||
self.logger.error("AppleScript execution failed: \(errorMessage) (code: \(errorNumber ?? -1))")
|
||||
|
||||
continuation.resume(throwing: AppleScriptError.executionFailed(
|
||||
message: errorMessage,
|
||||
errorCode: errorNumber
|
||||
))
|
||||
} else {
|
||||
self.logger.debug("AppleScript executed successfully")
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
} else {
|
||||
self.logger.error("Failed to create NSAppleScript object")
|
||||
continuation.resume(throwing: AppleScriptError.scriptCreationFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:
|
||||
return "Failed to create AppleScript object"
|
||||
case .executionFailed(let message, let errorCode):
|
||||
if let code = errorCode {
|
||||
return "AppleScript error \(code): \(message)"
|
||||
} else {
|
||||
return "AppleScript error: \(message)"
|
||||
}
|
||||
case .permissionDenied:
|
||||
return "AppleScript permission denied. Please grant permission in System Settings."
|
||||
case .timeout:
|
||||
return "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 -1743:
|
||||
return "User permission is required to control other applications."
|
||||
case -1728:
|
||||
return "The application is not running or cannot be controlled."
|
||||
case -1708:
|
||||
return "The event was not handled by the target application."
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if this error represents a permission denial
|
||||
var isPermissionError: Bool {
|
||||
switch self {
|
||||
case .permissionDenied:
|
||||
return true
|
||||
case .executionFailed(_, let errorCode):
|
||||
return errorCode == -1743
|
||||
default:
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,35 +35,7 @@ final class AppleScriptPermissionManager: ObservableObject {
|
|||
isChecking = true
|
||||
defer { isChecking = false }
|
||||
|
||||
// Try to execute a simple AppleScript to test permissions
|
||||
let testScript = NSAppleScript(source: """
|
||||
tell application "System Events"
|
||||
return name of first process whose frontmost is true
|
||||
end tell
|
||||
""")
|
||||
|
||||
var errorDict: NSDictionary?
|
||||
let result = await withCheckedContinuation { continuation in
|
||||
// Defer execution to next run loop to avoid crashes
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
let result = testScript?.executeAndReturnError(&errorDict)
|
||||
continuation.resume(returning: (result, errorDict))
|
||||
}
|
||||
}
|
||||
|
||||
if let error = result.1 {
|
||||
logger.debug("AppleScript permission check failed: \(error)")
|
||||
|
||||
// Check for specific permission denied error
|
||||
if let errorCode = error["NSAppleScriptErrorNumber"] as? Int,
|
||||
errorCode == -1743 { // errAEEventNotPermitted
|
||||
hasPermission = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// If we got a result and no permission error, we have permission
|
||||
let permitted = result.0 != nil && result.1 == nil
|
||||
let permitted = await AppleScriptExecutor.shared.checkPermission()
|
||||
hasPermission = permitted
|
||||
|
||||
logger.info("AppleScript permission status: \(permitted)")
|
||||
|
|
|
|||
|
|
@ -390,54 +390,14 @@ final class TerminalLauncher {
|
|||
}
|
||||
|
||||
private func executeAppleScript(_ script: String) throws {
|
||||
// Create a semaphore to wait for async execution
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var executionError: Error?
|
||||
|
||||
// Defer AppleScript execution to next run loop to avoid crashes
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
var error: NSDictionary?
|
||||
if let scriptObject = NSAppleScript(source: script) {
|
||||
_ = scriptObject.executeAndReturnError(&error)
|
||||
|
||||
if let error = error {
|
||||
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
|
||||
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
|
||||
|
||||
// Log all error details
|
||||
self.logger.error("AppleScript execution failed:")
|
||||
self.logger.error(" Error code: \(errorNumber ?? -1)")
|
||||
self.logger.error(" Error message: \(errorMessage)")
|
||||
if let errorRange = error["NSAppleScriptErrorRange"] as? NSRange {
|
||||
self.logger.error(" Error range: \(errorRange)")
|
||||
}
|
||||
if let errorBriefMessage = error["NSAppleScriptErrorBriefMessage"] as? String {
|
||||
self.logger.error(" Brief message: \(errorBriefMessage)")
|
||||
}
|
||||
|
||||
// Check for specific permission errors
|
||||
if let code = errorNumber, code == -1_743 {
|
||||
self.logger.error(" This is a permission error - user needs to grant Automation permission")
|
||||
executionError = TerminalLauncherError.appleScriptPermissionDenied
|
||||
} else {
|
||||
executionError = TerminalLauncherError.appleScriptExecutionFailed(errorMessage, errorCode: errorNumber)
|
||||
}
|
||||
} else {
|
||||
// Log successful execution
|
||||
self.logger.debug("AppleScript executed successfully")
|
||||
}
|
||||
} else {
|
||||
self.logger.error("Failed to create NSAppleScript object")
|
||||
executionError = TerminalLauncherError.appleScriptExecutionFailed("Failed to create AppleScript object", errorCode: nil)
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
// Wait for execution to complete
|
||||
_ = semaphore.wait(timeout: .now() + 5.0) // 5 second timeout
|
||||
|
||||
if let error = executionError {
|
||||
throw error
|
||||
do {
|
||||
try AppleScriptExecutor.shared.execute(script)
|
||||
} catch let error as AppleScriptError {
|
||||
// Convert AppleScriptError to TerminalLauncherError
|
||||
throw error.toTerminalLauncherError()
|
||||
} catch {
|
||||
// Handle any unexpected errors
|
||||
throw TerminalLauncherError.appleScriptExecutionFailed(error.localizedDescription, errorCode: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue