Also request accessibility

This commit is contained in:
Peter Steinberger 2025-06-17 22:08:21 +02:00
parent ee1a48848c
commit b4f8600ffd
8 changed files with 359 additions and 101 deletions

View file

@ -0,0 +1,48 @@
import Foundation
import AppKit
import ApplicationServices
import OSLog
/// Manages Accessibility permissions required for sending keystrokes.
///
/// This class provides methods to check and request accessibility permissions
/// required for simulating keyboard input via AppleScript/System Events.
final class AccessibilityPermissionManager {
@MainActor static let shared = AccessibilityPermissionManager()
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "VibeTunnel",
category: "AccessibilityPermissions"
)
private init() {}
/// Checks if we have Accessibility permissions.
func hasPermission() -> Bool {
let permitted = AXIsProcessTrusted()
logger.info("Accessibility permission status: \(permitted)")
return permitted
}
/// Requests Accessibility permissions by triggering the system dialog.
func requestPermission() {
logger.info("Requesting Accessibility permissions")
// Create options dictionary with the prompt key
// Using hardcoded string to avoid concurrency issues with kAXTrustedCheckOptionPrompt
let options: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
let alreadyTrusted = AXIsProcessTrustedWithOptions(options)
if alreadyTrusted {
logger.info("Accessibility permission already granted")
} else {
logger.info("Accessibility permission dialog triggered")
// After a short delay, also open System Settings as a fallback
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
NSWorkspace.shared.open(url)
}
}
}
}
}

View file

@ -1,7 +1,12 @@
import Foundation import Foundation
import AppKit @preconcurrency import AppKit
import OSLog 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. /// 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 /// This class ensures AppleScript execution is deferred to the next run loop to avoid
@ -21,8 +26,8 @@ final class AppleScriptExecutor {
/// Executes an AppleScript synchronously with proper error handling. /// Executes an AppleScript synchronously with proper error handling.
/// ///
/// This method defers the actual AppleScript execution to the next run loop /// This method runs on the main thread and is suitable for use in
/// to prevent crashes when called from SwiftUI actions. /// synchronous contexts where async/await is not available.
/// ///
/// - Parameters: /// - Parameters:
/// - script: The AppleScript source code to execute /// - script: The AppleScript source code to execute
@ -31,100 +36,155 @@ final class AppleScriptExecutor {
/// - Returns: The result of the AppleScript execution, if any /// - Returns: The result of the AppleScript execution, if any
@discardableResult @discardableResult
func execute(_ script: String, timeout: TimeInterval = 5.0) throws -> NSAppleEventDescriptor? { func execute(_ script: String, timeout: TimeInterval = 5.0) throws -> NSAppleEventDescriptor? {
// Create a semaphore to wait for async execution // If we're already on the main thread, execute directly
let semaphore = DispatchSemaphore(value: 0) if Thread.isMainThread {
var executionResult: NSAppleEventDescriptor? // Add a small delay to avoid crashes from SwiftUI actions
var executionError: Error? RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1))
// Defer AppleScript execution to next run loop to avoid crashes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
var error: NSDictionary? var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script) { guard let scriptObject = NSAppleScript(source: script) else {
executionResult = scriptObject.executeAndReturnError(&error) logger.error("Failed to create NSAppleScript object")
throw AppleScriptError.scriptCreationFailed
if let error = error { }
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int let result = scriptObject.executeAndReturnError(&error)
// Log all error details if let error = error {
self.logger.error("AppleScript execution failed:") let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
self.logger.error(" Error code: \(errorNumber ?? -1)") let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
self.logger.error(" Error message: \(errorMessage)")
if let errorRange = error["NSAppleScriptErrorRange"] as? NSRange { logger.error("AppleScript execution failed: \(errorMessage) (code: \(errorNumber ?? -1))")
self.logger.error(" Error range: \(errorRange)")
} throw AppleScriptError.executionFailed(
if let errorBriefMessage = error["NSAppleScriptErrorBriefMessage"] as? String { message: errorMessage,
self.logger.error(" Brief message: \(errorBriefMessage)") errorCode: errorNumber
} )
}
// Create appropriate error
executionError = AppleScriptError.executionFailed( logger.debug("AppleScript executed successfully")
message: errorMessage, return result
errorCode: errorNumber } else {
) // If on background thread, dispatch to main and wait
} else { var result: Result<NSAppleEventDescriptor?, Error>?
// Log successful execution
self.logger.debug("AppleScript executed successfully") DispatchQueue.main.sync {
} do {
} else { result = .success(try execute(script, timeout: timeout))
self.logger.error("Failed to create NSAppleScript object") } catch {
executionError = AppleScriptError.scriptCreationFailed result = .failure(error)
}
}
switch result! {
case .success(let value):
return value
case .failure(let error):
throw error
} }
semaphore.signal()
} }
// Wait for execution to complete with timeout (default 5 seconds, max 30 seconds)
let timeoutDuration = min(timeout, 30.0)
let waitResult = semaphore.wait(timeout: .now() + timeoutDuration)
if waitResult == .timedOut {
logger.error("AppleScript execution timed out after \(timeoutDuration) seconds")
throw AppleScriptError.timeout
}
if let error = executionError {
throw error
}
return executionResult
} }
/// Executes an AppleScript asynchronously. /// Executes an AppleScript asynchronously.
/// ///
/// This method is useful when you don't need to wait for the result /// This method ensures AppleScript runs on the main thread with proper
/// and want to avoid blocking the current thread. /// timeout handling using Swift's modern concurrency features.
/// ///
/// - Parameters: /// - Parameters:
/// - script: The AppleScript source code to execute /// - script: The AppleScript source code to execute
/// - timeout: The timeout in seconds (default: 5.0, max: 30.0) /// - timeout: The timeout in seconds (default: 5.0, max: 30.0)
/// - Returns: The result of the AppleScript execution, if any /// - Returns: The result of the AppleScript execution, if any
func executeAsync(_ script: String, timeout: TimeInterval = 5.0) async throws -> NSAppleEventDescriptor? { func executeAsync(_ script: String, timeout: TimeInterval = 5.0) async throws -> NSAppleEventDescriptor? {
return try await withCheckedThrowingContinuation { continuation in let timeoutDuration = min(timeout, 30.0)
// Defer execution to next run loop to avoid crashes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // Use a class with NSLock to ensure thread-safe access
var error: NSDictionary? final class ContinuationWrapper: @unchecked Sendable {
if let scriptObject = NSAppleScript(source: script) { private let lock = NSLock()
private var hasResumed = false
private let continuation: CheckedContinuation<SendableDescriptor, Error>
init(continuation: CheckedContinuation<SendableDescriptor, Error>) {
self.continuation = continuation
}
func resume(throwing error: Error) {
lock.lock()
defer { lock.unlock() }
guard !hasResumed else { return }
hasResumed = true
continuation.resume(throwing: error)
}
func resume(returning value: NSAppleEventDescriptor?) {
lock.lock()
defer { lock.unlock() }
guard !hasResumed else { return }
hasResumed = true
continuation.resume(returning: SendableDescriptor(descriptor: value))
}
}
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
}
var error: NSDictionary?
guard let scriptObject = NSAppleScript(source: script) else {
logger.error("Failed to create NSAppleScript object")
wrapper.resume(throwing: AppleScriptError.scriptCreationFailed)
return
}
let result = scriptObject.executeAndReturnError(&error) let result = scriptObject.executeAndReturnError(&error)
if let error = error { if let error = error {
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error" let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
self.logger.error("AppleScript execution failed: \(errorMessage) (code: \(errorNumber ?? -1))") 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)")
}
continuation.resume(throwing: AppleScriptError.executionFailed( wrapper.resume(throwing: AppleScriptError.executionFailed(
message: errorMessage, message: errorMessage,
errorCode: errorNumber errorCode: errorNumber
)) ))
} else { } else {
self.logger.debug("AppleScript executed successfully") logger.debug("AppleScript executed successfully")
continuation.resume(returning: result) wrapper.resume(returning: result)
}
}
// 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
} }
} else {
self.logger.error("Failed to create NSAppleScript object")
continuation.resume(throwing: AppleScriptError.scriptCreationFailed)
} }
} }
return sendableResult.descriptor
} onCancel: {
// Handle cancellation if needed
} }
} }

View file

@ -33,5 +33,7 @@
<string>AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=</string> <string>AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=</string>
<key>SUScheduledCheckInterval</key> <key>SUScheduledCheckInterval</key>
<integer>86400</integer> <integer>86400</integer>
<key>NSAppleEventsUsageDescription</key>
<string>VibeTunnel needs to control terminal applications to create new terminal sessions from the dashboard.</string>
</dict> </dict>
</plist> </plist>

View file

@ -151,6 +151,9 @@ private struct TerminalPreferenceSection: View {
case .appleScriptPermissionDenied: case .appleScriptPermissionDenied:
errorTitle = "Permission Denied" errorTitle = "Permission Denied"
errorMessage = "VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation." errorMessage = "VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
case .accessibilityPermissionDenied:
errorTitle = "Accessibility Permission Required"
errorMessage = "VibeTunnel needs Accessibility permission to send keystrokes to \(Terminal(rawValue: preferredTerminal)?.displayName ?? "terminal").\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
case .terminalNotFound: case .terminalNotFound:
errorTitle = "Terminal Not Found" errorTitle = "Terminal Not Found"
errorMessage = "The selected terminal application could not be found. Please select a different terminal." errorMessage = "The selected terminal application could not be found. Please select a different terminal."
@ -166,6 +169,9 @@ private struct TerminalPreferenceSection: View {
case -1_708: case -1_708:
errorTitle = "Terminal Communication Error" errorTitle = "Terminal Communication Error"
errorMessage = "The terminal did not respond to the command.\n\nDetails: \(details)" errorMessage = "The terminal did not respond to the command.\n\nDetails: \(details)"
case -25211:
errorTitle = "Accessibility Permission Required"
errorMessage = "System Events requires Accessibility permission to send keystrokes.\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
default: default:
errorTitle = "Terminal Launch Failed" errorTitle = "Terminal Launch Failed"
errorMessage = "AppleScript error \(code): \(details)" errorMessage = "AppleScript error \(code): \(details)"

View file

@ -828,23 +828,25 @@ private struct NgrokErrorView: View {
// MARK: - Permissions Section // MARK: - Permissions Section
private struct PermissionsSection: View { private struct PermissionsSection: View {
@StateObject private var permissionManager = AppleScriptPermissionManager.shared @StateObject private var appleScriptManager = AppleScriptPermissionManager.shared
@State private var hasAccessibilityPermission = false
var body: some View { var body: some View {
Section { Section {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 16) {
// Automation permission
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Terminal Automation") Text("Terminal Automation")
.font(.body) .font(.body)
Text("Required to spawn terminal sessions from the dashboard") Text("Required to launch and control terminal applications")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if permissionManager.hasPermission { if appleScriptManager.hasPermission {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
@ -854,7 +856,38 @@ private struct PermissionsSection: View {
.font(.caption) .font(.caption)
} else { } else {
Button("Grant Permission") { Button("Grant Permission") {
permissionManager.requestPermission() appleScriptManager.requestPermission()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Divider()
// Accessibility permission
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Accessibility")
.font(.body)
Text("Required for terminals that need keystroke input (Ghostty, Warp, Hyper)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if hasAccessibilityPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Granted")
.foregroundColor(.secondary)
}
.font(.caption)
} else {
Button("Grant Permission") {
AccessibilityPermissionManager.shared.requestPermission()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@ -865,11 +898,16 @@ private struct PermissionsSection: View {
Text("Permissions") Text("Permissions")
.font(.headline) .font(.headline)
} footer: { } footer: {
Text("AppleScript permission is required to open terminal applications when creating new sessions.") Text("Terminal automation is required for all terminals. Accessibility is only needed for terminals that simulate keyboard input.")
.font(.caption) .font(.caption)
} }
.task { .task {
_ = await permissionManager.checkPermission() _ = await appleScriptManager.checkPermission()
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
}
.onReceive(Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()) { _ in
// Check accessibility permission status periodically
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
} }
} }
} }

View file

@ -80,9 +80,14 @@ struct GeneralSettingsView: View {
// Show in Dock // Show in Dock
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Toggle("Show in Dock", isOn: showInDockBinding) Toggle("Show in Dock", isOn: showInDockBinding)
Text("Show VibeTunnel icon in the Dock.") VStack(alignment: .leading, spacing: 2) {
.font(.caption) Text("Show VibeTunnel icon in the Dock.")
.foregroundStyle(.secondary) .font(.caption)
.foregroundStyle(.secondary)
Text("The dock icon is always displayed when the Settings dialog is visible.")
.font(.caption)
.foregroundStyle(.secondary)
}
} }
} header: { } header: {
Text("Appearance") Text("Appearance")
@ -114,7 +119,8 @@ struct GeneralSettingsView: View {
get: { showInDock }, get: { showInDock },
set: { newValue in set: { newValue in
showInDock = newValue showInDock = newValue
NSApp.setActivationPolicy(newValue ? .regular : .accessory) // Don't change activation policy while settings window is open
// The change will be applied when the settings window closes
} }
) )
} }

View file

@ -236,9 +236,10 @@ private struct VTCommandPageView: View {
// MARK: - Request Permissions Page // MARK: - Request Permissions Page
/// Third page requesting AppleScript automation permissions. /// Third page requesting AppleScript automation and accessibility permissions.
private struct RequestPermissionsPageView: View { private struct RequestPermissionsPageView: View {
@StateObject private var permissionManager = AppleScriptPermissionManager.shared @StateObject private var appleScriptManager = AppleScriptPermissionManager.shared
@State private var hasAccessibilityPermission = false
var body: some View { var body: some View {
VStack(spacing: 30) { VStack(spacing: 30) {
@ -254,7 +255,7 @@ private struct RequestPermissionsPageView: View {
.fontWeight(.semibold) .fontWeight(.semibold)
Text( Text(
"VibeTunnel uses AppleScript to spawn a terminal when you create a new session in the dashboard." "VibeTunnel needs permissions to launch terminal sessions and send commands to certain terminals."
) )
.font(.body) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -262,30 +263,89 @@ private struct RequestPermissionsPageView: View {
.frame(maxWidth: 480) .frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
// Permission status and button // Permissions list
VStack(spacing: 12) { VStack(spacing: 20) {
if permissionManager.hasPermission { // AppleScript permission
VStack(spacing: 12) {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "applescript")
.foregroundColor(.green) .font(.title2)
Text("Permission granted") VStack(alignment: .leading, spacing: 4) {
.foregroundColor(.secondary) Text("Automation Permission")
.font(.headline)
Text("Required to launch and control terminal applications")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
} }
.font(.body)
} else { if appleScriptManager.hasPermission {
Button("Grant Permission") { HStack {
permissionManager.requestPermission() Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Automation permission granted")
.foregroundColor(.secondary)
}
.font(.body)
} else {
Button("Grant Automation Permission") {
appleScriptManager.requestPermission()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
} }
.buttonStyle(.borderedProminent)
.controlSize(.large)
} }
.padding()
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
// Accessibility permission
VStack(spacing: 12) {
HStack {
Image(systemName: "accessibility")
.font(.title2)
VStack(alignment: .leading, spacing: 4) {
Text("Accessibility Permission")
.font(.headline)
Text("Required for terminals like Ghostty that need keystroke input")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
if hasAccessibilityPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Accessibility permission granted")
.foregroundColor(.secondary)
}
.font(.body)
} else {
Button("Grant Accessibility Permission") {
AccessibilityPermissionManager.shared.requestPermission()
}
.buttonStyle(.bordered)
.controlSize(.large)
}
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
} }
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding() .padding()
.task { .task {
_ = await permissionManager.checkPermission() _ = await appleScriptManager.checkPermission()
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
}
.onReceive(Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()) { _ in
// Check accessibility permission status periodically
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
} }
} }
} }

View file

@ -27,6 +27,14 @@ struct TerminalLaunchConfig {
fullCommand fullCommand
.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"") .replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "'", with: "'\\''")
}
var keystrokeEscapedCommand: String {
// For keystroke commands, we need to escape quotes differently
fullCommand
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\\\\\"")
.replacingOccurrences(of: "'", with: "\\'") .replacingOccurrences(of: "'", with: "\\'")
} }
} }
@ -119,7 +127,7 @@ enum Terminal: String, CaseIterable {
end tell end tell
delay 0.2 delay 0.2
tell application "System Events" tell application "System Events"
keystroke "\(config.appleScriptEscapedCommand)" keystroke "\(config.keystrokeEscapedCommand)"
key code 36 key code 36
end tell end tell
end tell end tell
@ -138,7 +146,7 @@ enum Terminal: String, CaseIterable {
keystroke "n" using {command down} keystroke "n" using {command down}
end tell end tell
delay 0.1 delay 0.1
do script "\(config.command)" in front window do script "\(config.escapedCommand)" in front window
end tell end tell
""") """)
@ -156,7 +164,7 @@ enum Terminal: String, CaseIterable {
activate activate
create window with default profile create window with default profile
tell current session of current window tell current session of current window
write text "\(config.command)" write text "\(config.escapedCommand)"
end tell end tell
end tell end tell
""") """)
@ -209,6 +217,22 @@ enum Terminal: String, CaseIterable {
case .wezterm: return "WezTerm" case .wezterm: return "WezTerm"
} }
} }
/// Whether this terminal requires keystroke-based input (needs Accessibility permission)
var requiresKeystrokeInput: Bool {
switch self {
case .terminal:
return false // Uses 'do script' command, not keystrokes
case .iTerm2:
return false // Uses URL scheme or 'write text' command
case .ghostty, .warp, .hyper:
return true // Uses keystroke-based input
case .tabby:
return true // Uses processWithTyping which requires keystrokes
case .alacritty, .wezterm:
return false // Uses command line arguments
}
}
} }
/// Errors that can occur when launching terminal commands. /// Errors that can occur when launching terminal commands.
@ -218,6 +242,7 @@ enum Terminal: String, CaseIterable {
enum TerminalLauncherError: LocalizedError { enum TerminalLauncherError: LocalizedError {
case terminalNotFound case terminalNotFound
case appleScriptPermissionDenied case appleScriptPermissionDenied
case accessibilityPermissionDenied
case appleScriptExecutionFailed(String, errorCode: Int?) case appleScriptExecutionFailed(String, errorCode: Int?)
case processLaunchFailed(String) case processLaunchFailed(String)
@ -227,6 +252,8 @@ enum TerminalLauncherError: LocalizedError {
return "Selected terminal application not found" return "Selected terminal application not found"
case .appleScriptPermissionDenied: case .appleScriptPermissionDenied:
return "AppleScript permission denied. Please grant permission in System Settings." return "AppleScript permission denied. Please grant permission in System Settings."
case .accessibilityPermissionDenied:
return "Accessibility permission required to send keystrokes. Please grant permission in System Settings."
case .appleScriptExecutionFailed(let message, let errorCode): case .appleScriptExecutionFailed(let message, let errorCode):
if let code = errorCode { if let code = errorCode {
return "AppleScript error \(code): \(message)" return "AppleScript error \(code): \(message)"
@ -242,6 +269,8 @@ enum TerminalLauncherError: LocalizedError {
switch self { switch self {
case .appleScriptPermissionDenied: case .appleScriptPermissionDenied:
return "VibeTunnel needs Automation permission to control terminal applications." return "VibeTunnel needs Automation permission to control terminal applications."
case .accessibilityPermissionDenied:
return "VibeTunnel needs Accessibility permission to send keystrokes to terminal applications."
case .appleScriptExecutionFailed(_, let errorCode): case .appleScriptExecutionFailed(_, let errorCode):
if let code = errorCode { if let code = errorCode {
switch code { switch code {
@ -251,6 +280,8 @@ enum TerminalLauncherError: LocalizedError {
return "The application is not running or cannot be controlled." return "The application is not running or cannot be controlled."
case -1_708: case -1_708:
return "The event was not handled by the target application." return "The event was not handled by the target application."
case -25211:
return "Accessibility permission is required to send keystrokes."
default: default:
return nil return nil
} }
@ -395,6 +426,13 @@ final class TerminalLauncher {
// as some terminals (like Ghostty) can take longer to start up // as some terminals (like Ghostty) can take longer to start up
try AppleScriptExecutor.shared.execute(script, timeout: 15.0) try AppleScriptExecutor.shared.execute(script, timeout: 15.0)
} catch let error as AppleScriptError { } catch let error as AppleScriptError {
// Check if this is a keystroke permission error
if case .executionFailed(_, let errorCode) = error,
let code = errorCode,
(code == -25211 || code == -1719) {
// These error codes indicate accessibility permission issues
throw TerminalLauncherError.accessibilityPermissionDenied
}
// Convert AppleScriptError to TerminalLauncherError // Convert AppleScriptError to TerminalLauncherError
throw error.toTerminalLauncherError() throw error.toTerminalLauncherError()
} catch { } catch {